Initial commit

This commit is contained in:
MaeLucia 2026-02-26 09:43:07 +08:00
commit fd6d468797
41 changed files with 9597 additions and 0 deletions

21
.env Normal file
View File

@ -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 - 智能控制系统

21
.env.development Normal file
View File

@ -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

22
.env.production Normal file
View File

@ -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

36
.gitignore vendored Normal file
View File

@ -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

328
README.md Normal file
View File

@ -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

16
index.html Normal file
View File

@ -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>

5307
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View File

@ -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"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

12
public/vite.svg Normal file
View File

@ -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

38
src/App.tsx Normal file
View File

@ -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

92
src/api/index.ts Normal file
View File

@ -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
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

106
src/config/index.ts Normal file
View File

@ -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

5
src/hooks/useRedux.ts Normal file
View File

@ -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

129
src/index.css Normal file
View File

@ -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;
}
}

50
src/main.tsx Normal file
View File

@ -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>,
)

View File

@ -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

198
src/pages/Dashboard.tsx Normal file
View File

@ -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

329
src/pages/DeviceStatus.tsx Normal file
View File

@ -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

311
src/pages/EnergyMonitor.tsx Normal file
View File

@ -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

298
src/pages/Lighting.tsx Normal file
View File

@ -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

19
src/store/index.ts Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

98
src/utils/request.ts Normal file
View File

@ -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

26
src/vite-env.d.ts vendored Normal file
View File

@ -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
}

47
tailwind.config.js Normal file
View File

@ -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: [],
}

25
tsconfig.json Normal file
View File

@ -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" }]
}

11
tsconfig.node.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

46
vite.config.ts Normal file
View File

@ -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'),
}),
},
}
})