Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a05b25bcc2 | ||
|
|
8e5e52e8ee | ||
|
|
72f4269cd4 | ||
|
|
56132bae32 | ||
|
|
475313855d | ||
|
|
60e7f08d7e | ||
|
|
1636dea8f1 | ||
|
|
2516b8d1de | ||
|
|
4095ba0b56 | ||
|
|
139ca4c128 |
33
CLAUDE.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# EMS Core Development Guidelines
|
||||
|
||||
## Overview
|
||||
This is the shared core library for all EMS customer projects. Changes here affect ALL customer deployments.
|
||||
|
||||
## Golden Rules
|
||||
- All changes must go through Pull Request with code review
|
||||
- Follow Conventional Commits: `<type>(<scope>): <description>`
|
||||
- Types: feat, fix, refactor, docs, test, chore, style, perf
|
||||
- Scopes: api, models, services, collector, hooks, tasks, config
|
||||
|
||||
## Architecture
|
||||
- `backend/app/api/v1/` — API endpoint modules
|
||||
- `backend/app/collectors/` — Device data collectors (Sungrow, Modbus, MQTT, HTTP)
|
||||
- `backend/app/hooks/` — Customer plugin system (base + loader)
|
||||
- `backend/app/models/` — SQLAlchemy ORM models
|
||||
- `backend/app/services/` — Business logic layer
|
||||
- `backend/app/tasks/` — Background tasks (Celery + APScheduler)
|
||||
- `backend/alembic/` — Database migrations
|
||||
- **Note:** Frontend is NOT included in ems-core. Each customer repo owns its own frontend.
|
||||
|
||||
## Database Migrations
|
||||
- Every model change requires an Alembic migration
|
||||
- Test migrations before deploying: `alembic upgrade head`
|
||||
- Never manually edit `alembic_version` table
|
||||
|
||||
## Testing
|
||||
- Run tests: `cd backend && pytest`
|
||||
- Minimum test coverage for new features
|
||||
|
||||
## Version
|
||||
- Follow SemVer (MAJOR.MINOR.PATCH)
|
||||
- Current: see VERSION file
|
||||
348
README.md
@@ -1,252 +1,254 @@
|
||||
# 天普零碳园区智慧能源管理平台
|
||||
# EMS Core Platform (ems-core)
|
||||
|
||||
> Tianpu Zero-Carbon Park Smart Energy Management System
|
||||
> Backend + shared libraries for all EMS customer projects
|
||||
|
||||
天普零碳园区智慧能源管理平台是面向工业园区业主的一站式能源管理解决方案。通过实时数据采集、智能分析和可视化大屏,帮助园区实现能源消耗透明化、碳排放精准核算和运营效率全面提升。
|
||||
**Current Version: 1.2.0** (see `VERSIONS.json`)
|
||||
|
||||
ems-core is the shared backend core for all EMS deployments. It provides the FastAPI backend, database models, data collectors, and customer hooks system. **Frontend and Nginx are NOT included** -- each customer repo provides its own frontend.
|
||||
|
||||
---
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **实时监控大屏** — 3D 园区可视化 + 多维度能源数据实时展示
|
||||
- **设备管理** — 园区设备台账、运行状态监控、故障告警
|
||||
- **能耗分析** — 多维度能耗统计、同比环比分析、能耗排名
|
||||
- **碳排放管理** — 碳排放核算、碳足迹追踪、减排目标管理
|
||||
- **告警中心** — 实时告警推送、告警分级处理、历史告警查询
|
||||
- **报表中心** — 自动生成日/周/月报表、支持 Excel 导出
|
||||
- **系统管理** — 用户权限管理、操作日志、系统配置
|
||||
|
||||
---
|
||||
|
||||
## 系统架构
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ Nginx │
|
||||
│ 反向代理 │
|
||||
│ :80 / :443 │
|
||||
└──────┬───────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
│ │
|
||||
┌──────▼──────┐ ┌────────▼────────┐
|
||||
│ Frontend │ │ Backend │
|
||||
│ React 19 │ │ FastAPI │
|
||||
│ Ant Design │ │ :8000 │
|
||||
│ ECharts │ │ │
|
||||
│ Three.js │ │ /api/v1/* │
|
||||
└─────────────┘ └───────┬──────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
│ │
|
||||
┌──────▼──────┐ ┌───────▼───────┐
|
||||
│ TimescaleDB │ │ Redis │
|
||||
│ PostgreSQL │ │ 缓存/队列 │
|
||||
│ :5432 │ │ :6379 │
|
||||
└─────────────┘ └───────────────┘
|
||||
+--------------------+
|
||||
| Backend (Core) |
|
||||
| FastAPI |
|
||||
| :8000 |
|
||||
| /api/v1/* |
|
||||
+--------+-----------+
|
||||
|
|
||||
+-----------+-----------+
|
||||
| |
|
||||
+------v------+ +------v-------+
|
||||
| TimescaleDB | | Redis |
|
||||
| PostgreSQL | | Cache/Queue|
|
||||
| :5432 | | :6379 |
|
||||
+-------------+ +--------------+
|
||||
|
||||
Note: Frontend and Nginx are NOT part of ems-core.
|
||||
Each customer repo provides its own frontend and reverse proxy.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
## Tech Stack
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 前端框架 | React 19 + TypeScript |
|
||||
| UI 组件库 | Ant Design 5 + ProComponents |
|
||||
| 数据可视化 | ECharts 6 |
|
||||
| 3D 渲染 | Three.js + React Three Fiber |
|
||||
| 后端框架 | FastAPI (Python 3.11) |
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Backend | FastAPI (Python 3.11) |
|
||||
| ORM | SQLAlchemy 2.0 (async) |
|
||||
| 数据库 | TimescaleDB (PostgreSQL 16) |
|
||||
| 缓存 | Redis 7 |
|
||||
| 任务队列 | Celery + APScheduler |
|
||||
| 数据库迁移 | Alembic |
|
||||
| 容器化 | Docker + Docker Compose |
|
||||
| Database | TimescaleDB (PostgreSQL 16) |
|
||||
| Cache | Redis 7 |
|
||||
| Task Queue | Celery + APScheduler |
|
||||
| Migrations | Alembic |
|
||||
| Container | Docker + Docker Compose |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
## Quick Start
|
||||
|
||||
### 前置要求
|
||||
### Prerequisites
|
||||
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.0+
|
||||
|
||||
### 一键启动
|
||||
### Start Services
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone http://100.108.180.60:3300/tianpu/tianpu-ems.git
|
||||
cd tianpu-ems
|
||||
# Clone
|
||||
git clone http://100.69.143.96:3300/tianpu/ems-core.git
|
||||
cd ems-core
|
||||
|
||||
# 复制环境变量
|
||||
# Configure
|
||||
cp .env.example .env
|
||||
|
||||
# 启动所有服务
|
||||
docker-compose up -d
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
|
||||
# 初始化数据库 & 写入种子数据
|
||||
docker exec tianpu_backend python scripts/init_db.py
|
||||
docker exec tianpu_backend python scripts/seed_data.py
|
||||
# Initialize database
|
||||
docker compose exec backend python scripts/init_db.py
|
||||
```
|
||||
|
||||
或使用快速启动脚本:
|
||||
Or use the quick-start script:
|
||||
|
||||
```bash
|
||||
bash scripts/quick-start.sh
|
||||
```
|
||||
|
||||
### 访问地址
|
||||
### Access
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| 前端页面 | http://localhost:3000 |
|
||||
| 后端 API | http://localhost:8000 |
|
||||
| API 文档 (Swagger) | http://localhost:8000/docs |
|
||||
| 健康检查 | http://localhost:8000/health |
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| Backend API | http://localhost:8000 |
|
||||
| API Docs (Swagger) | http://localhost:8000/docs |
|
||||
| Health Check | http://localhost:8000/health |
|
||||
|
||||
### 默认账号
|
||||
### Default Login
|
||||
|
||||
| 角色 | 用户名 | 密码 |
|
||||
|------|--------|------|
|
||||
| 管理员 | admin | admin123 |
|
||||
| Role | Username | Password |
|
||||
|------|----------|----------|
|
||||
| Admin | admin | admin123 |
|
||||
|
||||
> 请在首次登录后立即修改默认密码。
|
||||
> Change the default password after first login.
|
||||
|
||||
---
|
||||
|
||||
## 本地开发
|
||||
|
||||
### 后端开发
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 创建虚拟环境
|
||||
# Create venv
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
|
||||
# 安装依赖
|
||||
# Install deps
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 启动开发服务器 (需先启动 PostgreSQL 和 Redis)
|
||||
# Start dev server (requires PostgreSQL and Redis running)
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
### 前端开发
|
||||
Start only infrastructure:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
docker compose up -d postgres redis
|
||||
```
|
||||
|
||||
### 仅启动基础设施
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
ems-core/
|
||||
+-- backend/ # Backend service
|
||||
| +-- app/
|
||||
| | +-- api/v1/ # API routes
|
||||
| | +-- collectors/ # Data collectors (Sungrow, Modbus, MQTT, HTTP)
|
||||
| | +-- core/ # Core config
|
||||
| | +-- hooks/ # Customer plugin system
|
||||
| | +-- models/ # SQLAlchemy ORM models
|
||||
| | +-- services/ # Business logic
|
||||
| | +-- tasks/ # Background tasks (Celery + APScheduler)
|
||||
| | +-- templates/ # Email templates
|
||||
| | +-- main.py # App entry point
|
||||
| +-- alembic/ # Database migrations
|
||||
| +-- tests/ # Test suite
|
||||
| +-- Dockerfile
|
||||
| +-- requirements.txt
|
||||
+-- scripts/ # Utility scripts
|
||||
| +-- init_db.py # Database initialization
|
||||
| +-- backfill_data.py # Historical data backfill
|
||||
| +-- quick-start.sh # Quick start script
|
||||
+-- docker-compose.yml # Dev environment
|
||||
+-- docker-compose.prod.yml # Production environment
|
||||
+-- VERSION # Version file
|
||||
+-- VERSIONS.json # Version metadata
|
||||
+-- .env.example # Environment variables template
|
||||
+-- CLAUDE.md # AI assistant guidelines
|
||||
+-- README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Main modules:
|
||||
|
||||
- `/api/v1/auth` -- Authentication
|
||||
- `/api/v1/devices` -- Device management
|
||||
- `/api/v1/energy` -- Energy data
|
||||
- `/api/v1/carbon` -- Carbon emissions
|
||||
- `/api/v1/alarms` -- Alarm management
|
||||
- `/api/v1/reports` -- Reports
|
||||
- `/api/v1/system` -- System management
|
||||
|
||||
Full Swagger docs at [http://localhost:8000/docs](http://localhost:8000/docs) after starting the backend.
|
||||
|
||||
---
|
||||
|
||||
## Version Management
|
||||
|
||||
We use **Semantic Versioning** (MAJOR.MINOR.PATCH) and **git tags** to manage releases.
|
||||
|
||||
### Where Version Is Tracked
|
||||
|
||||
Version must be consistent across **three places**:
|
||||
|
||||
| Location | Example | How to update |
|
||||
|----------|---------|---------------|
|
||||
| `VERSIONS.json` | `"project_version": "1.2.0"` | Edit the file |
|
||||
| Git tag | `v1.2.0` | `git tag -a v1.2.0 -m "description"` |
|
||||
| Gitea repo description | `[v1.2.0] EMS Core...` | Gitea Settings or API |
|
||||
|
||||
### Releasing a New Version
|
||||
|
||||
```bash
|
||||
# 只启动数据库和 Redis
|
||||
docker-compose up -d postgres redis
|
||||
# 1. Update VERSIONS.json (set project_version, last_updated, notes)
|
||||
# For customer repos, also update core_version if core was updated
|
||||
|
||||
# 2. Commit the version bump
|
||||
git add VERSIONS.json
|
||||
git commit -m "chore: bump version to vX.Y.Z"
|
||||
|
||||
# 3. Create annotated tag (MUST be after VERSIONS.json is updated)
|
||||
git tag -a vX.Y.Z -m "vX.Y.Z: brief description of this release"
|
||||
|
||||
# 4. Push commit AND tag together
|
||||
git push origin main
|
||||
git push origin vX.Y.Z
|
||||
|
||||
# 5. Update Gitea repo description
|
||||
curl -X PATCH "http://<GITEA_HOST>/api/v1/repos/tianpu/<REPO>" \
|
||||
-H "Authorization: token <TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"description": "[vX.Y.Z] Updated description"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 生产部署
|
||||
### Downloading a Specific Version
|
||||
|
||||
```bash
|
||||
# 使用生产配置
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
# Clone and checkout a specific version
|
||||
git clone http://100.69.143.96:3300/tianpu/ems-core.git
|
||||
cd ems-core
|
||||
git checkout v1.1.0 # switch to v1.1.0 (detached HEAD)
|
||||
|
||||
# Or download as zip without cloning
|
||||
curl -o ems-core-v1.1.0.zip \
|
||||
"http://100.69.143.96:3300/tianpu/ems-core/archive/v1.1.0.zip"
|
||||
```
|
||||
|
||||
生产环境使用 Nginx 反向代理,前端编译为静态文件,后端使用 Gunicorn + Uvicorn workers。
|
||||
### Viewing / Comparing Versions
|
||||
|
||||
---
|
||||
```bash
|
||||
# List all available versions
|
||||
git tag -l
|
||||
|
||||
## 项目结构
|
||||
# See what changed between two versions
|
||||
git diff v1.1.0 v1.2.0
|
||||
|
||||
# View a specific file at an old version
|
||||
git show v1.1.0:VERSIONS.json
|
||||
|
||||
# View commit log between versions
|
||||
git log v1.1.0..v1.2.0 --oneline
|
||||
```
|
||||
tianpu-ems/
|
||||
├── backend/ # 后端服务
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API 路由
|
||||
│ │ │ └── v1/ # v1 版本接口
|
||||
│ │ ├── collectors/ # 数据采集器
|
||||
│ │ ├── core/ # 核心配置
|
||||
│ │ ├── models/ # 数据模型
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ ├── tasks/ # 后台任务
|
||||
│ │ └── main.py # 应用入口
|
||||
│ ├── Dockerfile
|
||||
│ └── requirements.txt
|
||||
├── frontend/ # 前端应用
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # 公共组件
|
||||
│ │ ├── layouts/ # 布局组件
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ │ ├── BigScreen/ # 数据大屏
|
||||
│ │ │ ├── BigScreen3D/ # 3D 大屏
|
||||
│ │ │ ├── Dashboard/ # 仪表盘
|
||||
│ │ │ ├── Monitoring/ # 实时监控
|
||||
│ │ │ ├── Devices/ # 设备管理
|
||||
│ │ │ ├── Analysis/ # 能耗分析
|
||||
│ │ │ ├── Carbon/ # 碳排放管理
|
||||
│ │ │ ├── Alarms/ # 告警中心
|
||||
│ │ │ ├── Reports/ # 报表中心
|
||||
│ │ │ ├── System/ # 系统管理
|
||||
│ │ │ └── Login/ # 登录页
|
||||
│ │ ├── services/ # API 服务
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ ├── Dockerfile
|
||||
│ └── package.json
|
||||
├── nginx/ # Nginx 配置
|
||||
│ ├── nginx.conf
|
||||
│ └── Dockerfile
|
||||
├── scripts/ # 脚本工具
|
||||
│ ├── init_db.py # 数据库初始化
|
||||
│ ├── seed_data.py # 种子数据
|
||||
│ ├── backfill_data.py # 历史数据回填
|
||||
│ └── quick-start.sh # 快速启动脚本
|
||||
├── docker-compose.yml # 开发环境编排
|
||||
├── docker-compose.prod.yml # 生产环境编排
|
||||
├── .env.example # 环境变量模板
|
||||
└── README.md
|
||||
|
||||
### Switching Back to Latest
|
||||
|
||||
```bash
|
||||
git checkout main # go back to latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 文档
|
||||
## Note
|
||||
|
||||
启动后端服务后访问 [http://localhost:8000/docs](http://localhost:8000/docs) 查看完整的 Swagger API 文档。
|
||||
|
||||
主要接口模块:
|
||||
|
||||
- `/api/v1/auth` — 认证与授权
|
||||
- `/api/v1/devices` — 设备管理
|
||||
- `/api/v1/energy` — 能耗数据
|
||||
- `/api/v1/carbon` — 碳排放
|
||||
- `/api/v1/alarms` — 告警管理
|
||||
- `/api/v1/reports` — 报表
|
||||
- `/api/v1/system` — 系统管理
|
||||
|
||||
---
|
||||
|
||||
## 截图预览
|
||||
|
||||
> 截图待补充
|
||||
|
||||
| 页面 | 说明 |
|
||||
|------|------|
|
||||
| 数据大屏 | 园区能源全景概览 |
|
||||
| 3D 园区 | 三维可视化园区模型 |
|
||||
| 仪表盘 | 关键能耗指标看板 |
|
||||
| 设备监控 | 设备运行状态实时监控 |
|
||||
ems-core is the backend-only shared library. Each customer project (tp-ems, zpark-ems) owns its own frontend, deployed independently.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2026 天普集团. All rights reserved.
|
||||
Copyright 2026. All rights reserved.
|
||||
|
||||
6
VERSIONS.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"project": "ems-core",
|
||||
"project_version": "1.4.1",
|
||||
"last_updated": "2026-04-06",
|
||||
"notes": "Add alembic migration 009 for ai_ops/strategy/weather/prediction tables"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = postgresql://tianpu:tianpu2026@localhost:5432/tianpu_ems
|
||||
sqlalchemy.url = postgresql://ems:ems2026@localhost:5432/ems
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
@@ -26,7 +26,7 @@ def upgrade() -> None:
|
||||
# Seed default settings
|
||||
op.execute("""
|
||||
INSERT INTO system_settings (key, value, description) VALUES
|
||||
('platform_name', '天普零碳园区智慧能源管理平台', '平台名称'),
|
||||
('platform_name', 'Smart Energy Management Platform', '平台名称'),
|
||||
('data_retention_days', '365', '数据保留天数'),
|
||||
('alarm_auto_resolve_minutes', '30', '告警自动解除时间(分钟)'),
|
||||
('simulator_interval_seconds', '15', '模拟器采集间隔(秒)'),
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Add ai_ops, energy_strategy, weather, prediction tables
|
||||
|
||||
Revision ID: 009_aiops_strategy
|
||||
Revises: 008_management
|
||||
Create Date: 2026-04-10
|
||||
|
||||
Adds tables for features that were previously missing from alembic history:
|
||||
- AI Ops: device_health_scores, anomaly_detections, diagnostic_reports,
|
||||
maintenance_predictions, ops_insights
|
||||
- Energy Strategy: tou_pricing, tou_pricing_periods, energy_strategies,
|
||||
strategy_executions, monthly_cost_reports
|
||||
- Weather: weather_data, weather_config
|
||||
- Prediction: prediction_tasks, prediction_results, optimization_schedules
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "009_aiops_strategy"
|
||||
down_revision = "008_management"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# =========================================================================
|
||||
# AI Ops tables
|
||||
# =========================================================================
|
||||
op.create_table(
|
||||
"device_health_scores",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id"), nullable=False),
|
||||
sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("health_score", sa.Float, nullable=False),
|
||||
sa.Column("status", sa.String(20), default="healthy"),
|
||||
sa.Column("factors", sa.JSON),
|
||||
sa.Column("trend", sa.String(20), default="stable"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_device_health_scores_device_id", "device_health_scores", ["device_id"])
|
||||
op.create_index("ix_device_health_scores_timestamp", "device_health_scores", ["timestamp"])
|
||||
|
||||
op.create_table(
|
||||
"anomaly_detections",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id"), nullable=False),
|
||||
sa.Column("detected_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("anomaly_type", sa.String(50), nullable=False),
|
||||
sa.Column("severity", sa.String(20), default="warning"),
|
||||
sa.Column("description", sa.Text),
|
||||
sa.Column("metric_name", sa.String(50)),
|
||||
sa.Column("expected_value", sa.Float),
|
||||
sa.Column("actual_value", sa.Float),
|
||||
sa.Column("deviation_percent", sa.Float),
|
||||
sa.Column("status", sa.String(20), default="detected"),
|
||||
sa.Column("resolution_notes", sa.Text),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_anomaly_detections_device_id", "anomaly_detections", ["device_id"])
|
||||
op.create_index("ix_anomaly_detections_detected_at", "anomaly_detections", ["detected_at"])
|
||||
|
||||
op.create_table(
|
||||
"diagnostic_reports",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id"), nullable=False),
|
||||
sa.Column("generated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("report_type", sa.String(20), default="routine"),
|
||||
sa.Column("findings", sa.JSON),
|
||||
sa.Column("recommendations", sa.JSON),
|
||||
sa.Column("estimated_impact", sa.JSON),
|
||||
sa.Column("status", sa.String(20), default="generated"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_diagnostic_reports_device_id", "diagnostic_reports", ["device_id"])
|
||||
|
||||
op.create_table(
|
||||
"maintenance_predictions",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id"), nullable=False),
|
||||
sa.Column("predicted_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("component", sa.String(100)),
|
||||
sa.Column("failure_mode", sa.String(200)),
|
||||
sa.Column("probability", sa.Float),
|
||||
sa.Column("predicted_failure_date", sa.DateTime(timezone=True)),
|
||||
sa.Column("recommended_action", sa.Text),
|
||||
sa.Column("urgency", sa.String(20), default="medium"),
|
||||
sa.Column("estimated_downtime_hours", sa.Float),
|
||||
sa.Column("estimated_repair_cost", sa.Float),
|
||||
sa.Column("status", sa.String(20), default="predicted"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_maintenance_predictions_device_id", "maintenance_predictions", ["device_id"])
|
||||
|
||||
op.create_table(
|
||||
"ops_insights",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("insight_type", sa.String(50), nullable=False),
|
||||
sa.Column("title", sa.String(200), nullable=False),
|
||||
sa.Column("description", sa.Text),
|
||||
sa.Column("data", sa.JSON),
|
||||
sa.Column("impact_level", sa.String(20), default="medium"),
|
||||
sa.Column("actionable", sa.Boolean, default=False),
|
||||
sa.Column("recommended_action", sa.Text),
|
||||
sa.Column("generated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("valid_until", sa.DateTime(timezone=True)),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Energy Strategy tables
|
||||
# =========================================================================
|
||||
op.create_table(
|
||||
"tou_pricing",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("region", sa.String(100), default="北京"),
|
||||
sa.Column("effective_date", sa.Date),
|
||||
sa.Column("end_date", sa.Date),
|
||||
sa.Column("is_active", sa.Boolean, default=True),
|
||||
sa.Column("created_by", sa.Integer, sa.ForeignKey("users.id")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"tou_pricing_periods",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("pricing_id", sa.Integer, sa.ForeignKey("tou_pricing.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("period_type", sa.String(20), nullable=False),
|
||||
sa.Column("start_time", sa.String(10), nullable=False),
|
||||
sa.Column("end_time", sa.String(10), nullable=False),
|
||||
sa.Column("price_yuan_per_kwh", sa.Float, nullable=False),
|
||||
sa.Column("month_range", sa.String(50)),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"energy_strategies",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("strategy_type", sa.String(50), nullable=False),
|
||||
sa.Column("description", sa.String(500)),
|
||||
sa.Column("parameters", sa.JSON),
|
||||
sa.Column("is_enabled", sa.Boolean, default=False),
|
||||
sa.Column("priority", sa.Integer, default=0),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"strategy_executions",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("strategy_id", sa.Integer, sa.ForeignKey("energy_strategies.id"), nullable=False),
|
||||
sa.Column("date", sa.Date, nullable=False),
|
||||
sa.Column("actions_taken", sa.JSON),
|
||||
sa.Column("savings_kwh", sa.Float, default=0),
|
||||
sa.Column("savings_yuan", sa.Float, default=0),
|
||||
sa.Column("status", sa.String(20), default="planned"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"monthly_cost_reports",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("year_month", sa.String(7), nullable=False, unique=True),
|
||||
sa.Column("total_consumption_kwh", sa.Float, default=0),
|
||||
sa.Column("total_cost_yuan", sa.Float, default=0),
|
||||
sa.Column("peak_consumption", sa.Float, default=0),
|
||||
sa.Column("valley_consumption", sa.Float, default=0),
|
||||
sa.Column("flat_consumption", sa.Float, default=0),
|
||||
sa.Column("sharp_peak_consumption", sa.Float, default=0),
|
||||
sa.Column("pv_self_consumption", sa.Float, default=0),
|
||||
sa.Column("pv_feed_in", sa.Float, default=0),
|
||||
sa.Column("optimized_cost", sa.Float, default=0),
|
||||
sa.Column("baseline_cost", sa.Float, default=0),
|
||||
sa.Column("savings_yuan", sa.Float, default=0),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Weather tables
|
||||
# =========================================================================
|
||||
op.create_table(
|
||||
"weather_data",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("data_type", sa.String(20), nullable=False),
|
||||
sa.Column("temperature", sa.Float),
|
||||
sa.Column("humidity", sa.Float),
|
||||
sa.Column("solar_radiation", sa.Float),
|
||||
sa.Column("cloud_cover", sa.Float),
|
||||
sa.Column("wind_speed", sa.Float),
|
||||
sa.Column("source", sa.String(20), default="mock"),
|
||||
sa.Column("fetched_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_weather_data_timestamp", "weather_data", ["timestamp"])
|
||||
|
||||
op.create_table(
|
||||
"weather_config",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("api_provider", sa.String(50), default="mock"),
|
||||
sa.Column("api_key", sa.String(200)),
|
||||
sa.Column("location_lat", sa.Float, default=39.9),
|
||||
sa.Column("location_lon", sa.Float, default=116.4),
|
||||
sa.Column("fetch_interval_minutes", sa.Integer, default=30),
|
||||
sa.Column("is_enabled", sa.Boolean, default=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Prediction tables
|
||||
# =========================================================================
|
||||
op.create_table(
|
||||
"prediction_tasks",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id")),
|
||||
sa.Column("prediction_type", sa.String(50), nullable=False),
|
||||
sa.Column("horizon_hours", sa.Integer, default=24),
|
||||
sa.Column("status", sa.String(20), default="pending"),
|
||||
sa.Column("parameters", sa.JSON),
|
||||
sa.Column("error_message", sa.Text),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"prediction_results",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("task_id", sa.Integer, sa.ForeignKey("prediction_tasks.id"), nullable=False),
|
||||
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("predicted_value", sa.Float, nullable=False),
|
||||
sa.Column("confidence_lower", sa.Float),
|
||||
sa.Column("confidence_upper", sa.Float),
|
||||
sa.Column("actual_value", sa.Float),
|
||||
sa.Column("unit", sa.String(20)),
|
||||
)
|
||||
op.create_index("ix_prediction_results_task_id", "prediction_results", ["task_id"])
|
||||
op.create_index("ix_prediction_results_timestamp", "prediction_results", ["timestamp"])
|
||||
|
||||
op.create_table(
|
||||
"optimization_schedules",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id")),
|
||||
sa.Column("date", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("schedule_data", sa.JSON),
|
||||
sa.Column("expected_savings_kwh", sa.Float, default=0),
|
||||
sa.Column("expected_savings_yuan", sa.Float, default=0),
|
||||
sa.Column("status", sa.String(20), default="pending"),
|
||||
sa.Column("approved_by", sa.Integer, sa.ForeignKey("users.id")),
|
||||
sa.Column("approved_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_optimization_schedules_date", "optimization_schedules", ["date"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Prediction
|
||||
op.drop_index("ix_optimization_schedules_date", table_name="optimization_schedules")
|
||||
op.drop_table("optimization_schedules")
|
||||
op.drop_index("ix_prediction_results_timestamp", table_name="prediction_results")
|
||||
op.drop_index("ix_prediction_results_task_id", table_name="prediction_results")
|
||||
op.drop_table("prediction_results")
|
||||
op.drop_table("prediction_tasks")
|
||||
|
||||
# Weather
|
||||
op.drop_table("weather_config")
|
||||
op.drop_index("ix_weather_data_timestamp", table_name="weather_data")
|
||||
op.drop_table("weather_data")
|
||||
|
||||
# Energy Strategy
|
||||
op.drop_table("monthly_cost_reports")
|
||||
op.drop_table("strategy_executions")
|
||||
op.drop_table("energy_strategies")
|
||||
op.drop_table("tou_pricing_periods")
|
||||
op.drop_table("tou_pricing")
|
||||
|
||||
# AI Ops
|
||||
op.drop_table("ops_insights")
|
||||
op.drop_index("ix_maintenance_predictions_device_id", table_name="maintenance_predictions")
|
||||
op.drop_table("maintenance_predictions")
|
||||
op.drop_index("ix_diagnostic_reports_device_id", table_name="diagnostic_reports")
|
||||
op.drop_table("diagnostic_reports")
|
||||
op.drop_index("ix_anomaly_detections_detected_at", table_name="anomaly_detections")
|
||||
op.drop_index("ix_anomaly_detections_device_id", table_name="anomaly_detections")
|
||||
op.drop_table("anomaly_detections")
|
||||
op.drop_index("ix_device_health_scores_timestamp", table_name="device_health_scores")
|
||||
op.drop_index("ix_device_health_scores_device_id", table_name="device_health_scores")
|
||||
op.drop_table("device_health_scores")
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings, charging, quota, cost, maintenance, management, prediction, energy_strategy, weather, ai_ops, branding
|
||||
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings, charging, quota, cost, maintenance, management, prediction, energy_strategy, weather, ai_ops, branding, version, kpi, meters
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -26,3 +26,6 @@ api_router.include_router(energy_strategy.router)
|
||||
api_router.include_router(weather.router)
|
||||
api_router.include_router(ai_ops.router)
|
||||
api_router.include_router(branding.router)
|
||||
api_router.include_router(version.router)
|
||||
api_router.include_router(kpi.router)
|
||||
api_router.include_router(meters.router)
|
||||
|
||||
@@ -52,7 +52,10 @@ class ReportGenerate(BaseModel):
|
||||
|
||||
@router.get("/overview")
|
||||
async def carbon_overview(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""碳排放总览"""
|
||||
"""碳排放总览 - 优先从carbon_emissions表读取,为空时从energy_data实时计算"""
|
||||
from app.models.energy import EnergyData
|
||||
from app.models.device import Device
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
@@ -70,6 +73,52 @@ async def carbon_overview(db: AsyncSession = Depends(get_db), user: User = Depen
|
||||
month = await sum_carbon(month_start, now)
|
||||
year = await sum_carbon(year_start, now)
|
||||
|
||||
# Fallback: if carbon_emissions is empty, compute reduction from PV generation
|
||||
has_carbon_data = (today["emission"] + today["reduction"] +
|
||||
month["emission"] + month["reduction"] +
|
||||
year["emission"] + year["reduction"]) > 0
|
||||
|
||||
if not has_carbon_data:
|
||||
# Get grid emission factor (华北电网 0.582 kgCO2/kWh)
|
||||
factor_q = await db.execute(
|
||||
select(EmissionFactor.factor).where(
|
||||
EmissionFactor.energy_type == "electricity"
|
||||
).order_by(EmissionFactor.id).limit(1)
|
||||
)
|
||||
grid_factor = factor_q.scalar() or 0.582 # default fallback
|
||||
|
||||
# Compute PV generation from energy_data using latest daily_energy per station
|
||||
# Device names like AP1xx belong to station 1, AP2xx to station 2
|
||||
# To avoid double-counting station-level data written to multiple devices,
|
||||
# we group by station prefix (first 3 chars of device name) and take MAX
|
||||
async def compute_pv_reduction(start, end):
|
||||
q = await db.execute(
|
||||
select(
|
||||
func.substring(Device.name, text("1"), text("3")).label("station"),
|
||||
func.max(EnergyData.value).label("max_energy"),
|
||||
).select_from(EnergyData).join(
|
||||
Device, EnergyData.device_id == Device.id
|
||||
).where(
|
||||
and_(
|
||||
EnergyData.timestamp >= start,
|
||||
EnergyData.timestamp < end,
|
||||
EnergyData.data_type == "daily_energy",
|
||||
Device.device_type.in_(["pv_inverter", "sungrow_inverter"]),
|
||||
)
|
||||
).group_by(text("station"))
|
||||
)
|
||||
total_kwh = sum(row[1] or 0 for row in q.all())
|
||||
# Carbon reduction (kg CO2) = generation (kWh) * grid emission factor
|
||||
return round(total_kwh * grid_factor / 1000, 4) # convert to tons
|
||||
|
||||
today_reduction = await compute_pv_reduction(today_start, now)
|
||||
month_reduction = await compute_pv_reduction(month_start, now)
|
||||
year_reduction = await compute_pv_reduction(year_start, now)
|
||||
|
||||
today = {"emission": 0, "reduction": today_reduction}
|
||||
month = {"emission": 0, "reduction": month_reduction}
|
||||
year = {"emission": 0, "reduction": year_reduction}
|
||||
|
||||
# 各scope分布
|
||||
scope_q = await db.execute(
|
||||
select(CarbonEmission.scope, func.sum(CarbonEmission.emission))
|
||||
|
||||
@@ -26,7 +26,7 @@ async def get_overview(db: AsyncSession = Depends(get_db), user: User = Depends(
|
||||
)
|
||||
device_stats = {row[0]: row[1] for row in device_stats_q.all()}
|
||||
|
||||
# 今日能耗汇总
|
||||
# 今日能耗汇总 (from daily summary table)
|
||||
daily_q = await db.execute(
|
||||
select(
|
||||
EnergyDailySummary.energy_type,
|
||||
@@ -38,6 +38,31 @@ async def get_overview(db: AsyncSession = Depends(get_db), user: User = Depends(
|
||||
for row in daily_q.all():
|
||||
energy_summary[row[0]] = {"consumption": row[1] or 0, "generation": row[2] or 0}
|
||||
|
||||
# Fallback: if daily summary is empty, compute from raw energy_data
|
||||
if not energy_summary:
|
||||
# Get the latest daily_energy per station (avoid double-counting).
|
||||
# The collector writes station-level daily_energy to individual device rows,
|
||||
# so multiple devices from the same station share the same value.
|
||||
# Group by station prefix (first 3 chars of device name, e.g. "AP1", "AP2")
|
||||
# and take MAX per station to deduplicate.
|
||||
latest_energy_q = await db.execute(
|
||||
select(
|
||||
func.substring(Device.name, text("1"), text("3")).label("station"),
|
||||
func.max(EnergyData.value).label("max_energy"),
|
||||
).select_from(EnergyData).join(
|
||||
Device, EnergyData.device_id == Device.id
|
||||
).where(
|
||||
and_(
|
||||
EnergyData.timestamp >= today_start,
|
||||
EnergyData.data_type == "daily_energy",
|
||||
Device.device_type.in_(["pv_inverter", "sungrow_inverter"]),
|
||||
)
|
||||
).group_by(text("station"))
|
||||
)
|
||||
total_gen = sum(row[1] or 0 for row in latest_energy_q.all())
|
||||
if total_gen > 0:
|
||||
energy_summary["electricity"] = {"consumption": 0, "generation": round(total_gen, 2)}
|
||||
|
||||
# 今日碳排放
|
||||
carbon_q = await db.execute(
|
||||
select(func.sum(CarbonEmission.emission), func.sum(CarbonEmission.reduction))
|
||||
@@ -80,21 +105,58 @@ async def get_overview(db: AsyncSession = Depends(get_db), user: User = Depends(
|
||||
|
||||
@router.get("/realtime")
|
||||
async def get_realtime_data(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""实时功率数据 - 获取最近的采集数据"""
|
||||
"""实时功率数据 - 获取最近的采集数据,按站去重防止重复计数"""
|
||||
now = datetime.now(timezone.utc)
|
||||
five_min_ago = now - timedelta(minutes=5)
|
||||
|
||||
latest_q = await db.execute(
|
||||
select(EnergyData).where(
|
||||
and_(EnergyData.timestamp >= five_min_ago, EnergyData.data_type == "power")
|
||||
).order_by(EnergyData.timestamp.desc()).limit(50)
|
||||
)
|
||||
data_points = latest_q.scalars().all()
|
||||
window_start = now - timedelta(minutes=20)
|
||||
|
||||
# Get latest power per station (dedup by device name prefix)
|
||||
# Sungrow collectors report station-level power, so multiple devices
|
||||
# sharing the same station (AP1xx = Phase 1, AP2xx = Phase 2) report
|
||||
# identical values. GROUP BY station prefix and take MAX to avoid
|
||||
# double-counting.
|
||||
from sqlalchemy import text as sa_text
|
||||
pv_ids = await _get_pv_device_ids(db)
|
||||
hp_ids = await _get_hp_device_ids(db)
|
||||
pv_power = sum(d.value for d in data_points if d.device_id in pv_ids)
|
||||
heatpump_power = sum(d.value for d in data_points if d.device_id in hp_ids)
|
||||
|
||||
# PV power: dedup by station prefix
|
||||
if pv_ids:
|
||||
pv_q = await db.execute(
|
||||
select(
|
||||
func.substring(Device.name, 1, 3).label("station"),
|
||||
func.max(EnergyData.value).label("power"),
|
||||
).select_from(EnergyData).join(
|
||||
Device, EnergyData.device_id == Device.id
|
||||
).where(
|
||||
and_(
|
||||
EnergyData.timestamp >= window_start,
|
||||
EnergyData.data_type == "power",
|
||||
EnergyData.device_id.in_(pv_ids),
|
||||
)
|
||||
).group_by(sa_text("1"))
|
||||
)
|
||||
pv_power = sum(row[1] or 0 for row in pv_q.all())
|
||||
else:
|
||||
pv_power = 0
|
||||
|
||||
# Heat pump power: dedup by station prefix
|
||||
if hp_ids:
|
||||
hp_q = await db.execute(
|
||||
select(
|
||||
func.substring(Device.name, 1, 3).label("station"),
|
||||
func.max(EnergyData.value).label("power"),
|
||||
).select_from(EnergyData).join(
|
||||
Device, EnergyData.device_id == Device.id
|
||||
).where(
|
||||
and_(
|
||||
EnergyData.timestamp >= window_start,
|
||||
EnergyData.data_type == "power",
|
||||
EnergyData.device_id.in_(hp_ids),
|
||||
)
|
||||
).group_by(sa_text("1"))
|
||||
)
|
||||
heatpump_power = sum(row[1] or 0 for row in hp_q.all())
|
||||
else:
|
||||
heatpump_power = 0
|
||||
|
||||
return {
|
||||
"timestamp": str(now),
|
||||
@@ -134,7 +196,10 @@ async def get_load_curve(
|
||||
|
||||
async def _get_pv_device_ids(db: AsyncSession):
|
||||
result = await db.execute(
|
||||
select(Device.id).where(Device.device_type == "pv_inverter", Device.is_active == True)
|
||||
select(Device.id).where(
|
||||
Device.device_type.in_(["pv_inverter", "sungrow_inverter"]),
|
||||
Device.is_active == True,
|
||||
)
|
||||
)
|
||||
return [r[0] for r in result.fetchall()]
|
||||
|
||||
|
||||
@@ -32,13 +32,27 @@ async def query_history(
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""历史数据查询"""
|
||||
# Parse time strings to datetime for proper PostgreSQL timestamp comparison
|
||||
start_dt = None
|
||||
end_dt = None
|
||||
if start_time:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start_time)
|
||||
except ValueError:
|
||||
start_dt = datetime.strptime(start_time, "%Y-%m-%d")
|
||||
if end_time:
|
||||
try:
|
||||
end_dt = datetime.fromisoformat(end_time)
|
||||
except ValueError:
|
||||
end_dt = datetime.strptime(end_time, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
|
||||
|
||||
query = select(EnergyData).where(EnergyData.data_type == data_type)
|
||||
if device_id:
|
||||
query = query.where(EnergyData.device_id == device_id)
|
||||
if start_time:
|
||||
query = query.where(EnergyData.timestamp >= start_time)
|
||||
if end_time:
|
||||
query = query.where(EnergyData.timestamp <= end_time)
|
||||
if start_dt:
|
||||
query = query.where(EnergyData.timestamp >= start_dt)
|
||||
if end_dt:
|
||||
query = query.where(EnergyData.timestamp <= end_dt)
|
||||
|
||||
if granularity == "raw":
|
||||
query = query.order_by(EnergyData.timestamp.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
@@ -74,10 +88,10 @@ async def query_history(
|
||||
).where(EnergyData.data_type == data_type)
|
||||
if device_id:
|
||||
agg_query = agg_query.where(EnergyData.device_id == device_id)
|
||||
if start_time:
|
||||
agg_query = agg_query.where(EnergyData.timestamp >= start_time)
|
||||
if end_time:
|
||||
agg_query = agg_query.where(EnergyData.timestamp <= end_time)
|
||||
if start_dt:
|
||||
agg_query = agg_query.where(EnergyData.timestamp >= start_dt)
|
||||
if end_dt:
|
||||
agg_query = agg_query.where(EnergyData.timestamp <= end_dt)
|
||||
agg_query = agg_query.group_by(text('time_bucket')).order_by(text('time_bucket'))
|
||||
result = await db.execute(agg_query)
|
||||
return [{"time": str(r[0]), "avg": round(r[1], 2), "max": round(r[2], 2), "min": round(r[3], 2)}
|
||||
|
||||
94
backend/app/api/v1/kpi.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.device import Device
|
||||
from app.models.energy import EnergyData
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/kpi", tags=["关键指标"])
|
||||
|
||||
|
||||
@router.get("/solar")
|
||||
async def get_solar_kpis(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""Solar performance KPIs - PR, self-consumption, equivalent hours, revenue"""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Get PV devices and their rated power
|
||||
pv_q = await db.execute(
|
||||
select(Device.id, Device.rated_power).where(
|
||||
Device.device_type.in_(["pv_inverter", "sungrow_inverter"]),
|
||||
Device.is_active == True,
|
||||
)
|
||||
)
|
||||
pv_devices = pv_q.all()
|
||||
pv_ids = [d[0] for d in pv_devices]
|
||||
total_rated_kw = sum(d[1] or 0 for d in pv_devices) # kW
|
||||
|
||||
if not pv_ids or total_rated_kw == 0:
|
||||
return {
|
||||
"pr": 0, "self_consumption_rate": 0,
|
||||
"equivalent_hours": 0, "revenue_today": 0,
|
||||
"total_rated_kw": 0, "daily_generation_kwh": 0,
|
||||
}
|
||||
|
||||
# Get latest daily_energy per station (dedup by device name prefix)
|
||||
# Sungrow collectors report station-level data per device, so multiple
|
||||
# devices sharing the same station report identical values.
|
||||
# Group by station prefix (first 3 chars of name, e.g. "AP1" vs "AP2")
|
||||
# and take MAX per station to avoid double-counting.
|
||||
from sqlalchemy import text as sa_text
|
||||
daily_gen_q = await db.execute(
|
||||
select(
|
||||
func.substring(Device.name, 1, 3).label("station"),
|
||||
func.max(EnergyData.value).label("max_energy"),
|
||||
).select_from(EnergyData).join(
|
||||
Device, EnergyData.device_id == Device.id
|
||||
).where(
|
||||
and_(
|
||||
EnergyData.timestamp >= today_start,
|
||||
EnergyData.data_type == "daily_energy",
|
||||
EnergyData.device_id.in_(pv_ids),
|
||||
)
|
||||
).group_by(sa_text("1"))
|
||||
)
|
||||
|
||||
daily_values = daily_gen_q.all()
|
||||
if not daily_values:
|
||||
daily_generation_kwh = 0
|
||||
else:
|
||||
daily_generation_kwh = sum(row[1] or 0 for row in daily_values)
|
||||
|
||||
# Performance Ratio (PR) = actual output / (rated capacity * peak sun hours)
|
||||
# Approximate peak sun hours from time of day (simplified)
|
||||
hours_since_sunrise = max(0, min(12, (now.hour + now.minute / 60) - 6)) # approx 6am sunrise
|
||||
theoretical_kwh = total_rated_kw * hours_since_sunrise * 0.8 # 0.8 = typical irradiance factor
|
||||
pr = (daily_generation_kwh / theoretical_kwh * 100) if theoretical_kwh > 0 else 0
|
||||
pr = min(100, round(pr, 1)) # Cap at 100%
|
||||
|
||||
# Self-consumption rate (without grid export meter, assume 100% self-consumed for now)
|
||||
# TODO: integrate grid export meter data when available
|
||||
self_consumption_rate = 100.0
|
||||
|
||||
# Equivalent utilization hours = daily generation / rated capacity
|
||||
equivalent_hours = round(daily_generation_kwh / total_rated_kw, 2) if total_rated_kw > 0 else 0
|
||||
|
||||
# Revenue = daily generation * electricity price
|
||||
# TODO: get actual price from electricity_pricing table
|
||||
# Default industrial TOU average price in Beijing: ~0.65 CNY/kWh
|
||||
avg_price = 0.65
|
||||
revenue_today = round(daily_generation_kwh * avg_price, 2)
|
||||
|
||||
return {
|
||||
"pr": pr,
|
||||
"self_consumption_rate": round(self_consumption_rate, 1),
|
||||
"equivalent_hours": equivalent_hours,
|
||||
"revenue_today": revenue_today,
|
||||
"total_rated_kw": total_rated_kw,
|
||||
"daily_generation_kwh": round(daily_generation_kwh, 2),
|
||||
"avg_price_per_kwh": avg_price,
|
||||
"pv_device_count": len(pv_ids),
|
||||
}
|
||||
201
backend/app/api/v1/meters.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.meter import MeterReading
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/meters", tags=["电表管理"])
|
||||
|
||||
settings = get_settings()
|
||||
_customer_config = settings.load_customer_config()
|
||||
|
||||
|
||||
# --------------- Pydantic schemas ---------------
|
||||
|
||||
class MeterInfo(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
meter_no: str | None = None
|
||||
modbus_addr: int | None = None
|
||||
|
||||
|
||||
class MeterReadingResponse(BaseModel):
|
||||
time: datetime
|
||||
meter_id: int
|
||||
meter_name: str | None = None
|
||||
forward_active_energy: float | None = None
|
||||
reverse_active_energy: float | None = None
|
||||
active_power: float | None = None
|
||||
reactive_power: float | None = None
|
||||
power_factor: float | None = None
|
||||
voltage_a: float | None = None
|
||||
voltage_b: float | None = None
|
||||
voltage_c: float | None = None
|
||||
current_a: float | None = None
|
||||
current_b: float | None = None
|
||||
current_c: float | None = None
|
||||
|
||||
|
||||
class MeterLatestResponse(BaseModel):
|
||||
meter: MeterInfo
|
||||
latest: MeterReadingResponse | None = None
|
||||
|
||||
|
||||
class MeterOverviewItem(BaseModel):
|
||||
meter: MeterInfo
|
||||
latest: MeterReadingResponse | None = None
|
||||
|
||||
|
||||
class MeterOverviewResponse(BaseModel):
|
||||
meters: list[MeterOverviewItem]
|
||||
total_forward_energy: float
|
||||
total_reverse_energy: float
|
||||
total_active_power: float
|
||||
|
||||
|
||||
class MeterListResponse(BaseModel):
|
||||
items: list[MeterInfo]
|
||||
total: int
|
||||
|
||||
|
||||
# --------------- Helpers ---------------
|
||||
|
||||
def _get_meter_configs() -> list[dict]:
|
||||
"""Load meter list from customer config.yaml."""
|
||||
return _customer_config.get("meters", [])
|
||||
|
||||
|
||||
def _meter_config_to_info(cfg: dict) -> MeterInfo:
|
||||
return MeterInfo(
|
||||
id=cfg["id"],
|
||||
name=cfg.get("name", f"Meter-{cfg['id']}"),
|
||||
meter_no=cfg.get("meter_no"),
|
||||
modbus_addr=cfg.get("modbus_addr"),
|
||||
)
|
||||
|
||||
|
||||
def _reading_to_response(r: MeterReading) -> MeterReadingResponse:
|
||||
return MeterReadingResponse(
|
||||
time=r.time,
|
||||
meter_id=r.meter_id,
|
||||
meter_name=r.meter_name,
|
||||
forward_active_energy=r.forward_active_energy,
|
||||
reverse_active_energy=r.reverse_active_energy,
|
||||
active_power=r.active_power,
|
||||
reactive_power=r.reactive_power,
|
||||
power_factor=r.power_factor,
|
||||
voltage_a=r.voltage_a,
|
||||
voltage_b=r.voltage_b,
|
||||
voltage_c=r.voltage_c,
|
||||
current_a=r.current_a,
|
||||
current_b=r.current_b,
|
||||
current_c=r.current_c,
|
||||
)
|
||||
|
||||
|
||||
# --------------- Endpoints ---------------
|
||||
|
||||
@router.get("", response_model=MeterListResponse)
|
||||
async def list_meters(
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""列出所有已配置的电表"""
|
||||
configs = _get_meter_configs()
|
||||
items = [_meter_config_to_info(c) for c in configs]
|
||||
return MeterListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/overview", response_model=MeterOverviewResponse)
|
||||
async def meter_overview(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""仪表盘概览 - 所有电表最新值及汇总"""
|
||||
configs = _get_meter_configs()
|
||||
overview_items: list[MeterOverviewItem] = []
|
||||
total_forward = 0.0
|
||||
total_reverse = 0.0
|
||||
total_power = 0.0
|
||||
|
||||
for cfg in configs:
|
||||
info = _meter_config_to_info(cfg)
|
||||
result = await db.execute(
|
||||
select(MeterReading)
|
||||
.where(MeterReading.meter_id == cfg["id"])
|
||||
.order_by(desc(MeterReading.time))
|
||||
.limit(1)
|
||||
)
|
||||
reading = result.scalar_one_or_none()
|
||||
latest = _reading_to_response(reading) if reading else None
|
||||
if reading:
|
||||
total_forward += reading.forward_active_energy or 0
|
||||
total_reverse += reading.reverse_active_energy or 0
|
||||
total_power += reading.active_power or 0
|
||||
overview_items.append(MeterOverviewItem(meter=info, latest=latest))
|
||||
|
||||
return MeterOverviewResponse(
|
||||
meters=overview_items,
|
||||
total_forward_energy=total_forward,
|
||||
total_reverse_energy=total_reverse,
|
||||
total_active_power=total_power,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{meter_id}/latest", response_model=MeterLatestResponse)
|
||||
async def get_meter_latest(
|
||||
meter_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取指定电表的最新读数"""
|
||||
configs = _get_meter_configs()
|
||||
cfg = next((c for c in configs if c["id"] == meter_id), None)
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="电表不存在")
|
||||
|
||||
result = await db.execute(
|
||||
select(MeterReading)
|
||||
.where(MeterReading.meter_id == meter_id)
|
||||
.order_by(desc(MeterReading.time))
|
||||
.limit(1)
|
||||
)
|
||||
reading = result.scalar_one_or_none()
|
||||
return MeterLatestResponse(
|
||||
meter=_meter_config_to_info(cfg),
|
||||
latest=_reading_to_response(reading) if reading else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{meter_id}/readings", response_model=list[MeterReadingResponse])
|
||||
async def get_meter_readings(
|
||||
meter_id: int,
|
||||
start: Optional[datetime] = Query(None, description="开始时间 ISO8601"),
|
||||
end: Optional[datetime] = Query(None, description="结束时间 ISO8601"),
|
||||
limit: int = Query(100, ge=1, le=10000, description="返回条数上限"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""查询历史读数 (时间范围 + limit)"""
|
||||
configs = _get_meter_configs()
|
||||
cfg = next((c for c in configs if c["id"] == meter_id), None)
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="电表不存在")
|
||||
|
||||
query = select(MeterReading).where(MeterReading.meter_id == meter_id)
|
||||
if start:
|
||||
query = query.where(MeterReading.time >= start)
|
||||
if end:
|
||||
query = query.where(MeterReading.time <= end)
|
||||
query = query.order_by(desc(MeterReading.time)).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
readings = result.scalars().all()
|
||||
return [_reading_to_response(r) for r in readings]
|
||||
@@ -12,7 +12,7 @@ router = APIRouter(prefix="/settings", tags=["系统设置"])
|
||||
|
||||
# Default settings — used when keys are missing from DB
|
||||
DEFAULTS: dict[str, str] = {
|
||||
"platform_name": "天普零碳园区智慧能源管理平台",
|
||||
"platform_name": "Smart Energy Management Platform",
|
||||
"data_retention_days": "365",
|
||||
"alarm_auto_resolve_minutes": "30",
|
||||
"simulator_interval_seconds": "15",
|
||||
|
||||
32
backend/app/api/v1/version.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import os
|
||||
import json
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/version", tags=["版本信息"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_version():
|
||||
"""Return platform version information for display on login/dashboard"""
|
||||
# Read VERSIONS.json from project root (2 levels up from backend/)
|
||||
backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# Try multiple paths for VERSIONS.json
|
||||
for path in [
|
||||
os.path.join(backend_dir, "VERSIONS.json"), # standalone
|
||||
os.path.join(backend_dir, "..", "VERSIONS.json"), # inside core/ subtree
|
||||
os.path.join(backend_dir, "..", "..", "VERSIONS.json"), # customer project root
|
||||
]:
|
||||
if os.path.exists(path):
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
versions = json.load(f)
|
||||
return versions
|
||||
|
||||
# Fallback: read VERSION file
|
||||
version_file = os.path.join(backend_dir, "VERSION")
|
||||
version = "unknown"
|
||||
if os.path.exists(version_file):
|
||||
with open(version_file, 'r') as f:
|
||||
version = f.read().strip()
|
||||
|
||||
return {"project_version": version, "project": "ems-core"}
|
||||
@@ -6,23 +6,23 @@ import yaml
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "TianpuEMS"
|
||||
APP_NAME: str = "EMS Platform"
|
||||
DEBUG: bool = True
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
|
||||
# Customer configuration
|
||||
CUSTOMER: str = "tianpu" # tianpu, zpark, etc.
|
||||
CUSTOMER: str = "default" # tianpu, zpark, etc.
|
||||
CUSTOMER_DISPLAY_NAME: str = "" # Loaded from customer config
|
||||
|
||||
# Database: set DATABASE_URL in .env to override.
|
||||
# Default: SQLite for local dev. Docker sets PostgreSQL via env var.
|
||||
# Examples:
|
||||
# SQLite: sqlite+aiosqlite:///./tianpu_ems.db
|
||||
# PostgreSQL: postgresql+asyncpg://tianpu:tianpu2026@localhost:5432/tianpu_ems
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./tianpu_ems.db"
|
||||
# SQLite: sqlite+aiosqlite:///./ems.db
|
||||
# PostgreSQL: postgresql+asyncpg://ems:ems2026@localhost:5432/ems
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./ems.db"
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
SECRET_KEY: str = "tianpu-ems-secret-key-change-in-production-2026"
|
||||
SECRET_KEY: str = "ems-secret-key-change-in-production-2026"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
|
||||
|
||||
@@ -40,7 +40,7 @@ class Settings(BaseSettings):
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM: str = "noreply@tianpu-ems.com"
|
||||
SMTP_FROM: str = "noreply@ems-platform.com"
|
||||
SMTP_ENABLED: bool = False
|
||||
|
||||
# Platform URL for links in emails
|
||||
|
||||
@@ -84,8 +84,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=customer_config.get("platform_name", "天普零碳园区智慧能源管理平台"),
|
||||
description=customer_config.get("platform_name_en", "Tianpu Zero-Carbon Park Smart Energy Management System"),
|
||||
title=customer_config.get("platform_name", "Smart Energy Management Platform"),
|
||||
description=customer_config.get("platform_name_en", "Smart Energy Management System"),
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.models.prediction import PredictionTask, PredictionResult, Optimization
|
||||
from app.models.energy_strategy import TouPricing, TouPricingPeriod, EnergyStrategy, StrategyExecution, MonthlyCostReport
|
||||
from app.models.weather import WeatherData, WeatherConfig
|
||||
from app.models.ai_ops import DeviceHealthScore, AnomalyDetection, DiagnosticReport, MaintenancePrediction, OpsInsight
|
||||
from app.models.meter import MeterReading
|
||||
|
||||
__all__ = [
|
||||
"User", "Role", "AuditLog",
|
||||
@@ -40,4 +41,5 @@ __all__ = [
|
||||
"TouPricing", "TouPricingPeriod", "EnergyStrategy", "StrategyExecution", "MonthlyCostReport",
|
||||
"WeatherData", "WeatherConfig",
|
||||
"DeviceHealthScore", "AnomalyDetection", "DiagnosticReport", "MaintenancePrediction", "OpsInsight",
|
||||
"MeterReading",
|
||||
]
|
||||
|
||||
24
backend/app/models/meter.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class MeterReading(Base):
|
||||
"""电表读数时序数据"""
|
||||
__tablename__ = "meter_readings"
|
||||
|
||||
time = Column(DateTime(timezone=True), primary_key=True, nullable=False)
|
||||
meter_id = Column(Integer, primary_key=True, nullable=False)
|
||||
meter_name = Column(String(100))
|
||||
forward_active_energy = Column(Float) # 正向有功电能 kWh
|
||||
reverse_active_energy = Column(Float) # 反向有功电能 kWh
|
||||
active_power = Column(Float) # 有功功率 kW
|
||||
reactive_power = Column(Float) # 无功功率 kvar
|
||||
power_factor = Column(Float) # 功率因数
|
||||
voltage_a = Column(Float) # A相电压 V
|
||||
voltage_b = Column(Float) # B相电压 V
|
||||
voltage_c = Column(Float) # C相电压 V
|
||||
current_a = Column(Float) # A相电流 A
|
||||
current_b = Column(Float) # B相电流 A
|
||||
current_c = Column(Float) # C相电流 A
|
||||
raw_json = Column(JSON) # 原始数据包
|
||||
@@ -104,7 +104,7 @@ async def _send_alarm_email(
|
||||
platform_url=settings.PLATFORM_URL,
|
||||
)
|
||||
|
||||
subject = f"[{severity_cfg['label']}] {event.title} - 天普EMS告警通知"
|
||||
subject = f"[{severity_cfg['label']}] {event.title} - EMS Alarm Notification"
|
||||
asyncio.create_task(send_email(to=emails, subject=subject, body_html=body_html))
|
||||
|
||||
# Rate limit: don't create duplicate events for the same rule+device within this window
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
报表生成服务 - PDF/Excel report generation for Tianpu EMS.
|
||||
报表生成服务 - PDF/Excel report generation for EMS Platform.
|
||||
"""
|
||||
import os
|
||||
import io
|
||||
@@ -18,7 +18,7 @@ from app.models.carbon import CarbonEmission
|
||||
REPORTS_DIR = Path(__file__).resolve().parent.parent.parent / "reports"
|
||||
REPORTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
PLATFORM_TITLE = "天普零碳园区智慧能源管理平台"
|
||||
PLATFORM_TITLE = "Smart Energy Management Platform"
|
||||
|
||||
ENERGY_TYPE_LABELS = {
|
||||
"electricity": "电力",
|
||||
|
||||
@@ -103,10 +103,10 @@ async def _run_report_task(task_id: int):
|
||||
recipients = task.recipients or []
|
||||
if isinstance(recipients, list) and recipients:
|
||||
report_name = task.name or template.name
|
||||
subject = f"{report_name} - 天普EMS自动报表"
|
||||
subject = f"{report_name} - EMS Auto Report"
|
||||
body_html = f"""
|
||||
<div style="font-family: 'Microsoft YaHei', sans-serif; padding: 20px;">
|
||||
<h2 style="color: #1a73e8;">天普零碳园区智慧能源管理平台</h2>
|
||||
<h2 style="color: #1a73e8;">Smart Energy Management Platform</h2>
|
||||
<p>您好,</p>
|
||||
<p>系统已自动生成 <strong>{report_name}</strong>,请查收附件。</p>
|
||||
<p style="color: #666; font-size: 13px;">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""模拟数据生成器 - 为天普园区设备生成真实感的模拟数据
|
||||
"""模拟数据生成器 - 为园区设备生成真实感的模拟数据
|
||||
|
||||
Uses physics-based solar position, Beijing weather models, cloud transients,
|
||||
temperature derating, and realistic building load patterns to produce data
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Shared by both the real-time simulator and the backfill script.
|
||||
Deterministic when given a seed — call set_seed() for reproducible backfills.
|
||||
|
||||
Tianpu campus: 39.9N, 116.4E (Beijing / Daxing district)
|
||||
Default campus: 39.9N, 116.4E (Beijing / Daxing district)
|
||||
"""
|
||||
|
||||
import math
|
||||
@@ -87,7 +87,7 @@ def solar_altitude(dt: datetime) -> float:
|
||||
|
||||
# Local solar time
|
||||
beijing_dt = dt + timedelta(hours=BEIJING_TZ_OFFSET) if dt.tzinfo else dt
|
||||
# Standard meridian for UTC+8 is 120E; Tianpu is at 116.4E
|
||||
# Standard meridian for UTC+8 is 120E; default campus is at 116.4E
|
||||
time_offset = _equation_of_time(doy) + 4 * (BEIJING_LON - 120.0)
|
||||
solar_hour = beijing_dt.hour + beijing_dt.minute / 60.0 + beijing_dt.second / 3600.0
|
||||
solar_hour += time_offset / 60.0
|
||||
|
||||
@@ -4,7 +4,7 @@ from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
celery_app = Celery(
|
||||
"tianpu_ems",
|
||||
"ems_platform",
|
||||
broker=settings.REDIS_URL,
|
||||
backend=settings.REDIS_URL,
|
||||
)
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background: linear-gradient(135deg, #1a73e8, #0d47a1); padding:24px 32px; text-align:center;">
|
||||
<div style="font-size:12px; color:rgba(255,255,255,0.8); margin-bottom:4px;">TIANPU EMS</div>
|
||||
<div style="font-size:20px; font-weight:bold; color:#ffffff; letter-spacing:1px;">天普零碳园区智慧能源管理平台</div>
|
||||
<div style="font-size:12px; color:rgba(255,255,255,0.8); margin-bottom:4px;">EMS PLATFORM</div>
|
||||
<div style="font-size:20px; font-weight:bold; color:#ffffff; letter-spacing:1px;">Smart Energy Management Platform</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
<td style="background-color:#f8f9fa; padding:16px 32px; border-top:1px solid #e8e8e8;">
|
||||
<div style="font-size:12px; color:#999; text-align:center; line-height:1.6;">
|
||||
此为系统自动发送,请勿回复。<br>
|
||||
天普零碳园区智慧能源管理平台 © 2026
|
||||
Smart Energy Management Platform © 2026
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,28 +1,6 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
nginx:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: nginx/Dockerfile
|
||||
container_name: tianpu_nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
postgres:
|
||||
image: timescale/timescaledb:latest-pg16
|
||||
container_name: tianpu_db
|
||||
|
||||
@@ -46,18 +46,6 @@ services:
|
||||
condition: service_healthy
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
container_name: tianpu_frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
depends_on:
|
||||
- backend
|
||||
command: npm run dev
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
redisdata:
|
||||
|
||||
24
frontend/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,11 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
@@ -1,20 +0,0 @@
|
||||
# Multi-stage production build for standalone use
|
||||
# In docker-compose.prod.yml, the nginx Dockerfile handles frontend building directly
|
||||
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with nginx
|
||||
FROM nginx:1.27-alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# SPA fallback
|
||||
RUN echo 'server { listen 80; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } }' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,73 +0,0 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>天普智慧能源管理平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6012
frontend/package-lock.json
generated
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@ant-design/pro-components": "^2.8.10",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@react-three/postprocessing": "^3.0.4",
|
||||
"antd": "^5.29.3",
|
||||
"axios": "^1.14.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"i18next": "^24.2.2",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"three": "^0.183.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.183.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0f172a;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="300" fill="url(#bg)" rx="12"/>
|
||||
<!-- Device icon -->
|
||||
<rect x="150" y="60" width="100" height="120" rx="8" fill="none" stroke="#64748b" stroke-width="3"/>
|
||||
<circle cx="200" cy="110" r="25" fill="none" stroke="#64748b" stroke-width="2"/>
|
||||
<path d="M 185 110 L 200 95 L 215 110 L 200 125 Z" fill="#64748b" opacity="0.5"/>
|
||||
<!-- Signal -->
|
||||
<line x1="200" y1="60" x2="200" y2="40" stroke="#64748b" stroke-width="2"/>
|
||||
<circle cx="200" cy="36" r="4" fill="#64748b"/>
|
||||
<!-- Label -->
|
||||
<text x="200" y="228" text-anchor="middle" fill="#94a3b8" font-family="Arial, sans-serif" font-size="20" font-weight="bold">IoT Device</text>
|
||||
<text x="200" y="248" text-anchor="middle" fill="#6b7280" font-family="Arial, sans-serif" font-size="12">通用设备</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,30 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2d1b1b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1a0f0f;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="300" fill="url(#bg)" rx="12"/>
|
||||
<!-- Meter body -->
|
||||
<rect x="120" y="50" width="160" height="140" rx="10" fill="#f3f4f6" stroke="#d1d5db" stroke-width="2"/>
|
||||
<!-- Screen -->
|
||||
<rect x="138" y="65" width="124" height="50" rx="4" fill="#1f2937"/>
|
||||
<text x="200" y="92" text-anchor="middle" fill="#ef4444" font-family="monospace" font-size="20" font-weight="bold">256.8</text>
|
||||
<text x="255" y="92" fill="#ef4444" font-family="monospace" font-size="10">GJ</text>
|
||||
<text x="145" y="78" fill="#9ca3af" font-family="monospace" font-size="9">累计热量</text>
|
||||
<!-- Flow rate -->
|
||||
<text x="200" y="135" text-anchor="middle" fill="#6b7280" font-family="Arial" font-size="11">流量: 2.4 m³/h</text>
|
||||
<text x="200" y="150" text-anchor="middle" fill="#6b7280" font-family="Arial" font-size="11">温差: 8.2°C</text>
|
||||
<!-- Pipes -->
|
||||
<rect x="70" y="155" width="55" height="16" rx="8" fill="#ef4444" opacity="0.7"/>
|
||||
<rect x="275" y="155" width="55" height="16" rx="8" fill="#3b82f6" opacity="0.7"/>
|
||||
<text x="97" y="166" text-anchor="middle" fill="white" font-size="9" font-family="Arial">供水</text>
|
||||
<text x="302" y="166" text-anchor="middle" fill="white" font-size="9" font-family="Arial">回水</text>
|
||||
<!-- Flow arrows in pipes -->
|
||||
<text x="80" y="166" fill="white" font-size="10">→</text>
|
||||
<text x="315" y="166" fill="white" font-size="10">←</text>
|
||||
<!-- Label -->
|
||||
<text x="200" y="238" text-anchor="middle" fill="#f87171" font-family="Arial, sans-serif" font-size="20" font-weight="bold">热量表</text>
|
||||
<text x="200" y="258" text-anchor="middle" fill="#6b7280" font-family="Arial, sans-serif" font-size="12">Ultrasonic Heat Meter</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,46 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1a2332;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0f172a;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="body" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#e5e7eb;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#9ca3af;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="300" fill="url(#bg)" rx="12"/>
|
||||
<!-- Heat pump body -->
|
||||
<rect x="80" y="50" width="240" height="170" rx="12" fill="url(#body)" stroke="#6b7280" stroke-width="2"/>
|
||||
<!-- Fan grille -->
|
||||
<circle cx="200" cy="120" r="55" fill="#374151" stroke="#4b5563" stroke-width="3"/>
|
||||
<circle cx="200" cy="120" r="45" fill="none" stroke="#6b7280" stroke-width="1"/>
|
||||
<circle cx="200" cy="120" r="35" fill="none" stroke="#6b7280" stroke-width="1"/>
|
||||
<circle cx="200" cy="120" r="25" fill="none" stroke="#6b7280" stroke-width="1"/>
|
||||
<circle cx="200" cy="120" r="8" fill="#1f2937"/>
|
||||
<!-- Fan blades -->
|
||||
<g transform="translate(200, 120)" fill="#4b5563" opacity="0.8">
|
||||
<ellipse cx="0" cy="-22" rx="8" ry="22" transform="rotate(0)"/>
|
||||
<ellipse cx="0" cy="-22" rx="8" ry="22" transform="rotate(90)"/>
|
||||
<ellipse cx="0" cy="-22" rx="8" ry="22" transform="rotate(180)"/>
|
||||
<ellipse cx="0" cy="-22" rx="8" ry="22" transform="rotate(270)"/>
|
||||
</g>
|
||||
<!-- Side vents -->
|
||||
<g fill="#9ca3af">
|
||||
<rect x="90" y="170" width="40" height="3" rx="1.5"/>
|
||||
<rect x="90" y="178" width="40" height="3" rx="1.5"/>
|
||||
<rect x="90" y="186" width="40" height="3" rx="1.5"/>
|
||||
<rect x="90" y="194" width="40" height="3" rx="1.5"/>
|
||||
<rect x="90" y="202" width="40" height="3" rx="1.5"/>
|
||||
</g>
|
||||
<!-- Pipes -->
|
||||
<rect x="280" y="140" width="30" height="12" rx="6" fill="#ef4444" opacity="0.8"/>
|
||||
<rect x="280" y="165" width="30" height="12" rx="6" fill="#3b82f6" opacity="0.8"/>
|
||||
<text x="295" y="149" text-anchor="middle" fill="white" font-size="8" font-family="Arial">热</text>
|
||||
<text x="295" y="174" text-anchor="middle" fill="white" font-size="8" font-family="Arial">冷</text>
|
||||
<!-- Status LED -->
|
||||
<circle cx="100" cy="68" r="4" fill="#22c55e"/>
|
||||
<!-- Label -->
|
||||
<text x="200" y="258" text-anchor="middle" fill="#ff8c00" font-family="Arial, sans-serif" font-size="20" font-weight="bold">空气源热泵</text>
|
||||
<text x="200" y="278" text-anchor="middle" fill="#6b7280" font-family="Arial, sans-serif" font-size="12">Air Source Heat Pump</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,41 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1a1a2e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16213e;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="300" fill="url(#bg)" rx="12"/>
|
||||
<!-- Meter body -->
|
||||
<rect x="110" y="30" width="180" height="220" rx="10" fill="#f3f4f6" stroke="#d1d5db" stroke-width="2"/>
|
||||
<!-- Screen -->
|
||||
<rect x="130" y="50" width="140" height="60" rx="6" fill="#1f2937" stroke="#374151" stroke-width="1"/>
|
||||
<!-- LCD display -->
|
||||
<text x="200" y="82" text-anchor="middle" fill="#22c55e" font-family="monospace" font-size="24" font-weight="bold">1847.5</text>
|
||||
<text x="260" y="82" text-anchor="end" fill="#22c55e" font-family="monospace" font-size="12">kWh</text>
|
||||
<text x="140" y="65" fill="#6b7280" font-family="monospace" font-size="10">正向有功总</text>
|
||||
<!-- Status LEDs -->
|
||||
<circle cx="145" cy="125" r="4" fill="#22c55e"/>
|
||||
<circle cx="160" cy="125" r="4" fill="#ef4444" opacity="0.3"/>
|
||||
<circle cx="175" cy="125" r="4" fill="#eab308" opacity="0.3"/>
|
||||
<text x="190" y="128" fill="#6b7280" font-family="Arial" font-size="9">运行</text>
|
||||
<!-- Buttons -->
|
||||
<circle cx="150" cy="155" r="10" fill="#e5e7eb" stroke="#9ca3af" stroke-width="1"/>
|
||||
<circle cx="200" cy="155" r="10" fill="#e5e7eb" stroke="#9ca3af" stroke-width="1"/>
|
||||
<circle cx="250" cy="155" r="10" fill="#e5e7eb" stroke="#9ca3af" stroke-width="1"/>
|
||||
<text x="150" y="158" text-anchor="middle" fill="#6b7280" font-size="8" font-family="Arial">▲</text>
|
||||
<text x="200" y="158" text-anchor="middle" fill="#6b7280" font-size="8" font-family="Arial">OK</text>
|
||||
<text x="250" y="158" text-anchor="middle" fill="#6b7280" font-size="8" font-family="Arial">▼</text>
|
||||
<!-- Terminal block -->
|
||||
<rect x="130" y="190" width="140" height="40" rx="4" fill="#e5e7eb" stroke="#d1d5db"/>
|
||||
<g fill="#374151">
|
||||
<circle cx="150" cy="210" r="6" />
|
||||
<circle cx="175" cy="210" r="6" />
|
||||
<circle cx="200" cy="210" r="6" />
|
||||
<circle cx="225" cy="210" r="6" />
|
||||
<circle cx="250" cy="210" r="6" />
|
||||
</g>
|
||||
<!-- Label -->
|
||||
<text x="200" y="272" text-anchor="middle" fill="#00d4ff" font-family="Arial, sans-serif" font-size="20" font-weight="bold">智能电表</text>
|
||||
<text x="200" y="292" text-anchor="middle" fill="#6b7280" font-family="Arial, sans-serif" font-size="12">Smart Power Meter</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1,42 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1a3a2a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0d2818;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="panel" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e40af;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="300" fill="url(#bg)" rx="12"/>
|
||||
<!-- Solar panels -->
|
||||
<g transform="translate(80, 40)">
|
||||
<!-- Panel frame -->
|
||||
<rect x="0" y="0" width="240" height="160" rx="6" fill="url(#panel)" stroke="#3b82f6" stroke-width="2"/>
|
||||
<!-- Grid lines -->
|
||||
<line x1="80" y1="0" x2="80" y2="160" stroke="#1e3a5f" stroke-width="1.5"/>
|
||||
<line x1="160" y1="0" x2="160" y2="160" stroke="#1e3a5f" stroke-width="1.5"/>
|
||||
<line x1="0" y1="40" x2="240" y2="40" stroke="#1e3a5f" stroke-width="1.5"/>
|
||||
<line x1="0" y1="80" x2="240" y2="80" stroke="#1e3a5f" stroke-width="1.5"/>
|
||||
<line x1="0" y1="120" x2="240" y2="120" stroke="#1e3a5f" stroke-width="1.5"/>
|
||||
<!-- Cells shine -->
|
||||
<rect x="4" y="4" width="72" height="34" rx="2" fill="#3b82f6" opacity="0.6"/>
|
||||
<rect x="84" y="4" width="72" height="34" rx="2" fill="#60a5fa" opacity="0.4"/>
|
||||
<rect x="164" y="4" width="72" height="34" rx="2" fill="#3b82f6" opacity="0.5"/>
|
||||
<!-- Sun icon -->
|
||||
<circle cx="220" cy="20" r="12" fill="#fbbf24" opacity="0.8"/>
|
||||
<g stroke="#fbbf24" stroke-width="2" opacity="0.6">
|
||||
<line x1="220" y1="2" x2="220" y2="8"/>
|
||||
<line x1="220" y1="32" x2="220" y2="38"/>
|
||||
<line x1="202" y1="20" x2="208" y2="20"/>
|
||||
<line x1="232" y1="20" x2="238" y2="20"/>
|
||||
</g>
|
||||
<!-- Stand -->
|
||||
<rect x="110" y="160" width="20" height="40" fill="#374151"/>
|
||||
<rect x="80" y="195" width="80" height="8" rx="4" fill="#4b5563"/>
|
||||
</g>
|
||||
<!-- Label -->
|
||||
<text x="200" y="270" text-anchor="middle" fill="#00ff88" font-family="Arial, sans-serif" font-size="20" font-weight="bold">光伏逆变器</text>
|
||||
<text x="200" y="290" text-anchor="middle" fill="#6b7280" font-family="Arial, sans-serif" font-size="12">Huawei SUN2000-110KTL</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,39 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1a2332;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0c1524;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" style="stop-color:#3b82f6;stop-opacity:0.3" />
|
||||
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="400" height="300" fill="url(#bg)" rx="12"/>
|
||||
<!-- Glow effect -->
|
||||
<circle cx="200" cy="130" r="80" fill="url(#glow)"/>
|
||||
<!-- Sensor body -->
|
||||
<rect x="160" y="60" width="80" height="120" rx="12" fill="#f9fafb" stroke="#e5e7eb" stroke-width="2"/>
|
||||
<!-- Display -->
|
||||
<rect x="172" y="75" width="56" height="35" rx="4" fill="#1f2937"/>
|
||||
<text x="200" y="96" text-anchor="middle" fill="#22c55e" font-family="monospace" font-size="18" font-weight="bold">23.5</text>
|
||||
<text x="200" y="106" text-anchor="middle" fill="#6b7280" font-family="monospace" font-size="8">°C</text>
|
||||
<!-- Humidity bar -->
|
||||
<rect x="175" y="120" width="50" height="6" rx="3" fill="#1f2937"/>
|
||||
<rect x="175" y="120" width="32" height="6" rx="3" fill="#3b82f6"/>
|
||||
<text x="200" y="138" text-anchor="middle" fill="#6b7280" font-family="Arial" font-size="9">湿度 64%</text>
|
||||
<!-- Antenna -->
|
||||
<line x1="200" y1="60" x2="200" y2="30" stroke="#9ca3af" stroke-width="2"/>
|
||||
<circle cx="200" cy="26" r="4" fill="#22c55e"/>
|
||||
<!-- Signal waves -->
|
||||
<path d="M 210 35 Q 218 26, 210 17" fill="none" stroke="#22c55e" stroke-width="1.5" opacity="0.6"/>
|
||||
<path d="M 214 39 Q 226 26, 214 13" fill="none" stroke="#22c55e" stroke-width="1.5" opacity="0.4"/>
|
||||
<path d="M 218 43 Q 234 26, 218 9" fill="none" stroke="#22c55e" stroke-width="1.5" opacity="0.2"/>
|
||||
<!-- Wall mount -->
|
||||
<rect x="185" y="180" width="30" height="20" rx="4" fill="#d1d5db"/>
|
||||
<circle cx="195" cy="190" r="3" fill="#9ca3af"/>
|
||||
<circle cx="205" cy="190" r="3" fill="#9ca3af"/>
|
||||
<!-- Label -->
|
||||
<text x="200" y="238" text-anchor="middle" fill="#a78bfa" font-family="Arial, sans-serif" font-size="20" font-weight="bold">温湿度传感器</text>
|
||||
<text x="200" y="258" text-anchor="middle" fill="#6b7280" font-family="Arial, sans-serif" font-size="12">Temperature & Humidity Sensor</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,41 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1a1a2e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16213e;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="300" fill="url(#bg)" rx="12"/>
|
||||
<!-- Meter body -->
|
||||
<rect x="110" y="30" width="180" height="220" rx="10" fill="#f3f4f6" stroke="#d1d5db" stroke-width="2"/>
|
||||
<!-- Screen -->
|
||||
<rect x="130" y="50" width="140" height="60" rx="6" fill="#1f2937" stroke="#374151" stroke-width="1"/>
|
||||
<!-- LCD display -->
|
||||
<text x="200" y="82" text-anchor="middle" fill="#22c55e" font-family="monospace" font-size="24" font-weight="bold">1847.5</text>
|
||||
<text x="260" y="82" text-anchor="end" fill="#22c55e" font-family="monospace" font-size="12">kWh</text>
|
||||
<text x="140" y="65" fill="#6b7280" font-family="monospace" font-size="10">正向有功总</text>
|
||||
<!-- Status LEDs -->
|
||||
<circle cx="145" cy="125" r="4" fill="#22c55e"/>
|
||||
<circle cx="160" cy="125" r="4" fill="#ef4444" opacity="0.3"/>
|
||||
<circle cx="175" cy="125" r="4" fill="#eab308" opacity="0.3"/>
|
||||
<text x="190" y="128" fill="#6b7280" font-family="Arial" font-size="9">运行</text>
|
||||
<!-- Buttons -->
|
||||
<circle cx="150" cy="155" r="10" fill="#e5e7eb" stroke="#9ca3af" stroke-width="1"/>
|
||||
<circle cx="200" cy="155" r="10" fill="#e5e7eb" stroke="#9ca3af" stroke-width="1"/>
|
||||
<circle cx="250" cy="155" r="10" fill="#e5e7eb" stroke="#9ca3af" stroke-width="1"/>
|
||||
<text x="150" y="158" text-anchor="middle" fill="#6b7280" font-size="8" font-family="Arial">▲</text>
|
||||
<text x="200" y="158" text-anchor="middle" fill="#6b7280" font-size="8" font-family="Arial">OK</text>
|
||||
<text x="250" y="158" text-anchor="middle" fill="#6b7280" font-size="8" font-family="Arial">▼</text>
|
||||
<!-- Terminal block -->
|
||||
<rect x="130" y="190" width="140" height="40" rx="4" fill="#e5e7eb" stroke="#d1d5db"/>
|
||||
<g fill="#374151">
|
||||
<circle cx="150" cy="210" r="6" />
|
||||
<circle cx="175" cy="210" r="6" />
|
||||
<circle cx="200" cy="210" r="6" />
|
||||
<circle cx="225" cy="210" r="6" />
|
||||
<circle cx="250" cy="210" r="6" />
|
||||
</g>
|
||||
<!-- Label -->
|
||||
<text x="200" y="272" text-anchor="middle" fill="#00d4ff" font-family="Arial, sans-serif" font-size="20" font-weight="bold">智能电表</text>
|
||||
<text x="200" y="292" text-anchor="middle" fill="#6b7280" font-family="Arial, sans-serif" font-size="12">Smart Power Meter</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,84 +0,0 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ConfigProvider, theme } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import enUS from 'antd/locale/en_US';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
|
||||
import './i18n';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import LoginPage from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Monitoring from './pages/Monitoring';
|
||||
import Analysis from './pages/Analysis';
|
||||
import Alarms from './pages/Alarms';
|
||||
import Carbon from './pages/Carbon';
|
||||
import Reports from './pages/Reports';
|
||||
import Devices from './pages/Devices';
|
||||
import DeviceDetail from './pages/DeviceDetail';
|
||||
import SystemManagement from './pages/System';
|
||||
import Quota from './pages/Quota';
|
||||
import Charging from './pages/Charging';
|
||||
import Maintenance from './pages/Maintenance';
|
||||
import DataQuery from './pages/DataQuery';
|
||||
import Management from './pages/Management';
|
||||
import Prediction from './pages/Prediction';
|
||||
import EnergyStrategy from './pages/EnergyStrategy';
|
||||
import AIOperations from './pages/AIOperations';
|
||||
import BigScreen from './pages/BigScreen';
|
||||
import BigScreen3D from './pages/BigScreen3D';
|
||||
import { isLoggedIn } from './utils/auth';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
if (!isLoggedIn()) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const { darkMode } = useTheme();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={i18n.language === 'en' ? enUS : zhCN}
|
||||
theme={{
|
||||
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
token: { colorPrimary: '#1890ff', borderRadius: 6 },
|
||||
}}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/bigscreen" element={<BigScreen />} />
|
||||
<Route path="/bigscreen-3d" element={<BigScreen3D />} />
|
||||
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="monitoring" element={<Monitoring />} />
|
||||
<Route path="devices" element={<Devices />} />
|
||||
<Route path="devices/:id" element={<DeviceDetail />} />
|
||||
<Route path="analysis" element={<Analysis />} />
|
||||
<Route path="alarms" element={<Alarms />} />
|
||||
<Route path="carbon" element={<Carbon />} />
|
||||
<Route path="reports" element={<Reports />} />
|
||||
<Route path="quota" element={<Quota />} />
|
||||
<Route path="charging/*" element={<Charging />} />
|
||||
<Route path="maintenance" element={<Maintenance />} />
|
||||
<Route path="data-query" element={<DataQuery />} />
|
||||
<Route path="management" element={<Management />} />
|
||||
<Route path="prediction" element={<Prediction />} />
|
||||
<Route path="energy-strategy" element={<EnergyStrategy />} />
|
||||
<Route path="ai-operations" element={<AIOperations />} />
|
||||
<Route path="system/*" element={<SystemManagement />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AppContent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 44 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,33 +0,0 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
|
||||
interface ThemeContextType {
|
||||
darkMode: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
darkMode: false,
|
||||
toggleDarkMode: () => {},
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
const saved = localStorage.getItem('tianpu-dark-mode');
|
||||
return saved === 'true';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('tianpu-dark-mode', String(darkMode));
|
||||
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
|
||||
}, [darkMode]);
|
||||
|
||||
const toggleDarkMode = () => setDarkMode(prev => !prev);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ darkMode, toggleDarkMode }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
@@ -1,196 +0,0 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { getToken } from '../utils/auth';
|
||||
|
||||
export interface RealtimeData {
|
||||
pv_power: number;
|
||||
heatpump_power: number;
|
||||
total_load: number;
|
||||
grid_power: number;
|
||||
active_alarms: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AlarmEventData {
|
||||
id: number;
|
||||
title: string;
|
||||
severity: string;
|
||||
message?: string;
|
||||
device_name?: string;
|
||||
triggered_at?: string;
|
||||
}
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: 'realtime_update' | 'alarm_event' | 'pong';
|
||||
data?: RealtimeData | AlarmEventData;
|
||||
}
|
||||
|
||||
interface UseRealtimeWebSocketOptions {
|
||||
/** Called when a new alarm event arrives */
|
||||
onAlarmEvent?: (alarm: AlarmEventData) => void;
|
||||
/** Polling interval in ms when WS is unavailable (default: 15000) */
|
||||
fallbackInterval?: number;
|
||||
/** Whether the hook is enabled (default: true) */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseRealtimeWebSocketResult {
|
||||
/** Latest realtime data from WebSocket */
|
||||
data: RealtimeData | null;
|
||||
/** Whether WebSocket is currently connected */
|
||||
connected: boolean;
|
||||
/** Whether we are using fallback polling */
|
||||
usingFallback: boolean;
|
||||
}
|
||||
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
const INITIAL_RECONNECT_DELAY = 1000;
|
||||
|
||||
export default function useRealtimeWebSocket(
|
||||
options: UseRealtimeWebSocketOptions = {}
|
||||
): UseRealtimeWebSocketResult {
|
||||
const { onAlarmEvent, fallbackInterval = 15000, enabled = true } = options;
|
||||
const [data, setData] = useState<RealtimeData | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [usingFallback, setUsingFallback] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectDelayRef = useRef(INITIAL_RECONNECT_DELAY);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const fallbackTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const onAlarmEventRef = useRef(onAlarmEvent);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Keep callback ref up to date
|
||||
useEffect(() => {
|
||||
onAlarmEventRef.current = onAlarmEvent;
|
||||
}, [onAlarmEvent]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (pingTimerRef.current) {
|
||||
clearInterval(pingTimerRef.current);
|
||||
pingTimerRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.onerror = null;
|
||||
wsRef.current.onmessage = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startFallbackPolling = useCallback(() => {
|
||||
if (fallbackTimerRef.current) return;
|
||||
setUsingFallback(true);
|
||||
// We don't do actual polling here - the parent component's
|
||||
// existing polling handles data fetch. This flag signals the
|
||||
// parent to keep its polling active.
|
||||
}, []);
|
||||
|
||||
const stopFallbackPolling = useCallback(() => {
|
||||
if (fallbackTimerRef.current) {
|
||||
clearInterval(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}
|
||||
setUsingFallback(false);
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current || !enabled) return;
|
||||
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
startFallbackPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const wsUrl = `${protocol}//${host}/api/v1/ws/realtime?token=${encodeURIComponent(token)}`;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mountedRef.current) return;
|
||||
setConnected(true);
|
||||
stopFallbackPolling();
|
||||
reconnectDelayRef.current = INITIAL_RECONNECT_DELAY;
|
||||
|
||||
// Ping every 30s to keep alive
|
||||
pingTimerRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send('ping');
|
||||
}
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (!mountedRef.current) return;
|
||||
try {
|
||||
const msg: WebSocketMessage = JSON.parse(event.data);
|
||||
if (msg.type === 'realtime_update' && msg.data) {
|
||||
setData(msg.data as RealtimeData);
|
||||
} else if (msg.type === 'alarm_event' && msg.data) {
|
||||
onAlarmEventRef.current?.(msg.data as AlarmEventData);
|
||||
}
|
||||
// pong is just a keepalive ack, ignore
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!mountedRef.current) return;
|
||||
setConnected(false);
|
||||
if (pingTimerRef.current) {
|
||||
clearInterval(pingTimerRef.current);
|
||||
pingTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Don't reconnect if closed intentionally (4001 = auth error)
|
||||
if (event.code === 4001) {
|
||||
startFallbackPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconnect with exponential backoff
|
||||
startFallbackPolling();
|
||||
const delay = reconnectDelayRef.current;
|
||||
reconnectDelayRef.current = Math.min(delay * 2, MAX_RECONNECT_DELAY);
|
||||
reconnectTimerRef.current = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after this, which handles reconnection
|
||||
};
|
||||
} catch {
|
||||
startFallbackPolling();
|
||||
const delay = reconnectDelayRef.current;
|
||||
reconnectDelayRef.current = Math.min(delay * 2, MAX_RECONNECT_DELAY);
|
||||
reconnectTimerRef.current = setTimeout(connect, delay);
|
||||
}
|
||||
}, [enabled, cleanup, startFallbackPolling, stopFallbackPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
if (enabled) {
|
||||
connect();
|
||||
}
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
cleanup();
|
||||
stopFallbackPolling();
|
||||
};
|
||||
}, [enabled, connect, cleanup, stopFallbackPolling]);
|
||||
|
||||
return { data, connected, usingFallback };
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import zh from './locales/zh.json';
|
||||
import en from './locales/en.json';
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
zh: { translation: zh },
|
||||
en: { translation: en },
|
||||
},
|
||||
lng: localStorage.getItem('tianpu-lang') || 'zh',
|
||||
fallbackLng: 'zh',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"menu": {
|
||||
"dashboard": "Energy Overview",
|
||||
"monitoring": "Real-time Monitoring",
|
||||
"devices": "Device Management",
|
||||
"analysis": "Energy Analysis",
|
||||
"alarms": "Alarm Management",
|
||||
"carbon": "Carbon Management",
|
||||
"reports": "Reports",
|
||||
"bigscreen": "Visual Dashboard",
|
||||
"bigscreen2d": "2D Energy Screen",
|
||||
"bigscreen3d": "3D Park Screen",
|
||||
"system": "System Management",
|
||||
"users": "User Management",
|
||||
"roles": "Roles & Permissions",
|
||||
"settings": "System Settings",
|
||||
"audit": "Audit Log",
|
||||
"quota": "Quota Management",
|
||||
"charging": "Charging Management",
|
||||
"maintenance": "O&M Management",
|
||||
"dataQuery": "Data Query",
|
||||
"management": "Management System",
|
||||
"prediction": "AI Prediction"
|
||||
},
|
||||
"header": {
|
||||
"alarmNotification": "Alarm Notifications",
|
||||
"activeAlarms": "active",
|
||||
"noActiveAlarms": "No active alarms",
|
||||
"viewAllAlarms": "View all alarms",
|
||||
"profile": "Profile",
|
||||
"logout": "Sign Out",
|
||||
"brandName": "Tianpu EMS"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"search": "Search",
|
||||
"reset": "Reset",
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
"loading": "Loading",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"noData": "No data"
|
||||
},
|
||||
"analysis": {
|
||||
"dataComparison": "Data Comparison",
|
||||
"energyTrend": "Energy Trend",
|
||||
"dailySummary": "Daily Energy Summary",
|
||||
"period1": "Period 1",
|
||||
"period2": "Period 2",
|
||||
"totalConsumption": "Total Consumption",
|
||||
"peakPower": "Peak Power",
|
||||
"avgLoad": "Average Load",
|
||||
"carbonEmission": "Carbon Emission",
|
||||
"change": "Change",
|
||||
"compare": "Compare",
|
||||
"selectDateRange": "Select date range"
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"menu": {
|
||||
"dashboard": "能源总览",
|
||||
"monitoring": "实时监控",
|
||||
"devices": "设备管理",
|
||||
"analysis": "能耗分析",
|
||||
"alarms": "告警管理",
|
||||
"carbon": "碳排放管理",
|
||||
"reports": "报表管理",
|
||||
"bigscreen": "可视化大屏",
|
||||
"bigscreen2d": "2D 能源大屏",
|
||||
"bigscreen3d": "3D 园区大屏",
|
||||
"system": "系统管理",
|
||||
"users": "用户管理",
|
||||
"roles": "角色权限",
|
||||
"settings": "系统设置",
|
||||
"audit": "审计日志",
|
||||
"quota": "定额管理",
|
||||
"charging": "充电管理",
|
||||
"maintenance": "运维管理",
|
||||
"dataQuery": "数据查询",
|
||||
"management": "管理体系",
|
||||
"prediction": "AI预测"
|
||||
},
|
||||
"header": {
|
||||
"alarmNotification": "告警通知",
|
||||
"activeAlarms": "条活跃",
|
||||
"noActiveAlarms": "暂无活跃告警",
|
||||
"viewAllAlarms": "查看全部告警",
|
||||
"profile": "个人信息",
|
||||
"logout": "退出登录",
|
||||
"brandName": "天普EMS"
|
||||
},
|
||||
"common": {
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"add": "新增",
|
||||
"search": "搜索",
|
||||
"reset": "重置",
|
||||
"export": "导出",
|
||||
"import": "导入",
|
||||
"loading": "加载中",
|
||||
"success": "操作成功",
|
||||
"error": "操作失败",
|
||||
"noData": "暂无数据"
|
||||
},
|
||||
"analysis": {
|
||||
"dataComparison": "数据对比",
|
||||
"energyTrend": "能耗趋势",
|
||||
"dailySummary": "每日能耗汇总",
|
||||
"period1": "时段一",
|
||||
"period2": "时段二",
|
||||
"totalConsumption": "总用电量",
|
||||
"peakPower": "峰值功率",
|
||||
"avgLoad": "平均负荷",
|
||||
"carbonEmission": "碳排放",
|
||||
"change": "变化",
|
||||
"compare": "对比",
|
||||
"selectDateRange": "选择日期范围"
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MainLayout responsive styles
|
||||
============================================ */
|
||||
|
||||
/* Tablet: collapse sidebar by default */
|
||||
@media (max-width: 768px) {
|
||||
.ant-layout-sider {
|
||||
position: fixed !important;
|
||||
z-index: 1000;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.ant-layout-sider-collapsed {
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 0 !important;
|
||||
flex: 0 0 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-layout-header {
|
||||
padding: 0 12px !important;
|
||||
}
|
||||
|
||||
.ant-layout-content {
|
||||
margin: 8px !important;
|
||||
padding: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Dark mode support
|
||||
============================================ */
|
||||
|
||||
[data-theme='dark'] body {
|
||||
background: #141414;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .ant-layout-content {
|
||||
background: #1f1f1f !important;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .ant-card {
|
||||
background: #1f1f1f;
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .ant-table {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
/* BigScreen pages are already dark themed, no overrides needed */
|
||||
|
||||
/* Mobile: tighter spacing */
|
||||
@media (max-width: 375px) {
|
||||
.ant-layout-header {
|
||||
padding: 0 8px !important;
|
||||
height: 48px !important;
|
||||
line-height: 48px !important;
|
||||
}
|
||||
|
||||
.ant-layout-content {
|
||||
margin: 4px !important;
|
||||
padding: 8px !important;
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Layout, Menu, Avatar, Dropdown, Typography, Badge, Popover, List, Tag, Empty, Select } from 'antd';
|
||||
import {
|
||||
DashboardOutlined, MonitorOutlined, BarChartOutlined, AlertOutlined,
|
||||
FileTextOutlined, CloudOutlined, SettingOutlined, UserOutlined,
|
||||
MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, BellOutlined,
|
||||
ThunderboltOutlined, AppstoreOutlined, WarningOutlined, CloseCircleOutlined,
|
||||
InfoCircleOutlined, FundProjectionScreenOutlined, GlobalOutlined,
|
||||
BulbOutlined, BulbFilled, FundOutlined, CarOutlined, ToolOutlined,
|
||||
SearchOutlined, SolutionOutlined, RobotOutlined, ExperimentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getUser, removeToken } from '../utils/auth';
|
||||
import { getAlarmStats, getAlarmEvents } from '../services/api';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Text } = Typography;
|
||||
|
||||
const SEVERITY_CONFIG: Record<string, { icon: React.ReactNode; color: string }> = {
|
||||
critical: { icon: <CloseCircleOutlined style={{ color: '#f5222d' }} />, color: 'red' },
|
||||
warning: { icon: <WarningOutlined style={{ color: '#faad14' }} />, color: 'orange' },
|
||||
info: { icon: <InfoCircleOutlined style={{ color: '#1890ff' }} />, color: 'blue' },
|
||||
};
|
||||
|
||||
export default function MainLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [alarmCount, setAlarmCount] = useState(0);
|
||||
const [recentAlarms, setRecentAlarms] = useState<any[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = getUser();
|
||||
const { darkMode, toggleDarkMode } = useTheme();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: t('menu.dashboard') },
|
||||
{ key: '/monitoring', icon: <MonitorOutlined />, label: t('menu.monitoring') },
|
||||
{ key: '/devices', icon: <AppstoreOutlined />, label: t('menu.devices') },
|
||||
{ key: '/analysis', icon: <BarChartOutlined />, label: t('menu.analysis') },
|
||||
{ key: '/alarms', icon: <AlertOutlined />, label: t('menu.alarms') },
|
||||
{ key: '/carbon', icon: <CloudOutlined />, label: t('menu.carbon') },
|
||||
{ key: '/reports', icon: <FileTextOutlined />, label: t('menu.reports') },
|
||||
{ key: '/quota', icon: <FundOutlined />, label: t('menu.quota', '定额管理') },
|
||||
{ key: '/charging', icon: <CarOutlined />, label: t('menu.charging', '充电管理') },
|
||||
{ key: '/maintenance', icon: <ToolOutlined />, label: t('menu.maintenance', '运维管理') },
|
||||
{ key: '/data-query', icon: <SearchOutlined />, label: t('menu.dataQuery', '数据查询') },
|
||||
{ key: '/prediction', icon: <RobotOutlined />, label: t('menu.prediction', 'AI预测') },
|
||||
{ key: '/management', icon: <SolutionOutlined />, label: t('menu.management', '管理体系') },
|
||||
{ key: '/energy-strategy', icon: <ThunderboltOutlined />, label: t('menu.energyStrategy', '策略优化') },
|
||||
{ key: '/ai-operations', icon: <ExperimentOutlined />, label: t('menu.aiOperations', 'AI运维') },
|
||||
{ key: 'bigscreen-group', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen'),
|
||||
children: [
|
||||
{ key: '/bigscreen', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen2d') },
|
||||
{ key: '/bigscreen-3d', icon: <GlobalOutlined />, label: t('menu.bigscreen3d') },
|
||||
],
|
||||
},
|
||||
{ key: '/system', icon: <SettingOutlined />, label: t('menu.system'),
|
||||
children: [
|
||||
{ key: '/system/users', label: t('menu.users') },
|
||||
{ key: '/system/roles', label: t('menu.roles') },
|
||||
{ key: '/system/settings', label: t('menu.settings') },
|
||||
{ key: '/system/audit', label: t('menu.audit', '审计日志') },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fetchAlarms = useCallback(async () => {
|
||||
try {
|
||||
const [stats, events] = await Promise.all([
|
||||
getAlarmStats(),
|
||||
getAlarmEvents({ status: 'active', page_size: 5 }),
|
||||
]);
|
||||
const statsData = (stats as any) || {};
|
||||
let activeTotal = 0;
|
||||
for (const severity of Object.values(statsData)) {
|
||||
if (severity && typeof severity === 'object') {
|
||||
activeTotal += (severity as any).active || 0;
|
||||
}
|
||||
}
|
||||
setAlarmCount(activeTotal);
|
||||
const items = (events as any)?.items || (events as any) || [];
|
||||
setRecentAlarms(Array.isArray(items) ? items : []);
|
||||
} catch {
|
||||
// silently ignore - notifications are non-critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlarms();
|
||||
const timer = setInterval(fetchAlarms, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchAlarms]);
|
||||
|
||||
const handleLogout = () => {
|
||||
removeToken();
|
||||
localStorage.removeItem('user');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleLanguageChange = (lang: string) => {
|
||||
i18n.changeLanguage(lang);
|
||||
localStorage.setItem('tianpu-lang', lang);
|
||||
};
|
||||
|
||||
const userMenu = {
|
||||
items: [
|
||||
{ key: 'profile', icon: <UserOutlined />, label: t('header.profile') },
|
||||
{ type: 'divider' as const },
|
||||
{ key: 'logout', icon: <LogoutOutlined />, label: t('header.logout'), onClick: handleLogout },
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider trigger={null} collapsible collapsed={collapsed} width={220}
|
||||
style={{ background: darkMode ? '#141414' : '#001529' }}>
|
||||
<div style={{
|
||||
height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.1)',
|
||||
}}>
|
||||
<ThunderboltOutlined style={{ fontSize: 24, color: '#1890ff', marginRight: collapsed ? 0 : 8 }} />
|
||||
{!collapsed && <Text strong style={{ color: '#fff', fontSize: 16 }}>{t('header.brandName')}</Text>}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark" mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
defaultOpenKeys={['/system']}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => {
|
||||
if (key === '/bigscreen' || key === '/bigscreen-3d') {
|
||||
window.open(key, '_blank');
|
||||
} else {
|
||||
navigate(key);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{
|
||||
padding: '0 24px', background: darkMode ? '#141414' : '#fff', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'space-between',
|
||||
boxShadow: darkMode ? '0 1px 4px rgba(0,0,0,0.3)' : '0 1px 4px rgba(0,0,0,0.08)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||
onClick={() => setCollapsed(!collapsed)}>
|
||||
{collapsed ? <MenuUnfoldOutlined style={{ fontSize: 18 }} /> :
|
||||
<MenuFoldOutlined style={{ fontSize: 18 }} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Select
|
||||
value={i18n.language}
|
||||
onChange={handleLanguageChange}
|
||||
size="small"
|
||||
style={{ width: 90 }}
|
||||
options={[
|
||||
{ label: '中文', value: 'zh' },
|
||||
{ label: 'English', value: 'en' },
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
style={{ cursor: 'pointer', fontSize: 18, display: 'flex', alignItems: 'center' }}
|
||||
onClick={toggleDarkMode}
|
||||
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{darkMode ? <BulbFilled style={{ color: '#faad14' }} /> : <BulbOutlined />}
|
||||
</div>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
title={<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{t('header.alarmNotification')}</span>
|
||||
{alarmCount > 0 && <Tag color="red">{alarmCount} {t('header.activeAlarms')}</Tag>}
|
||||
</div>}
|
||||
content={
|
||||
<div style={{ width: 320, maxHeight: 360, overflow: 'auto' }}>
|
||||
{recentAlarms.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('header.noActiveAlarms')} />
|
||||
) : (
|
||||
<List size="small" dataSource={recentAlarms} renderItem={(alarm: any) => {
|
||||
const sev = SEVERITY_CONFIG[alarm.severity] || SEVERITY_CONFIG.info;
|
||||
return (
|
||||
<List.Item
|
||||
style={{ cursor: 'pointer', padding: '8px 0' }}
|
||||
onClick={() => navigate('/alarms')}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={sev.icon}
|
||||
title={<span style={{ fontSize: 13 }}>{alarm.device_name || alarm.title || '未知设备'}</span>}
|
||||
description={<>
|
||||
<div style={{ fontSize: 12 }}>{alarm.message || alarm.title}</div>
|
||||
<div style={{ fontSize: 11, color: '#999' }}>{alarm.triggered_at}</div>
|
||||
</>}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}} />
|
||||
)}
|
||||
<div style={{ textAlign: 'center', padding: '8px 0', borderTop: '1px solid #f0f0f0' }}>
|
||||
<a onClick={() => navigate('/alarms')} style={{ fontSize: 13 }}>{t('header.viewAllAlarms')}</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Badge count={alarmCount} size="small" overflowCount={99}>
|
||||
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
|
||||
</Badge>
|
||||
</Popover>
|
||||
<Dropdown menu={userMenu} placement="bottomRight">
|
||||
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Avatar size="small" icon={<UserOutlined />} style={{ background: '#1890ff' }} />
|
||||
<Text>{user?.full_name || user?.username || '用户'}</Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Header>
|
||||
<Content style={{ margin: 16, padding: 24, background: darkMode ? '#1f1f1f' : '#f5f5f5', minHeight: 280 }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -1,859 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card, Row, Col, Statistic, Tag, Tabs, Button, Table, Space, Progress,
|
||||
Drawer, Descriptions, Timeline, Badge, Select, message, Tooltip, Empty,
|
||||
Modal, List, Calendar, Input,
|
||||
} from 'antd';
|
||||
import {
|
||||
RobotOutlined, HeartOutlined, AlertOutlined, MedicineBoxOutlined,
|
||||
ToolOutlined, BulbOutlined, SyncOutlined, ArrowUpOutlined,
|
||||
ArrowDownOutlined, MinusOutlined, ThunderboltOutlined,
|
||||
ExperimentOutlined, SafetyCertificateOutlined, EyeOutlined,
|
||||
CheckCircleOutlined, CloseCircleOutlined, WarningOutlined,
|
||||
InfoCircleOutlined, FireOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
getAiOpsDashboard, getAiOpsHealth, getAiOpsHealthHistory,
|
||||
getAiOpsAnomalies, updateAnomalyStatus, triggerAnomalyScan,
|
||||
getAiOpsDiagnostics, runDeviceDiagnostics,
|
||||
getAiOpsPredictions, getAiOpsMaintenanceSchedule,
|
||||
getAiOpsInsights, triggerInsights, triggerHealthCalc, triggerPredictions,
|
||||
} from '../../services/api';
|
||||
|
||||
const severityColors: Record<string, string> = {
|
||||
critical: 'red', warning: 'orange', info: 'blue',
|
||||
};
|
||||
const statusColors: Record<string, string> = {
|
||||
healthy: 'green', warning: 'orange', critical: 'red',
|
||||
detected: 'red', investigating: 'orange', resolved: 'green', false_positive: 'default',
|
||||
generated: 'blue', reviewed: 'cyan', action_taken: 'green',
|
||||
predicted: 'orange', scheduled: 'blue', completed: 'green', false_alarm: 'default',
|
||||
};
|
||||
const trendIcons: Record<string, React.ReactNode> = {
|
||||
improving: <ArrowUpOutlined style={{ color: '#52c41a' }} />,
|
||||
stable: <MinusOutlined style={{ color: '#1890ff' }} />,
|
||||
degrading: <ArrowDownOutlined style={{ color: '#f5222d' }} />,
|
||||
};
|
||||
const anomalyTypeLabels: Record<string, string> = {
|
||||
power_drop: '功率下降', efficiency_loss: '能效降低', abnormal_temperature: '温度异常',
|
||||
communication_loss: '通讯中断', pattern_deviation: '模式偏移',
|
||||
};
|
||||
const urgencyColors: Record<string, string> = {
|
||||
critical: 'red', high: 'orange', medium: 'blue', low: 'default',
|
||||
};
|
||||
const impactColors: Record<string, string> = {
|
||||
high: 'red', medium: 'orange', low: 'blue',
|
||||
};
|
||||
const insightTypeLabels: Record<string, string> = {
|
||||
efficiency_trend: '效率趋势', cost_anomaly: '费用异常',
|
||||
performance_comparison: '性能对比', seasonal_pattern: '季节性规律',
|
||||
};
|
||||
|
||||
// ── Tab: Health Overview ───────────────────────────────────────────
|
||||
|
||||
function HealthOverview() {
|
||||
const [devices, setDevices] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailDevice, setDetailDevice] = useState<any>(null);
|
||||
const [history, setHistory] = useState<any[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
|
||||
useEffect(() => { loadHealth(); }, []);
|
||||
|
||||
const loadHealth = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAiOpsHealth();
|
||||
setDevices(Array.isArray(data) ? data : []);
|
||||
} catch { message.error('加载健康数据失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const loadHistory = async (deviceId: number) => {
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const data = await getAiOpsHealthHistory(deviceId, { days: 30 });
|
||||
setHistory(Array.isArray(data) ? data : []);
|
||||
} catch { /* ignore */ }
|
||||
finally { setHistoryLoading(false); }
|
||||
};
|
||||
|
||||
const showDetail = (device: any) => {
|
||||
setDetailDevice(device);
|
||||
loadHistory(device.device_id);
|
||||
};
|
||||
|
||||
const handleRecalculate = async () => {
|
||||
message.loading({ content: '正在计算健康评分...', key: 'calc' });
|
||||
try {
|
||||
await triggerHealthCalc();
|
||||
message.success({ content: '健康评分计算完成', key: 'calc' });
|
||||
loadHealth();
|
||||
} catch { message.error({ content: '计算失败', key: 'calc' }); }
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return '#52c41a';
|
||||
if (score >= 60) return '#faad14';
|
||||
return '#f5222d';
|
||||
};
|
||||
|
||||
const gaugeOption = (score: number) => ({
|
||||
series: [{
|
||||
type: 'gauge',
|
||||
startAngle: 200,
|
||||
endAngle: -20,
|
||||
min: 0,
|
||||
max: 100,
|
||||
itemStyle: { color: getScoreColor(score) },
|
||||
progress: { show: true, width: 12 },
|
||||
pointer: { show: false },
|
||||
axisLine: { lineStyle: { width: 12, color: [[1, '#e6e6e6']] } },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
detail: {
|
||||
valueAnimation: true,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
offsetCenter: [0, '0%'],
|
||||
formatter: '{value}',
|
||||
color: getScoreColor(score),
|
||||
},
|
||||
data: [{ value: score }],
|
||||
}],
|
||||
});
|
||||
|
||||
const historyOption = history.length > 0 ? {
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: history.map((h: any) => dayjs(h.timestamp).format('MM-DD HH:mm')),
|
||||
},
|
||||
yAxis: { type: 'value', min: 0, max: 100, name: '健康评分' },
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: history.map((h: any) => h.health_score),
|
||||
smooth: true,
|
||||
areaStyle: { opacity: 0.15 },
|
||||
markLine: {
|
||||
data: [
|
||||
{ yAxis: 80, label: { formatter: '健康' }, lineStyle: { color: '#52c41a', type: 'dashed' } },
|
||||
{ yAxis: 60, label: { formatter: '警告' }, lineStyle: { color: '#faad14', type: 'dashed' } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
grid: { top: 30, right: 30, bottom: 30, left: 50 },
|
||||
} : null;
|
||||
|
||||
const radarOption = detailDevice?.factors ? {
|
||||
radar: {
|
||||
indicator: [
|
||||
{ name: '功率稳定', max: 100 },
|
||||
{ name: '能效水平', max: 100 },
|
||||
{ name: '告警频率', max: 100 },
|
||||
{ name: '运行时间', max: 100 },
|
||||
{ name: '温度状态', max: 100 },
|
||||
],
|
||||
},
|
||||
series: [{
|
||||
type: 'radar',
|
||||
data: [{
|
||||
value: [
|
||||
detailDevice.factors.power_stability || 0,
|
||||
detailDevice.factors.efficiency || 0,
|
||||
detailDevice.factors.alarm_frequency || 0,
|
||||
detailDevice.factors.uptime || 0,
|
||||
detailDevice.factors.temperature || 0,
|
||||
],
|
||||
areaStyle: { opacity: 0.2 },
|
||||
}],
|
||||
}],
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
<HeartOutlined style={{ marginRight: 8 }} />
|
||||
设备健康总览
|
||||
</span>
|
||||
<Button icon={<SyncOutlined />} onClick={handleRecalculate}>重新计算</Button>
|
||||
</div>
|
||||
<Row gutter={[16, 16]}>
|
||||
{loading ? (
|
||||
<Col span={24}><Card loading /></Col>
|
||||
) : devices.length === 0 ? (
|
||||
<Col span={24}><Card><Empty description="暂无健康评分数据,请点击「重新计算」" /></Card></Col>
|
||||
) : devices.map((d: any) => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={d.device_id}>
|
||||
<Card
|
||||
hoverable
|
||||
onClick={() => showDetail(d)}
|
||||
styles={{ body: { padding: 16, textAlign: 'center' } }}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>{d.device_name}</div>
|
||||
<Tag color="blue" style={{ marginBottom: 8 }}>{d.device_type}</Tag>
|
||||
<div style={{ height: 130 }}>
|
||||
<ReactECharts option={gaugeOption(d.health_score)} style={{ height: 130 }} />
|
||||
</div>
|
||||
<Space>
|
||||
<Tag color={statusColors[d.status]}>{d.status === 'healthy' ? '健康' : d.status === 'warning' ? '警告' : '危险'}</Tag>
|
||||
<span>{trendIcons[d.trend]} {d.trend === 'improving' ? '改善' : d.trend === 'degrading' ? '下降' : '稳定'}</span>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Drawer
|
||||
title={detailDevice ? `${detailDevice.device_name} - 健康详情` : ''}
|
||||
open={!!detailDevice}
|
||||
onClose={() => setDetailDevice(null)}
|
||||
width={720}
|
||||
>
|
||||
{detailDevice && (
|
||||
<>
|
||||
<Descriptions column={2} bordered size="small" style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="设备">{detailDevice.device_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{detailDevice.device_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="健康评分">
|
||||
<span style={{ fontSize: 24, fontWeight: 'bold', color: getScoreColor(detailDevice.health_score) }}>
|
||||
{detailDevice.health_score}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={statusColors[detailDevice.status]}>
|
||||
{detailDevice.status === 'healthy' ? '健康' : detailDevice.status === 'warning' ? '警告' : '危险'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="趋势" span={2}>
|
||||
{trendIcons[detailDevice.trend]} {detailDevice.trend === 'improving' ? '持续改善' : detailDevice.trend === 'degrading' ? '持续下降' : '保持稳定'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card title="因素分析" size="small">
|
||||
{radarOption && <ReactECharts option={radarOption} style={{ height: 250 }} />}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="评分历史" size="small" loading={historyLoading}>
|
||||
{historyOption ? (
|
||||
<ReactECharts option={historyOption} style={{ height: 250 }} />
|
||||
) : (
|
||||
<Empty description="暂无历史数据" />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab: Anomaly Center ────────────────────────────────────────────
|
||||
|
||||
function AnomalyCenter() {
|
||||
const [anomalies, setAnomalies] = useState<any>({ total: 0, items: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<Record<string, any>>({});
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => { loadAnomalies(); }, [page, filters]);
|
||||
|
||||
const loadAnomalies = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAiOpsAnomalies({ ...filters, page, page_size: 15 });
|
||||
setAnomalies(data || { total: 0, items: [] });
|
||||
} catch { message.error('加载异常数据失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleScan = async () => {
|
||||
message.loading({ content: '正在扫描异常...', key: 'scan' });
|
||||
try {
|
||||
const result = await triggerAnomalyScan() as any;
|
||||
message.success({ content: `扫描完成,发现 ${result?.anomalies_found || 0} 个异常`, key: 'scan' });
|
||||
loadAnomalies();
|
||||
} catch { message.error({ content: '扫描失败', key: 'scan' }); }
|
||||
};
|
||||
|
||||
const handleStatusUpdate = async (id: number, status: string) => {
|
||||
try {
|
||||
await updateAnomalyStatus(id, { status });
|
||||
message.success('状态已更新');
|
||||
loadAnomalies();
|
||||
} catch { message.error('更新失败'); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间', dataIndex: 'detected_at', width: 160,
|
||||
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{ title: '设备', dataIndex: 'device_name', width: 120 },
|
||||
{
|
||||
title: '异常类型', dataIndex: 'anomaly_type', width: 100,
|
||||
render: (v: string) => anomalyTypeLabels[v] || v,
|
||||
},
|
||||
{
|
||||
title: '严重度', dataIndex: 'severity', width: 80,
|
||||
render: (v: string) => <Tag color={severityColors[v]}>{v === 'critical' ? '严重' : v === 'warning' ? '警告' : '信息'}</Tag>,
|
||||
},
|
||||
{ title: '描述', dataIndex: 'description', ellipsis: true },
|
||||
{
|
||||
title: '偏差', dataIndex: 'deviation_percent', width: 80,
|
||||
render: (v: number) => v != null ? `${v}%` : '-',
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 100,
|
||||
render: (v: string) => <Tag color={statusColors[v]}>{v === 'detected' ? '已检测' : v === 'investigating' ? '调查中' : v === 'resolved' ? '已解决' : '误报'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作', width: 200,
|
||||
render: (_: any, r: any) => r.status === 'detected' ? (
|
||||
<Space size="small">
|
||||
<Button size="small" onClick={() => handleStatusUpdate(r.id, 'investigating')}>调查</Button>
|
||||
<Button size="small" onClick={() => handleStatusUpdate(r.id, 'resolved')}>已解决</Button>
|
||||
<Button size="small" onClick={() => handleStatusUpdate(r.id, 'false_positive')}>误报</Button>
|
||||
</Space>
|
||||
) : r.status === 'investigating' ? (
|
||||
<Space size="small">
|
||||
<Button size="small" type="primary" onClick={() => handleStatusUpdate(r.id, 'resolved')}>已解决</Button>
|
||||
<Button size="small" onClick={() => handleStatusUpdate(r.id, 'false_positive')}>误报</Button>
|
||||
</Space>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
placeholder="严重度" allowClear style={{ width: 120 }}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, severity: v }))}
|
||||
options={[
|
||||
{ label: '严重', value: 'critical' },
|
||||
{ label: '警告', value: 'warning' },
|
||||
{ label: '信息', value: 'info' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态" allowClear style={{ width: 120 }}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, status: v }))}
|
||||
options={[
|
||||
{ label: '已检测', value: 'detected' },
|
||||
{ label: '调查中', value: 'investigating' },
|
||||
{ label: '已解决', value: 'resolved' },
|
||||
{ label: '误报', value: 'false_positive' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<Button type="primary" icon={<ExperimentOutlined />} onClick={handleScan}>扫描异常</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={anomalies.items}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: 15,
|
||||
total: anomalies.total,
|
||||
onChange: setPage,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab: Diagnostic Panel ──────────────────────────────────────────
|
||||
|
||||
function DiagnosticPanel() {
|
||||
const [reports, setReports] = useState<any>({ total: 0, items: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailReport, setDetailReport] = useState<any>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [devices, setDevices] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => { loadReports(); loadDeviceList(); }, [page]);
|
||||
|
||||
const loadReports = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAiOpsDiagnostics({ page, page_size: 15 });
|
||||
setReports(data || { total: 0, items: [] });
|
||||
} catch { message.error('加载诊断报告失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const loadDeviceList = async () => {
|
||||
try {
|
||||
const data = await getAiOpsHealth();
|
||||
setDevices(Array.isArray(data) ? data : []);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleRun = async (deviceId: number) => {
|
||||
message.loading({ content: '正在运行诊断...', key: 'diag' });
|
||||
try {
|
||||
const result = await runDeviceDiagnostics(deviceId);
|
||||
message.success({ content: '诊断完成', key: 'diag' });
|
||||
setDetailReport(result);
|
||||
loadReports();
|
||||
} catch { message.error({ content: '诊断失败', key: 'diag' }); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间', dataIndex: 'generated_at', width: 160,
|
||||
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{ title: '设备', dataIndex: 'device_name', width: 120 },
|
||||
{
|
||||
title: '类型', dataIndex: 'report_type', width: 80,
|
||||
render: (v: string) => v === 'routine' ? '常规' : v === 'triggered' ? '触发' : '综合',
|
||||
},
|
||||
{
|
||||
title: '发现', dataIndex: 'findings', ellipsis: true,
|
||||
render: (v: any[]) => v?.length ? v.map((f: any) => f.finding).join('; ') : '-',
|
||||
},
|
||||
{
|
||||
title: '影响评估', dataIndex: 'estimated_impact',
|
||||
render: (v: any) => v ? (
|
||||
<span>
|
||||
{v.energy_loss_kwh > 0 && <Tag>电量损失 {v.energy_loss_kwh} kWh</Tag>}
|
||||
{v.cost_impact_yuan > 0 && <Tag color="orange">费用 {v.cost_impact_yuan} 元</Tag>}
|
||||
{v.energy_loss_kwh === 0 && v.cost_impact_yuan === 0 && <Tag color="green">无影响</Tag>}
|
||||
</span>
|
||||
) : '-',
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 80,
|
||||
render: (v: string) => <Tag color={statusColors[v]}>{v === 'generated' ? '已生成' : v === 'reviewed' ? '已审阅' : '已处理'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_: any, r: any) => (
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => setDetailReport(r)}>查看</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
<MedicineBoxOutlined style={{ marginRight: 8 }} />
|
||||
诊断报告
|
||||
</span>
|
||||
<Select
|
||||
placeholder="选择设备运行诊断" style={{ width: 300 }}
|
||||
showSearch optionFilterProp="label"
|
||||
onChange={(v) => handleRun(v)}
|
||||
options={devices.map((d: any) => ({ label: `${d.device_name} (${d.device_type})`, value: d.device_id }))}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={reports.items}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ current: page, pageSize: 15, total: reports.total, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="诊断报告详情"
|
||||
open={!!detailReport}
|
||||
onClose={() => setDetailReport(null)}
|
||||
width={640}
|
||||
>
|
||||
{detailReport && (
|
||||
<>
|
||||
<Descriptions column={2} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="设备">{detailReport.device_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{detailReport.report_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间" span={2}>{dayjs(detailReport.generated_at).format('YYYY-MM-DD HH:mm')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Card title="诊断发现" size="small" style={{ marginBottom: 16 }}>
|
||||
<Timeline
|
||||
items={(detailReport.findings || []).map((f: any) => ({
|
||||
color: f.severity === 'warning' ? 'orange' : f.severity === 'critical' ? 'red' : 'blue',
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{f.finding}</div>
|
||||
<div style={{ color: '#888', fontSize: 12 }}>{f.detail}</div>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{detailReport.recommendations?.length > 0 && (
|
||||
<Card title="建议措施" size="small" style={{ marginBottom: 16 }}>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={detailReport.recommendations}
|
||||
renderItem={(r: any) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={<Tag color={r.priority === 'high' ? 'red' : r.priority === 'medium' ? 'orange' : 'blue'}>{r.priority === 'high' ? '高' : r.priority === 'medium' ? '中' : '低'}</Tag>}
|
||||
title={r.action}
|
||||
description={r.detail}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{detailReport.estimated_impact && (
|
||||
<Card title="影响评估" size="small">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Statistic title="预计电量损失" value={detailReport.estimated_impact.energy_loss_kwh} suffix="kWh" />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic title="预计费用影响" value={detailReport.estimated_impact.cost_impact_yuan} suffix="元" prefix="¥" />
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab: Maintenance Predictor ─────────────────────────────────────
|
||||
|
||||
function MaintenancePredictor() {
|
||||
const [predictions, setPredictions] = useState<any>({ total: 0, items: [] });
|
||||
const [schedule, setSchedule] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => { loadData(); }, [page]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [pred, sched] = await Promise.all([
|
||||
getAiOpsPredictions({ page, page_size: 15 }),
|
||||
getAiOpsMaintenanceSchedule(),
|
||||
]);
|
||||
setPredictions(pred || { total: 0, items: [] });
|
||||
setSchedule(Array.isArray(sched) ? sched : []);
|
||||
} catch { message.error('加载预测数据失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
message.loading({ content: '正在生成维护预测...', key: 'pred' });
|
||||
try {
|
||||
const result = await triggerPredictions() as any;
|
||||
message.success({ content: `生成 ${result?.generated || 0} 条预测`, key: 'pred' });
|
||||
loadData();
|
||||
} catch { message.error({ content: '生成失败', key: 'pred' }); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '设备', dataIndex: 'device_name', width: 120,
|
||||
},
|
||||
{ title: '部件', dataIndex: 'component', width: 120 },
|
||||
{ title: '故障模式', dataIndex: 'failure_mode', ellipsis: true },
|
||||
{
|
||||
title: '概率', dataIndex: 'probability', width: 80,
|
||||
render: (v: number) => <Progress percent={Math.round(v * 100)} size="small" />,
|
||||
},
|
||||
{
|
||||
title: '预计故障日期', dataIndex: 'predicted_failure_date', width: 120,
|
||||
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-',
|
||||
},
|
||||
{
|
||||
title: '紧急度', dataIndex: 'urgency', width: 80,
|
||||
render: (v: string) => <Tag color={urgencyColors[v]}>{v === 'critical' ? '紧急' : v === 'high' ? '高' : v === 'medium' ? '中' : '低'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '停机(h)', dataIndex: 'estimated_downtime_hours', width: 80,
|
||||
},
|
||||
{
|
||||
title: '维修费(元)', dataIndex: 'estimated_repair_cost', width: 100,
|
||||
render: (v: number) => v ? `${v.toLocaleString()}` : '-',
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 80,
|
||||
render: (v: string) => <Tag color={statusColors[v]}>{v === 'predicted' ? '预测' : v === 'scheduled' ? '已排期' : v === 'completed' ? '完成' : '误报'}</Tag>,
|
||||
},
|
||||
];
|
||||
|
||||
const calendarSchedule = schedule.reduce((acc: any, item: any) => {
|
||||
if (item.predicted_failure_date) {
|
||||
const key = dayjs(item.predicted_failure_date).format('YYYY-MM-DD');
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(item);
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, any[]>);
|
||||
|
||||
const dateCellRender = (value: dayjs.Dayjs) => {
|
||||
const key = value.format('YYYY-MM-DD');
|
||||
const items = calendarSchedule[key];
|
||||
if (!items) return null;
|
||||
return (
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{items.slice(0, 2).map((item: any, i: number) => (
|
||||
<li key={i}>
|
||||
<Badge color={urgencyColors[item.urgency] || 'blue'} text={<span style={{ fontSize: 11 }}>{item.device_name}</span>} />
|
||||
</li>
|
||||
))}
|
||||
{items.length > 2 && <li style={{ fontSize: 11, color: '#999' }}>+{items.length - 2} more</li>}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
<ToolOutlined style={{ marginRight: 8 }} />
|
||||
预测性维护
|
||||
</span>
|
||||
<Button type="primary" icon={<ExperimentOutlined />} onClick={handleGenerate}>生成预测</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'list',
|
||||
label: '预测列表',
|
||||
children: (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={predictions.items}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ current: page, pageSize: 15, total: predictions.total, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'calendar',
|
||||
label: '维护日历',
|
||||
children: (
|
||||
<Card>
|
||||
<Calendar cellRender={(value, info) => {
|
||||
if (info.type === 'date') return dateCellRender(value);
|
||||
return null;
|
||||
}} />
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab: Insights Board ────────────────────────────────────────────
|
||||
|
||||
function InsightsBoard() {
|
||||
const [insights, setInsights] = useState<any>({ total: 0, items: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => { loadInsights(); }, []);
|
||||
|
||||
const loadInsights = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAiOpsInsights({ page_size: 50 });
|
||||
setInsights(data || { total: 0, items: [] });
|
||||
} catch { message.error('加载洞察数据失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
message.loading({ content: '正在生成运营洞察...', key: 'ins' });
|
||||
try {
|
||||
await triggerInsights();
|
||||
message.success({ content: '洞察生成完成', key: 'ins' });
|
||||
loadInsights();
|
||||
} catch { message.error({ content: '生成失败', key: 'ins' }); }
|
||||
};
|
||||
|
||||
const typeIcons: Record<string, React.ReactNode> = {
|
||||
efficiency_trend: <ThunderboltOutlined />,
|
||||
cost_anomaly: <FireOutlined />,
|
||||
performance_comparison: <BarChartOutlined />,
|
||||
seasonal_pattern: <SafetyCertificateOutlined />,
|
||||
};
|
||||
|
||||
const BarChartOutlined = () => <span>{"#"}</span>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
<BulbOutlined style={{ marginRight: 8 }} />
|
||||
运营洞察
|
||||
</span>
|
||||
<Button type="primary" icon={<BulbOutlined />} onClick={handleGenerate}>生成洞察</Button>
|
||||
</div>
|
||||
{loading ? <Card loading /> : insights.items?.length === 0 ? (
|
||||
<Card><Empty description="暂无运营洞察,请点击「生成洞察」" /></Card>
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{insights.items?.map((insight: any) => (
|
||||
<Col xs={24} sm={12} lg={8} key={insight.id}>
|
||||
<Card
|
||||
hoverable
|
||||
styles={{ body: { padding: 16 } }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Tag color={impactColors[insight.impact_level]}>
|
||||
{insight.impact_level === 'high' ? '高影响' : insight.impact_level === 'medium' ? '中影响' : '低影响'}
|
||||
</Tag>
|
||||
<Tag>{insightTypeLabels[insight.insight_type] || insight.insight_type}</Tag>
|
||||
</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, marginBottom: 8 }}>{insight.title}</div>
|
||||
<div style={{ color: '#666', fontSize: 13, marginBottom: 12, minHeight: 40 }}>{insight.description}</div>
|
||||
{insight.actionable && insight.recommended_action && (
|
||||
<div style={{
|
||||
background: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: 4,
|
||||
padding: '8px 12px', fontSize: 12,
|
||||
}}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 4 }} />
|
||||
{insight.recommended_action}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#999' }}>
|
||||
{dayjs(insight.generated_at).format('YYYY-MM-DD HH:mm')}
|
||||
{insight.valid_until && ` | 有效至 ${dayjs(insight.valid_until).format('MM-DD')}`}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ──────────────────────────────────────────────────────
|
||||
|
||||
export default function AIOperations() {
|
||||
const [dashboard, setDashboard] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => { loadDashboard(); }, []);
|
||||
|
||||
const loadDashboard = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAiOpsDashboard();
|
||||
setDashboard(data);
|
||||
} catch { /* initial load may fail if no data */ }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const health = dashboard?.health || {};
|
||||
const anomalyStats = dashboard?.anomalies?.stats || {};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Overview cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card styles={{ body: { padding: 16 } }}>
|
||||
<Statistic
|
||||
title="平均健康评分"
|
||||
value={health.avg_score || '--'}
|
||||
suffix="/100"
|
||||
prefix={<HeartOutlined style={{ color: '#52c41a' }} />}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card styles={{ body: { padding: 16 } }}>
|
||||
<Statistic
|
||||
title="健康/警告/危险"
|
||||
value={`${health.healthy || 0}/${health.warning || 0}/${health.critical || 0}`}
|
||||
prefix={<SafetyCertificateOutlined style={{ color: '#1890ff' }} />}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card styles={{ body: { padding: 16 } }}>
|
||||
<Statistic
|
||||
title="近7天异常"
|
||||
value={anomalyStats.total || 0}
|
||||
prefix={<AlertOutlined style={{ color: '#faad14' }} />}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card styles={{ body: { padding: 16 } }}>
|
||||
<Statistic
|
||||
title="待处理预测"
|
||||
value={dashboard?.predictions?.length || 0}
|
||||
prefix={<ToolOutlined style={{ color: '#722ed1' }} />}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Tabs */}
|
||||
<Card>
|
||||
<Tabs
|
||||
defaultActiveKey="health"
|
||||
items={[
|
||||
{
|
||||
key: 'health',
|
||||
label: <span><HeartOutlined /> 设备健康</span>,
|
||||
children: <HealthOverview />,
|
||||
},
|
||||
{
|
||||
key: 'anomalies',
|
||||
label: <span><AlertOutlined /> 异常检测</span>,
|
||||
children: <AnomalyCenter />,
|
||||
},
|
||||
{
|
||||
key: 'diagnostics',
|
||||
label: <span><MedicineBoxOutlined /> 智能诊断</span>,
|
||||
children: <DiagnosticPanel />,
|
||||
},
|
||||
{
|
||||
key: 'maintenance',
|
||||
label: <span><ToolOutlined /> 预测维护</span>,
|
||||
children: <MaintenancePredictor />,
|
||||
},
|
||||
{
|
||||
key: 'insights',
|
||||
label: <span><BulbOutlined /> 运营洞察</span>,
|
||||
children: <InsightsBoard />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Table, Tag, Button, Tabs, Modal, Form, Input, Select, InputNumber, Space, Switch, Drawer, Row, Col, Statistic, message } from 'antd';
|
||||
import { PlusOutlined, CheckOutlined, ToolOutlined, HistoryOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import {
|
||||
getAlarmEvents, getAlarmRules, createAlarmRule, acknowledgeAlarm, resolveAlarm,
|
||||
getAlarmAnalytics, getTopAlarmDevices, getAlarmMttr, toggleAlarmRule, getAlarmRuleHistory,
|
||||
} from '../../services/api';
|
||||
|
||||
const severityMap: Record<string, { color: string; text: string }> = {
|
||||
critical: { color: 'red', text: '紧急' },
|
||||
major: { color: 'orange', text: '重要' },
|
||||
warning: { color: 'gold', text: '一般' },
|
||||
};
|
||||
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
active: { color: 'red', text: '活跃' },
|
||||
acknowledged: { color: 'orange', text: '已确认' },
|
||||
resolved: { color: 'green', text: '已解决' },
|
||||
};
|
||||
|
||||
function AlarmAnalyticsTab() {
|
||||
const [analytics, setAnalytics] = useState<any>(null);
|
||||
const [topDevices, setTopDevices] = useState<any[]>([]);
|
||||
const [mttr, setMttr] = useState<any>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalytics();
|
||||
}, []);
|
||||
|
||||
const loadAnalytics = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ana, top, mt] = await Promise.all([
|
||||
getAlarmAnalytics({}),
|
||||
getTopAlarmDevices({}),
|
||||
getAlarmMttr({}),
|
||||
]);
|
||||
setAnalytics(ana);
|
||||
setTopDevices(top as any[]);
|
||||
setMttr(mt);
|
||||
} catch {
|
||||
message.error('加载告警分析数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const trendOption = analytics ? {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['紧急', '重要', '一般'] },
|
||||
grid: { top: 50, right: 40, bottom: 30, left: 60 },
|
||||
xAxis: { type: 'category', data: analytics.daily_trend.map((d: any) => d.date) },
|
||||
yAxis: { type: 'value', name: '次数' },
|
||||
series: [
|
||||
{ name: '紧急', type: 'line', smooth: true, data: analytics.daily_trend.map((d: any) => d.critical), lineStyle: { color: '#f5222d' }, itemStyle: { color: '#f5222d' } },
|
||||
{ name: '重要', type: 'line', smooth: true, data: analytics.daily_trend.map((d: any) => d.major), lineStyle: { color: '#fa8c16' }, itemStyle: { color: '#fa8c16' } },
|
||||
{ name: '一般', type: 'line', smooth: true, data: analytics.daily_trend.map((d: any) => d.warning), lineStyle: { color: '#fadb14' }, itemStyle: { color: '#fadb14' } },
|
||||
],
|
||||
} : {};
|
||||
|
||||
const topDevicesOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { top: 20, right: 40, bottom: 30, left: 120 },
|
||||
xAxis: { type: 'value', name: '告警次数' },
|
||||
yAxis: { type: 'category', data: [...topDevices].reverse().map(d => d.device_name) },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: [...topDevices].reverse().map(d => d.alarm_count),
|
||||
itemStyle: { color: '#fa8c16' },
|
||||
}],
|
||||
};
|
||||
|
||||
const totals = analytics?.totals || {};
|
||||
const pieOption = {
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
data: [
|
||||
{ value: totals.critical || 0, name: '紧急', itemStyle: { color: '#f5222d' } },
|
||||
{ value: totals.major || 0, name: '重要', itemStyle: { color: '#fa8c16' } },
|
||||
{ value: totals.warning || 0, name: '一般', itemStyle: { color: '#fadb14' } },
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
{(['critical', 'major', 'warning'] as const).map(sev => (
|
||||
<Col xs={24} sm={8} key={sev}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={`${severityMap[sev].text} MTTR`}
|
||||
value={mttr[sev]?.avg_hours || 0}
|
||||
suffix="小时"
|
||||
precision={1}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||
已解决 {mttr[sev]?.count || 0} 条
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title="告警趋势 (近30天)" size="small" loading={loading}>
|
||||
{analytics && <ReactECharts option={trendOption} style={{ height: 300 }} />}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="告警类型分布" size="small" loading={loading}>
|
||||
<ReactECharts option={pieOption} style={{ height: 300 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="告警设备 Top 10" size="small" loading={loading}>
|
||||
<ReactECharts option={topDevicesOption} style={{ height: 350 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Alarms() {
|
||||
const [events, setEvents] = useState<any>({ total: 0, items: [] });
|
||||
const [rules, setRules] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showRuleModal, setShowRuleModal] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [historyDrawer, setHistoryDrawer] = useState<{ visible: boolean; ruleId: number; ruleName: string }>({ visible: false, ruleId: 0, ruleName: '' });
|
||||
const [historyData, setHistoryData] = useState<any>({ total: 0, items: [] });
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ev, ru] = await Promise.all([getAlarmEvents({}), getAlarmRules()]);
|
||||
setEvents(ev);
|
||||
setRules(ru as any[]);
|
||||
} catch { message.error('加载告警数据失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleAcknowledge = async (id: number) => {
|
||||
try {
|
||||
await acknowledgeAlarm(id);
|
||||
message.success('已确认');
|
||||
loadData();
|
||||
} catch { message.error('确认操作失败'); }
|
||||
};
|
||||
|
||||
const handleResolve = async (id: number) => {
|
||||
try {
|
||||
await resolveAlarm(id);
|
||||
message.success('已解决');
|
||||
loadData();
|
||||
} catch { message.error('解决操作失败'); }
|
||||
};
|
||||
|
||||
const handleCreateRule = async (values: any) => {
|
||||
try {
|
||||
await createAlarmRule(values);
|
||||
message.success('规则创建成功');
|
||||
setShowRuleModal(false);
|
||||
form.resetFields();
|
||||
loadData();
|
||||
} catch { message.error('规则创建失败'); }
|
||||
};
|
||||
|
||||
const handleToggleRule = async (ruleId: number) => {
|
||||
try {
|
||||
await toggleAlarmRule(ruleId);
|
||||
message.success('状态已更新');
|
||||
loadData();
|
||||
} catch { message.error('切换状态失败'); }
|
||||
};
|
||||
|
||||
const handleShowHistory = async (ruleId: number, ruleName: string) => {
|
||||
setHistoryDrawer({ visible: true, ruleId, ruleName });
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await getAlarmRuleHistory(ruleId, { page: 1, page_size: 20 });
|
||||
setHistoryData(res);
|
||||
} catch { message.error('加载规则历史失败'); }
|
||||
finally { setHistoryLoading(false); }
|
||||
};
|
||||
|
||||
const eventColumns = [
|
||||
{ title: '级别', dataIndex: 'severity', width: 80, render: (s: string) => {
|
||||
const sv = severityMap[s] || { color: 'default', text: s };
|
||||
return <Tag color={sv.color}>{sv.text}</Tag>;
|
||||
}},
|
||||
{ title: '告警标题', dataIndex: 'title' },
|
||||
{ title: '触发值', dataIndex: 'value', render: (v: number) => v?.toFixed(2) },
|
||||
{ title: '阈值', dataIndex: 'threshold', render: (v: number) => v?.toFixed(2) },
|
||||
{ title: '状态', dataIndex: 'status', render: (s: string) => {
|
||||
const st = statusMap[s] || { color: 'default', text: s };
|
||||
return <Tag color={st.color}>{st.text}</Tag>;
|
||||
}},
|
||||
{ title: '触发时间', dataIndex: 'triggered_at', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 180, render: (_: any, r: any) => (
|
||||
<Space>
|
||||
{r.status === 'active' && <Button size="small" icon={<CheckOutlined />} onClick={() => handleAcknowledge(r.id)}>确认</Button>}
|
||||
{r.status !== 'resolved' && <Button size="small" type="primary" icon={<ToolOutlined />} onClick={() => handleResolve(r.id)}>解决</Button>}
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
const ruleColumns = [
|
||||
{ title: '规则名称', dataIndex: 'name' },
|
||||
{ title: '数据类型', dataIndex: 'data_type' },
|
||||
{ title: '条件', dataIndex: 'condition' },
|
||||
{ title: '阈值', dataIndex: 'threshold' },
|
||||
{ title: '级别', dataIndex: 'severity', render: (s: string) => <Tag color={severityMap[s]?.color}>{severityMap[s]?.text}</Tag> },
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'is_active',
|
||||
width: 80,
|
||||
render: (v: boolean, r: any) => (
|
||||
<Switch checked={v} onChange={() => handleToggleRule(r.id)} size="small" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (_: any, r: any) => (
|
||||
<Button size="small" icon={<HistoryOutlined />} onClick={() => handleShowHistory(r.id, r.name)}>历史</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const historyColumns = [
|
||||
{ title: '级别', dataIndex: 'severity', width: 80, render: (s: string) => <Tag color={severityMap[s]?.color}>{severityMap[s]?.text}</Tag> },
|
||||
{ title: '告警标题', dataIndex: 'title' },
|
||||
{ title: '触发值', dataIndex: 'value', render: (v: number) => v?.toFixed(2) },
|
||||
{ title: '状态', dataIndex: 'status', render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text}</Tag> },
|
||||
{ title: '触发时间', dataIndex: 'triggered_at', width: 180 },
|
||||
{ title: '解决时间', dataIndex: 'resolved_at', width: 180 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs items={[
|
||||
{ key: 'events', label: `告警事件 (${events.total})`, children: (
|
||||
<Card size="small">
|
||||
<Table columns={eventColumns} dataSource={events.items} rowKey="id"
|
||||
loading={loading} size="small" pagination={{ pageSize: 15 }} />
|
||||
</Card>
|
||||
)},
|
||||
{ key: 'rules', label: '告警规则', children: (
|
||||
<Card size="small" extra={<Button type="primary" icon={<PlusOutlined />}
|
||||
onClick={() => setShowRuleModal(true)}>新建规则</Button>}>
|
||||
<Table columns={ruleColumns} dataSource={rules} rowKey="id"
|
||||
loading={loading} size="small" />
|
||||
</Card>
|
||||
)},
|
||||
{ key: 'analytics', label: '分析', children: <AlarmAnalyticsTab /> },
|
||||
]} />
|
||||
|
||||
<Modal title="新建告警规则" open={showRuleModal} onCancel={() => setShowRuleModal(false)}
|
||||
onOk={() => form.submit()} okText="创建" cancelText="取消">
|
||||
<Form form={form} layout="vertical" onFinish={handleCreateRule}>
|
||||
<Form.Item name="name" label="规则名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="例: 热泵出水温度过高" />
|
||||
</Form.Item>
|
||||
<Form.Item name="data_type" label="监控数据" rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ label: '功率 (power)', value: 'power' },
|
||||
{ label: '温度 (temperature)', value: 'temperature' },
|
||||
{ label: 'COP', value: 'cop' },
|
||||
{ label: '电压 (voltage)', value: 'voltage' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="condition" label="条件" rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ label: '大于', value: 'gt' }, { label: '小于', value: 'lt' },
|
||||
{ label: '等于', value: 'eq' }, { label: '范围外', value: 'range_out' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="threshold" label="阈值">
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="severity" label="告警级别" initialValue="warning">
|
||||
<Select options={[
|
||||
{ label: '紧急', value: 'critical' }, { label: '重要', value: 'major' }, { label: '一般', value: 'warning' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="duration" label="持续时间(秒)" initialValue={0}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={`规则触发历史 - ${historyDrawer.ruleName}`}
|
||||
open={historyDrawer.visible}
|
||||
onClose={() => setHistoryDrawer({ visible: false, ruleId: 0, ruleName: '' })}
|
||||
width={700}
|
||||
>
|
||||
<Table columns={historyColumns} dataSource={historyData.items} rowKey="id"
|
||||
loading={historyLoading} size="small"
|
||||
pagination={{ total: historyData.total, pageSize: 20 }} />
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, DatePicker, Select, Statistic, Button, Space, message } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { getCostSummary, getCostComparison, getCostBreakdown } from '../../services/api';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
export default function CostAnalysis() {
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(30, 'day'), dayjs(),
|
||||
]);
|
||||
const [groupBy, setGroupBy] = useState('day');
|
||||
const [comparison, setComparison] = useState<any>(null);
|
||||
const [summary, setSummary] = useState<any[]>([]);
|
||||
const [breakdown, setBreakdown] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const start = dateRange[0].format('YYYY-MM-DD');
|
||||
const end = dateRange[1].format('YYYY-MM-DD');
|
||||
const [comp, sum, bkd] = await Promise.all([
|
||||
getCostComparison({ energy_type: 'electricity', period: 'month' }),
|
||||
getCostSummary({ start_date: start, end_date: end, group_by: groupBy, energy_type: 'electricity' }),
|
||||
getCostBreakdown({ start_date: start, end_date: end, energy_type: 'electricity' }),
|
||||
]);
|
||||
setComparison(comp);
|
||||
setSummary(sum as any[]);
|
||||
setBreakdown(bkd);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载费用数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [groupBy]);
|
||||
|
||||
// KPI calculations
|
||||
const todayCost = comparison?.current || 0;
|
||||
const monthCost = comparison?.current || 0;
|
||||
const yearCost = comparison?.yoy || 0;
|
||||
const momChange = comparison?.mom_change || 0;
|
||||
const yoyChange = comparison?.yoy_change || 0;
|
||||
|
||||
// Breakdown pie chart
|
||||
const breakdownPieOption = {
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} 元 ({d}%)' },
|
||||
legend: { bottom: 10 },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: true, formatter: '{b}\n{d}%' },
|
||||
data: (breakdown?.periods || []).map((p: any) => ({
|
||||
value: p.cost,
|
||||
name: p.period_label || p.period_name,
|
||||
itemStyle: {
|
||||
color: p.period_name === 'peak' || p.period_name === 'sharp' ? '#f5222d'
|
||||
: p.period_name === 'valley' || p.period_name === 'off_peak' ? '#52c41a'
|
||||
: p.period_name === 'flat' ? '#1890ff' : '#faad14',
|
||||
},
|
||||
})),
|
||||
}],
|
||||
};
|
||||
|
||||
// Cost trend line chart
|
||||
const trendChartOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['费用(元)', '用电量(kWh)'] },
|
||||
grid: { top: 50, right: 60, bottom: 30, left: 60 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: summary.map((d: any) => {
|
||||
if (d.date) return dayjs(d.date).format('MM/DD');
|
||||
if (d.period) return d.period;
|
||||
if (d.device_name) return d.device_name;
|
||||
return '';
|
||||
}),
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '元', position: 'left' },
|
||||
{ type: 'value', name: 'kWh', position: 'right' },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '费用(元)',
|
||||
type: groupBy === 'device' ? 'bar' : 'line',
|
||||
smooth: true,
|
||||
data: summary.map((d: any) => d.cost || 0),
|
||||
lineStyle: { color: '#f5222d' },
|
||||
itemStyle: { color: '#f5222d' },
|
||||
yAxisIndex: 0,
|
||||
},
|
||||
{
|
||||
name: '用电量(kWh)',
|
||||
type: groupBy === 'device' ? 'bar' : 'line',
|
||||
smooth: true,
|
||||
data: summary.map((d: any) => d.consumption || 0),
|
||||
lineStyle: { color: '#1890ff' },
|
||||
itemStyle: { color: '#1890ff' },
|
||||
yAxisIndex: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Cost by building bar chart (using device grouping)
|
||||
const [deviceSummary, setDeviceSummary] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
const loadDeviceSummary = async () => {
|
||||
try {
|
||||
const start = dateRange[0].format('YYYY-MM-DD');
|
||||
const end = dateRange[1].format('YYYY-MM-DD');
|
||||
const data = await getCostSummary({
|
||||
start_date: start, end_date: end, group_by: 'device', energy_type: 'electricity',
|
||||
});
|
||||
setDeviceSummary(data as any[]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
loadDeviceSummary();
|
||||
}, [dateRange]);
|
||||
|
||||
const deviceBarOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { top: 30, right: 20, bottom: 60, left: 60 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: deviceSummary.map((d: any) => d.device_name || `#${d.device_id}`),
|
||||
axisLabel: { rotate: 30, fontSize: 11 },
|
||||
},
|
||||
yAxis: { type: 'value', name: '元' },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: deviceSummary.map((d: any) => d.cost || 0),
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#1890ff' },
|
||||
{ offset: 1, color: '#69c0ff' },
|
||||
],
|
||||
},
|
||||
},
|
||||
barMaxWidth: 40,
|
||||
}],
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const rows = summary.map((d: any) => {
|
||||
const label = d.date || d.period || d.device_name || '';
|
||||
return `${label},${d.consumption || 0},${d.cost || 0}`;
|
||||
});
|
||||
const csv = '\ufeff日期/分组,用电量(kWh),费用(元)\n' + rows.join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `cost_analysis_${dateRange[0].format('YYYYMMDD')}_${dateRange[1].format('YYYYMMDD')}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('导出成功');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Controls */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => dates && setDateRange(dates as [Dayjs, Dayjs])}
|
||||
/>
|
||||
<Select value={groupBy} onChange={setGroupBy} style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '按天', value: 'day' },
|
||||
{ label: '按月', value: 'month' },
|
||||
{ label: '按设备', value: 'device' },
|
||||
]}
|
||||
/>
|
||||
<Button type="primary" loading={loading} onClick={loadData}>查询</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="今日费用" value={todayCost} suffix="元" precision={2}
|
||||
valueStyle={{ color: '#f5222d' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="本月费用" value={monthCost} suffix="元" precision={2} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="环比变化" value={Math.abs(momChange)} suffix="%"
|
||||
prefix={momChange >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
valueStyle={{ color: momChange >= 0 ? '#f5222d' : '#52c41a' }} precision={1} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="同比变化" value={Math.abs(yoyChange)} suffix="%"
|
||||
prefix={yoyChange >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
valueStyle={{ color: yoyChange >= 0 ? '#f5222d' : '#52c41a' }} precision={1} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Charts Row */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} md={10}>
|
||||
<Card title="分时电价费用分布" size="small">
|
||||
<ReactECharts option={breakdownPieOption} style={{ height: 320 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={14}>
|
||||
<Card title="费用趋势" size="small">
|
||||
<ReactECharts option={trendChartOption} style={{ height: 320 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Device cost bar chart */}
|
||||
<Card title="设备/区域费用分布" size="small">
|
||||
<ReactECharts option={deviceBarOption} style={{ height: 350 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Table, DatePicker, Select, Space, Tag, message } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { getEnergyLoss } from '../../services/api';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
interface LossItem {
|
||||
group_name: string;
|
||||
parent_consumption: number;
|
||||
children_consumption: number;
|
||||
loss: number;
|
||||
loss_rate_pct: number;
|
||||
}
|
||||
|
||||
export default function LossAnalysis() {
|
||||
const [data, setData] = useState<LossItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(30, 'day'), dayjs(),
|
||||
]);
|
||||
const [energyType, setEnergyType] = useState('electricity');
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getEnergyLoss({
|
||||
start_date: dateRange[0].format('YYYY-MM-DD'),
|
||||
end_date: dateRange[1].format('YYYY-MM-DD'),
|
||||
energy_type: energyType,
|
||||
});
|
||||
setData(res as LossItem[]);
|
||||
} catch {
|
||||
message.error('加载损耗数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(); }, [dateRange, energyType]);
|
||||
|
||||
const getLossColor = (rate: number) => {
|
||||
if (rate > 10) return 'red';
|
||||
if (rate >= 5) return 'gold';
|
||||
return 'green';
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '区域', dataIndex: 'group_name' },
|
||||
{ title: '供给量', dataIndex: 'parent_consumption', render: (v: number) => `${v.toFixed(2)} kWh` },
|
||||
{ title: '消耗量', dataIndex: 'children_consumption', render: (v: number) => `${v.toFixed(2)} kWh` },
|
||||
{ title: '损耗量', dataIndex: 'loss', render: (v: number) => `${v.toFixed(2)} kWh` },
|
||||
{
|
||||
title: '损耗率',
|
||||
dataIndex: 'loss_rate_pct',
|
||||
render: (v: number) => <Tag color={getLossColor(v)}>{v.toFixed(1)}%</Tag>,
|
||||
sorter: (a: LossItem, b: LossItem) => a.loss_rate_pct - b.loss_rate_pct,
|
||||
},
|
||||
];
|
||||
|
||||
const chartOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { top: 40, right: 40, bottom: 30, left: 80 },
|
||||
xAxis: { type: 'value', name: 'kWh' },
|
||||
yAxis: { type: 'category', data: data.map(d => d.group_name) },
|
||||
series: [
|
||||
{
|
||||
name: '损耗量',
|
||||
type: 'bar',
|
||||
data: data.map(d => ({
|
||||
value: d.loss,
|
||||
itemStyle: { color: d.loss_rate_pct > 10 ? '#f5222d' : d.loss_rate_pct >= 5 ? '#faad14' : '#52c41a' },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<span>日期范围:</span>
|
||||
<RangePicker value={dateRange} onChange={(dates) => dates && setDateRange(dates as [Dayjs, Dayjs])} />
|
||||
<span>能源类型:</span>
|
||||
<Select value={energyType} onChange={setEnergyType} style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '电力', value: 'electricity' },
|
||||
{ label: '热力', value: 'heat' },
|
||||
{ label: '水', value: 'water' },
|
||||
{ label: '燃气', value: 'gas' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="损耗分布" size="small" style={{ marginBottom: 16 }}>
|
||||
<ReactECharts option={chartOption} style={{ height: 300 }} />
|
||||
</Card>
|
||||
|
||||
<Card title="损耗明细" size="small">
|
||||
<Table columns={columns} dataSource={data} rowKey="group_name"
|
||||
loading={loading} size="small" pagination={false} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, Select, Space, Statistic, message } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { getEnergyMom } from '../../services/api';
|
||||
|
||||
interface MomItem {
|
||||
label: string;
|
||||
current_period: number;
|
||||
previous_period: number;
|
||||
change_pct: number;
|
||||
}
|
||||
|
||||
interface MomData {
|
||||
items: MomItem[];
|
||||
total_current: number;
|
||||
total_previous: number;
|
||||
total_change_pct: number;
|
||||
}
|
||||
|
||||
export default function MomAnalysis() {
|
||||
const [data, setData] = useState<MomData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [period, setPeriod] = useState('month');
|
||||
const [energyType, setEnergyType] = useState('electricity');
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getEnergyMom({ period, energy_type: energyType });
|
||||
setData(res as MomData);
|
||||
} catch {
|
||||
message.error('加载环比数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(); }, [period, energyType]);
|
||||
|
||||
const periodLabels: Record<string, [string, string]> = {
|
||||
month: ['本月', '上月'],
|
||||
week: ['本周', '上周'],
|
||||
day: ['今日', '昨日'],
|
||||
};
|
||||
const [curLabel, prevLabel] = periodLabels[period] || ['当前', '上期'];
|
||||
|
||||
const chartOption = data ? {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: [curLabel, prevLabel] },
|
||||
grid: { top: 50, right: 40, bottom: 30, left: 60 },
|
||||
xAxis: { type: 'category', data: data.items.map(d => d.label) },
|
||||
yAxis: { type: 'value', name: 'kWh' },
|
||||
series: [
|
||||
{
|
||||
name: curLabel,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: data.items.map(d => d.current_period),
|
||||
lineStyle: { color: '#1890ff' },
|
||||
itemStyle: { color: '#1890ff' },
|
||||
},
|
||||
{
|
||||
name: prevLabel,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: data.items.map(d => d.previous_period),
|
||||
lineStyle: { color: '#faad14', type: 'dashed' },
|
||||
itemStyle: { color: '#faad14' },
|
||||
},
|
||||
],
|
||||
} : {};
|
||||
|
||||
const changePct = data?.total_change_pct || 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<span>对比周期:</span>
|
||||
<Select value={period} onChange={setPeriod} style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '按月', value: 'month' },
|
||||
{ label: '按周', value: 'week' },
|
||||
{ label: '按日', value: 'day' },
|
||||
]}
|
||||
/>
|
||||
<span>能源类型:</span>
|
||||
<Select value={energyType} onChange={setEnergyType} style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '电力', value: 'electricity' },
|
||||
{ label: '热力', value: 'heat' },
|
||||
{ label: '水', value: 'water' },
|
||||
{ label: '燃气', value: 'gas' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Statistic title={`${curLabel}用量`} value={data?.total_current || 0} suffix="kWh" precision={2} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Statistic title={`${prevLabel}用量`} value={data?.total_previous || 0} suffix="kWh" precision={2} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="环比变化"
|
||||
value={Math.abs(changePct)}
|
||||
suffix="%"
|
||||
prefix={changePct >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
valueStyle={{ color: changePct >= 0 ? '#f5222d' : '#52c41a' }}
|
||||
precision={1}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="环比趋势" size="small">
|
||||
{data && <ReactECharts option={chartOption} style={{ height: 350 }} />}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, DatePicker, Checkbox, Table, message } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import api from '../../services/api';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
color: string;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
interface ByCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
color: string;
|
||||
consumption: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface RankingItem {
|
||||
name: string;
|
||||
color: string;
|
||||
consumption: number;
|
||||
}
|
||||
|
||||
interface TrendItem {
|
||||
date: string;
|
||||
category: string;
|
||||
color: string;
|
||||
consumption: number;
|
||||
}
|
||||
|
||||
export default function SubitemAnalysis() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [flatCategories, setFlatCategories] = useState<Category[]>([]);
|
||||
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(30, 'day'), dayjs(),
|
||||
]);
|
||||
const [byCategory, setByCategory] = useState<ByCategory[]>([]);
|
||||
const [ranking, setRanking] = useState<RankingItem[]>([]);
|
||||
const [trend, setTrend] = useState<TrendItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const flatten = (cats: Category[]): Category[] => {
|
||||
const result: Category[] = [];
|
||||
const walk = (list: Category[]) => {
|
||||
for (const c of list) {
|
||||
result.push(c);
|
||||
if (c.children) walk(c.children);
|
||||
}
|
||||
};
|
||||
walk(cats);
|
||||
return result;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const cats = await api.get('/energy/categories') as any as Category[];
|
||||
setCategories(cats);
|
||||
const flat = flatten(cats);
|
||||
setFlatCategories(flat);
|
||||
setSelectedCodes(flat.map(c => c.code));
|
||||
} catch {
|
||||
message.error('加载分项类别失败');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCodes.length > 0) loadData();
|
||||
}, [selectedCodes, dateRange]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
const params = {
|
||||
start_date: dateRange[0].format('YYYY-MM-DD'),
|
||||
end_date: dateRange[1].format('YYYY-MM-DD'),
|
||||
energy_type: 'electricity',
|
||||
};
|
||||
try {
|
||||
const [byCat, rank, trendData] = await Promise.all([
|
||||
api.get('/energy/by-category', { params }),
|
||||
api.get('/energy/category-ranking', { params }),
|
||||
api.get('/energy/category-trend', { params }),
|
||||
]);
|
||||
setByCategory((byCat as any[]).filter(c => selectedCodes.includes(c.code)));
|
||||
setRanking((rank as any[]).filter(c => selectedCodes.includes(c.name) || true));
|
||||
setTrend(trendData as any[]);
|
||||
} catch {
|
||||
message.error('加载分项数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pieOption = {
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} kWh ({d}%)' },
|
||||
legend: { orient: 'vertical' as const, right: 10, top: 'center' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: true, formatter: '{b}\n{d}%' },
|
||||
data: byCategory.map(c => ({
|
||||
name: c.name, value: c.consumption,
|
||||
itemStyle: c.color ? { color: c.color } : undefined,
|
||||
})),
|
||||
}],
|
||||
};
|
||||
|
||||
const barOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { top: 10, right: 30, bottom: 30, left: 100 },
|
||||
xAxis: { type: 'value' as const, name: 'kWh' },
|
||||
yAxis: {
|
||||
type: 'category' as const,
|
||||
data: [...ranking].reverse().map(r => r.name),
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: [...ranking].reverse().map(r => ({
|
||||
value: r.consumption,
|
||||
itemStyle: r.color ? { color: r.color } : undefined,
|
||||
})),
|
||||
}],
|
||||
};
|
||||
|
||||
// Group trend data by category for line chart
|
||||
const trendCategories = [...new Set(trend.map(t => t.category))];
|
||||
const trendDates = [...new Set(trend.map(t => t.date))].sort();
|
||||
const colorMap: Record<string, string> = {};
|
||||
trend.forEach(t => { if (t.color) colorMap[t.category] = t.color; });
|
||||
|
||||
const lineOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: trendCategories },
|
||||
grid: { top: 40, right: 20, bottom: 30, left: 60 },
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: trendDates.map(d => dayjs(d).format('MM/DD')),
|
||||
},
|
||||
yAxis: { type: 'value' as const, name: 'kWh' },
|
||||
series: trendCategories.map(cat => ({
|
||||
name: cat,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: trendDates.map(d => {
|
||||
const item = trend.find(t => t.date === d && t.category === cat);
|
||||
return item ? item.consumption : 0;
|
||||
}),
|
||||
lineStyle: colorMap[cat] ? { color: colorMap[cat] } : undefined,
|
||||
itemStyle: colorMap[cat] ? { color: colorMap[cat] } : undefined,
|
||||
})),
|
||||
};
|
||||
|
||||
const tableColumns = [
|
||||
{ title: '分项名称', dataIndex: 'name' },
|
||||
{ title: '用量 (kWh)', dataIndex: 'consumption', render: (v: number) => v?.toFixed(2) },
|
||||
{ title: '占比 (%)', dataIndex: 'percentage', render: (v: number) => v?.toFixed(1) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16} align="middle">
|
||||
<Col>
|
||||
<span style={{ marginRight: 8 }}>日期范围:</span>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => dates && setDateRange(dates as [Dayjs, Dayjs])}
|
||||
/>
|
||||
</Col>
|
||||
<Col flex="auto">
|
||||
<span style={{ marginRight: 8 }}>分项类别:</span>
|
||||
<Checkbox.Group
|
||||
value={selectedCodes}
|
||||
onChange={(vals) => setSelectedCodes(vals as string[])}
|
||||
options={flatCategories.map(c => ({ label: c.name, value: c.code }))}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="能耗构成" size="small" loading={loading}>
|
||||
<ReactECharts option={pieOption} style={{ height: 320 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="分项排名" size="small" loading={loading}>
|
||||
<ReactECharts option={barOption} style={{ height: 320 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="分项趋势" size="small" style={{ marginTop: 16 }} loading={loading}>
|
||||
<ReactECharts option={lineOption} style={{ height: 350 }} />
|
||||
</Card>
|
||||
|
||||
<Card title="分项明细" size="small" style={{ marginTop: 16 }}>
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
dataSource={byCategory}
|
||||
rowKey="code"
|
||||
size="small"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Table, DatePicker, Select, Space, message } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { getEnergyYoy } from '../../services/api';
|
||||
|
||||
interface YoyItem {
|
||||
month: number;
|
||||
current_year: number;
|
||||
previous_year: number;
|
||||
change_pct: number;
|
||||
}
|
||||
|
||||
export default function YoyAnalysis() {
|
||||
const [data, setData] = useState<YoyItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [year, setYear] = useState(dayjs().year());
|
||||
const [energyType, setEnergyType] = useState('electricity');
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getEnergyYoy({ year, energy_type: energyType });
|
||||
setData(res as YoyItem[]);
|
||||
} catch {
|
||||
message.error('加载同比数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(); }, [year, energyType]);
|
||||
|
||||
const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
|
||||
|
||||
const chartOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: [`${year}年`, `${year - 1}年`] },
|
||||
grid: { top: 50, right: 40, bottom: 30, left: 60 },
|
||||
xAxis: { type: 'category', data: months },
|
||||
yAxis: { type: 'value', name: 'kWh' },
|
||||
series: [
|
||||
{
|
||||
name: `${year}年`,
|
||||
type: 'bar',
|
||||
data: data.map(d => d.current_year),
|
||||
itemStyle: { color: '#1890ff' },
|
||||
},
|
||||
{
|
||||
name: `${year - 1}年`,
|
||||
type: 'bar',
|
||||
data: data.map(d => d.previous_year),
|
||||
itemStyle: { color: '#faad14' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '月份', dataIndex: 'month', render: (v: number) => `${v}月` },
|
||||
{ title: `${year}年 (kWh)`, dataIndex: 'current_year', render: (v: number) => v?.toFixed(2) },
|
||||
{ title: `${year - 1}年 (kWh)`, dataIndex: 'previous_year', render: (v: number) => v?.toFixed(2) },
|
||||
{
|
||||
title: '同比变化',
|
||||
dataIndex: 'change_pct',
|
||||
render: (v: number) => (
|
||||
<span style={{ color: v > 0 ? '#f5222d' : v < 0 ? '#52c41a' : '#666' }}>
|
||||
{v > 0 ? <ArrowUpOutlined /> : v < 0 ? <ArrowDownOutlined /> : null}
|
||||
{' '}{Math.abs(v).toFixed(1)}%
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const yearOptions = [];
|
||||
for (let y = dayjs().year(); y >= dayjs().year() - 5; y--) {
|
||||
yearOptions.push({ label: `${y}年`, value: y });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<span>年份:</span>
|
||||
<Select value={year} onChange={setYear} style={{ width: 120 }} options={yearOptions} />
|
||||
<span>能源类型:</span>
|
||||
<Select value={energyType} onChange={setEnergyType} style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '电力', value: 'electricity' },
|
||||
{ label: '热力', value: 'heat' },
|
||||
{ label: '水', value: 'water' },
|
||||
{ label: '燃气', value: 'gas' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="同比分析" size="small" style={{ marginBottom: 16 }}>
|
||||
<ReactECharts option={chartOption} style={{ height: 350 }} />
|
||||
</Card>
|
||||
|
||||
<Card title="月度明细" size="small">
|
||||
<Table columns={columns} dataSource={data} rowKey="month"
|
||||
loading={loading} size="small" pagination={false} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, DatePicker, Select, Statistic, Table, Button, Space, message, Tabs } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, DownloadOutlined, SwapOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { getEnergyHistory, getEnergyComparison, getEnergyDailySummary, exportEnergyData } from '../../services/api';
|
||||
import LossAnalysis from './LossAnalysis';
|
||||
import YoyAnalysis from './YoyAnalysis';
|
||||
import MomAnalysis from './MomAnalysis';
|
||||
import CostAnalysis from './CostAnalysis';
|
||||
import SubitemAnalysis from './SubitemAnalysis';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
function ComparisonView() {
|
||||
const [range1, setRange1] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(30, 'day'), dayjs(),
|
||||
]);
|
||||
const [range2, setRange2] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(60, 'day'), dayjs().subtract(30, 'day'),
|
||||
]);
|
||||
const [data1, setData1] = useState<any[]>([]);
|
||||
const [data2, setData2] = useState<any[]>([]);
|
||||
const [summary1, setSummary1] = useState<any[]>([]);
|
||||
const [summary2, setSummary2] = useState<any[]>([]);
|
||||
const [comparison, setComparison] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadComparisonData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [hist1, hist2, daily1, daily2, comp] = await Promise.all([
|
||||
getEnergyHistory({
|
||||
data_type: 'power', granularity: 'day',
|
||||
start_time: range1[0].format('YYYY-MM-DD'),
|
||||
end_time: range1[1].format('YYYY-MM-DD'),
|
||||
}),
|
||||
getEnergyHistory({
|
||||
data_type: 'power', granularity: 'day',
|
||||
start_time: range2[0].format('YYYY-MM-DD'),
|
||||
end_time: range2[1].format('YYYY-MM-DD'),
|
||||
}),
|
||||
getEnergyDailySummary({
|
||||
energy_type: 'electricity',
|
||||
start_date: range1[0].format('YYYY-MM-DD'),
|
||||
end_date: range1[1].format('YYYY-MM-DD'),
|
||||
}),
|
||||
getEnergyDailySummary({
|
||||
energy_type: 'electricity',
|
||||
start_date: range2[0].format('YYYY-MM-DD'),
|
||||
end_date: range2[1].format('YYYY-MM-DD'),
|
||||
}),
|
||||
getEnergyComparison({ energy_type: 'electricity', period: 'month' }),
|
||||
]);
|
||||
setData1(hist1 as any[]);
|
||||
setData2(hist2 as any[]);
|
||||
setSummary1(daily1 as any[]);
|
||||
setSummary2(daily2 as any[]);
|
||||
setComparison(comp);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载对比数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadComparisonData();
|
||||
}, []);
|
||||
|
||||
const calcMetrics = (summaryData: any[]) => {
|
||||
if (!summaryData || summaryData.length === 0) {
|
||||
return { totalConsumption: 0, peakPower: 0, avgLoad: 0, carbonEmission: 0 };
|
||||
}
|
||||
const totalConsumption = summaryData.reduce((s, d) => s + (d.consumption || 0), 0);
|
||||
const peakPower = Math.max(...summaryData.map(d => d.peak_power || 0));
|
||||
const avgLoad = summaryData.reduce((s, d) => s + (d.avg_power || 0), 0) / summaryData.length;
|
||||
const carbonEmission = summaryData.reduce((s, d) => s + (d.carbon_emission || 0), 0);
|
||||
return { totalConsumption, peakPower, avgLoad, carbonEmission };
|
||||
};
|
||||
|
||||
const m1 = calcMetrics(summary1);
|
||||
const m2 = calcMetrics(summary2);
|
||||
|
||||
const pctChange = (v1: number, v2: number) => {
|
||||
if (v2 === 0) return 0;
|
||||
return ((v1 - v2) / v2) * 100;
|
||||
};
|
||||
|
||||
const comparisonChartOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['时段一', '时段二'] },
|
||||
grid: { top: 50, right: 40, bottom: 30, left: 60 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: (data1.length >= data2.length ? data1 : data2).map((_, i) => `Day ${i + 1}`),
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: 'kW', position: 'left' },
|
||||
{ type: 'value', name: 'kW', position: 'right' },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '时段一',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: data1.map(d => d.avg),
|
||||
lineStyle: { color: '#1890ff' },
|
||||
itemStyle: { color: '#1890ff' },
|
||||
yAxisIndex: 0,
|
||||
},
|
||||
{
|
||||
name: '时段二',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: data2.map(d => d.avg),
|
||||
lineStyle: { color: '#faad14' },
|
||||
itemStyle: { color: '#faad14' },
|
||||
yAxisIndex: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const renderMetricCard = (
|
||||
label: string, v1: number, v2: number, unit: string, precision = 1,
|
||||
) => {
|
||||
const change = pctChange(v1, v2);
|
||||
const isImproved = change < 0; // less consumption = improvement
|
||||
return (
|
||||
<Col xs={24} sm={12} md={6} key={label}>
|
||||
<Card size="small">
|
||||
<div style={{ marginBottom: 8, fontWeight: 500, color: '#666' }}>{label}</div>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>时段一</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{v1.toFixed(precision)}</div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>{unit}</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>时段二</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{v2.toFixed(precision)}</div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>{unit}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{
|
||||
marginTop: 8, fontSize: 13, fontWeight: 500,
|
||||
color: isImproved ? '#52c41a' : change > 0 ? '#f5222d' : '#666',
|
||||
}}>
|
||||
{change > 0 ? <ArrowUpOutlined /> : change < 0 ? <ArrowDownOutlined /> : null}
|
||||
{' '}{Math.abs(change).toFixed(1)}% {isImproved ? '减少' : change > 0 ? '增加' : '持平'}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<span>时段一:</span>
|
||||
<RangePicker
|
||||
value={range1}
|
||||
onChange={(dates) => dates && setRange1(dates as [Dayjs, Dayjs])}
|
||||
/>
|
||||
<span>时段二:</span>
|
||||
<RangePicker
|
||||
value={range2}
|
||||
onChange={(dates) => dates && setRange2(dates as [Dayjs, Dayjs])}
|
||||
/>
|
||||
<Button type="primary" icon={<SwapOutlined />} loading={loading} onClick={loadComparisonData}>
|
||||
对比
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
{renderMetricCard('总用电量', m1.totalConsumption, m2.totalConsumption, 'kWh')}
|
||||
{renderMetricCard('峰值功率', m1.peakPower, m2.peakPower, 'kW')}
|
||||
{renderMetricCard('平均负荷', m1.avgLoad, m2.avgLoad, 'kW')}
|
||||
{renderMetricCard('碳排放', m1.carbonEmission, m2.carbonEmission, 'kg', 2)}
|
||||
</Row>
|
||||
|
||||
<Card title="能耗趋势对比" size="small">
|
||||
<ReactECharts option={comparisonChartOption} style={{ height: 350 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Analysis() {
|
||||
const [historyData, setHistoryData] = useState<any[]>([]);
|
||||
const [comparison, setComparison] = useState<any>(null);
|
||||
const [dailySummary, setDailySummary] = useState<any[]>([]);
|
||||
const [granularity, setGranularity] = useState('hour');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [granularity]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [hist, comp, daily] = await Promise.all([
|
||||
getEnergyHistory({ data_type: 'power', granularity }),
|
||||
getEnergyComparison({ energy_type: 'electricity', period: 'month' }),
|
||||
getEnergyDailySummary({ energy_type: 'electricity' }),
|
||||
]);
|
||||
setHistoryData(hist as any[]);
|
||||
setComparison(comp);
|
||||
setDailySummary(daily as any[]);
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleExport = async (format: 'csv' | 'xlsx' = 'csv') => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const end = dayjs().format('YYYY-MM-DD');
|
||||
const start = dayjs().subtract(30, 'day').format('YYYY-MM-DD');
|
||||
await exportEnergyData({
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
format,
|
||||
});
|
||||
message.success('导出成功');
|
||||
} catch (e) {
|
||||
message.error('导出失败,请重试');
|
||||
console.error(e);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const historyChartOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['平均', '最大', '最小'] },
|
||||
grid: { top: 40, right: 20, bottom: 30, left: 60 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: historyData.map(d => {
|
||||
const t = new Date(d.time);
|
||||
return `${t.getMonth() + 1}/${t.getDate()} ${t.getHours()}:00`;
|
||||
}),
|
||||
},
|
||||
yAxis: { type: 'value', name: 'kW' },
|
||||
series: [
|
||||
{ name: '平均', type: 'line', smooth: true, data: historyData.map(d => d.avg), lineStyle: { color: '#1890ff' }, itemStyle: { color: '#1890ff' } },
|
||||
{ name: '最大', type: 'line', smooth: true, data: historyData.map(d => d.max), lineStyle: { color: '#f5222d', type: 'dashed' }, itemStyle: { color: '#f5222d' } },
|
||||
{ name: '最小', type: 'line', smooth: true, data: historyData.map(d => d.min), lineStyle: { color: '#52c41a', type: 'dashed' }, itemStyle: { color: '#52c41a' } },
|
||||
],
|
||||
};
|
||||
|
||||
const dailyColumns = [
|
||||
{ title: '日期', dataIndex: 'date', render: (v: string) => dayjs(v).format('YYYY-MM-DD') },
|
||||
{ title: '消耗(kWh)', dataIndex: 'consumption', render: (v: number) => v?.toFixed(1) },
|
||||
{ title: '产出(kWh)', dataIndex: 'generation', render: (v: number) => v?.toFixed(1) },
|
||||
{ title: '峰值功率(kW)', dataIndex: 'peak_power', render: (v: number) => v?.toFixed(1) },
|
||||
{ title: '平均功率(kW)', dataIndex: 'avg_power', render: (v: number) => v?.toFixed(1) },
|
||||
{ title: '碳排放(kg)', dataIndex: 'carbon_emission', render: (v: number) => v?.toFixed(2) },
|
||||
];
|
||||
|
||||
const overviewContent = (
|
||||
<div>
|
||||
<Row justify="end" style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={() => handleExport('csv')}>
|
||||
导出CSV
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={() => handleExport('xlsx')}>
|
||||
导出Excel
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Statistic title="本月用电量" value={comparison?.current || 0} suffix="kWh" precision={1} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Statistic title="环比变化" value={Math.abs(comparison?.mom_change || 0)} suffix="%"
|
||||
prefix={comparison?.mom_change >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
valueStyle={{ color: comparison?.mom_change >= 0 ? '#f5222d' : '#52c41a' }} precision={1} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Statistic title="同比变化" value={Math.abs(comparison?.yoy_change || 0)} suffix="%"
|
||||
prefix={comparison?.yoy_change >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
valueStyle={{ color: comparison?.yoy_change >= 0 ? '#f5222d' : '#52c41a' }} precision={1} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="能耗趋势" size="small" style={{ marginTop: 16 }}
|
||||
extra={
|
||||
<Select value={granularity} onChange={setGranularity} style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '按小时', value: 'hour' },
|
||||
{ label: '按天', value: 'day' },
|
||||
{ label: '5分钟', value: '5min' },
|
||||
]} />
|
||||
}>
|
||||
<ReactECharts option={historyChartOption} style={{ height: 350 }} />
|
||||
</Card>
|
||||
|
||||
<Card title="每日能耗汇总" size="small" style={{ marginTop: 16 }}>
|
||||
<Table columns={dailyColumns} dataSource={dailySummary} rowKey="date"
|
||||
size="small" pagination={{ pageSize: 10 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{ key: 'overview', label: '能耗概览', children: overviewContent },
|
||||
{ key: 'comparison', label: '数据对比', children: <ComparisonView /> },
|
||||
{ key: 'loss', label: '损耗分析', children: <LossAnalysis /> },
|
||||
{ key: 'yoy', label: '同比分析', children: <YoyAnalysis /> },
|
||||
{ key: 'mom', label: '环比分析', children: <MomAnalysis /> },
|
||||
{ key: 'cost', label: '费用分析', children: <CostAnalysis /> },
|
||||
{ key: 'subitem', label: '分项分析', children: <SubitemAnalysis /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import styles from '../styles.module.css';
|
||||
import AnimatedNumber from './AnimatedNumber';
|
||||
|
||||
interface Props {
|
||||
alarmEvents: any[];
|
||||
alarmStats: any;
|
||||
}
|
||||
|
||||
export default function AlarmCard({ alarmEvents, alarmStats }: Props) {
|
||||
const activeCount = alarmStats?.active_count ?? 0;
|
||||
const weeklyTrend = alarmStats?.weekly_trend ?? [3, 5, 2, 8, 4, 6, 1];
|
||||
const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
|
||||
const trendOption = {
|
||||
grid: { left: 30, right: 8, top: 8, bottom: 20 },
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: weekDays,
|
||||
axisLine: { lineStyle: { color: 'rgba(0,212,255,0.2)' } },
|
||||
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.5)' },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value' as const,
|
||||
splitLine: { lineStyle: { color: 'rgba(0,212,255,0.08)' } },
|
||||
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.4)' },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: weeklyTrend,
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#ff8c00' },
|
||||
{ offset: 1, color: 'rgba(255, 140, 0, 0.2)' },
|
||||
],
|
||||
} as any,
|
||||
borderRadius: [2, 2, 0, 0],
|
||||
},
|
||||
barWidth: '50%',
|
||||
}],
|
||||
tooltip: { trigger: 'axis' as const, backgroundColor: 'rgba(6,30,62,0.9)', borderColor: 'rgba(0,212,255,0.3)', textStyle: { color: '#e0e8f0', fontSize: 12 } },
|
||||
};
|
||||
|
||||
const getSeverityClass = (severity: string) => {
|
||||
if (severity === 'critical' || severity === 'high') return styles.alarmSeverityCritical;
|
||||
if (severity === 'warning' || severity === 'medium') return styles.alarmSeverityWarning;
|
||||
return styles.alarmSeverityInfo;
|
||||
};
|
||||
|
||||
const formatTime = (ts: string) => {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.card} style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className={styles.cardTitle}>告警信息</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<div>
|
||||
<span className={styles.statLabel}>活跃告警</span>
|
||||
<div>
|
||||
<AnimatedNumber value={activeCount} className={styles.bigNumberRed} />
|
||||
<span className={styles.unit}> 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.alarmList}>
|
||||
{(alarmEvents ?? []).slice(0, 5).map((alarm: any, idx: number) => (
|
||||
<div key={alarm.id ?? idx} className={styles.alarmItem}>
|
||||
<span className={getSeverityClass(alarm.severity ?? 'info')} />
|
||||
<span className={styles.alarmMsg}>{alarm.message ?? alarm.description ?? '未知告警'}</span>
|
||||
<span className={styles.alarmTime}>{formatTime(alarm.triggered_at ?? alarm.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
{(!alarmEvents || alarmEvents.length === 0) && (
|
||||
<div style={{ color: 'rgba(224,232,240,0.3)', fontSize: 12, padding: '8px 0' }}>暂无告警</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.chartWrap} style={{ flex: 1, minHeight: 80 }}>
|
||||
<ReactECharts option={trendOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
duration?: number;
|
||||
decimals?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AnimatedNumber({ value, duration = 1500, decimals = 0, className }: Props) {
|
||||
const [display, setDisplay] = useState(0);
|
||||
const rafRef = useRef<number>(0);
|
||||
const startRef = useRef<number>(0);
|
||||
const fromRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
fromRef.current = display;
|
||||
startRef.current = performance.now();
|
||||
|
||||
const animate = (now: number) => {
|
||||
const elapsed = now - startRef.current;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
// ease-out cubic
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const current = fromRef.current + (value - fromRef.current) * eased;
|
||||
setDisplay(current);
|
||||
if (progress < 1) {
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value, duration]);
|
||||
|
||||
return <span className={className}>{display.toFixed(decimals)}</span>;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import styles from '../styles.module.css';
|
||||
import AnimatedNumber from './AnimatedNumber';
|
||||
|
||||
interface Props {
|
||||
carbonOverview: any;
|
||||
carbonTrend: any;
|
||||
}
|
||||
|
||||
export default function CarbonCard({ carbonOverview, carbonTrend }: Props) {
|
||||
const annualEmission = carbonOverview?.annual_emission ?? 0;
|
||||
const annualReduction = carbonOverview?.annual_reduction ?? 0;
|
||||
const neutralityRate = annualEmission > 0
|
||||
? Math.min((annualReduction / annualEmission) * 100, 100)
|
||||
: 0;
|
||||
|
||||
// Monthly trend
|
||||
const months = carbonTrend?.months ?? ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
|
||||
const emissionData = carbonTrend?.emission ?? [];
|
||||
const reductionData = carbonTrend?.reduction ?? [];
|
||||
|
||||
const trendOption = {
|
||||
grid: { left: 40, right: 12, top: 24, bottom: 24 },
|
||||
legend: {
|
||||
data: ['碳排放', '碳减排'],
|
||||
textStyle: { color: 'rgba(224,232,240,0.6)', fontSize: 10 },
|
||||
top: 0,
|
||||
right: 8,
|
||||
itemWidth: 12,
|
||||
itemHeight: 8,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis' as const,
|
||||
backgroundColor: 'rgba(6,30,62,0.9)',
|
||||
borderColor: 'rgba(0,212,255,0.3)',
|
||||
textStyle: { color: '#e0e8f0', fontSize: 12 },
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: months,
|
||||
axisLine: { lineStyle: { color: 'rgba(0,212,255,0.2)' } },
|
||||
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.5)' },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value' as const,
|
||||
name: 'kgCO2',
|
||||
nameTextStyle: { color: 'rgba(224,232,240,0.4)', fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: 'rgba(0,212,255,0.08)' } },
|
||||
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.4)' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '碳排放',
|
||||
type: 'line',
|
||||
data: emissionData,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#ff8c00', width: 2 },
|
||||
areaStyle: { color: 'rgba(255, 140, 0, 0.08)' },
|
||||
},
|
||||
{
|
||||
name: '碳减排',
|
||||
type: 'line',
|
||||
data: reductionData,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#00ff88', width: 2 },
|
||||
areaStyle: { color: 'rgba(0, 255, 136, 0.08)' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Neutrality gauge
|
||||
const gaugeOption = {
|
||||
series: [{
|
||||
type: 'gauge',
|
||||
startAngle: 220,
|
||||
endAngle: -40,
|
||||
radius: '90%',
|
||||
center: ['50%', '55%'],
|
||||
min: 0,
|
||||
max: 100,
|
||||
progress: {
|
||||
show: true,
|
||||
width: 10,
|
||||
itemStyle: {
|
||||
color: neutralityRate >= 80 ? '#00ff88' : neutralityRate >= 50 ? '#ff8c00' : '#ff4757',
|
||||
},
|
||||
},
|
||||
axisLine: { lineStyle: { width: 10, color: [[1, 'rgba(0, 255, 136, 0.1)']] } },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
pointer: { show: false },
|
||||
title: { show: false },
|
||||
detail: {
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: neutralityRate >= 80 ? '#00ff88' : neutralityRate >= 50 ? '#ff8c00' : '#ff4757',
|
||||
offsetCenter: [0, '10%'],
|
||||
formatter: '{value}%',
|
||||
},
|
||||
data: [{ value: neutralityRate.toFixed(1) }],
|
||||
}],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.card} style={{ flex: '0 0 auto' }}>
|
||||
<div className={styles.cardTitle}>碳排放</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
|
||||
<div style={{ width: 90, height: 80, flexShrink: 0 }}>
|
||||
<ReactECharts option={gaugeOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>年碳排放</span>
|
||||
<span className={styles.statValueOrange}>
|
||||
<AnimatedNumber value={annualEmission} decimals={0} />
|
||||
<span className={styles.unit}> kg</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem} style={{ marginTop: 4 }}>
|
||||
<span className={styles.statLabel}>年碳减排</span>
|
||||
<span className={styles.statValueGreen}>
|
||||
<AnimatedNumber value={annualReduction} decimals={0} />
|
||||
<span className={styles.unit}> kg</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.chartWrap} style={{ flex: 1, minHeight: 100 }}>
|
||||
<ReactECharts option={trendOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import styles from '../styles.module.css';
|
||||
|
||||
interface Props {
|
||||
realtime: any;
|
||||
overview: any;
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
progress: number;
|
||||
speed: number;
|
||||
pathIndex: number;
|
||||
}
|
||||
|
||||
export default function EnergyFlowDiagram({ realtime, overview }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
const gridPower = realtime?.grid_power ?? 0;
|
||||
const pvPower = realtime?.pv_power ?? 0;
|
||||
const totalPower = realtime?.total_power ?? 0;
|
||||
const hpPower = realtime?.heatpump_power ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!container || !canvas) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
canvas.width = rect.width * window.devicePixelRatio;
|
||||
canvas.height = rect.height * window.devicePixelRatio;
|
||||
canvas.style.width = rect.width + 'px';
|
||||
canvas.style.height = rect.height + 'px';
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Initialize particles
|
||||
particlesRef.current = [];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
particlesRef.current.push({
|
||||
x: 0, y: 0,
|
||||
progress: Math.random(),
|
||||
speed: 0.002 + Math.random() * 0.003,
|
||||
pathIndex: Math.floor(Math.random() * 4),
|
||||
});
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const dpr = window.devicePixelRatio;
|
||||
const w = canvas.width / dpr;
|
||||
const h = canvas.height / dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Node positions
|
||||
const nodes = {
|
||||
grid: { x: w * 0.12, y: h * 0.32, label: '电网', color: '#ff8c00' },
|
||||
pv: { x: w * 0.12, y: h * 0.68, label: '光伏', color: '#00ff88' },
|
||||
building: { x: w * 0.5, y: h * 0.5, label: '建筑负载', color: '#00d4ff' },
|
||||
heatpump: { x: w * 0.85, y: h * 0.38, label: '热泵', color: '#00d4ff' },
|
||||
heating: { x: w * 0.85, y: h * 0.68, label: '供暖', color: '#ff4757' },
|
||||
};
|
||||
|
||||
// Define paths: [from, to]
|
||||
const paths = [
|
||||
{ from: nodes.grid, to: nodes.building, color: '#ff8c00', value: gridPower },
|
||||
{ from: nodes.pv, to: nodes.building, color: '#00ff88', value: pvPower },
|
||||
{ from: nodes.building, to: nodes.heatpump, color: '#00d4ff', value: hpPower },
|
||||
{ from: nodes.heatpump, to: nodes.heating, color: '#ff4757', value: hpPower * 3.5 },
|
||||
];
|
||||
|
||||
// Draw paths
|
||||
paths.forEach((path) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(path.from.x, path.from.y);
|
||||
// Bezier curve
|
||||
const mx = (path.from.x + path.to.x) / 2;
|
||||
ctx.bezierCurveTo(mx, path.from.y, mx, path.to.y, path.to.x, path.to.y);
|
||||
ctx.strokeStyle = path.color + '30';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
// Animate particles
|
||||
particlesRef.current.forEach((p) => {
|
||||
p.progress += p.speed;
|
||||
if (p.progress > 1) {
|
||||
p.progress = 0;
|
||||
p.pathIndex = Math.floor(Math.random() * paths.length);
|
||||
}
|
||||
|
||||
const path = paths[p.pathIndex];
|
||||
if (!path) return;
|
||||
const t = p.progress;
|
||||
const mx = (path.from.x + path.to.x) / 2;
|
||||
// Cubic bezier interpolation
|
||||
const u = 1 - t;
|
||||
const x = u * u * u * path.from.x + 3 * u * u * t * mx + 3 * u * t * t * mx + t * t * t * path.to.x;
|
||||
const y = u * u * u * path.from.y + 3 * u * u * t * path.from.y + 3 * u * t * t * path.to.y + t * t * t * path.to.y;
|
||||
|
||||
const alpha = t < 0.1 ? t / 0.1 : t > 0.9 ? (1 - t) / 0.1 : 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = path.color;
|
||||
ctx.globalAlpha = alpha * 0.9;
|
||||
ctx.fill();
|
||||
|
||||
// Glow
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 8, 0, Math.PI * 2);
|
||||
ctx.fillStyle = path.color;
|
||||
ctx.globalAlpha = alpha * 0.2;
|
||||
ctx.fill();
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
});
|
||||
|
||||
// Draw nodes
|
||||
Object.values(nodes).forEach((node) => {
|
||||
// Node bg
|
||||
ctx.beginPath();
|
||||
const rw = 60, rh = 36, r = 8;
|
||||
const nx = node.x - rw, ny = node.y - rh;
|
||||
const nw = rw * 2, nh = rh * 2;
|
||||
ctx.moveTo(nx + r, ny);
|
||||
ctx.lineTo(nx + nw - r, ny);
|
||||
ctx.quadraticCurveTo(nx + nw, ny, nx + nw, ny + r);
|
||||
ctx.lineTo(nx + nw, ny + nh - r);
|
||||
ctx.quadraticCurveTo(nx + nw, ny + nh, nx + nw - r, ny + nh);
|
||||
ctx.lineTo(nx + r, ny + nh);
|
||||
ctx.quadraticCurveTo(nx, ny + nh, nx, ny + nh - r);
|
||||
ctx.lineTo(nx, ny + r);
|
||||
ctx.quadraticCurveTo(nx, ny, nx + r, ny);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(6, 30, 62, 0.95)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = node.color + '66';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Shadow glow
|
||||
ctx.shadowColor = node.color;
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.strokeStyle = node.color + '33';
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = 'rgba(224, 232, 240, 0.7)';
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(node.label, node.x, node.y - 8);
|
||||
|
||||
// Value
|
||||
let val = '0 kW';
|
||||
if (node.label === '电网') val = gridPower.toFixed(1) + ' kW';
|
||||
else if (node.label === '光伏') val = pvPower.toFixed(1) + ' kW';
|
||||
else if (node.label === '建筑负载') val = totalPower.toFixed(1) + ' kW';
|
||||
else if (node.label === '热泵') val = hpPower.toFixed(1) + ' kW';
|
||||
else if (node.label === '供暖') val = (hpPower * 3.5).toFixed(1) + ' kW';
|
||||
|
||||
ctx.fillStyle = node.color;
|
||||
ctx.font = 'bold 15px system-ui, sans-serif';
|
||||
ctx.fillText(val, node.x, node.y + 12);
|
||||
});
|
||||
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [gridPower, pvPower, totalPower, hpPower]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.energyFlowWrap}>
|
||||
<canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import styles from '../styles.module.css';
|
||||
import AnimatedNumber from './AnimatedNumber';
|
||||
|
||||
interface Props {
|
||||
data: any;
|
||||
realtime: any;
|
||||
}
|
||||
|
||||
export default function EnergyOverviewCard({ data, realtime }: Props) {
|
||||
const selfUseRate = data?.self_consumption_rate ?? 0;
|
||||
|
||||
const gaugeOption = {
|
||||
series: [{
|
||||
type: 'gauge',
|
||||
startAngle: 220,
|
||||
endAngle: -40,
|
||||
radius: '90%',
|
||||
center: ['50%', '55%'],
|
||||
min: 0,
|
||||
max: 100,
|
||||
splitNumber: 5,
|
||||
progress: { show: true, width: 12, itemStyle: { color: '#00d4ff' } },
|
||||
axisLine: { lineStyle: { width: 12, color: [[1, 'rgba(0, 212, 255, 0.15)']] } },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
pointer: { show: false },
|
||||
title: { show: false },
|
||||
detail: {
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: '#00d4ff',
|
||||
offsetCenter: [0, '10%'],
|
||||
formatter: '{value}%',
|
||||
},
|
||||
data: [{ value: selfUseRate.toFixed(1) }],
|
||||
}],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.card} style={{ flex: '0 0 auto' }}>
|
||||
<div className={styles.cardTitle}>综合能源概览</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.statRow}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>今日用电</span>
|
||||
<span className={styles.statValueCyan}>
|
||||
<AnimatedNumber value={data?.energy_today ?? 0} decimals={1} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>光伏发电</span>
|
||||
<span className={styles.statValueGreen}>
|
||||
<AnimatedNumber value={data?.pv_generation_today ?? 0} decimals={1} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.statRow}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>电网购电</span>
|
||||
<span className={styles.statValueOrange}>
|
||||
<AnimatedNumber value={data?.grid_import_today ?? 0} decimals={1} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>实时功率</span>
|
||||
<span className={styles.statValue}>
|
||||
<AnimatedNumber value={realtime?.total_power ?? 0} decimals={1} />
|
||||
<span className={styles.unit}> kW</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 100, height: 100 }}>
|
||||
<ReactECharts option={gaugeOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>碳排放</span>
|
||||
<span className={styles.statValue}>
|
||||
<AnimatedNumber value={data?.carbon_emission ?? 0} decimals={0} />
|
||||
<span className={styles.unit}> kgCO2</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem} style={{ marginTop: 6 }}>
|
||||
<span className={styles.statLabel}>碳减排</span>
|
||||
<span className={styles.statValueGreen}>
|
||||
<AnimatedNumber value={data?.carbon_reduction ?? 0} decimals={0} />
|
||||
<span className={styles.unit}> kgCO2</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import styles from '../styles.module.css';
|
||||
import AnimatedNumber from './AnimatedNumber';
|
||||
|
||||
interface Props {
|
||||
realtime: any;
|
||||
overview: any;
|
||||
}
|
||||
|
||||
export default function HeatPumpCard({ realtime, overview }: Props) {
|
||||
const hpPower = realtime?.heatpump_power ?? 0;
|
||||
const cop = overview?.heatpump_cop ?? 3.5;
|
||||
const todayConsumption = overview?.heatpump_consumption_today ?? 0;
|
||||
const monthlyConsumption = overview?.heatpump_monthly_consumption ?? 0;
|
||||
const operatingHours = overview?.heatpump_operating_hours ?? 0;
|
||||
|
||||
const copGaugeOption = {
|
||||
series: [{
|
||||
type: 'gauge',
|
||||
startAngle: 220,
|
||||
endAngle: -40,
|
||||
radius: '90%',
|
||||
center: ['50%', '55%'],
|
||||
min: 0,
|
||||
max: 6,
|
||||
splitNumber: 3,
|
||||
progress: {
|
||||
show: true,
|
||||
width: 10,
|
||||
itemStyle: { color: cop >= 3 ? '#00ff88' : '#ff8c00' },
|
||||
},
|
||||
axisLine: { lineStyle: { width: 10, color: [[1, 'rgba(0, 255, 136, 0.12)']] } },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
pointer: { show: false },
|
||||
title: { show: false },
|
||||
detail: {
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: '#00ff88',
|
||||
offsetCenter: [0, '10%'],
|
||||
formatter: '{value}',
|
||||
},
|
||||
data: [{ value: cop.toFixed(2) }],
|
||||
}],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.card} style={{ flex: '0 0 auto' }}>
|
||||
<div className={styles.cardTitle}>热泵系统</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<div>
|
||||
<div className={styles.statLabel}>实时功率</div>
|
||||
<span>
|
||||
<AnimatedNumber value={hpPower} decimals={1} className={styles.bigNumberCyan} />
|
||||
<span className={styles.unit}> kW</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ width: 90, height: 80, flexShrink: 0 }}>
|
||||
<ReactECharts option={copGaugeOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'rgba(224,232,240,0.5)' }}>
|
||||
平均COP
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.statRow}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>今日用电</span>
|
||||
<span className={styles.statValueCyan}>
|
||||
<AnimatedNumber value={todayConsumption} decimals={1} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>本月用电</span>
|
||||
<span className={styles.statValueCyan}>
|
||||
<AnimatedNumber value={monthlyConsumption} decimals={0} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.statRow}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>今日运行</span>
|
||||
<span className={styles.statValue}>
|
||||
<AnimatedNumber value={operatingHours} decimals={1} />
|
||||
<span className={styles.unit}> h</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import styles from '../styles.module.css';
|
||||
|
||||
interface Props {
|
||||
loadData: any;
|
||||
}
|
||||
|
||||
export default function LoadCurveCard({ loadData }: Props) {
|
||||
const hours = loadData?.hours ?? Array.from({ length: 24 }, (_, i) => `${i}:00`);
|
||||
const values = loadData?.values ?? new Array(24).fill(0);
|
||||
const peak = values.length ? Math.max(...values) : 0;
|
||||
const valley = values.length ? Math.min(...values.filter((v: number) => v > 0)) || 0 : 0;
|
||||
const avg = values.length ? values.reduce((a: number, b: number) => a + b, 0) / values.filter((v: number) => v > 0).length || 0 : 0;
|
||||
|
||||
const option = {
|
||||
grid: { left: 40, right: 12, top: 30, bottom: 24 },
|
||||
tooltip: {
|
||||
trigger: 'axis' as const,
|
||||
backgroundColor: 'rgba(6,30,62,0.9)',
|
||||
borderColor: 'rgba(0,212,255,0.3)',
|
||||
textStyle: { color: '#e0e8f0', fontSize: 12 },
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: hours,
|
||||
axisLine: { lineStyle: { color: 'rgba(0,212,255,0.2)' } },
|
||||
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.5)', interval: 3 },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value' as const,
|
||||
name: 'kW',
|
||||
nameTextStyle: { color: 'rgba(224,232,240,0.4)', fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: 'rgba(0,212,255,0.08)' } },
|
||||
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.4)' },
|
||||
},
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: values,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#00d4ff', width: 2 },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(0, 212, 255, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(0, 212, 255, 0.02)' },
|
||||
],
|
||||
} as any,
|
||||
},
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { type: 'dashed' as const, width: 1 },
|
||||
data: [
|
||||
{ yAxis: peak, label: { formatter: '峰值 {c}kW', color: '#ff4757', fontSize: 10 }, lineStyle: { color: '#ff475740' } },
|
||||
{ yAxis: avg, label: { formatter: '均值 {c}kW', color: '#ff8c00', fontSize: 10 }, lineStyle: { color: '#ff8c0040' } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.card} style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className={styles.cardTitle}>用电分析</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.statRow} style={{ marginBottom: 4 }}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>峰值 <span style={{ color: '#ff4757' }}>{peak.toFixed(1)} kW</span></span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>谷值 <span style={{ color: '#00ff88' }}>{valley.toFixed(1)} kW</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.chartWrap} style={{ flex: 1, minHeight: 0 }}>
|
||||
<ReactECharts option={option} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import styles from '../styles.module.css';
|
||||
import AnimatedNumber from './AnimatedNumber';
|
||||
|
||||
interface Props {
|
||||
realtime: any;
|
||||
overview: any;
|
||||
}
|
||||
|
||||
export default function PVCard({ realtime, overview }: Props) {
|
||||
const pvPower = realtime?.pv_power ?? 0;
|
||||
const todayGen = overview?.pv_generation_today ?? 0;
|
||||
const monthlyGen = overview?.pv_monthly_generation ?? 0;
|
||||
const selfUseRate = overview?.self_consumption_rate ?? 0;
|
||||
|
||||
// Donut for self-use ratio
|
||||
const donutOption = {
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['60%', '80%'],
|
||||
center: ['50%', '50%'],
|
||||
silent: true,
|
||||
label: { show: false },
|
||||
data: [
|
||||
{ value: selfUseRate, itemStyle: { color: '#00ff88' } },
|
||||
{ value: 100 - selfUseRate, itemStyle: { color: 'rgba(0, 255, 136, 0.1)' } },
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
// Monthly PV bar chart (mock 12 months)
|
||||
const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
|
||||
const monthlyData = overview?.monthly_pv_data ?? months.map(() => Math.round(Math.random() * 3000 + 1000));
|
||||
|
||||
const barOption = {
|
||||
grid: { left: 35, right: 8, top: 8, bottom: 20 },
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: months,
|
||||
axisLine: { lineStyle: { color: 'rgba(0,212,255,0.2)' } },
|
||||
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.5)' },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value' as const,
|
||||
splitLine: { lineStyle: { color: 'rgba(0,212,255,0.08)' } },
|
||||
axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.4)' },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: monthlyData,
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#00ff88' },
|
||||
{ offset: 1, color: 'rgba(0, 255, 136, 0.2)' },
|
||||
],
|
||||
} as any,
|
||||
borderRadius: [2, 2, 0, 0],
|
||||
},
|
||||
barWidth: '50%',
|
||||
}],
|
||||
tooltip: { trigger: 'axis' as const, backgroundColor: 'rgba(6,30,62,0.9)', borderColor: 'rgba(0,212,255,0.3)', textStyle: { color: '#e0e8f0', fontSize: 12 } },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.card} style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className={styles.cardTitle}>光伏发电</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8 }}>
|
||||
<div>
|
||||
<div className={styles.statLabel}>实时功率</div>
|
||||
<span className={styles.bigNumberGreen || styles.bigNumber}>
|
||||
<AnimatedNumber value={pvPower} decimals={1} className={styles.bigNumber} />
|
||||
<span className={styles.unit}> kW</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ width: 64, height: 64, flexShrink: 0 }}>
|
||||
<ReactECharts option={donutOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'rgba(224,232,240,0.5)' }}>
|
||||
自用率<br/>
|
||||
<span style={{ fontSize: 16, fontWeight: 700, color: '#00ff88' }}>{selfUseRate.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.statRow}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>今日发电</span>
|
||||
<span className={styles.statValueGreen}>
|
||||
<AnimatedNumber value={todayGen} decimals={1} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>本月发电</span>
|
||||
<span className={styles.statValueGreen}>
|
||||
<AnimatedNumber value={monthlyGen} decimals={0} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.chartWrap} style={{ flex: 1, minHeight: 0 }}>
|
||||
<ReactECharts option={barOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import styles from './styles.module.css';
|
||||
import EnergyOverviewCard from './components/EnergyOverviewCard';
|
||||
import PVCard from './components/PVCard';
|
||||
import HeatPumpCard from './components/HeatPumpCard';
|
||||
import EnergyFlowDiagram from './components/EnergyFlowDiagram';
|
||||
import LoadCurveCard from './components/LoadCurveCard';
|
||||
import AlarmCard from './components/AlarmCard';
|
||||
import CarbonCard from './components/CarbonCard';
|
||||
import AnimatedNumber from './components/AnimatedNumber';
|
||||
import useRealtimeWebSocket from '../../hooks/useRealtimeWebSocket';
|
||||
import {
|
||||
getDashboardOverview,
|
||||
getRealtimeData,
|
||||
getLoadCurve,
|
||||
getAlarmEvents,
|
||||
getAlarmStats,
|
||||
getCarbonOverview,
|
||||
getCarbonTrend,
|
||||
getDeviceStats,
|
||||
} from '../../services/api';
|
||||
|
||||
export default function BigScreen() {
|
||||
const [clock, setClock] = useState(new Date());
|
||||
const [overview, setOverview] = useState<any>(null);
|
||||
const [realtime, setRealtime] = useState<any>(null);
|
||||
const [loadData, setLoadData] = useState<any>(null);
|
||||
const [alarmEvents, setAlarmEvents] = useState<any[]>([]);
|
||||
const [alarmStats, setAlarmStats] = useState<any>(null);
|
||||
const [carbonOverview, setCarbonOverview] = useState<any>(null);
|
||||
const [carbonTrend, setCarbonTrend] = useState<any>(null);
|
||||
const [deviceStats, setDeviceStats] = useState<any>(null);
|
||||
const timerRef = useRef<any>(null);
|
||||
|
||||
// WebSocket for real-time updates
|
||||
const { data: wsData, connected: wsConnected, usingFallback } = useRealtimeWebSocket({
|
||||
onAlarmEvent: (alarm) => {
|
||||
// Prepend new alarm to events list
|
||||
setAlarmEvents((prev) => [alarm as any, ...prev].slice(0, 5));
|
||||
// Increment active alarm count
|
||||
setAlarmStats((prev: any) => prev ? { ...prev, active_count: (prev.active_count ?? 0) + 1 } : prev);
|
||||
},
|
||||
});
|
||||
|
||||
// Merge WebSocket realtime data into state
|
||||
useEffect(() => {
|
||||
if (wsData) {
|
||||
setRealtime((prev: any) => ({
|
||||
...prev,
|
||||
pv_power: wsData.pv_power,
|
||||
heatpump_power: wsData.heatpump_power,
|
||||
total_power: wsData.total_load,
|
||||
grid_power: wsData.grid_power,
|
||||
}));
|
||||
}
|
||||
}, [wsData]);
|
||||
|
||||
// Clock update every second
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setClock(new Date()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
// Fetch all data
|
||||
const fetchAll = useCallback(async () => {
|
||||
try {
|
||||
const results = await Promise.allSettled([
|
||||
getDashboardOverview(),
|
||||
getRealtimeData(),
|
||||
getLoadCurve(24),
|
||||
getAlarmEvents({ limit: 5 }),
|
||||
getAlarmStats(),
|
||||
getCarbonOverview(),
|
||||
getCarbonTrend(365),
|
||||
getDeviceStats(),
|
||||
]);
|
||||
|
||||
const get = (i: number) => {
|
||||
const r = results[i];
|
||||
if (r.status === 'fulfilled') {
|
||||
const val = r.value as any;
|
||||
return val?.data ?? val;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (get(0)) setOverview(get(0));
|
||||
if (get(1)) setRealtime(get(1));
|
||||
if (get(2)) setLoadData(get(2));
|
||||
if (get(3)) {
|
||||
const alarms = get(3);
|
||||
setAlarmEvents(Array.isArray(alarms) ? alarms : alarms?.items ?? alarms?.events ?? []);
|
||||
}
|
||||
if (get(4)) setAlarmStats(get(4));
|
||||
if (get(5)) setCarbonOverview(get(5));
|
||||
if (get(6)) setCarbonTrend(get(6));
|
||||
if (get(7)) setDeviceStats(get(7));
|
||||
} catch (e) {
|
||||
console.error('BigScreen fetch error:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial fetch always. Polling at 15s only if WS is disconnected (fallback).
|
||||
// When WS is connected, poll at 60s for non-realtime data (overview, load curve, carbon, etc.)
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
const interval = wsConnected && !usingFallback ? 60000 : 15000;
|
||||
timerRef.current = setInterval(fetchAll, interval);
|
||||
return () => clearInterval(timerRef.current);
|
||||
}, [fetchAll, wsConnected, usingFallback]);
|
||||
|
||||
const formatDate = (d: Date) => {
|
||||
const y = d.getFullYear();
|
||||
const m = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = d.getDate().toString().padStart(2, '0');
|
||||
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
||||
return `${y}年${m}月${day}日 ${weekdays[d.getDay()]}`;
|
||||
};
|
||||
|
||||
const formatTime = (d: Date) => {
|
||||
return d.toLocaleTimeString('zh-CN', { hour12: false });
|
||||
};
|
||||
|
||||
const totalDevices = deviceStats?.total ?? 0;
|
||||
const onlineDevices = deviceStats?.online ?? 0;
|
||||
const offlineDevices = deviceStats?.offline ?? 0;
|
||||
const alarmDevices = deviceStats?.alarm_count ?? alarmStats?.active_count ?? 0;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Header */}
|
||||
<div className={styles.header}>
|
||||
<span className={styles.headerDate}>{formatDate(clock)}</span>
|
||||
<h1 className={styles.headerTitle}>天普零碳园区智慧能源管理平台</h1>
|
||||
<span className={styles.headerTime}>{formatTime(clock)}</span>
|
||||
</div>
|
||||
|
||||
{/* WebSocket connection indicator */}
|
||||
<div className={styles.wsIndicator}>
|
||||
<span className={wsConnected ? styles.wsIndicatorDotConnected : styles.wsIndicatorDotDisconnected} />
|
||||
<span>{wsConnected ? '实时' : '轮询'}</span>
|
||||
</div>
|
||||
|
||||
{/* Main 3-column grid */}
|
||||
<div className={styles.mainGrid}>
|
||||
{/* Left Column */}
|
||||
<div className={styles.column}>
|
||||
<EnergyOverviewCard data={overview} realtime={realtime} />
|
||||
<PVCard realtime={realtime} overview={overview} />
|
||||
<HeatPumpCard realtime={realtime} overview={overview} />
|
||||
</div>
|
||||
|
||||
{/* Center Column */}
|
||||
<div className={styles.column}>
|
||||
<div className={styles.centerCard} style={{ flex: 1 }}>
|
||||
<div className={styles.cardTitle}>能源流向</div>
|
||||
<EnergyFlowDiagram realtime={realtime} overview={overview} />
|
||||
</div>
|
||||
<div className={styles.card} style={{ flex: '0 0 auto', padding: '10px 16px' }}>
|
||||
<div className={styles.cardTitle}>设备状态</div>
|
||||
<div className={styles.deviceStatusBar}>
|
||||
<div className={styles.deviceStatusItem}>
|
||||
<span className={styles.statusDotCyan} />
|
||||
<span className={styles.statusLabel}>总设备</span>
|
||||
<AnimatedNumber value={totalDevices} className={styles.statusCount} />
|
||||
</div>
|
||||
<div className={styles.deviceStatusItem}>
|
||||
<span className={styles.statusDotGreen} />
|
||||
<span className={styles.statusLabel}>在线</span>
|
||||
<AnimatedNumber value={onlineDevices} className={styles.statusCount} />
|
||||
</div>
|
||||
<div className={styles.deviceStatusItem}>
|
||||
<span className={styles.statusDotRed} />
|
||||
<span className={styles.statusLabel}>离线</span>
|
||||
<AnimatedNumber value={offlineDevices} className={styles.statusCount} />
|
||||
</div>
|
||||
<div className={styles.deviceStatusItem}>
|
||||
<span className={styles.statusDotOrange} />
|
||||
<span className={styles.statusLabel}>告警</span>
|
||||
<AnimatedNumber value={alarmDevices} className={styles.statusCount} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className={styles.column}>
|
||||
<LoadCurveCard loadData={loadData} />
|
||||
<AlarmCard alarmEvents={alarmEvents} alarmStats={alarmStats} />
|
||||
<CarbonCard carbonOverview={carbonOverview} carbonTrend={carbonTrend} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,658 +0,0 @@
|
||||
/* Big Screen Visualization Dashboard - Dark Theme */
|
||||
|
||||
.container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #0a1628;
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(0, 212, 255, 0.06) 1px, transparent 0);
|
||||
background-size: 40px 40px;
|
||||
color: #e0e8f0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, rgba(0, 212, 255, 0.12) 0%, transparent 100%);
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 6px;
|
||||
background: linear-gradient(90deg, #00d4ff, #00ff88, #00d4ff);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: shimmer 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
.headerTime {
|
||||
position: absolute;
|
||||
right: 32px;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 212, 255, 0.8);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.headerDate {
|
||||
position: absolute;
|
||||
left: 32px;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 212, 255, 0.6);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Main Grid */
|
||||
.mainGrid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: rgba(6, 30, 62, 0.85);
|
||||
border: 1px solid rgba(0, 212, 255, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 0 12px rgba(0, 212, 255, 0.08),
|
||||
inset 0 1px 0 rgba(0, 212, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #00d4ff;
|
||||
margin-bottom: 12px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid #00d4ff;
|
||||
letter-spacing: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Big numbers */
|
||||
.bigNumber {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #00ff88;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bigNumberCyan {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #00d4ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bigNumberOrange {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ff8c00;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bigNumberRed {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #ff4757;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: rgba(224, 232, 240, 0.6);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Stat grid rows */
|
||||
.statRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.statItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 12px;
|
||||
color: rgba(224, 232, 240, 0.5);
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #e0e8f0;
|
||||
}
|
||||
|
||||
.statValueCyan {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.statValueGreen {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.statValueOrange {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ff8c00;
|
||||
}
|
||||
|
||||
/* Center - Energy Flow */
|
||||
.centerCard {
|
||||
background: rgba(6, 30, 62, 0.85);
|
||||
border: 1px solid rgba(0, 212, 255, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 0 12px rgba(0, 212, 255, 0.08),
|
||||
inset 0 1px 0 rgba(0, 212, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.centerCard::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
/* Device status bar */
|
||||
.deviceStatusBar {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.deviceStatusItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.statusDotGreen {
|
||||
composes: statusDot;
|
||||
background: #00ff88;
|
||||
box-shadow: 0 0 8px rgba(0, 255, 136, 0.5);
|
||||
}
|
||||
|
||||
.statusDotRed {
|
||||
composes: statusDot;
|
||||
background: #ff4757;
|
||||
box-shadow: 0 0 8px rgba(255, 71, 87, 0.5);
|
||||
}
|
||||
|
||||
.statusDotOrange {
|
||||
composes: statusDot;
|
||||
background: #ff8c00;
|
||||
box-shadow: 0 0 8px rgba(255, 140, 0, 0.5);
|
||||
}
|
||||
|
||||
.statusDotCyan {
|
||||
composes: statusDot;
|
||||
background: #00d4ff;
|
||||
box-shadow: 0 0 8px rgba(0, 212, 255, 0.5);
|
||||
}
|
||||
|
||||
.statusLabel {
|
||||
font-size: 13px;
|
||||
color: rgba(224, 232, 240, 0.6);
|
||||
}
|
||||
|
||||
.statusCount {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #e0e8f0;
|
||||
}
|
||||
|
||||
/* Alarm list */
|
||||
.alarmList {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.alarmItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alarmSeverity {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alarmSeverityCritical {
|
||||
composes: alarmSeverity;
|
||||
background: #ff4757;
|
||||
box-shadow: 0 0 6px rgba(255, 71, 87, 0.6);
|
||||
}
|
||||
|
||||
.alarmSeverityWarning {
|
||||
composes: alarmSeverity;
|
||||
background: #ff8c00;
|
||||
box-shadow: 0 0 6px rgba(255, 140, 0, 0.6);
|
||||
}
|
||||
|
||||
.alarmSeverityInfo {
|
||||
composes: alarmSeverity;
|
||||
background: #00d4ff;
|
||||
box-shadow: 0 0 6px rgba(0, 212, 255, 0.6);
|
||||
}
|
||||
|
||||
.alarmMsg {
|
||||
flex: 1;
|
||||
color: rgba(224, 232, 240, 0.8);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.alarmTime {
|
||||
color: rgba(224, 232, 240, 0.4);
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Progress ring */
|
||||
.progressRing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Chart wrapper */
|
||||
.chartWrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Energy Flow SVG area */
|
||||
.energyFlowWrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Animated flow particles */
|
||||
@keyframes flowRight {
|
||||
0% { offset-distance: 0%; opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { offset-distance: 100%; opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes flowLeft {
|
||||
0% { offset-distance: 100%; opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { offset-distance: 0%; opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.6; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
}
|
||||
|
||||
.pulseAnim {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Energy flow node */
|
||||
.flowNode {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 140px;
|
||||
height: 100px;
|
||||
background: rgba(6, 30, 62, 0.95);
|
||||
border: 1.5px solid rgba(0, 212, 255, 0.4);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 20px rgba(0, 212, 255, 0.15);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.flowNodeLabel {
|
||||
font-size: 13px;
|
||||
color: rgba(224, 232, 240, 0.7);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.flowNodeValue {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.flowNodeUnit {
|
||||
font-size: 11px;
|
||||
color: rgba(224, 232, 240, 0.5);
|
||||
}
|
||||
|
||||
/* Gauge display */
|
||||
.gaugeWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.gaugeLabel {
|
||||
font-size: 12px;
|
||||
color: rgba(224, 232, 240, 0.5);
|
||||
}
|
||||
|
||||
/* WebSocket connection indicator */
|
||||
.wsIndicator {
|
||||
position: fixed;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
font-size: 11px;
|
||||
color: rgba(224, 232, 240, 0.4);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wsIndicatorDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.wsIndicatorDotConnected {
|
||||
composes: wsIndicatorDot;
|
||||
background: #00ff88;
|
||||
box-shadow: 0 0 6px rgba(0, 255, 136, 0.5);
|
||||
}
|
||||
|
||||
.wsIndicatorDotDisconnected {
|
||||
composes: wsIndicatorDot;
|
||||
background: #ff8c00;
|
||||
box-shadow: 0 0 6px rgba(255, 140, 0, 0.5);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Responsive: Tablet (768px and below)
|
||||
============================================ */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 56px;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
font-size: 18px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.headerDate {
|
||||
position: static;
|
||||
font-size: 12px;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.headerTime {
|
||||
position: static;
|
||||
font-size: 14px;
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.mainGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.column {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.centerCard {
|
||||
padding: 12px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bigNumber,
|
||||
.bigNumberCyan,
|
||||
.bigNumberOrange {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.bigNumberRed {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.statValue,
|
||||
.statValueCyan,
|
||||
.statValueGreen,
|
||||
.statValueOrange {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.deviceStatusBar {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.statusCount {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.flowNode {
|
||||
width: 110px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.flowNodeValue {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.flowNodeLabel {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Responsive: Mobile (375px and below)
|
||||
============================================ */
|
||||
@media (max-width: 375px) {
|
||||
.header {
|
||||
height: 48px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
font-size: 14px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.headerDate {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.headerTime {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mainGrid {
|
||||
padding: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.column {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.centerCard {
|
||||
min-height: 250px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.bigNumber,
|
||||
.bigNumberCyan,
|
||||
.bigNumberOrange {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.bigNumberRed {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.statRow {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.statValue,
|
||||
.statValueCyan,
|
||||
.statValueGreen,
|
||||
.statValueOrange {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.deviceStatusBar {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.alarmItem {
|
||||
font-size: 11px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.flowNode {
|
||||
width: 90px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.flowNodeValue {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flowNodeLabel {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { Html } from '@react-three/drei';
|
||||
import { BUILDINGS, COLORS } from '../constants';
|
||||
|
||||
interface BuildingsProps {
|
||||
detailMode?: boolean;
|
||||
onBuildingClick?: (building: string) => void;
|
||||
}
|
||||
|
||||
function WindowGrid({ width, height, depth }: { width: number; height: number; depth: number }) {
|
||||
const windows = useMemo(() => {
|
||||
const cols = 4;
|
||||
const rows = 3;
|
||||
const winW = 1.5;
|
||||
const winH = 0.8;
|
||||
const winD = 0.05;
|
||||
const gapX = (width - cols * winW) / (cols + 1);
|
||||
const gapY = (height - rows * winH) / (rows + 1);
|
||||
const items: { pos: [number, number, number] }[] = [];
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = -width / 2 + gapX + winW / 2 + c * (winW + gapX);
|
||||
const y = -height / 2 + gapY + winH / 2 + r * (winH + gapY);
|
||||
items.push({ pos: [x, y, depth / 2 + winD / 2] });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [width, height, depth]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{windows.map((w, i) => (
|
||||
<mesh key={i} position={w.pos}>
|
||||
<boxGeometry args={[1.5, 0.8, 0.05]} />
|
||||
<meshStandardMaterial
|
||||
color="#ffcc66"
|
||||
emissive="#ffcc66"
|
||||
emissiveIntensity={0.3}
|
||||
transparent
|
||||
opacity={0.6}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function Building({
|
||||
label,
|
||||
position,
|
||||
size,
|
||||
opacity,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
position: [number, number, number];
|
||||
size: [number, number, number];
|
||||
opacity: number;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const [w, h, d] = size;
|
||||
|
||||
const edgesGeo = useMemo(() => {
|
||||
const box = new THREE.BoxGeometry(w, h, d);
|
||||
return new THREE.EdgesGeometry(box);
|
||||
}, [w, h, d]);
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: '13px',
|
||||
color: COLORS.text,
|
||||
background: 'rgba(6, 30, 62, 0.8)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid rgba(0, 212, 255, 0.2)',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position} onClick={onClick ? (e) => { e.stopPropagation(); onClick(); } : undefined}>
|
||||
{/* Main body */}
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[w, h, d]} />
|
||||
<meshStandardMaterial
|
||||
color={COLORS.buildingBase}
|
||||
transparent
|
||||
opacity={opacity}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Edge highlight */}
|
||||
<lineSegments geometry={edgesGeo}>
|
||||
<lineBasicMaterial color="#00d4ff" transparent opacity={0.3} />
|
||||
</lineSegments>
|
||||
|
||||
{/* Windows on front face */}
|
||||
<WindowGrid width={w} height={h} depth={d} />
|
||||
|
||||
{/* Label */}
|
||||
<Html position={[0, h / 2 + 1.5, 0]} center>
|
||||
<div style={labelStyle}>{label}</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Buildings({ detailMode, onBuildingClick }: BuildingsProps) {
|
||||
const opacity = detailMode ? 0.15 : 0.85;
|
||||
|
||||
return (
|
||||
<group>
|
||||
<Building
|
||||
label={BUILDINGS.east.label}
|
||||
position={[...BUILDINGS.east.position]}
|
||||
size={[...BUILDINGS.east.size]}
|
||||
opacity={opacity}
|
||||
onClick={onBuildingClick ? () => onBuildingClick('east') : undefined}
|
||||
/>
|
||||
<Building
|
||||
label={BUILDINGS.west.label}
|
||||
position={[...BUILDINGS.west.position]}
|
||||
size={[...BUILDINGS.west.size]}
|
||||
opacity={opacity}
|
||||
onClick={onBuildingClick ? () => onBuildingClick('west') : undefined}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import { EffectComposer, Bloom } from '@react-three/postprocessing';
|
||||
import { CAMERA, COLORS } from '../constants';
|
||||
import { useCameraAnimation } from '../hooks/useCameraAnimation';
|
||||
import SceneEnvironment from './SceneEnvironment';
|
||||
import Ground from './Ground';
|
||||
import Buildings from './Buildings';
|
||||
import PVPanels from './PVPanels';
|
||||
import HeatPumps from './HeatPumps';
|
||||
import DeviceMarkers from './DeviceMarkers';
|
||||
import EnergyParticles from './EnergyParticles';
|
||||
import DeviceDetailView from './DeviceDetailView';
|
||||
|
||||
interface CampusSceneProps {
|
||||
devices: Array<any>;
|
||||
energyFlow: { nodes: any[]; links: any[] };
|
||||
realtimeData: any | null;
|
||||
selectedDevice: any | null;
|
||||
selectedDevicePosition: [number, number, number] | null;
|
||||
detailRealtimeData: Record<string, { value: number; unit: string }> | null;
|
||||
hoveredDeviceId: number | null;
|
||||
viewMode: 'campus' | 'device-detail';
|
||||
onDeviceSelect: (device: any) => void;
|
||||
onDeviceHover: (id: number | null) => void;
|
||||
}
|
||||
|
||||
// Inner component: handles camera animation from within Canvas context
|
||||
function CameraController({
|
||||
selectedDevicePosition,
|
||||
viewMode,
|
||||
}: {
|
||||
selectedDevicePosition: [number, number, number] | null;
|
||||
viewMode: 'campus' | 'device-detail';
|
||||
}) {
|
||||
const { animateTo, resetToOverview } = useCameraAnimation();
|
||||
const prevViewMode = useRef(viewMode);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'device-detail' && selectedDevicePosition) {
|
||||
const [x, y, z] = selectedDevicePosition;
|
||||
animateTo([x + 8, y + 6, z + 10], [x, y, z], 1.5);
|
||||
} else if (viewMode === 'campus' && prevViewMode.current === 'device-detail') {
|
||||
resetToOverview();
|
||||
}
|
||||
prevViewMode.current = viewMode;
|
||||
}, [viewMode, selectedDevicePosition, animateTo, resetToOverview]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Inner scene content (must be inside Canvas to use R3F hooks)
|
||||
function SceneContent({
|
||||
devices,
|
||||
energyFlow,
|
||||
realtimeData,
|
||||
selectedDevice,
|
||||
selectedDevicePosition,
|
||||
detailRealtimeData,
|
||||
hoveredDeviceId,
|
||||
viewMode,
|
||||
onDeviceSelect,
|
||||
onDeviceHover,
|
||||
}: CampusSceneProps) {
|
||||
const detailMode = viewMode === 'device-detail';
|
||||
|
||||
const { pvDevices, hpDevices, markerDevices } = useMemo(() => {
|
||||
const pv: Array<{ id: number; code: string; status: string; power?: number; rated_power?: number }> = [];
|
||||
const hp: Array<{ id: number; code: string; status: string; power?: number }> = [];
|
||||
const markers: Array<{
|
||||
id: number;
|
||||
code: string;
|
||||
device_type: string;
|
||||
name: string;
|
||||
status: string;
|
||||
primaryValue?: string;
|
||||
}> = [];
|
||||
|
||||
devices.forEach((d: any) => {
|
||||
const type: string = d.device_type ?? '';
|
||||
const code: string = d.code ?? '';
|
||||
if (type === 'pv_inverter') {
|
||||
pv.push({ id: d.id, code, status: d.status, power: d.power, rated_power: d.rated_power });
|
||||
} else if (type === 'heat_pump') {
|
||||
hp.push({ id: d.id, code, status: d.status, power: d.power });
|
||||
} else if (['meter', 'sensor', 'heat_meter'].includes(type)) {
|
||||
markers.push({
|
||||
id: d.id,
|
||||
code,
|
||||
device_type: type,
|
||||
name: d.name ?? code,
|
||||
status: d.status,
|
||||
primaryValue: d.primaryValue,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { pvDevices: pv, hpDevices: hp, markerDevices: markers };
|
||||
}, [devices]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CameraController
|
||||
selectedDevicePosition={selectedDevicePosition}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
|
||||
<SceneEnvironment />
|
||||
<Ground />
|
||||
|
||||
<Buildings detailMode={detailMode} />
|
||||
|
||||
<PVPanels
|
||||
devices={pvDevices}
|
||||
hoveredId={hoveredDeviceId}
|
||||
onHover={onDeviceHover}
|
||||
onClick={onDeviceSelect}
|
||||
detailMode={detailMode}
|
||||
/>
|
||||
|
||||
<HeatPumps
|
||||
devices={hpDevices}
|
||||
hoveredId={hoveredDeviceId}
|
||||
onHover={onDeviceHover}
|
||||
onClick={onDeviceSelect}
|
||||
detailMode={detailMode}
|
||||
/>
|
||||
|
||||
<DeviceMarkers
|
||||
devices={markerDevices}
|
||||
hoveredId={hoveredDeviceId}
|
||||
onHover={onDeviceHover}
|
||||
onClick={onDeviceSelect}
|
||||
detailMode={detailMode}
|
||||
/>
|
||||
|
||||
<EnergyParticles
|
||||
energyFlow={energyFlow}
|
||||
realtimeData={realtimeData}
|
||||
/>
|
||||
|
||||
{viewMode === 'device-detail' && selectedDevice && selectedDevicePosition && (
|
||||
<DeviceDetailView
|
||||
device={selectedDevice}
|
||||
position={selectedDevicePosition}
|
||||
realtimeData={detailRealtimeData}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OrbitControls
|
||||
enableDamping
|
||||
dampingFactor={0.05}
|
||||
maxPolarAngle={Math.PI / 2.2}
|
||||
minDistance={5}
|
||||
maxDistance={80}
|
||||
/>
|
||||
|
||||
<EffectComposer>
|
||||
<Bloom luminanceThreshold={0.5} intensity={0.6} />
|
||||
</EffectComposer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CampusScene(props: CampusSceneProps) {
|
||||
return (
|
||||
<Canvas
|
||||
camera={{
|
||||
position: CAMERA.campusPosition as unknown as [number, number, number],
|
||||
fov: CAMERA.fov,
|
||||
near: CAMERA.near,
|
||||
far: CAMERA.far,
|
||||
}}
|
||||
shadows="soft"
|
||||
style={{ background: COLORS.background }}
|
||||
gl={{ antialias: true, alpha: false }}
|
||||
>
|
||||
<SceneContent {...props} />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
import { useRef, useMemo } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { Html } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
|
||||
interface DeviceDetailViewProps {
|
||||
device: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
device_type: string;
|
||||
status: string;
|
||||
rated_power?: number;
|
||||
};
|
||||
position: [number, number, number];
|
||||
realtimeData: Record<string, { value: number; unit: string }> | null;
|
||||
}
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
background: 'rgba(6, 30, 62, 0.95)',
|
||||
border: '1px solid rgba(0, 212, 255, 0.3)',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
color: '#e0e8f0',
|
||||
fontSize: 12,
|
||||
minWidth: 200,
|
||||
pointerEvents: 'none' as const,
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
// ─── PV Inverter Detail ─────────────────────────────────────────────
|
||||
function PVDetail({
|
||||
getValue,
|
||||
}: {
|
||||
getValue: (key: string) => number;
|
||||
}) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (groupRef.current) {
|
||||
groupRef.current.rotation.y += 0.1 * delta;
|
||||
}
|
||||
});
|
||||
|
||||
const panels = useMemo(() => {
|
||||
const items: { pos: [number, number, number] }[] = [];
|
||||
const cols = 5;
|
||||
const rows = 3;
|
||||
const pw = 2.5;
|
||||
const ph = 1.2;
|
||||
const depth = 0.08;
|
||||
const gap = 0.3;
|
||||
const totalW = cols * pw + (cols - 1) * gap;
|
||||
const totalD = rows * ph + (rows - 1) * gap;
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = -totalW / 2 + pw / 2 + c * (pw + gap);
|
||||
const z = -totalD / 2 + ph / 2 + r * (ph + gap);
|
||||
items.push({ pos: [x, 0, z] });
|
||||
}
|
||||
}
|
||||
return { items, totalW, depth, pw, ph };
|
||||
}, []);
|
||||
|
||||
const tilt = Math.PI / 6; // 30 degrees
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={[0, 2, 0]}>
|
||||
{/* Scale the whole panel array 1.5x */}
|
||||
<group scale={1.5}>
|
||||
{panels.items.map((p, i) => (
|
||||
<mesh key={i} position={p.pos} rotation={[-tilt, 0, 0]} castShadow>
|
||||
<boxGeometry args={[panels.pw, panels.depth, panels.ph]} />
|
||||
<meshStandardMaterial
|
||||
color="#1e3a5f"
|
||||
metalness={0.85}
|
||||
roughness={0.2}
|
||||
emissive="#004488"
|
||||
emissiveIntensity={0.3}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
{/* Mounting rails */}
|
||||
<mesh position={[0, -0.15, -1]} rotation={[-tilt, 0, 0]}>
|
||||
<boxGeometry args={[panels.totalW, 0.05, 0.05]} />
|
||||
<meshStandardMaterial color="#555555" metalness={0.6} roughness={0.4} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.15, 1]} rotation={[-tilt, 0, 0]}>
|
||||
<boxGeometry args={[panels.totalW, 0.05, 0.05]} />
|
||||
<meshStandardMaterial color="#555555" metalness={0.6} roughness={0.4} />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* HTML overlay – block diagram + live data */}
|
||||
<Html position={[10, 1, 0]} distanceFactor={10}>
|
||||
<div style={overlayStyle}>
|
||||
<div style={{ marginBottom: 8, whiteSpace: 'pre', lineHeight: 1.4, fontSize: 11 }}>
|
||||
{`┌─────────┐ ┌──────┐ ┌─────────┐
|
||||
│ DC Input │ → │ MPPT │ → │ AC Output│
|
||||
└─────────┘ └──────┘ └─────────┘`}
|
||||
</div>
|
||||
<div style={{ borderTop: '1px solid rgba(0,212,255,0.2)', paddingTop: 6 }}>
|
||||
<div>DC电压: {getValue('dc_voltage').toFixed(1)} V</div>
|
||||
<div>AC电压: {getValue('ac_voltage').toFixed(1)} V</div>
|
||||
<div>功率: {getValue('power').toFixed(1)} kW</div>
|
||||
<div>温度: {getValue('temperature').toFixed(1)} ℃</div>
|
||||
</div>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Heat Pump Detail ───────────────────────────────────────────────
|
||||
function HeatPumpDetail({
|
||||
getValue,
|
||||
}: {
|
||||
getValue: (key: string) => number;
|
||||
}) {
|
||||
const particlesRef = useRef<THREE.Group>(null);
|
||||
const fanRef = useRef<THREE.Group>(null);
|
||||
|
||||
// Refrigerant circulation path
|
||||
const { curve, particleCount } = useMemo(() => {
|
||||
const points = [
|
||||
new THREE.Vector3(0, 0, 0), // compressor center
|
||||
new THREE.Vector3(1, 0, 0), // condenser
|
||||
new THREE.Vector3(1, -0.8, 0), // bottom right
|
||||
new THREE.Vector3(0, -0.9, 0), // expansion valve
|
||||
new THREE.Vector3(-1, -0.8, 0), // bottom left
|
||||
new THREE.Vector3(-1, 0, 0), // evaporator
|
||||
new THREE.Vector3(-0.5, 0.4, 0), // top left
|
||||
new THREE.Vector3(0, 0.4, 0), // top center back to compressor
|
||||
];
|
||||
return {
|
||||
curve: new THREE.CatmullRomCurve3(points, true),
|
||||
particleCount: 12,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
// Animate particles along path
|
||||
if (particlesRef.current) {
|
||||
const t = state.clock.elapsedTime;
|
||||
particlesRef.current.children.forEach((child, i) => {
|
||||
const offset = i / particleCount;
|
||||
const pos = curve.getPointAt((t * 0.15 + offset) % 1);
|
||||
child.position.copy(pos);
|
||||
});
|
||||
}
|
||||
// Spin fan
|
||||
if (fanRef.current) {
|
||||
fanRef.current.rotation.y += 3 * delta;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Main housing – transparent cutaway */}
|
||||
<mesh>
|
||||
<boxGeometry args={[3, 2.2, 2.2]} />
|
||||
<meshStandardMaterial
|
||||
color="#2a4a6a"
|
||||
transparent
|
||||
opacity={0.3}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Compressor */}
|
||||
<mesh position={[0, 0, 0]}>
|
||||
<cylinderGeometry args={[0.4, 0.4, 0.8, 16]} />
|
||||
<meshStandardMaterial color="#4488cc" metalness={0.5} roughness={0.3} />
|
||||
</mesh>
|
||||
|
||||
{/* Evaporator (left) */}
|
||||
<mesh position={[-1, 0, 0]}>
|
||||
<boxGeometry args={[0.1, 1.5, 1.5]} />
|
||||
<meshStandardMaterial color="#00aaff" metalness={0.3} roughness={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* Condenser (right) */}
|
||||
<mesh position={[1, 0, 0]}>
|
||||
<boxGeometry args={[0.1, 1.5, 1.5]} />
|
||||
<meshStandardMaterial color="#ff6644" metalness={0.3} roughness={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* Expansion valve */}
|
||||
<mesh position={[0, -0.9, 0]}>
|
||||
<sphereGeometry args={[0.15, 16, 16]} />
|
||||
<meshStandardMaterial color="#ffaa00" metalness={0.4} roughness={0.4} />
|
||||
</mesh>
|
||||
|
||||
{/* Refrigerant particles */}
|
||||
<group ref={particlesRef}>
|
||||
{Array.from({ length: particleCount }).map((_, i) => (
|
||||
<mesh key={i}>
|
||||
<sphereGeometry args={[0.08, 8, 8]} />
|
||||
<meshStandardMaterial
|
||||
color="#00d4ff"
|
||||
emissive="#00d4ff"
|
||||
emissiveIntensity={0.8}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
|
||||
{/* Fan on top */}
|
||||
<group ref={fanRef} position={[0, 1.3, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<mesh key={i} rotation={[0, 0, (i * Math.PI * 2) / 3]}>
|
||||
<boxGeometry args={[0.08, 0.6, 0.02]} />
|
||||
<meshStandardMaterial color="#aaaaaa" metalness={0.5} />
|
||||
</mesh>
|
||||
))}
|
||||
{/* Fan hub */}
|
||||
<mesh>
|
||||
<cylinderGeometry args={[0.08, 0.08, 0.05, 12]} />
|
||||
<meshStandardMaterial color="#666666" metalness={0.6} />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* HTML overlay */}
|
||||
<Html position={[3, 0, 0]} distanceFactor={10}>
|
||||
<div style={overlayStyle}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 6, color: '#00d4ff' }}>热泵详情</div>
|
||||
<div>功率: {getValue('power').toFixed(1)} kW</div>
|
||||
<div>COP: {getValue('cop').toFixed(2)}</div>
|
||||
<div>进水温度: {getValue('inlet_temp').toFixed(1)} ℃</div>
|
||||
<div>出水温度: {getValue('outlet_temp').toFixed(1)} ℃</div>
|
||||
<div>流量: {getValue('flow_rate').toFixed(2)} m³/h</div>
|
||||
<div>室外温度: {getValue('outdoor_temp').toFixed(1)} ℃</div>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Meter Detail ───────────────────────────────────────────────────
|
||||
function MeterDetail({
|
||||
getValue,
|
||||
}: {
|
||||
getValue: (key: string) => number;
|
||||
}) {
|
||||
const needleRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame(() => {
|
||||
if (needleRef.current) {
|
||||
const power = getValue('power');
|
||||
const angle = (power / 150) * Math.PI - Math.PI / 2;
|
||||
needleRef.current.rotation.z = angle;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Body */}
|
||||
<mesh>
|
||||
<boxGeometry args={[1.5, 2, 0.5]} />
|
||||
<meshStandardMaterial color="#ff8c00" metalness={0.4} roughness={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* Screen */}
|
||||
<mesh position={[0, 0.4, 0.27]}>
|
||||
<boxGeometry args={[1, 0.6, 0.02]} />
|
||||
<meshStandardMaterial
|
||||
color="#1a1a1a"
|
||||
emissive="#003300"
|
||||
emissiveIntensity={0.3}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Dial face */}
|
||||
<mesh position={[0, -0.3, 0.27]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.4, 0.4, 0.05, 32]} />
|
||||
<meshStandardMaterial color="#111111" metalness={0.3} />
|
||||
</mesh>
|
||||
|
||||
{/* Needle */}
|
||||
<mesh ref={needleRef} position={[0, -0.3, 0.32]}>
|
||||
<boxGeometry args={[0.02, 0.35, 0.02]} />
|
||||
<meshStandardMaterial color="#ff0000" emissive="#ff0000" emissiveIntensity={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* HTML overlay */}
|
||||
<Html position={[2, 0, 0]} distanceFactor={10}>
|
||||
<div style={overlayStyle}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 6, color: '#ff8c00' }}>电表详情</div>
|
||||
<div>功率: {getValue('power').toFixed(1)} kW</div>
|
||||
<div>电压: {getValue('voltage').toFixed(1)} V</div>
|
||||
<div>电流: {getValue('current').toFixed(2)} A</div>
|
||||
<div>功率因数: {getValue('power_factor').toFixed(3)}</div>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Heat Meter Detail ──────────────────────────────────────────────
|
||||
function HeatMeterDetail({
|
||||
getValue,
|
||||
}: {
|
||||
getValue: (key: string) => number;
|
||||
}) {
|
||||
const needleRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame(() => {
|
||||
if (needleRef.current) {
|
||||
const power = getValue('heat_power');
|
||||
const angle = (power / 200) * Math.PI - Math.PI / 2;
|
||||
needleRef.current.rotation.z = angle;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Body */}
|
||||
<mesh>
|
||||
<boxGeometry args={[1.5, 2, 0.5]} />
|
||||
<meshStandardMaterial color="#cc3366" metalness={0.4} roughness={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* Display screen */}
|
||||
<mesh position={[0, 0.4, 0.27]}>
|
||||
<boxGeometry args={[1, 0.6, 0.02]} />
|
||||
<meshStandardMaterial color="#1a1a1a" emissive="#220011" emissiveIntensity={0.3} />
|
||||
</mesh>
|
||||
|
||||
{/* Dial */}
|
||||
<mesh position={[0, -0.3, 0.27]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.4, 0.4, 0.05, 32]} />
|
||||
<meshStandardMaterial color="#111111" metalness={0.3} />
|
||||
</mesh>
|
||||
|
||||
{/* Needle */}
|
||||
<mesh ref={needleRef} position={[0, -0.3, 0.32]}>
|
||||
<boxGeometry args={[0.02, 0.35, 0.02]} />
|
||||
<meshStandardMaterial color="#ff3366" emissive="#ff3366" emissiveIntensity={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* HTML overlay */}
|
||||
<Html position={[2, 0, 0]} distanceFactor={10}>
|
||||
<div style={overlayStyle}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 6, color: '#cc3366' }}>热量表详情</div>
|
||||
<div>热功率: {getValue('heat_power').toFixed(1)} kW</div>
|
||||
<div>流量: {getValue('flow_rate').toFixed(2)} m³/h</div>
|
||||
<div>供水温度: {getValue('supply_temp').toFixed(1)} ℃</div>
|
||||
<div>回水温度: {getValue('return_temp').toFixed(1)} ℃</div>
|
||||
<div>累计热量: {getValue('cumulative_heat').toFixed(3)} GJ</div>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sensor Detail ──────────────────────────────────────────────────
|
||||
function SensorDetail({
|
||||
getValue,
|
||||
}: {
|
||||
getValue: (key: string) => number;
|
||||
}) {
|
||||
const sphereRef = useRef<THREE.Mesh>(null);
|
||||
const ringRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
// Pulsing glow
|
||||
if (sphereRef.current) {
|
||||
const mat = sphereRef.current.material as THREE.MeshStandardMaterial;
|
||||
mat.emissiveIntensity = 0.3 + 0.3 * Math.sin(state.clock.elapsedTime * 2);
|
||||
}
|
||||
// Rotating ring
|
||||
if (ringRef.current) {
|
||||
ringRef.current.rotation.y += 0.8 * delta;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Sensor sphere */}
|
||||
<mesh ref={sphereRef}>
|
||||
<sphereGeometry args={[0.5, 32, 32]} />
|
||||
<meshStandardMaterial
|
||||
color="#a78bfa"
|
||||
metalness={0.5}
|
||||
roughness={0.3}
|
||||
emissive="#a78bfa"
|
||||
emissiveIntensity={0.3}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Antenna */}
|
||||
<mesh position={[0, 0.9, 0]}>
|
||||
<cylinderGeometry args={[0.03, 0.03, 0.8, 8]} />
|
||||
<meshStandardMaterial color="#cccccc" metalness={0.6} />
|
||||
</mesh>
|
||||
|
||||
{/* Ring */}
|
||||
<mesh ref={ringRef} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.7, 0.02, 8, 32]} />
|
||||
<meshStandardMaterial
|
||||
color="#00d4ff"
|
||||
emissive="#00d4ff"
|
||||
emissiveIntensity={0.5}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* HTML overlay */}
|
||||
<Html position={[2, 0, 0]} distanceFactor={10}>
|
||||
<div style={overlayStyle}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 6, color: '#a78bfa' }}>传感器详情</div>
|
||||
<div>温度: {getValue('temperature').toFixed(1)} ℃</div>
|
||||
<div>湿度: {getValue('humidity').toFixed(1)} %</div>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fallback ───────────────────────────────────────────────────────
|
||||
function DefaultDetail({ name }: { name: string }) {
|
||||
const sphereRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (sphereRef.current) {
|
||||
const mat = sphereRef.current.material as THREE.MeshStandardMaterial;
|
||||
mat.emissiveIntensity = 0.3 + 0.2 * Math.sin(state.clock.elapsedTime * 2);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh ref={sphereRef}>
|
||||
<sphereGeometry args={[0.6, 32, 32]} />
|
||||
<meshStandardMaterial
|
||||
color="#00d4ff"
|
||||
emissive="#00d4ff"
|
||||
emissiveIntensity={0.3}
|
||||
metalness={0.4}
|
||||
roughness={0.4}
|
||||
/>
|
||||
</mesh>
|
||||
<Html position={[0, 1.2, 0]} distanceFactor={10}>
|
||||
<div style={overlayStyle}>
|
||||
<div style={{ textAlign: 'center', color: '#00d4ff' }}>{name}</div>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ─────────────────────────────────────────────────
|
||||
export default function DeviceDetailView({
|
||||
device,
|
||||
position,
|
||||
realtimeData,
|
||||
}: DeviceDetailViewProps) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (groupRef.current) {
|
||||
groupRef.current.rotation.y += 0.05 * delta;
|
||||
}
|
||||
});
|
||||
|
||||
const getValue = (key: string) => realtimeData?.[key]?.value ?? 0;
|
||||
|
||||
const renderDetail = () => {
|
||||
switch (device.device_type) {
|
||||
case 'pv_inverter':
|
||||
return <PVDetail getValue={getValue} />;
|
||||
case 'heat_pump':
|
||||
return <HeatPumpDetail getValue={getValue} />;
|
||||
case 'meter':
|
||||
return <MeterDetail getValue={getValue} />;
|
||||
case 'heat_meter':
|
||||
return <HeatMeterDetail getValue={getValue} />;
|
||||
case 'sensor':
|
||||
return <SensorDetail getValue={getValue} />;
|
||||
default:
|
||||
return <DefaultDetail name={device.name} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={position}>
|
||||
{renderDetail()}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import styles from '../styles.module.css';
|
||||
import { getDeviceRealtime } from '../../../services/api';
|
||||
import { getDevicePhoto } from '../../../utils/devicePhoto';
|
||||
|
||||
interface Device {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
device_type: string;
|
||||
status: string;
|
||||
model?: string;
|
||||
manufacturer?: string;
|
||||
rated_power?: number;
|
||||
}
|
||||
|
||||
interface DeviceInfoPanelProps {
|
||||
device: Device | null;
|
||||
onClose: () => void;
|
||||
onViewDetail: (device: Device) => void;
|
||||
}
|
||||
|
||||
interface ParamDef {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
const PARAMS_BY_TYPE: Record<string, ParamDef[]> = {
|
||||
pv_inverter: [
|
||||
{ key: 'power', label: '功率', unit: 'kW' },
|
||||
{ key: 'daily_energy', label: '日发电量', unit: 'kWh' },
|
||||
{ key: 'total_energy', label: '累计发电', unit: 'kWh' },
|
||||
{ key: 'dc_voltage', label: '直流电压', unit: 'V' },
|
||||
{ key: 'ac_voltage', label: '交流电压', unit: 'V' },
|
||||
{ key: 'temperature', label: '温度', unit: '℃' },
|
||||
],
|
||||
heat_pump: [
|
||||
{ key: 'power', label: '功率', unit: 'kW' },
|
||||
{ key: 'cop', label: 'COP', unit: '' },
|
||||
{ key: 'inlet_temp', label: '进水温度', unit: '℃' },
|
||||
{ key: 'outlet_temp', label: '出水温度', unit: '℃' },
|
||||
{ key: 'flow_rate', label: '流量', unit: 'm³/h' },
|
||||
{ key: 'outdoor_temp', label: '室外温度', unit: '℃' },
|
||||
],
|
||||
meter: [
|
||||
{ key: 'power', label: '功率', unit: 'kW' },
|
||||
{ key: 'voltage', label: '电压', unit: 'V' },
|
||||
{ key: 'current', label: '电流', unit: 'A' },
|
||||
{ key: 'power_factor', label: '功率因数', unit: '' },
|
||||
],
|
||||
sensor: [
|
||||
{ key: 'temperature', label: '温度', unit: '℃' },
|
||||
{ key: 'humidity', label: '湿度', unit: '%' },
|
||||
],
|
||||
heat_meter: [
|
||||
{ key: 'heat_power', label: '热功率', unit: 'kW' },
|
||||
{ key: 'flow_rate', label: '流量', unit: 'm³/h' },
|
||||
{ key: 'supply_temp', label: '供水温度', unit: '℃' },
|
||||
{ key: 'return_temp', label: '回水温度', unit: '℃' },
|
||||
{ key: 'cumulative_heat', label: '累计热量', unit: 'GJ' },
|
||||
],
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
online: '#00ff88',
|
||||
offline: '#666666',
|
||||
alarm: '#ff4757',
|
||||
maintenance: '#ff8c00',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
alarm: '告警',
|
||||
maintenance: '维护',
|
||||
};
|
||||
|
||||
export default function DeviceInfoPanel({ device, onClose, onViewDetail }: DeviceInfoPanelProps) {
|
||||
const [realtimeData, setRealtimeData] = useState<Record<string, { value: number; unit: string; timestamp: string }>>({});
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!device) {
|
||||
setRealtimeData({});
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const resp = await getDeviceRealtime(device.id) as any;
|
||||
// API returns { device: {...}, data: { power: {...}, ... } }
|
||||
const realtimeMap = resp?.data ?? resp;
|
||||
setRealtimeData(realtimeMap as Record<string, { value: number; unit: string; timestamp: string }>);
|
||||
} catch {
|
||||
// ignore fetch errors
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
timerRef.current = setInterval(fetchData, 5000);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [device?.id]);
|
||||
|
||||
if (!device) return null;
|
||||
|
||||
const params = PARAMS_BY_TYPE[device.device_type] || [];
|
||||
|
||||
return (
|
||||
<div className={styles.deviceInfoPanel}>
|
||||
<div className={styles.infoPanelHeader}>
|
||||
<span className={styles.infoPanelTitle}>{device.name}</span>
|
||||
<button className={styles.closeBtn} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 12px 8px', textAlign: 'center' }}>
|
||||
<img src={getDevicePhoto(device.device_type)} alt={device.name}
|
||||
style={{ width: '100%', height: 120, borderRadius: 8, objectFit: 'cover', border: '1px solid rgba(0,212,255,0.2)' }} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.paramRow}>
|
||||
<span className={styles.paramLabel}>状态</span>
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 10px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
backgroundColor: STATUS_COLORS[device.status] || '#666',
|
||||
}}
|
||||
>
|
||||
{STATUS_LABELS[device.status] || device.status}
|
||||
</span>
|
||||
</div>
|
||||
{device.model && (
|
||||
<div className={styles.paramRow}>
|
||||
<span className={styles.paramLabel}>型号</span>
|
||||
<span className={styles.paramValue}>{device.model}</span>
|
||||
</div>
|
||||
)}
|
||||
{device.manufacturer && (
|
||||
<div className={styles.paramRow}>
|
||||
<span className={styles.paramLabel}>厂家</span>
|
||||
<span className={styles.paramValue}>{device.manufacturer}</span>
|
||||
</div>
|
||||
)}
|
||||
{device.rated_power != null && (
|
||||
<div className={styles.paramRow}>
|
||||
<span className={styles.paramLabel}>额定功率</span>
|
||||
<span className={styles.paramValue}>{device.rated_power} kW</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{params.map(param => {
|
||||
const data = realtimeData[param.key];
|
||||
const valueStr = data != null ? `${data.value}${param.unit ? ' ' + param.unit : ''}` : '--';
|
||||
return (
|
||||
<div key={param.key} className={styles.paramRow}>
|
||||
<span className={styles.paramLabel}>{param.label}</span>
|
||||
<span className={styles.paramValue}>{valueStr}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button className={styles.detailBtn} onClick={() => onViewDetail(device)}>
|
||||
查看详情
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import styles from '../styles.module.css';
|
||||
import { getDevicePhoto } from '../../../utils/devicePhoto';
|
||||
|
||||
interface Device {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
device_type: string;
|
||||
status: string;
|
||||
primaryValue?: string;
|
||||
}
|
||||
|
||||
interface DeviceListPanelProps {
|
||||
devices: Device[];
|
||||
selectedDeviceId: number | null;
|
||||
onDeviceSelect: (device: Device) => void;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
pv_inverter: '光伏逆变器',
|
||||
heat_pump: '空气源热泵',
|
||||
meter: '电表',
|
||||
sensor: '温湿度传感器',
|
||||
heat_meter: '热量表',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
online: '#00ff88',
|
||||
offline: '#666666',
|
||||
alarm: '#ff4757',
|
||||
maintenance: '#ff8c00',
|
||||
};
|
||||
|
||||
export default function DeviceListPanel({ devices, selectedDeviceId, onDeviceSelect }: DeviceListPanelProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
|
||||
const groups: Record<string, Device[]> = {};
|
||||
for (const device of devices) {
|
||||
const type = device.device_type;
|
||||
if (!groups[type]) groups[type] = [];
|
||||
groups[type].push(device);
|
||||
}
|
||||
|
||||
const toggleGroup = (type: string) => {
|
||||
setCollapsed(prev => ({ ...prev, [type]: !prev[type] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.deviceListPanel}>
|
||||
{Object.entries(TYPE_LABELS).map(([type, label]) => {
|
||||
const group = groups[type];
|
||||
if (!group || group.length === 0) return null;
|
||||
const isCollapsed = collapsed[type] ?? false;
|
||||
|
||||
return (
|
||||
<div key={type}>
|
||||
<div className={styles.deviceGroupTitle} onClick={() => toggleGroup(type)}>
|
||||
{isCollapsed ? '▸' : '▾'} {label}
|
||||
</div>
|
||||
{!isCollapsed &&
|
||||
group.map(device => (
|
||||
<div
|
||||
key={device.id}
|
||||
className={`${styles.deviceItem} ${selectedDeviceId === device.id ? styles.deviceItemActive : ''}`}
|
||||
onClick={() => onDeviceSelect(device)}
|
||||
>
|
||||
<img src={getDevicePhoto(device.device_type)} alt=""
|
||||
style={{ width: 28, height: 28, borderRadius: 6, objectFit: 'cover', flexShrink: 0 }} />
|
||||
<span
|
||||
className={styles.statusDot}
|
||||
style={{ backgroundColor: STATUS_COLORS[device.status] || '#666666' }}
|
||||
/>
|
||||
<span className={styles.deviceName}>{device.name}</span>
|
||||
{device.primaryValue && (
|
||||
<span className={styles.deviceValue}>{device.primaryValue}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Html } from '@react-three/drei';
|
||||
import { DEVICE_POSITIONS, COLORS } from '../constants';
|
||||
|
||||
interface DeviceMarkersProps {
|
||||
devices: Array<{
|
||||
id: number;
|
||||
code: string;
|
||||
device_type: string;
|
||||
name: string;
|
||||
status: string;
|
||||
primaryValue?: string;
|
||||
}>;
|
||||
hoveredId: number | null;
|
||||
onHover: (id: number | null) => void;
|
||||
onClick: (device: { id: number; code: string; device_type: string; name: string; status: string; primaryValue?: string }) => void;
|
||||
detailMode?: boolean;
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: '11px',
|
||||
color: COLORS.text,
|
||||
background: 'rgba(6, 30, 62, 0.85)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
border: '1px solid rgba(0, 212, 255, 0.2)',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
textAlign: 'center',
|
||||
};
|
||||
|
||||
function MeterMarker({
|
||||
device,
|
||||
position,
|
||||
isHovered,
|
||||
accentColor,
|
||||
onHover,
|
||||
onClick,
|
||||
}: {
|
||||
device: DeviceMarkersProps['devices'][number];
|
||||
position: [number, number, number];
|
||||
isHovered: boolean;
|
||||
accentColor: string;
|
||||
onHover: (id: number | null) => void;
|
||||
onClick: (device: DeviceMarkersProps['devices'][number]) => void;
|
||||
}) {
|
||||
const scale: [number, number, number] = isHovered ? [1.2, 1.2, 1.2] : [1, 1, 1];
|
||||
|
||||
return (
|
||||
<group
|
||||
position={[position[0], position[1] + 0.6, position[2]]}
|
||||
scale={scale}
|
||||
onPointerOver={(e) => { e.stopPropagation(); onHover(device.id); }}
|
||||
onPointerOut={(e) => { e.stopPropagation(); onHover(null); }}
|
||||
onClick={(e) => { e.stopPropagation(); onClick(device); }}
|
||||
>
|
||||
{/* Body */}
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[0.8, 1.2, 0.3]} />
|
||||
<meshStandardMaterial color={accentColor} metalness={0.3} roughness={0.6} />
|
||||
</mesh>
|
||||
{/* Front dial */}
|
||||
<mesh position={[0, 0.1, 0.16]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.25, 0.25, 0.05, 16]} />
|
||||
<meshStandardMaterial color="#1a1a1a" metalness={0.5} />
|
||||
</mesh>
|
||||
{/* Label */}
|
||||
<Html position={[0, 1, 0]} center>
|
||||
<div style={labelStyle}>
|
||||
<div>{device.name}</div>
|
||||
{device.primaryValue && <div style={{ color: accentColor }}>{device.primaryValue}</div>}
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function SensorMarker({
|
||||
device,
|
||||
position,
|
||||
isHovered,
|
||||
onHover,
|
||||
onClick,
|
||||
}: {
|
||||
device: DeviceMarkersProps['devices'][number];
|
||||
position: [number, number, number];
|
||||
isHovered: boolean;
|
||||
onHover: (id: number | null) => void;
|
||||
onClick: (device: DeviceMarkersProps['devices'][number]) => void;
|
||||
}) {
|
||||
const scale: [number, number, number] = isHovered ? [1.2, 1.2, 1.2] : [1, 1, 1];
|
||||
|
||||
return (
|
||||
<group
|
||||
position={position}
|
||||
scale={scale}
|
||||
onPointerOver={(e) => { e.stopPropagation(); onHover(device.id); }}
|
||||
onPointerOut={(e) => { e.stopPropagation(); onHover(null); }}
|
||||
onClick={(e) => { e.stopPropagation(); onClick(device); }}
|
||||
>
|
||||
{/* Sphere */}
|
||||
<mesh castShadow>
|
||||
<sphereGeometry args={[0.25, 16, 16]} />
|
||||
<meshStandardMaterial
|
||||
color={COLORS.sensorPurple}
|
||||
metalness={0.5}
|
||||
roughness={0.4}
|
||||
emissive={COLORS.sensorPurple}
|
||||
emissiveIntensity={isHovered ? 0.3 : 0.1}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Antenna */}
|
||||
<mesh position={[0, 0.45, 0]}>
|
||||
<cylinderGeometry args={[0.02, 0.02, 0.4, 6]} />
|
||||
<meshStandardMaterial color="#b0b0b0" metalness={0.8} />
|
||||
</mesh>
|
||||
{/* Label */}
|
||||
<Html position={[0, 0.9, 0]} center>
|
||||
<div style={labelStyle}>
|
||||
<div>{device.name}</div>
|
||||
{device.primaryValue && <div style={{ color: COLORS.sensorPurple }}>{device.primaryValue}</div>}
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceMarkers({ devices, hoveredId, onHover, onClick }: DeviceMarkersProps) {
|
||||
const categorized = useMemo(() => {
|
||||
const meters: typeof devices = [];
|
||||
const sensors: typeof devices = [];
|
||||
const heatMeters: typeof devices = [];
|
||||
|
||||
devices.forEach((d) => {
|
||||
if (d.device_type === 'heat_meter' || d.code.startsWith('HM-')) {
|
||||
heatMeters.push(d);
|
||||
} else if (d.code.startsWith('MTR-')) {
|
||||
meters.push(d);
|
||||
} else if (d.code.startsWith('SENSOR-')) {
|
||||
sensors.push(d);
|
||||
}
|
||||
});
|
||||
|
||||
return { meters, sensors, heatMeters };
|
||||
}, [devices]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Regular meters */}
|
||||
{categorized.meters.map((d) => {
|
||||
const posInfo = DEVICE_POSITIONS[d.code];
|
||||
if (!posInfo) return null;
|
||||
return (
|
||||
<MeterMarker
|
||||
key={d.id}
|
||||
device={d}
|
||||
position={posInfo.position}
|
||||
isHovered={hoveredId === d.id}
|
||||
accentColor={COLORS.gridOrange}
|
||||
onHover={onHover}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Heat meters */}
|
||||
{categorized.heatMeters.map((d) => {
|
||||
const posInfo = DEVICE_POSITIONS[d.code];
|
||||
if (!posInfo) return null;
|
||||
return (
|
||||
<MeterMarker
|
||||
key={d.id}
|
||||
device={d}
|
||||
position={posInfo.position}
|
||||
isHovered={hoveredId === d.id}
|
||||
accentColor={COLORS.alarmRed}
|
||||
onHover={onHover}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Sensors */}
|
||||
{categorized.sensors.map((d) => {
|
||||
const posInfo = DEVICE_POSITIONS[d.code];
|
||||
if (!posInfo) return null;
|
||||
return (
|
||||
<SensorMarker
|
||||
key={d.id}
|
||||
device={d}
|
||||
position={posInfo.position}
|
||||
isHovered={hoveredId === d.id}
|
||||
onHover={onHover}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useRef, useMemo } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
import { ENERGY_FLOW_PATHS } from '../constants';
|
||||
|
||||
interface EnergyParticlesProps {
|
||||
energyFlow: { nodes: any[]; links: any[] };
|
||||
realtimeData: { pv_power?: number; grid_power?: number; heatpump_power?: number } | null;
|
||||
}
|
||||
|
||||
interface ParticlePathConfig {
|
||||
key: string;
|
||||
curve: THREE.CatmullRomCurve3;
|
||||
color: string;
|
||||
power: number;
|
||||
}
|
||||
|
||||
const MAX_PARTICLES_PER_PATH = 40;
|
||||
const MIN_PARTICLES = 3;
|
||||
const PARTICLE_SIZE = 0.4;
|
||||
const BASE_SPEED = 0.003;
|
||||
const SPEED_VARIANCE = 0.002;
|
||||
|
||||
export default function EnergyParticles({ energyFlow, realtimeData }: EnergyParticlesProps) {
|
||||
const pvPower = realtimeData?.pv_power ?? 0;
|
||||
const gridPower = realtimeData?.grid_power ?? 0;
|
||||
const hpPower = realtimeData?.heatpump_power ?? 0;
|
||||
|
||||
const paths = useMemo<ParticlePathConfig[]>(() => {
|
||||
const configs: ParticlePathConfig[] = [];
|
||||
|
||||
// PV -> Building
|
||||
if (pvPower > 0) {
|
||||
configs.push({
|
||||
key: 'pv',
|
||||
curve: new THREE.CatmullRomCurve3(
|
||||
ENERGY_FLOW_PATHS.pvToBuilding.waypoints.map((p) => new THREE.Vector3(...p)),
|
||||
),
|
||||
color: ENERGY_FLOW_PATHS.pvToBuilding.color,
|
||||
power: pvPower,
|
||||
});
|
||||
}
|
||||
|
||||
// Grid -> Building (only when importing, i.e. positive)
|
||||
if (gridPower > 0) {
|
||||
configs.push({
|
||||
key: 'grid',
|
||||
curve: new THREE.CatmullRomCurve3(
|
||||
ENERGY_FLOW_PATHS.gridToBuilding.waypoints.map((p) => new THREE.Vector3(...p)),
|
||||
),
|
||||
color: ENERGY_FLOW_PATHS.gridToBuilding.color,
|
||||
power: gridPower,
|
||||
});
|
||||
}
|
||||
|
||||
// Building -> HeatPump
|
||||
if (hpPower > 0) {
|
||||
configs.push({
|
||||
key: 'hp',
|
||||
curve: new THREE.CatmullRomCurve3(
|
||||
ENERGY_FLOW_PATHS.buildingToHeatPump.waypoints.map((p) => new THREE.Vector3(...p)),
|
||||
),
|
||||
color: ENERGY_FLOW_PATHS.buildingToHeatPump.color,
|
||||
power: hpPower,
|
||||
});
|
||||
}
|
||||
|
||||
return configs;
|
||||
}, [pvPower, gridPower, hpPower]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{paths.map((path) => (
|
||||
<ParticlePath key={path.key} config={path} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticlePath({ config }: { config: ParticlePathConfig }) {
|
||||
const { curve, color, power } = config;
|
||||
|
||||
const count = Math.max(
|
||||
MIN_PARTICLES,
|
||||
Math.min(Math.floor(power / 5), MAX_PARTICLES_PER_PATH),
|
||||
);
|
||||
|
||||
// Stable random speeds per particle
|
||||
const speeds = useMemo(
|
||||
() => Array.from({ length: count }, () => BASE_SPEED + Math.random() * SPEED_VARIANCE),
|
||||
[count],
|
||||
);
|
||||
|
||||
// Progress values (0..1) for each particle along the curve
|
||||
const progressRef = useRef<Float32Array>(new Float32Array(0));
|
||||
if (progressRef.current.length !== count) {
|
||||
progressRef.current = new Float32Array(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
progressRef.current[i] = Math.random();
|
||||
}
|
||||
}
|
||||
|
||||
const positionsRef = useRef<Float32Array>(new Float32Array(count * 3));
|
||||
if (positionsRef.current.length !== count * 3) {
|
||||
positionsRef.current = new Float32Array(count * 3);
|
||||
}
|
||||
|
||||
const geomRef = useRef<THREE.BufferGeometry>(null);
|
||||
|
||||
// Initialize positions
|
||||
useMemo(() => {
|
||||
const pos = positionsRef.current;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const pt = curve.getPointAt(progressRef.current[i]);
|
||||
pos[i * 3] = pt.x;
|
||||
pos[i * 3 + 1] = pt.y;
|
||||
pos[i * 3 + 2] = pt.z;
|
||||
}
|
||||
}, [curve, count]);
|
||||
|
||||
useFrame(() => {
|
||||
const prog = progressRef.current;
|
||||
const pos = positionsRef.current;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
prog[i] = (prog[i] + speeds[i]) % 1;
|
||||
const pt = curve.getPointAt(prog[i]);
|
||||
pos[i * 3] = pt.x;
|
||||
pos[i * 3 + 1] = pt.y;
|
||||
pos[i * 3 + 2] = pt.z;
|
||||
}
|
||||
|
||||
if (geomRef.current) {
|
||||
const attr = geomRef.current.getAttribute('position') as THREE.BufferAttribute;
|
||||
attr.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.PointsMaterial({
|
||||
size: PARTICLE_SIZE,
|
||||
color: new THREE.Color(color),
|
||||
sizeAttenuation: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[color],
|
||||
);
|
||||
|
||||
return (
|
||||
<points material={material}>
|
||||
<bufferGeometry ref={geomRef}>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
args={[positionsRef.current, 3]}
|
||||
count={count}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
</points>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Grid } from '@react-three/drei';
|
||||
|
||||
export default function Ground() {
|
||||
return (
|
||||
<group>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow position={[0, -0.01, 0]}>
|
||||
<planeGeometry args={[100, 100]} />
|
||||
<meshStandardMaterial color="#0d2137" />
|
||||
</mesh>
|
||||
<Grid
|
||||
args={[100, 100]}
|
||||
cellSize={2}
|
||||
cellColor="#1a3a5c"
|
||||
sectionSize={10}
|
||||
sectionColor="#2a5a8c"
|
||||
fadeDistance={80}
|
||||
position={[0, 0, 0]}
|
||||
infiniteGrid={false}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import styles from '../styles.module.css';
|
||||
import AnimatedNumber from '../../BigScreen/components/AnimatedNumber';
|
||||
|
||||
interface HUDOverlayProps {
|
||||
overview: {
|
||||
today_generation?: number;
|
||||
today_consumption?: number;
|
||||
carbon_reduction?: number;
|
||||
active_alarms?: number;
|
||||
} | null;
|
||||
realtimeData: {
|
||||
pv_power?: number;
|
||||
grid_power?: number;
|
||||
heat_pump_power?: number;
|
||||
total_load?: number;
|
||||
} | null;
|
||||
deviceStats: {
|
||||
online?: number;
|
||||
total?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const w = WEEKDAYS[date.getDay()];
|
||||
return `${y}年${m}月${d}日 星期${w}`;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
export default function HUDOverlay({ overview, realtimeData }: HUDOverlayProps) {
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.hudOverlay}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.headerDate}>{formatDate(now)}</span>
|
||||
<span className={styles.headerTitle}>天普零碳园区 3D智慧能源管理平台</span>
|
||||
<span className={styles.headerClock}>{formatTime(now)}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.metricsBar}>
|
||||
<div className={styles.metricCard}>
|
||||
<span className={styles.metricLabel}>光伏发电</span>
|
||||
<span className={styles.metricValue}>
|
||||
<AnimatedNumber value={realtimeData?.pv_power ?? 0} decimals={1} />
|
||||
<span className={styles.metricUnit}>kW</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.metricCard}>
|
||||
<span className={styles.metricLabel}>电网功率</span>
|
||||
<span className={styles.metricValue}>
|
||||
<AnimatedNumber value={realtimeData?.grid_power ?? 0} decimals={1} />
|
||||
<span className={styles.metricUnit}>kW</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.metricCard}>
|
||||
<span className={styles.metricLabel}>总负荷</span>
|
||||
<span className={styles.metricValue}>
|
||||
<AnimatedNumber value={realtimeData?.total_load ?? 0} decimals={1} />
|
||||
<span className={styles.metricUnit}>kW</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.metricCard}>
|
||||
<span className={styles.metricLabel}>今日发电</span>
|
||||
<span className={styles.metricValue}>
|
||||
<AnimatedNumber value={overview?.today_generation ?? 0} decimals={1} />
|
||||
<span className={styles.metricUnit}>kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.metricCard}>
|
||||
<span className={styles.metricLabel}>碳减排</span>
|
||||
<span className={styles.metricValue}>
|
||||
<AnimatedNumber value={overview?.carbon_reduction ?? 0} decimals={1} />
|
||||
<span className={styles.metricUnit}>kg</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useRef, useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { DEVICE_POSITIONS } from '../constants';
|
||||
|
||||
interface HeatPumpsProps {
|
||||
devices: Array<{ id: number; code: string; status: string; power?: number }>;
|
||||
hoveredId: number | null;
|
||||
onHover: (id: number | null) => void;
|
||||
onClick: (device: { id: number; code: string; status: string; power?: number }) => void;
|
||||
detailMode?: boolean;
|
||||
}
|
||||
|
||||
const HP_CODES = ['HP-01', 'HP-02', 'HP-03', 'HP-04'] as const;
|
||||
|
||||
function FanBlades({ speed, status }: { speed: number; status: string }) {
|
||||
const bladesRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!bladesRef.current) return;
|
||||
if (status === 'offline') return;
|
||||
bladesRef.current.rotation.y += speed * delta;
|
||||
});
|
||||
|
||||
return (
|
||||
<group position={[0, 0.8, 0]}>
|
||||
{/* Fan housing */}
|
||||
<mesh>
|
||||
<cylinderGeometry args={[0.6, 0.6, 0.1, 24]} />
|
||||
<meshStandardMaterial color="#3a5a7a" metalness={0.7} roughness={0.3} />
|
||||
</mesh>
|
||||
{/* Blades */}
|
||||
<group ref={bladesRef} position={[0, 0.06, 0]}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<mesh key={i} rotation={[0, (i * Math.PI * 2) / 3, 0]}>
|
||||
<boxGeometry args={[0.5, 0.02, 0.1]} />
|
||||
<meshStandardMaterial color="#b0c4de" metalness={0.8} roughness={0.2} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function HeatPumpUnit({
|
||||
position,
|
||||
device,
|
||||
isHovered,
|
||||
onHover,
|
||||
onClick,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
device: { id: number; code: string; status: string; power?: number } | undefined;
|
||||
isHovered: boolean;
|
||||
onHover: (id: number | null) => void;
|
||||
onClick: (device: { id: number; code: string; status: string; power?: number }) => void;
|
||||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
const status = device?.status ?? 'offline';
|
||||
const power = device?.power ?? 0;
|
||||
const fanSpeed = status !== 'offline' ? (power / 35) * 2 : 0;
|
||||
|
||||
useFrame((state) => {
|
||||
if (!meshRef.current) return;
|
||||
const mat = meshRef.current.material as THREE.MeshStandardMaterial;
|
||||
if (status === 'alarm') {
|
||||
mat.emissiveIntensity = Math.sin(state.clock.elapsedTime * 3) * 0.3 + 0.2;
|
||||
}
|
||||
});
|
||||
|
||||
const bodyColor = status === 'offline' ? '#444444' : '#2a4a6a';
|
||||
const emissiveColor = status === 'alarm' ? '#ff4757' : status === 'online' ? '#00d4ff' : '#000000';
|
||||
const emissiveIntensity = status === 'offline' ? 0 : isHovered ? 0.3 : 0.1;
|
||||
const scale: [number, number, number] = isHovered ? [1.05, 1.05, 1.05] : [1, 1, 1];
|
||||
|
||||
return (
|
||||
<group
|
||||
position={position}
|
||||
scale={scale}
|
||||
onPointerOver={(e) => { e.stopPropagation(); if (device) onHover(device.id); }}
|
||||
onPointerOut={(e) => { e.stopPropagation(); onHover(null); }}
|
||||
onClick={(e) => { e.stopPropagation(); if (device) onClick(device); }}
|
||||
>
|
||||
{/* Main body */}
|
||||
<mesh ref={meshRef} castShadow>
|
||||
<boxGeometry args={[2, 1.5, 1.5]} />
|
||||
<meshStandardMaterial
|
||||
color={bodyColor}
|
||||
metalness={0.6}
|
||||
roughness={0.4}
|
||||
emissive={emissiveColor}
|
||||
emissiveIntensity={emissiveIntensity}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Fan on top */}
|
||||
<FanBlades speed={fanSpeed} status={status} />
|
||||
|
||||
{/* Side pipes - left */}
|
||||
<mesh position={[-1.2, 0, 0.3]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<cylinderGeometry args={[0.05, 0.05, 0.8, 8]} />
|
||||
<meshStandardMaterial color="#556677" metalness={0.8} />
|
||||
</mesh>
|
||||
<mesh position={[-1.2, 0, -0.3]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<cylinderGeometry args={[0.05, 0.05, 0.8, 8]} />
|
||||
<meshStandardMaterial color="#556677" metalness={0.8} />
|
||||
</mesh>
|
||||
|
||||
{/* Side pipes - right */}
|
||||
<mesh position={[1.2, 0, 0.3]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<cylinderGeometry args={[0.05, 0.05, 0.8, 8]} />
|
||||
<meshStandardMaterial color="#556677" metalness={0.8} />
|
||||
</mesh>
|
||||
<mesh position={[1.2, 0, -0.3]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<cylinderGeometry args={[0.05, 0.05, 0.8, 8]} />
|
||||
<meshStandardMaterial color="#556677" metalness={0.8} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HeatPumps({ devices, hoveredId, onHover, onClick }: HeatPumpsProps) {
|
||||
const deviceMap = useMemo(() => {
|
||||
const map = new Map<string, (typeof devices)[number]>();
|
||||
devices.forEach((d) => map.set(d.code, d));
|
||||
return map;
|
||||
}, [devices]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{HP_CODES.map((code) => {
|
||||
const pos = DEVICE_POSITIONS[code].position;
|
||||
const device = deviceMap.get(code);
|
||||
return (
|
||||
<HeatPumpUnit
|
||||
key={code}
|
||||
position={[pos[0], pos[1] + 0.75, pos[2]]}
|
||||
device={device}
|
||||
isHovered={device ? hoveredId === device.id : false}
|
||||
onHover={onHover}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useRef, useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { DEVICE_POSITIONS, PV_ARRAY, COLORS } from '../constants';
|
||||
|
||||
interface PVPanelsProps {
|
||||
devices: Array<{ id: number; code: string; status: string; power?: number; rated_power?: number }>;
|
||||
hoveredId: number | null;
|
||||
onHover: (id: number | null) => void;
|
||||
onClick: (device: { id: number; code: string; status: string; power?: number; rated_power?: number }) => void;
|
||||
detailMode?: boolean;
|
||||
}
|
||||
|
||||
const PV_ZONES = [
|
||||
{ code: 'PV-INV-01', center: DEVICE_POSITIONS['PV-INV-01'].position },
|
||||
{ code: 'PV-INV-02', center: DEVICE_POSITIONS['PV-INV-02'].position },
|
||||
{ code: 'PV-INV-03', center: DEVICE_POSITIONS['PV-INV-03'].position },
|
||||
] as const;
|
||||
|
||||
function PVZone({
|
||||
center,
|
||||
device,
|
||||
isHovered,
|
||||
onHover,
|
||||
onClick,
|
||||
}: {
|
||||
center: readonly [number, number, number];
|
||||
device: { id: number; code: string; status: string; power?: number; rated_power?: number } | undefined;
|
||||
isHovered: boolean;
|
||||
onHover: (id: number | null) => void;
|
||||
onClick: (device: { id: number; code: string; status: string; power?: number; rated_power?: number }) => void;
|
||||
}) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
const panels = useMemo(() => {
|
||||
const items: { pos: [number, number, number] }[] = [];
|
||||
const { cols, rows, panelWidth, panelHeight, gap } = PV_ARRAY;
|
||||
const totalW = cols * panelWidth + (cols - 1) * gap;
|
||||
const totalD = rows * panelHeight + (rows - 1) * gap;
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = -totalW / 2 + panelWidth / 2 + c * (panelWidth + gap);
|
||||
const z = -totalD / 2 + panelHeight / 2 + r * (panelHeight + gap);
|
||||
items.push({ pos: [x, 0, z] });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, []);
|
||||
|
||||
const ratio = device && device.rated_power ? (device.power ?? 0) / device.rated_power : 0;
|
||||
const emissiveIntensity = Math.min(ratio * 0.5, 0.5);
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={[center[0], center[1], center[2]]}
|
||||
onPointerOver={(e) => { e.stopPropagation(); if (device) onHover(device.id); }}
|
||||
onPointerOut={(e) => { e.stopPropagation(); onHover(null); }}
|
||||
onClick={(e) => { e.stopPropagation(); if (device) onClick(device); }}
|
||||
>
|
||||
{panels.map((p, i) => (
|
||||
<mesh
|
||||
key={i}
|
||||
position={p.pos}
|
||||
rotation={[-PV_ARRAY.tiltAngle, 0, 0]}
|
||||
castShadow
|
||||
>
|
||||
<boxGeometry args={[PV_ARRAY.panelWidth, PV_ARRAY.panelDepth, PV_ARRAY.panelHeight]} />
|
||||
<meshStandardMaterial
|
||||
color={isHovered ? '#2a347e' : '#1a237e'}
|
||||
metalness={0.8}
|
||||
roughness={0.3}
|
||||
emissive={COLORS.pvGreen}
|
||||
emissiveIntensity={isHovered ? 0.5 : emissiveIntensity}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PVPanels({ devices, hoveredId, onHover, onClick }: PVPanelsProps) {
|
||||
const deviceMap = useMemo(() => {
|
||||
const map = new Map<string, (typeof devices)[number]>();
|
||||
devices.forEach((d) => map.set(d.code, d));
|
||||
return map;
|
||||
}, [devices]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{PV_ZONES.map((zone) => {
|
||||
const device = deviceMap.get(zone.code);
|
||||
return (
|
||||
<PVZone
|
||||
key={zone.code}
|
||||
center={zone.center}
|
||||
device={device}
|
||||
isHovered={device ? hoveredId === device.id : false}
|
||||
onHover={onHover}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Stars } from '@react-three/drei';
|
||||
|
||||
export default function SceneEnvironment() {
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={0.15} color="#4488cc" />
|
||||
<directionalLight
|
||||
position={[10, 20, 10]}
|
||||
intensity={0.4}
|
||||
color="#88bbff"
|
||||
castShadow
|
||||
shadow-mapSize-width={2048}
|
||||
shadow-mapSize-height={2048}
|
||||
shadow-camera-far={80}
|
||||
shadow-camera-left={-40}
|
||||
shadow-camera-right={40}
|
||||
shadow-camera-top={40}
|
||||
shadow-camera-bottom={-40}
|
||||
/>
|
||||
<Stars count={2000} factor={4} saturation={0} fade speed={1} />
|
||||
<fog attach="fog" args={['#0a1628', 50, 150]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
// Campus layout constants for 3D scene
|
||||
// Scale: 1 unit ≈ 2 meters
|
||||
|
||||
// ============ Colors (matching existing BigScreen dark theme) ============
|
||||
export const COLORS = {
|
||||
background: '#0a1628',
|
||||
primary: '#00d4ff',
|
||||
pvGreen: '#00ff88',
|
||||
gridOrange: '#ff8c00',
|
||||
alarmRed: '#ff4757',
|
||||
sensorPurple: '#a78bfa',
|
||||
heatPumpCyan: '#00d4ff',
|
||||
buildingBase: '#1a3a5c',
|
||||
buildingEdge: 'rgba(0, 212, 255, 0.3)',
|
||||
windowGlow: '#ffcc66',
|
||||
cardBg: 'rgba(6, 30, 62, 0.85)',
|
||||
cardBorder: 'rgba(0, 212, 255, 0.25)',
|
||||
text: '#e0e8f0',
|
||||
textSecondary: '#8899aa',
|
||||
groundGrid: '#0d2137',
|
||||
groundLine: '#1a3a5c',
|
||||
} as const;
|
||||
|
||||
// ============ Buildings ============
|
||||
export const BUILDINGS = {
|
||||
east: {
|
||||
label: '东楼',
|
||||
position: [12, 6, -5] as [number, number, number],
|
||||
size: [20, 12, 15] as [number, number, number],
|
||||
},
|
||||
west: {
|
||||
label: '西楼',
|
||||
position: [-12, 5, -5] as [number, number, number],
|
||||
size: [16, 10, 12] as [number, number, number],
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ============ Device Positions ============
|
||||
export const DEVICE_POSITIONS: Record<string, {
|
||||
position: [number, number, number];
|
||||
rotation?: [number, number, number];
|
||||
type: string;
|
||||
}> = {
|
||||
// PV Inverters (on rooftops)
|
||||
'PV-INV-01': { position: [6, 12.3, -8], type: 'pv_inverter' },
|
||||
'PV-INV-02': { position: [18, 12.3, -8], type: 'pv_inverter' },
|
||||
'PV-INV-03': { position: [-12, 10.3, -8], type: 'pv_inverter' },
|
||||
|
||||
// Heat Pumps (ground level, beside buildings)
|
||||
'HP-01': { position: [24, 0, 2], type: 'heat_pump' },
|
||||
'HP-02': { position: [24, 0, 6], type: 'heat_pump' },
|
||||
'HP-03': { position: [-24, 0, 2], type: 'heat_pump' },
|
||||
'HP-04': { position: [-24, 0, 6], type: 'heat_pump' },
|
||||
|
||||
// Meters (ground, near entrances)
|
||||
'MTR-GRID': { position: [0, 0, 14], type: 'meter' },
|
||||
'MTR-PV': { position: [3, 0, 14], type: 'meter' },
|
||||
'MTR-HP': { position: [26, 0, -1], type: 'meter' },
|
||||
'MTR-PUMP': { position: [-26, 0, -1], type: 'meter' },
|
||||
|
||||
// Sensors (elevated, on buildings)
|
||||
'SENSOR-01': { position: [8, 6, 2], type: 'sensor' },
|
||||
'SENSOR-02': { position: [16, 6, 2], type: 'sensor' },
|
||||
'SENSOR-03': { position: [-8, 5, 2], type: 'sensor' },
|
||||
'SENSOR-04': { position: [-16, 5, 2], type: 'sensor' },
|
||||
'SENSOR-05': { position: [0, 4, 5], type: 'sensor' },
|
||||
|
||||
// Heat Meter
|
||||
'HM-01': { position: [26, 0, 4], type: 'heat_meter' },
|
||||
};
|
||||
|
||||
// ============ Camera ============
|
||||
export const CAMERA = {
|
||||
campusPosition: [0, 35, 45] as [number, number, number],
|
||||
campusTarget: [0, 0, 0] as [number, number, number],
|
||||
fov: 45,
|
||||
near: 0.1,
|
||||
far: 500,
|
||||
detailDistance: 8,
|
||||
animationDuration: 1.5,
|
||||
} as const;
|
||||
|
||||
// ============ Energy Flow Paths ============
|
||||
export const ENERGY_FLOW_PATHS = {
|
||||
pvToBuilding: {
|
||||
color: '#00ff88',
|
||||
waypoints: [[12, 13, -8], [12, 10, 0], [0, 6, 5]] as [number, number, number][],
|
||||
},
|
||||
gridToBuilding: {
|
||||
color: '#ff8c00',
|
||||
waypoints: [[0, 1, 14], [0, 4, 10], [0, 6, 5]] as [number, number, number][],
|
||||
},
|
||||
buildingToHeatPump: {
|
||||
color: '#00d4ff',
|
||||
waypoints: [[0, 6, 5], [12, 3, 4], [24, 1, 4]] as [number, number, number][],
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ============ PV Panel Array ============
|
||||
export const PV_ARRAY = {
|
||||
cols: 5,
|
||||
rows: 3,
|
||||
panelWidth: 2,
|
||||
panelHeight: 1,
|
||||
panelDepth: 0.05,
|
||||
gap: 0.3,
|
||||
tiltAngle: Math.PI / 6, // 30 degrees
|
||||
} as const;
|
||||
|
||||
// ============ Polling ============
|
||||
export const POLL_INTERVAL = 15000; // 15 seconds
|
||||
export const DETAIL_POLL_INTERVAL = 5000; // 5 seconds for selected device
|
||||
|
||||
// ============ Status Colors ============
|
||||
export const STATUS_COLORS: Record<string, string> = {
|
||||
online: '#00ff88',
|
||||
offline: '#666666',
|
||||
alarm: '#ff4757',
|
||||
maintenance: '#ff8c00',
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useThree, useFrame } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
import { CAMERA } from '../constants';
|
||||
|
||||
export function useCameraAnimation() {
|
||||
const { camera } = useThree();
|
||||
|
||||
const isAnimating = useRef(false);
|
||||
const startPos = useRef(new THREE.Vector3());
|
||||
const endPos = useRef(new THREE.Vector3());
|
||||
const startTarget = useRef(new THREE.Vector3());
|
||||
const endTarget = useRef(new THREE.Vector3());
|
||||
const progress = useRef(0);
|
||||
const duration = useRef(CAMERA.animationDuration);
|
||||
const currentTarget = useRef(new THREE.Vector3());
|
||||
|
||||
const animateTo = useCallback(
|
||||
(position: [number, number, number], target: [number, number, number], dur?: number) => {
|
||||
startPos.current.copy(camera.position);
|
||||
endPos.current.set(...position);
|
||||
|
||||
// Estimate current look-at target from camera direction
|
||||
const dir = new THREE.Vector3();
|
||||
camera.getWorldDirection(dir);
|
||||
startTarget.current.copy(camera.position).add(dir.multiplyScalar(10));
|
||||
|
||||
endTarget.current.set(...target);
|
||||
duration.current = dur ?? CAMERA.animationDuration;
|
||||
progress.current = 0;
|
||||
isAnimating.current = true;
|
||||
},
|
||||
[camera],
|
||||
);
|
||||
|
||||
const resetToOverview = useCallback(() => {
|
||||
animateTo(
|
||||
CAMERA.campusPosition as [number, number, number],
|
||||
CAMERA.campusTarget as [number, number, number],
|
||||
);
|
||||
}, [animateTo]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!isAnimating.current) return;
|
||||
|
||||
progress.current = Math.min(progress.current + delta / duration.current, 1);
|
||||
|
||||
// Smooth ease-in-out
|
||||
const t = progress.current < 0.5
|
||||
? 2 * progress.current * progress.current
|
||||
: 1 - Math.pow(-2 * progress.current + 2, 2) / 2;
|
||||
|
||||
camera.position.lerpVectors(startPos.current, endPos.current, t);
|
||||
currentTarget.current.lerpVectors(startTarget.current, endTarget.current, t);
|
||||
camera.lookAt(currentTarget.current);
|
||||
|
||||
if (progress.current >= 1) {
|
||||
isAnimating.current = false;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
animateTo,
|
||||
resetToOverview,
|
||||
get isAnimating() {
|
||||
return isAnimating.current;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getDevices, getDeviceStats, getDashboardOverview, getRealtimeData } from '../../../services/api';
|
||||
import type { DeviceInfo, DeviceWithPosition, OverviewData, RealtimePowerData } from '../types';
|
||||
import { DEVICE_POSITIONS, POLL_INTERVAL } from '../constants';
|
||||
|
||||
interface DeviceStats {
|
||||
online: number;
|
||||
offline: number;
|
||||
alarm: number;
|
||||
maintenance: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Ordered position keys by device type for fuzzy matching
|
||||
const POSITION_KEYS_BY_TYPE: Record<string, string[]> = {
|
||||
pv_inverter: ['PV-INV-01', 'PV-INV-02', 'PV-INV-03'],
|
||||
heat_pump: ['HP-01', 'HP-02', 'HP-03', 'HP-04'],
|
||||
meter: ['MTR-GRID', 'MTR-PV', 'MTR-HP', 'MTR-PUMP'],
|
||||
sensor: ['SENSOR-01', 'SENSOR-02', 'SENSOR-03', 'SENSOR-04', 'SENSOR-05'],
|
||||
heat_meter: ['HM-01'],
|
||||
};
|
||||
|
||||
function matchDevicesToPositions(devices: DeviceInfo[]): DeviceWithPosition[] {
|
||||
const usedPositions = new Set<string>();
|
||||
const result: DeviceWithPosition[] = [];
|
||||
|
||||
// Group devices by type
|
||||
const byType: Record<string, DeviceInfo[]> = {};
|
||||
for (const device of devices) {
|
||||
const type = device.device_type || 'unknown';
|
||||
if (!byType[type]) byType[type] = [];
|
||||
byType[type].push(device);
|
||||
}
|
||||
|
||||
for (const [type, typeDevices] of Object.entries(byType)) {
|
||||
const positionKeys = POSITION_KEYS_BY_TYPE[type] || [];
|
||||
|
||||
typeDevices.forEach((device, index) => {
|
||||
// Try exact match by device code first
|
||||
let matchedKey: string | undefined;
|
||||
if (device.code && DEVICE_POSITIONS[device.code] && !usedPositions.has(device.code)) {
|
||||
matchedKey = device.code;
|
||||
}
|
||||
|
||||
// Fall back to ordered assignment by type
|
||||
if (!matchedKey && index < positionKeys.length) {
|
||||
const key = positionKeys[index];
|
||||
if (!usedPositions.has(key)) {
|
||||
matchedKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
const withPos: DeviceWithPosition = { ...device };
|
||||
if (matchedKey) {
|
||||
usedPositions.add(matchedKey);
|
||||
const posData = DEVICE_POSITIONS[matchedKey];
|
||||
withPos.position3D = posData.position;
|
||||
withPos.rotation3D = posData.rotation;
|
||||
// Override code with matched key so 3D components can look up positions by code
|
||||
withPos.code = matchedKey;
|
||||
}
|
||||
|
||||
result.push(withPos);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useDeviceData() {
|
||||
const [devices, setDevices] = useState<DeviceInfo[]>([]);
|
||||
const [deviceStats, setDeviceStats] = useState<DeviceStats | null>(null);
|
||||
const [overview, setOverview] = useState<OverviewData | null>(null);
|
||||
const [realtimeData, setRealtimeData] = useState<RealtimePowerData | null>(null);
|
||||
const [devicesWithPositions, setDevicesWithPositions] = useState<DeviceWithPosition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
try {
|
||||
const results = await Promise.allSettled([
|
||||
getDevices({ page_size: 100 }) as Promise<any>,
|
||||
getDeviceStats() as Promise<any>,
|
||||
getDashboardOverview() as Promise<any>,
|
||||
getRealtimeData() as Promise<any>,
|
||||
]);
|
||||
|
||||
// Devices
|
||||
if (results[0].status === 'fulfilled') {
|
||||
const devData = results[0].value;
|
||||
const items: DeviceInfo[] = devData?.items || [];
|
||||
setDevices(items);
|
||||
setDevicesWithPositions(matchDevicesToPositions(items));
|
||||
}
|
||||
|
||||
// Stats
|
||||
if (results[1].status === 'fulfilled') {
|
||||
setDeviceStats(results[1].value as DeviceStats);
|
||||
}
|
||||
|
||||
// Overview
|
||||
if (results[2].status === 'fulfilled') {
|
||||
setOverview(results[2].value as OverviewData);
|
||||
}
|
||||
|
||||
// Realtime
|
||||
if (results[3].status === 'fulfilled') {
|
||||
setRealtimeData(results[3].value as RealtimePowerData);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to fetch device data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
intervalRef.current = setInterval(fetchAll, POLL_INTERVAL);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [fetchAll]);
|
||||
|
||||
return { devices, deviceStats, overview, realtimeData, devicesWithPositions, loading, error };
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getEnergyFlow } from '../../../services/api';
|
||||
import type { EnergyFlowNode, EnergyFlowLink } from '../types';
|
||||
import { POLL_INTERVAL } from '../constants';
|
||||
|
||||
export function useEnergyFlow() {
|
||||
const [nodes, setNodes] = useState<EnergyFlowNode[]>([]);
|
||||
const [links, setLinks] = useState<EnergyFlowLink[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const fetchFlow = useCallback(async () => {
|
||||
try {
|
||||
const data = (await getEnergyFlow()) as any;
|
||||
setNodes(data?.nodes || []);
|
||||
setLinks(data?.links || []);
|
||||
} catch {
|
||||
// Silently ignore — stale data is acceptable for flow visualization
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFlow();
|
||||
intervalRef.current = setInterval(fetchFlow, POLL_INTERVAL);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [fetchFlow]);
|
||||
|
||||
return { nodes, links, loading };
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import styles from './styles.module.css';
|
||||
import type { DeviceInfo, ViewMode } from './types';
|
||||
import { useDeviceData } from './hooks/useDeviceData';
|
||||
import { useEnergyFlow } from './hooks/useEnergyFlow';
|
||||
import { getDeviceRealtime } from '../../services/api';
|
||||
import CampusScene from './components/CampusScene';
|
||||
import HUDOverlay from './components/HUDOverlay';
|
||||
import DeviceListPanel from './components/DeviceListPanel';
|
||||
import DeviceInfoPanel from './components/DeviceInfoPanel';
|
||||
|
||||
export default function BigScreen3D() {
|
||||
const { devices, deviceStats, overview, realtimeData, devicesWithPositions, loading } = useDeviceData();
|
||||
const { nodes, links } = useEnergyFlow();
|
||||
|
||||
const [selectedDevice, setSelectedDevice] = useState<DeviceInfo | null>(null);
|
||||
const [hoveredDeviceId, setHoveredDeviceId] = useState<number | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('campus');
|
||||
const [detailRealtimeData, setDetailRealtimeData] = useState<Record<string, { value: number; unit: string }> | null>(null);
|
||||
const detailTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Poll per-device realtime data when in device-detail view
|
||||
useEffect(() => {
|
||||
if (!selectedDevice || viewMode !== 'device-detail') {
|
||||
setDetailRealtimeData(null);
|
||||
if (detailTimerRef.current) clearInterval(detailTimerRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchDetail = async () => {
|
||||
try {
|
||||
const resp = await getDeviceRealtime(selectedDevice.id) as any;
|
||||
const realtimeMap = resp?.data ?? resp;
|
||||
setDetailRealtimeData(realtimeMap as Record<string, { value: number; unit: string }>);
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetail();
|
||||
detailTimerRef.current = setInterval(fetchDetail, 5000);
|
||||
|
||||
return () => {
|
||||
if (detailTimerRef.current) clearInterval(detailTimerRef.current);
|
||||
};
|
||||
}, [selectedDevice?.id, viewMode]);
|
||||
|
||||
const handleDeviceSelect = useCallback((device: DeviceInfo) => {
|
||||
setSelectedDevice(device);
|
||||
}, []);
|
||||
|
||||
const handleDeviceClose = useCallback(() => {
|
||||
setSelectedDevice(null);
|
||||
}, []);
|
||||
|
||||
const handleEnterDetail = useCallback((device: DeviceInfo) => {
|
||||
setSelectedDevice(device);
|
||||
setViewMode('device-detail');
|
||||
}, []);
|
||||
|
||||
const handleExitDetail = useCallback(() => {
|
||||
setViewMode('campus');
|
||||
}, []);
|
||||
|
||||
// Find 3D position of the selected device
|
||||
const selectedDevicePosition = selectedDevice
|
||||
? (devicesWithPositions.find((d) => d.id === selectedDevice.id)?.position3D ?? null)
|
||||
: null;
|
||||
|
||||
if (loading && devicesWithPositions.length === 0) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.placeholder}>
|
||||
<h2 className={styles.placeholderTitle}>天普零碳园区 3D智慧能源管理平台</h2>
|
||||
<p style={{ color: '#8899aa' }}>正在加载设备数据...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 3D Canvas — fills entire screen */}
|
||||
<div className={styles.canvasWrapper}>
|
||||
<CampusScene
|
||||
devices={devicesWithPositions}
|
||||
energyFlow={{ nodes, links }}
|
||||
realtimeData={realtimeData}
|
||||
selectedDevice={selectedDevice}
|
||||
selectedDevicePosition={selectedDevicePosition}
|
||||
detailRealtimeData={detailRealtimeData}
|
||||
hoveredDeviceId={hoveredDeviceId}
|
||||
viewMode={viewMode}
|
||||
onDeviceSelect={handleDeviceSelect}
|
||||
onDeviceHover={setHoveredDeviceId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HUD: header bar + bottom metrics — pointer-events: none */}
|
||||
<HUDOverlay
|
||||
overview={overview}
|
||||
realtimeData={realtimeData}
|
||||
deviceStats={deviceStats}
|
||||
/>
|
||||
|
||||
{/* Left device list panel (only in campus view) */}
|
||||
{viewMode === 'campus' && (
|
||||
<DeviceListPanel
|
||||
devices={devicesWithPositions}
|
||||
selectedDeviceId={selectedDevice?.id ?? null}
|
||||
onDeviceSelect={handleDeviceSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right device info panel (when device selected in campus view) */}
|
||||
{selectedDevice && viewMode === 'campus' && (
|
||||
<DeviceInfoPanel
|
||||
device={selectedDevice}
|
||||
onClose={handleDeviceClose}
|
||||
onViewDetail={handleEnterDetail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Return button in detail view */}
|
||||
{viewMode === 'device-detail' && (
|
||||
<button className={styles.returnBtn} onClick={handleExitDetail}>
|
||||
← 返回全景
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
/* BigScreen 3D - Dark monitoring theme */
|
||||
.container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #0a1628;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
color: #e0e8f0;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.placeholderTitle {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Canvas fills entire screen */
|
||||
.canvasWrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* HUD overlay on top of canvas */
|
||||
.hudOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Header bar */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(180deg, rgba(6, 30, 62, 0.95) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.headerDate {
|
||||
font-size: 14px;
|
||||
color: #8899aa;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(90deg, #00d4ff, #00ff88, #00d4ff);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shimmer 3s linear infinite;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
to { background-position: 200% center; }
|
||||
}
|
||||
|
||||
.headerClock {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #00d4ff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 200px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Bottom metrics bar */
|
||||
.metricsBar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding: 16px 24px;
|
||||
background: linear-gradient(0deg, rgba(6, 30, 62, 0.95) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.metricCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 20px;
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.metricLabel {
|
||||
font-size: 12px;
|
||||
color: #8899aa;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #00d4ff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.metricUnit {
|
||||
font-size: 12px;
|
||||
color: #8899aa;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Left device list panel */
|
||||
.deviceListPanel {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 16px;
|
||||
width: 240px;
|
||||
max-height: calc(100vh - 160px);
|
||||
overflow-y: auto;
|
||||
background: rgba(6, 30, 62, 0.85);
|
||||
border: 1px solid rgba(0, 212, 255, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.deviceListPanel::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.deviceListPanel::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 212, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.deviceGroupTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #00d4ff;
|
||||
padding: 8px 0 4px;
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.15);
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.deviceItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.deviceItem:hover {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.deviceItemActive {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.deviceName {
|
||||
font-size: 12px;
|
||||
color: #e0e8f0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deviceValue {
|
||||
font-size: 11px;
|
||||
color: #00d4ff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Right device info panel */
|
||||
.deviceInfoPanel {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 16px;
|
||||
width: 300px;
|
||||
max-height: calc(100vh - 160px);
|
||||
overflow-y: auto;
|
||||
background: rgba(6, 30, 62, 0.9);
|
||||
border: 1px solid rgba(0, 212, 255, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.infoPanelHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.infoPanelTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #e0e8f0;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
background: none;
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
color: #8899aa;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
color: #00d4ff;
|
||||
border-color: #00d4ff;
|
||||
}
|
||||
|
||||
.paramRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.paramLabel {
|
||||
font-size: 13px;
|
||||
color: #8899aa;
|
||||
}
|
||||
|
||||
.paramValue {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #00d4ff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.detailBtn {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
border: 1px solid rgba(0, 212, 255, 0.4);
|
||||
border-radius: 6px;
|
||||
color: #00d4ff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.detailBtn:hover {
|
||||
background: rgba(0, 212, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Return button (detail view) */
|
||||
.returnBtn {
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 8px 24px;
|
||||
background: rgba(6, 30, 62, 0.9);
|
||||
border: 1px solid rgba(0, 212, 255, 0.4);
|
||||
border-radius: 20px;
|
||||
color: #00d4ff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
z-index: 25;
|
||||
pointer-events: auto;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.returnBtn:hover {
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 3D label styles (used inside drei Html) */
|
||||
.label3d {
|
||||
font-size: 11px;
|
||||
color: #e0e8f0;
|
||||
background: rgba(6, 30, 62, 0.8);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.label3dValue {
|
||||
color: #00d4ff;
|
||||
font-weight: 600;
|
||||
margin-left: 4px;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// 3D BigScreen shared type definitions
|
||||
|
||||
export interface DeviceInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
device_type: string;
|
||||
device_type_id?: number;
|
||||
status: 'online' | 'offline' | 'alarm' | 'maintenance';
|
||||
model?: string;
|
||||
manufacturer?: string;
|
||||
rated_power?: number;
|
||||
location?: string;
|
||||
serial_number?: string;
|
||||
collect_interval?: number;
|
||||
}
|
||||
|
||||
export interface DeviceRealtimeEntry {
|
||||
value: number;
|
||||
unit: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type DeviceRealtimeData = Record<string, DeviceRealtimeEntry>;
|
||||
|
||||
export interface EnergyFlowNode {
|
||||
id: string;
|
||||
name: string;
|
||||
power: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface EnergyFlowLink {
|
||||
source: string;
|
||||
target: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface DevicePosition3D {
|
||||
deviceCode: string;
|
||||
position: [number, number, number];
|
||||
rotation?: [number, number, number];
|
||||
type: string;
|
||||
}
|
||||
|
||||
export type ViewMode = 'campus' | 'device-detail';
|
||||
|
||||
export interface SceneState {
|
||||
viewMode: ViewMode;
|
||||
selectedDevice: DeviceInfo | null;
|
||||
hoveredDeviceId: number | null;
|
||||
}
|
||||
|
||||
export interface DeviceWithPosition extends DeviceInfo {
|
||||
position3D?: [number, number, number];
|
||||
rotation3D?: [number, number, number];
|
||||
}
|
||||
|
||||
export interface OverviewData {
|
||||
total_devices?: number;
|
||||
online_devices?: number;
|
||||
today_consumption?: number;
|
||||
today_generation?: number;
|
||||
carbon_reduction?: number;
|
||||
active_alarms?: number;
|
||||
}
|
||||
|
||||
export interface RealtimePowerData {
|
||||
pv_power?: number;
|
||||
heatpump_power?: number;
|
||||
total_load?: number;
|
||||
grid_power?: number;
|
||||
}
|
||||
@@ -1,626 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Card, Row, Col, Statistic, Table, Select, Tabs, Tag, Progress,
|
||||
Button, Modal, Form, InputNumber, DatePicker, Input, message, Space, Badge, Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
CloudOutlined, FallOutlined, RiseOutlined, AimOutlined, SafetyCertificateOutlined,
|
||||
FileTextOutlined, BarChartOutlined, ThunderboltOutlined, PlusOutlined, ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
getCarbonOverview, getCarbonTrend, getEmissionFactors,
|
||||
getCarbonDashboard, getCarbonTargets, createCarbonTarget, updateCarbonTarget,
|
||||
getCarbonTargetProgress, getCarbonReductions, getCarbonReductionSummary,
|
||||
calculateCarbonReductions, getGreenCertificates, createGreenCertificate,
|
||||
updateGreenCertificate, getCertificatePortfolioValue,
|
||||
getCarbonReports, generateCarbonReport, getCarbonReportDetail,
|
||||
getCarbonBenchmarks, getCarbonBenchmarkComparison,
|
||||
} from '../../services/api';
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
pv_generation: '光伏发电',
|
||||
heat_pump_cop: '热泵节能',
|
||||
energy_saving: '节能措施',
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
on_track: 'green', warning: 'orange', exceeded: 'red',
|
||||
active: 'green', used: 'blue', expired: 'default', traded: 'purple',
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Overview Tab
|
||||
// ============================================================
|
||||
function OverviewTab() {
|
||||
const [dashboard, setDashboard] = useState<any>(null);
|
||||
const [trend, setTrend] = useState<any[]>([]);
|
||||
const [days, setDays] = useState(30);
|
||||
const [overview, setOverview] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getCarbonDashboard().then(setDashboard).catch(() => {});
|
||||
getCarbonOverview().then(setOverview).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => { getCarbonTrend(days).then((d: any) => setTrend(d || [])).catch(() => {}); }, [days]);
|
||||
|
||||
const kpi = dashboard?.kpi || {};
|
||||
const target = dashboard?.target_progress;
|
||||
const greenRate = kpi.green_rate || 0;
|
||||
|
||||
const trendOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['碳排放', '碳减排'] },
|
||||
grid: { top: 40, right: 20, bottom: 30, left: 60 },
|
||||
xAxis: { type: 'category', data: trend.map((d: any) => { const t = new Date(d.date); return `${t.getMonth() + 1}/${t.getDate()}`; }) },
|
||||
yAxis: { type: 'value', name: 'kgCO\u2082' },
|
||||
series: [
|
||||
{ name: '碳排放', type: 'bar', data: trend.map((d: any) => d.emission), itemStyle: { color: '#f5222d' } },
|
||||
{ name: '碳减排', type: 'bar', data: trend.map((d: any) => d.reduction), itemStyle: { color: '#52c41a' } },
|
||||
],
|
||||
};
|
||||
|
||||
const scopeOption = {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie', radius: ['40%', '65%'], center: ['50%', '45%'],
|
||||
data: [
|
||||
{ value: overview?.by_scope?.[1] || 0, name: 'Scope 1 (直接排放)', itemStyle: { color: '#f5222d' } },
|
||||
{ value: overview?.by_scope?.[2] || 0, name: 'Scope 2 (间接排放)', itemStyle: { color: '#faad14' } },
|
||||
{ value: overview?.by_scope?.[3] || 0, name: 'Scope 3 (其他排放)', itemStyle: { color: '#1890ff' } },
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
const reductionSourceOption = {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie', radius: '60%',
|
||||
data: (dashboard?.reduction_by_source || []).map((s: any) => ({
|
||||
value: s.reduction_tons, name: SOURCE_LABELS[s.source_type] || s.source_type,
|
||||
})),
|
||||
}],
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="年度总排放" value={kpi.total_emission_tons || 0} suffix="tCO\u2082" precision={2}
|
||||
prefix={<RiseOutlined style={{ color: '#f5222d' }} />} valueStyle={{ color: '#f5222d' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="年度总减排" value={kpi.total_reduction_tons || 0} suffix="tCO\u2082" precision={2}
|
||||
prefix={<FallOutlined style={{ color: '#52c41a' }} />} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="净排放" value={kpi.net_emission_tons || 0} suffix="tCO\u2082" precision={2}
|
||||
prefix={<CloudOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="绿色电力占比" value={greenRate} suffix="%" precision={1}
|
||||
prefix={<ThunderboltOutlined style={{ color: '#52c41a' }} />} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{target && (
|
||||
<Card size="small" title="碳中和目标进度" style={{ marginTop: 16 }}>
|
||||
<Progress
|
||||
percent={Math.min(target.progress_pct, 100)}
|
||||
status={target.status === 'exceeded' ? 'exception' : target.status === 'warning' ? 'active' : 'success'}
|
||||
format={() => `${target.actual_tons} / ${target.target_tons} tCO\u2082`}
|
||||
/>
|
||||
<Tag color={STATUS_COLOR[target.status]} style={{ marginTop: 8 }}>
|
||||
{target.status === 'on_track' ? '达标' : target.status === 'warning' ? '预警' : '超标'}
|
||||
</Tag>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title="碳排放趋势" size="small" extra={
|
||||
<Select value={days} onChange={setDays} style={{ width: 120 }}
|
||||
options={[{ label: '近7天', value: 7 }, { label: '近30天', value: 30 }, { label: '近90天', value: 90 }]} />
|
||||
}>
|
||||
<ReactECharts option={trendOption} style={{ height: 300 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="排放范围分布" size="small">
|
||||
<ReactECharts option={scopeOption} style={{ height: 300 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="减排来源分布" size="small">
|
||||
{(dashboard?.reduction_by_source || []).length > 0
|
||||
? <ReactECharts option={reductionSourceOption} style={{ height: 260 }} />
|
||||
: <Empty description="暂无减排数据" />}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="等效植树量" size="small">
|
||||
<Statistic value={kpi.equivalent_trees || 0} suffix="棵" precision={0}
|
||||
valueStyle={{ color: '#52c41a', fontSize: 36 }} />
|
||||
<p style={{ color: '#999', marginTop: 8 }}>基于年度减排量折算 (1棵树 ≈ 0.02 tCO₂/年)</p>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Targets Tab
|
||||
// ============================================================
|
||||
function TargetsTab() {
|
||||
const [targets, setTargets] = useState<any[]>([]);
|
||||
const [progress, setProgress] = useState<any>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const load = useCallback(() => {
|
||||
getCarbonTargets(year).then(setTargets).catch(() => {});
|
||||
getCarbonTargetProgress(year).then(setProgress).catch(() => {});
|
||||
}, [year]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const vals = await form.validateFields();
|
||||
await createCarbonTarget(vals);
|
||||
message.success('目标创建成功');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const cols = [
|
||||
{ title: '年份', dataIndex: 'year', width: 80 },
|
||||
{ title: '月份', dataIndex: 'month', width: 80, render: (v: any) => v || '年度' },
|
||||
{ title: '目标排放(tCO₂)', dataIndex: 'target_emission_tons', width: 140 },
|
||||
{ title: '实际排放(tCO₂)', dataIndex: 'actual_emission_tons', width: 140 },
|
||||
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string) => (
|
||||
<Tag color={STATUS_COLOR[v]}>{v === 'on_track' ? '达标' : v === 'warning' ? '预警' : '超标'}</Tag>
|
||||
)},
|
||||
];
|
||||
|
||||
const progressGaugeOption = progress?.annual_target ? {
|
||||
series: [{
|
||||
type: 'gauge', startAngle: 200, endAngle: -20, min: 0, max: 100,
|
||||
pointer: { show: true },
|
||||
progress: { show: true, width: 18 },
|
||||
axisLine: { lineStyle: { width: 18 } },
|
||||
detail: { valueAnimation: true, formatter: '{value}%', fontSize: 20 },
|
||||
data: [{ value: Math.min(progress.annual_target.progress_pct, 150), name: '排放进度' }],
|
||||
}],
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="年度目标进度" size="small" extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>新建目标</Button>
|
||||
}>
|
||||
{progressGaugeOption
|
||||
? <ReactECharts option={progressGaugeOption} style={{ height: 280 }} />
|
||||
: <Empty description="暂无年度目标" />}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="月度目标" size="small">
|
||||
{(progress?.monthly_targets || []).length > 0 ? (
|
||||
<ReactECharts option={{
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['目标', '实际'] },
|
||||
xAxis: { type: 'category', data: progress.monthly_targets.map((m: any) => `${m.month}月`) },
|
||||
yAxis: { type: 'value', name: 'tCO₂' },
|
||||
series: [
|
||||
{ name: '目标', type: 'bar', data: progress.monthly_targets.map((m: any) => m.target_tons), itemStyle: { color: '#1890ff' } },
|
||||
{ name: '实际', type: 'bar', data: progress.monthly_targets.map((m: any) => m.actual_tons), itemStyle: { color: '#f5222d' } },
|
||||
],
|
||||
}} style={{ height: 280 }} />
|
||||
) : <Empty description="暂无月度目标" />}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="目标列表" size="small" style={{ marginTop: 16 }}>
|
||||
<Table columns={cols} dataSource={targets} rowKey="id" size="small" pagination={false} />
|
||||
</Card>
|
||||
|
||||
<Modal title="新建碳减排目标" open={modalOpen} onOk={handleCreate} onCancel={() => setModalOpen(false)} okText="创建">
|
||||
<Form form={form} layout="vertical" initialValues={{ year }}>
|
||||
<Form.Item name="year" label="年份" rules={[{ required: true }]}><InputNumber style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="month" label="月份 (留空为年度目标)"><InputNumber min={1} max={12} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="target_emission_tons" label="目标排放量(tCO₂)" rules={[{ required: true }]}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Reductions Tab
|
||||
// ============================================================
|
||||
function ReductionsTab() {
|
||||
const [reductions, setReductions] = useState<any[]>([]);
|
||||
const [summary, setSummary] = useState<any[]>([]);
|
||||
const [calculating, setCalculating] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
getCarbonReductions().then(setReductions).catch(() => {});
|
||||
getCarbonReductionSummary().then((d: any) => setSummary(d || [])).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleCalc = async () => {
|
||||
setCalculating(true);
|
||||
try {
|
||||
const result: any = await calculateCarbonReductions();
|
||||
message.success(`计算完成,新增${result.records_created}条记录`);
|
||||
load();
|
||||
} catch { message.error('计算失败'); }
|
||||
setCalculating(false);
|
||||
};
|
||||
|
||||
const cols = [
|
||||
{ title: '日期', dataIndex: 'date', width: 120 },
|
||||
{ title: '来源', dataIndex: 'source_type', width: 120, render: (v: string) => SOURCE_LABELS[v] || v },
|
||||
{ title: '减排量(tCO₂)', dataIndex: 'reduction_tons', width: 130 },
|
||||
{ title: '等效植树(棵)', dataIndex: 'equivalent_trees', width: 120 },
|
||||
{ title: '方法学', dataIndex: 'methodology', ellipsis: true },
|
||||
{ title: '已核证', dataIndex: 'verified', width: 80, render: (v: boolean) => v ? <Tag color="green">是</Tag> : <Tag>否</Tag> },
|
||||
];
|
||||
|
||||
const sourceChartOption = {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie', radius: ['35%', '60%'],
|
||||
data: summary.map((s: any) => ({
|
||||
value: s.reduction_tons, name: SOURCE_LABELS[s.source_type] || s.source_type,
|
||||
})),
|
||||
}],
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="减排来源分布" size="small" extra={
|
||||
<Button icon={<ReloadOutlined />} loading={calculating} onClick={handleCalc}>重新计算</Button>
|
||||
}>
|
||||
{summary.length > 0 ? <ReactECharts option={sourceChartOption} style={{ height: 280 }} /> : <Empty description="暂无数据" />}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="减排汇总" size="small">
|
||||
<Row gutter={16}>
|
||||
{summary.map((s: any) => (
|
||||
<Col span={8} key={s.source_type}>
|
||||
<Statistic title={SOURCE_LABELS[s.source_type] || s.source_type} value={s.reduction_tons} suffix="tCO₂" precision={4} />
|
||||
<p style={{ color: '#999', fontSize: 12 }}>等效植树 {s.equivalent_trees} 棵</p>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="减排活动记录" size="small" style={{ marginTop: 16 }}>
|
||||
<Table columns={cols} dataSource={reductions} rowKey="id" size="small"
|
||||
pagination={{ pageSize: 15 }} scroll={{ x: 700 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Certificates Tab
|
||||
// ============================================================
|
||||
function CertificatesTab() {
|
||||
const [certs, setCerts] = useState<any[]>([]);
|
||||
const [portfolio, setPortfolio] = useState<any>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = useCallback(() => {
|
||||
getGreenCertificates().then(setCerts).catch(() => {});
|
||||
getCertificatePortfolioValue().then(setPortfolio).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const vals = await form.validateFields();
|
||||
if (vals.issue_date) vals.issue_date = vals.issue_date.format('YYYY-MM-DD');
|
||||
if (vals.expiry_date) vals.expiry_date = vals.expiry_date.format('YYYY-MM-DD');
|
||||
await createGreenCertificate(vals);
|
||||
message.success('绿证登记成功');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const cols = [
|
||||
{ title: '类型', dataIndex: 'certificate_type', width: 80, render: (v: string) => <Tag>{v}</Tag> },
|
||||
{ title: '编号', dataIndex: 'certificate_number', width: 160, ellipsis: true },
|
||||
{ title: '签发日期', dataIndex: 'issue_date', width: 110 },
|
||||
{ title: '到期日期', dataIndex: 'expiry_date', width: 110, render: (v: any) => v || '-' },
|
||||
{ title: '电量(MWh)', dataIndex: 'energy_mwh', width: 100 },
|
||||
{ title: '价格(元)', dataIndex: 'price_yuan', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (v: string) => <Tag color={STATUS_COLOR[v]}>{v}</Tag> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="绿证总数" value={portfolio?.total_certificates || 0} suffix="张" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="总电量" value={portfolio?.total_energy_mwh || 0} suffix="MWh" precision={2} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="总价值" value={portfolio?.total_value_yuan || 0} suffix="元" precision={2}
|
||||
valueStyle={{ color: '#1890ff' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Space>
|
||||
{Object.entries(portfolio?.by_status || {}).map(([k, v]: any) => (
|
||||
<Badge key={k} count={v.count} style={{ backgroundColor: STATUS_COLOR[k] === 'default' ? '#999' : undefined }}>
|
||||
<Tag color={STATUS_COLOR[k]}>{k}</Tag>
|
||||
</Badge>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="绿证清单" size="small" style={{ marginTop: 16 }} extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>登记绿证</Button>
|
||||
}>
|
||||
<Table columns={cols} dataSource={certs} rowKey="id" size="small"
|
||||
pagination={{ pageSize: 15 }} scroll={{ x: 800 }} />
|
||||
</Card>
|
||||
|
||||
<Modal title="登记绿证" open={modalOpen} onOk={handleCreate} onCancel={() => setModalOpen(false)} okText="登记" width={520}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="certificate_type" label="类型" rules={[{ required: true }]}>
|
||||
<Select options={[{ label: 'GEC (绿证)', value: 'GEC' }, { label: 'I-REC', value: 'IREC' }, { label: 'CCER', value: 'CCER' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="certificate_number" label="编号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="issue_date" label="签发日期" rules={[{ required: true }]}><DatePicker style={{ width: '100%' }} /></Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="expiry_date" label="到期日期"><DatePicker style={{ width: '100%' }} /></Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="energy_mwh" label="电量(MWh)" rules={[{ required: true }]}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="price_yuan" label="价格(元)"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="notes" label="备注"><Input.TextArea rows={2} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Reports Tab
|
||||
// ============================================================
|
||||
function ReportsTab() {
|
||||
const [reports, setReports] = useState<any[]>([]);
|
||||
const [detail, setDetail] = useState<any>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = useCallback(() => { getCarbonReports().then(setReports).catch(() => {}); }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
try {
|
||||
const vals = await form.validateFields();
|
||||
setGenerating(true);
|
||||
const data = {
|
||||
report_type: vals.report_type,
|
||||
period_start: vals.period[0].format('YYYY-MM-DD'),
|
||||
period_end: vals.period[1].format('YYYY-MM-DD'),
|
||||
};
|
||||
await generateCarbonReport(data);
|
||||
message.success('报告生成成功');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch { /* */ }
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const viewDetail = async (id: number) => {
|
||||
const d = await getCarbonReportDetail(id);
|
||||
setDetail(d);
|
||||
};
|
||||
|
||||
const cols = [
|
||||
{ title: '类型', dataIndex: 'report_type', width: 80, render: (v: string) => <Tag>{v}</Tag> },
|
||||
{ title: '起始', dataIndex: 'period_start', width: 110 },
|
||||
{ title: '截止', dataIndex: 'period_end', width: 110 },
|
||||
{ title: '总排放(t)', dataIndex: 'total_tons', width: 100 },
|
||||
{ title: '减排(t)', dataIndex: 'reduction_tons', width: 100 },
|
||||
{ title: '净排放(t)', dataIndex: 'net_tons', width: 100 },
|
||||
{ title: '生成时间', dataIndex: 'generated_at', width: 160, ellipsis: true },
|
||||
{ title: '操作', key: 'action', width: 80, render: (_: any, r: any) => (
|
||||
<Button type="link" size="small" onClick={() => viewDetail(r.id)}>详情</Button>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card title="碳排放报告" size="small" extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>生成报告</Button>
|
||||
}>
|
||||
<Table columns={cols} dataSource={reports} rowKey="id" size="small"
|
||||
pagination={{ pageSize: 10 }} scroll={{ x: 800 }} />
|
||||
</Card>
|
||||
|
||||
{detail && (
|
||||
<Card title={`报告详情 - ${detail.report_type} (${detail.period_start} ~ ${detail.period_end})`}
|
||||
size="small" style={{ marginTop: 16 }} extra={<Button onClick={() => setDetail(null)}>关闭</Button>}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={6}><Statistic title="Scope 1" value={detail.scope1_tons} suffix="t" precision={4} /></Col>
|
||||
<Col span={6}><Statistic title="Scope 2" value={detail.scope2_tons} suffix="t" precision={4} /></Col>
|
||||
<Col span={6}><Statistic title="总减排" value={detail.reduction_tons} suffix="t" precision={4} valueStyle={{ color: '#52c41a' }} /></Col>
|
||||
<Col span={6}><Statistic title="净排放" value={detail.net_tons} suffix="t" precision={4} /></Col>
|
||||
</Row>
|
||||
{detail.report_data?.monthly_breakdown && (
|
||||
<ReactECharts style={{ height: 260, marginTop: 16 }} option={{
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['排放', '减排'] },
|
||||
xAxis: { type: 'category', data: detail.report_data.monthly_breakdown.map((m: any) => m.month) },
|
||||
yAxis: { type: 'value', name: 'tCO₂' },
|
||||
series: [
|
||||
{ name: '排放', type: 'bar', data: detail.report_data.monthly_breakdown.map((m: any) => m.emission_tons), itemStyle: { color: '#f5222d' } },
|
||||
{ name: '减排', type: 'bar', data: detail.report_data.monthly_breakdown.map((m: any) => m.reduction_tons), itemStyle: { color: '#52c41a' } },
|
||||
],
|
||||
}} />
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal title="生成碳报告" open={modalOpen} onOk={handleGenerate} onCancel={() => setModalOpen(false)}
|
||||
okText="生成" confirmLoading={generating}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="report_type" label="报告类型" rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ label: '月报', value: 'monthly' }, { label: '季报', value: 'quarterly' }, { label: '年报', value: 'annual' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="period" label="报告周期" rules={[{ required: true }]}>
|
||||
<DatePicker.RangePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Benchmarks Tab
|
||||
// ============================================================
|
||||
function BenchmarksTab() {
|
||||
const [benchmarks, setBenchmarks] = useState<any[]>([]);
|
||||
const [comparison, setComparison] = useState<any>(null);
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
useEffect(() => {
|
||||
getCarbonBenchmarks(year).then(setBenchmarks).catch(() => {});
|
||||
getCarbonBenchmarkComparison(year).then(setComparison).catch(() => {});
|
||||
}, [year]);
|
||||
|
||||
const cols = [
|
||||
{ title: '行业', dataIndex: 'industry' },
|
||||
{ title: '指标', dataIndex: 'metric_name' },
|
||||
{ title: '基准值', dataIndex: 'benchmark_value' },
|
||||
{ title: '单位', dataIndex: 'unit' },
|
||||
{ title: '年份', dataIndex: 'year', width: 80 },
|
||||
{ title: '来源', dataIndex: 'source', ellipsis: true },
|
||||
];
|
||||
|
||||
const chartOption = benchmarks.length > 0 ? {
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: benchmarks.map(b => b.industry) },
|
||||
yAxis: { type: 'value', name: benchmarks[0]?.unit || '' },
|
||||
series: [
|
||||
{ name: '行业基准', type: 'bar', data: benchmarks.map(b => b.benchmark_value), itemStyle: { color: '#1890ff' } },
|
||||
...(comparison ? [{
|
||||
name: '本园区', type: 'bar',
|
||||
data: benchmarks.map(() => comparison.net_tons),
|
||||
itemStyle: { color: '#52c41a' },
|
||||
}] : []),
|
||||
],
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{comparison && (
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}><Card size="small"><Statistic title="本园区排放" value={comparison.actual_emission_tons} suffix="t" precision={4} /></Card></Col>
|
||||
<Col span={8}><Card size="small"><Statistic title="本园区减排" value={comparison.actual_reduction_tons} suffix="t" precision={4} valueStyle={{ color: '#52c41a' }} /></Card></Col>
|
||||
<Col span={8}><Card size="small"><Statistic title="净排放" value={comparison.net_tons} suffix="t" precision={4} /></Card></Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card title="行业对标" size="small">
|
||||
{chartOption ? <ReactECharts option={chartOption} style={{ height: 300 }} /> : <Empty description="暂无基准数据" />}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card title="基准数据" size="small">
|
||||
<Table columns={cols} dataSource={benchmarks} rowKey="id" size="small" pagination={false} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main Page
|
||||
// ============================================================
|
||||
export default function Carbon() {
|
||||
const items = [
|
||||
{ key: 'overview', label: '总览', icon: <BarChartOutlined />, children: <OverviewTab /> },
|
||||
{ key: 'targets', label: '目标管理', icon: <AimOutlined />, children: <TargetsTab /> },
|
||||
{ key: 'reductions', label: '减排追踪', icon: <FallOutlined />, children: <ReductionsTab /> },
|
||||
{ key: 'certificates', label: '绿证管理', icon: <SafetyCertificateOutlined />, children: <CertificatesTab /> },
|
||||
{ key: 'reports', label: '碳报告', icon: <FileTextOutlined />, children: <ReportsTab /> },
|
||||
{ key: 'benchmarks', label: '行业对标', icon: <BarChartOutlined />, children: <BenchmarksTab /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs items={items.map(t => ({ ...t, label: <span>{t.icon} {t.label}</span> }))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, Statistic, message } from 'antd';
|
||||
import { DollarOutlined, ThunderboltOutlined, CarOutlined, DashboardOutlined } from '@ant-design/icons';
|
||||
import { getChargingDashboard } from '../../services/api';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
export default function ChargingDashboard() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, []);
|
||||
|
||||
const loadDashboard = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getChargingDashboard();
|
||||
setData(res);
|
||||
} catch {
|
||||
message.error('加载充电总览失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pileStatusData = data ? [
|
||||
{ type: '空闲', value: data.pile_status?.idle || 0 },
|
||||
{ type: '充电中', value: data.pile_status?.charging || 0 },
|
||||
{ type: '故障', value: data.pile_status?.fault || 0 },
|
||||
{ type: '离线', value: data.pile_status?.offline || 0 },
|
||||
].filter(d => d.value > 0) : [];
|
||||
|
||||
const pileStatusColors: Record<string, string> = {
|
||||
'空闲': '#52c41a',
|
||||
'充电中': '#1890ff',
|
||||
'故障': '#ff4d4f',
|
||||
'离线': '#d9d9d9',
|
||||
};
|
||||
|
||||
const revenueLineOption = {
|
||||
tooltip: { trigger: 'axis' as const },
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: (data?.revenue_trend || []).map((d: any) => d.date),
|
||||
axisLabel: { rotate: 45 },
|
||||
},
|
||||
yAxis: { type: 'value' as const, name: '营收 (元)' },
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: (data?.revenue_trend || []).map((d: any) => d.revenue),
|
||||
smooth: true,
|
||||
symbolSize: 6,
|
||||
}],
|
||||
grid: { left: 60, right: 20, bottom: 60, top: 30 },
|
||||
};
|
||||
|
||||
const pieOption = {
|
||||
tooltip: { trigger: 'item' as const },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
data: pileStatusData.map(d => ({
|
||||
name: d.type,
|
||||
value: d.value,
|
||||
itemStyle: { color: pileStatusColors[d.type] || '#d9d9d9' },
|
||||
})),
|
||||
label: { formatter: '{b} {c}' },
|
||||
}],
|
||||
};
|
||||
|
||||
const barOption = {
|
||||
tooltip: { trigger: 'axis' as const },
|
||||
xAxis: { type: 'value' as const, name: '营收 (元)' },
|
||||
yAxis: {
|
||||
type: 'category' as const,
|
||||
data: (data?.station_ranking || []).map((d: any) => d.station),
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: (data?.station_ranking || []).map((d: any) => d.revenue),
|
||||
label: { show: true, position: 'right' },
|
||||
}],
|
||||
grid: { left: 120, right: 40, bottom: 20, top: 20 },
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" loading={loading}>
|
||||
<Statistic
|
||||
title="总营收 (元)"
|
||||
value={data?.total_revenue || 0}
|
||||
prefix={<DollarOutlined />}
|
||||
precision={2}
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" loading={loading}>
|
||||
<Statistic
|
||||
title="充电量 (kWh)"
|
||||
value={data?.total_energy || 0}
|
||||
prefix={<ThunderboltOutlined />}
|
||||
precision={1}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" loading={loading}>
|
||||
<Statistic
|
||||
title="充电中"
|
||||
value={data?.active_sessions || 0}
|
||||
prefix={<CarOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" loading={loading}>
|
||||
<Statistic
|
||||
title="利用率"
|
||||
value={data?.utilization_rate || 0}
|
||||
prefix={<DashboardOutlined />}
|
||||
suffix="%"
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card size="small" title="营收趋势 (近30天)" loading={loading}>
|
||||
{data?.revenue_trend?.length > 0 ? (
|
||||
<ReactECharts option={revenueLineOption} style={{ height: 300 }} />
|
||||
) : (
|
||||
<div style={{ height: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>暂无数据</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card size="small" title="充电桩状态分布" loading={loading}>
|
||||
{pileStatusData.length > 0 ? (
|
||||
<ReactECharts option={pieOption} style={{ height: 300 }} />
|
||||
) : (
|
||||
<div style={{ height: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>暂无数据</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Card size="small" title="充电站营收排名" loading={loading}>
|
||||
{data?.station_ranking?.length > 0 ? (
|
||||
<ReactECharts option={barOption} style={{ height: 300 }} />
|
||||
) : (
|
||||
<div style={{ height: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>暂无数据</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Card, Table, Tag, Button, Tabs, Space, DatePicker, Select, message } from 'antd';
|
||||
import { SyncOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import { getChargingOrders, getChargingRealtimeOrders, getChargingAbnormalOrders, settleChargingOrder } from '../../services/api';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const orderStatusMap: Record<string, { color: string; text: string }> = {
|
||||
charging: { color: 'processing', text: '充电中' },
|
||||
pending_pay: { color: 'warning', text: '待支付' },
|
||||
completed: { color: 'success', text: '已完成' },
|
||||
failed: { color: 'error', text: '失败' },
|
||||
refunded: { color: 'default', text: '已退款' },
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number | null) => {
|
||||
if (!seconds) return '-';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return h > 0 ? `${h}时${m}分` : `${m}分`;
|
||||
};
|
||||
|
||||
export default function Orders() {
|
||||
return (
|
||||
<Tabs
|
||||
defaultActiveKey="realtime"
|
||||
items={[
|
||||
{ key: 'realtime', label: '实时充电', children: <RealtimeOrders /> },
|
||||
{ key: 'history', label: '历史订单', children: <HistoryOrders /> },
|
||||
{ key: 'abnormal', label: '异常订单', children: <AbnormalOrders /> },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RealtimeOrders() {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getChargingRealtimeOrders();
|
||||
setData(res as any[]);
|
||||
} catch { message.error('加载实时充电失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', dataIndex: 'order_no', width: 160 },
|
||||
{ title: '充电站', dataIndex: 'station_name', width: 150, ellipsis: true },
|
||||
{ title: '充电桩', dataIndex: 'pile_name', width: 120 },
|
||||
{ title: '用户', dataIndex: 'user_name', width: 100 },
|
||||
{ title: '车牌', dataIndex: 'car_no', width: 100 },
|
||||
{ title: '开始时间', dataIndex: 'start_time', width: 170 },
|
||||
{ title: '时长', dataIndex: 'charge_duration', width: 80, render: formatDuration },
|
||||
{ title: '充电量(kWh)', dataIndex: 'energy', width: 110, render: (v: number) => v != null ? v.toFixed(2) : '-' },
|
||||
{ title: '起始SOC', dataIndex: 'start_soc', width: 90, render: (v: number) => v != null ? `${v}%` : '-' },
|
||||
{ title: '当前SOC', dataIndex: 'end_soc', width: 90, render: (v: number) => v != null ? `${v}%` : '-' },
|
||||
{ title: '状态', dataIndex: 'order_status', width: 90, render: () => (
|
||||
<Tag icon={<SyncOutlined spin />} color="processing">充电中</Tag>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small" extra={<Button onClick={loadData}>刷新</Button>}>
|
||||
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} size="small" scroll={{ x: 1300 }}
|
||||
pagination={false} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryOrders() {
|
||||
const [data, setData] = useState<any>({ total: 0, items: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const cleanQuery: Record<string, any> = {};
|
||||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') cleanQuery[k] = v;
|
||||
});
|
||||
const res = await getChargingOrders(cleanQuery);
|
||||
setData(res as any);
|
||||
} catch { message.error('加载订单失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => { loadData(); }, [filters, loadData]);
|
||||
|
||||
const handleDateChange = (_: any, dates: [string, string]) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
start_date: dates[0] || undefined,
|
||||
end_date: dates[1] || undefined,
|
||||
page: 1,
|
||||
}));
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', dataIndex: 'order_no', width: 160 },
|
||||
{ title: '充电站', dataIndex: 'station_name', width: 150, ellipsis: true },
|
||||
{ title: '充电桩', dataIndex: 'pile_name', width: 120 },
|
||||
{ title: '用户', dataIndex: 'user_name', width: 100 },
|
||||
{ title: '车牌', dataIndex: 'car_no', width: 100 },
|
||||
{ title: '充电量(kWh)', dataIndex: 'energy', width: 110, render: (v: number) => v != null ? v.toFixed(2) : '-' },
|
||||
{ title: '时长', dataIndex: 'charge_duration', width: 80, render: formatDuration },
|
||||
{ title: '电费(元)', dataIndex: 'elec_amt', width: 90, render: (v: number) => v != null ? v.toFixed(2) : '-' },
|
||||
{ title: '服务费(元)', dataIndex: 'serve_amt', width: 100, render: (v: number) => v != null ? v.toFixed(2) : '-' },
|
||||
{ title: '实付(元)', dataIndex: 'paid_price', width: 90, render: (v: number) => v != null ? v.toFixed(2) : '-' },
|
||||
{ title: '状态', dataIndex: 'order_status', width: 90, render: (s: string) => {
|
||||
const st = orderStatusMap[s] || { color: 'default', text: s || '-' };
|
||||
return <Tag color={st.color}>{st.text}</Tag>;
|
||||
}},
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select allowClear placeholder="订单状态" style={{ width: 120 }}
|
||||
options={Object.entries(orderStatusMap).map(([k, v]) => ({ label: v.text, value: k }))}
|
||||
onChange={v => setFilters(prev => ({ ...prev, order_status: v, page: 1 }))} />
|
||||
<RangePicker onChange={handleDateChange} />
|
||||
</Space>
|
||||
<Table columns={columns} dataSource={data.items} rowKey="id" loading={loading} size="small" scroll={{ x: 1500 }}
|
||||
pagination={{
|
||||
current: filters.page,
|
||||
pageSize: filters.page_size,
|
||||
total: data.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 条订单`,
|
||||
onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AbnormalOrders() {
|
||||
const [data, setData] = useState<any>({ total: 0, items: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getChargingAbnormalOrders(filters);
|
||||
setData(res as any);
|
||||
} catch { message.error('加载异常订单失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => { loadData(); }, [filters, loadData]);
|
||||
|
||||
const handleSettle = async (id: number) => {
|
||||
try {
|
||||
await settleChargingOrder(id);
|
||||
message.success('手动结算成功');
|
||||
loadData();
|
||||
} catch { message.error('结算失败'); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', dataIndex: 'order_no', width: 160 },
|
||||
{ title: '充电站', dataIndex: 'station_name', width: 150, ellipsis: true },
|
||||
{ title: '充电桩', dataIndex: 'pile_name', width: 120 },
|
||||
{ title: '用户', dataIndex: 'user_name', width: 100 },
|
||||
{ title: '充电量(kWh)', dataIndex: 'energy', width: 110, render: (v: number) => v != null ? v.toFixed(2) : '-' },
|
||||
{ title: '状态', dataIndex: 'order_status', width: 90, render: (s: string) => {
|
||||
const st = orderStatusMap[s] || { color: 'default', text: s || '-' };
|
||||
return <Tag icon={<WarningOutlined />} color={st.color}>{st.text}</Tag>;
|
||||
}},
|
||||
{ title: '异常原因', dataIndex: 'abno_cause', width: 200, ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
|
||||
{ title: '操作', key: 'action', width: 100, render: (_: any, record: any) => (
|
||||
record.order_status === 'failed' && (
|
||||
<Button size="small" type="primary" onClick={() => handleSettle(record.id)}>手动结算</Button>
|
||||
)
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<Table columns={columns} dataSource={data.items} rowKey="id" loading={loading} size="small" scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: filters.page,
|
||||
pageSize: filters.page_size,
|
||||
total: data.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 条异常订单`,
|
||||
onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber, Space, message } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { getChargingPiles, createChargingPile, updateChargingPile, deleteChargingPile, getChargingStations, getChargingBrands } from '../../services/api';
|
||||
|
||||
const workStatusMap: Record<string, { color: string; text: string }> = {
|
||||
idle: { color: 'green', text: '空闲' },
|
||||
charging: { color: 'blue', text: '充电中' },
|
||||
fault: { color: 'red', text: '故障' },
|
||||
offline: { color: 'default', text: '离线' },
|
||||
};
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '交流慢充', value: 'AC_slow' },
|
||||
{ label: '直流快充', value: 'DC_fast' },
|
||||
{ label: '直流超充', value: 'DC_superfast' },
|
||||
];
|
||||
|
||||
const connectorOptions = [
|
||||
{ label: 'GB/T', value: 'GB_T' },
|
||||
{ label: 'CCS', value: 'CCS' },
|
||||
{ label: 'CHAdeMO', value: 'CHAdeMO' },
|
||||
];
|
||||
|
||||
export default function Piles() {
|
||||
const [data, setData] = useState<any>({ total: 0, items: [] });
|
||||
const [stations, setStations] = useState<any[]>([]);
|
||||
const [brands, setBrands] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const cleanQuery: Record<string, any> = {};
|
||||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') cleanQuery[k] = v;
|
||||
});
|
||||
const res = await getChargingPiles(cleanQuery);
|
||||
setData(res as any);
|
||||
} catch { message.error('加载充电桩失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [filters]);
|
||||
|
||||
const loadMeta = async () => {
|
||||
try {
|
||||
const [st, br] = await Promise.all([
|
||||
getChargingStations({ page_size: 100 }),
|
||||
getChargingBrands(),
|
||||
]);
|
||||
setStations((st as any).items || []);
|
||||
setBrands(br as any[]);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => { loadMeta(); }, []);
|
||||
useEffect(() => { loadData(); }, [filters, loadData]);
|
||||
|
||||
const handleFilterChange = (key: string, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value, page: 1 }));
|
||||
};
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ status: 'active', work_status: 'offline' });
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (record: any) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue(record);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
if (editing) {
|
||||
await updateChargingPile(editing.id, values);
|
||||
message.success('充电桩更新成功');
|
||||
} else {
|
||||
await createChargingPile(values);
|
||||
message.success('充电桩创建成功');
|
||||
}
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.detail || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteChargingPile(id);
|
||||
message.success('已停用');
|
||||
loadData();
|
||||
} catch { message.error('操作失败'); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '终端编码', dataIndex: 'encoding', width: 140 },
|
||||
{ title: '名称', dataIndex: 'name', width: 150, ellipsis: true },
|
||||
{ title: '所属充电站', dataIndex: 'station_id', width: 150, render: (id: number) => {
|
||||
const s = stations.find((st: any) => st.id === id);
|
||||
return s ? s.name : id;
|
||||
}},
|
||||
{ title: '类型', dataIndex: 'type', width: 100, render: (v: string) => typeOptions.find(o => o.value === v)?.label || v || '-' },
|
||||
{ title: '额定功率(kW)', dataIndex: 'rated_power_kw', width: 120, render: (v: number) => v != null ? v : '-' },
|
||||
{ title: '品牌', dataIndex: 'brand', width: 100 },
|
||||
{ title: '型号', dataIndex: 'model', width: 100 },
|
||||
{ title: '接口类型', dataIndex: 'connector_type', width: 100 },
|
||||
{ title: '工作状态', dataIndex: 'work_status', width: 100, render: (s: string) => {
|
||||
const st = workStatusMap[s] || { color: 'default', text: s || '-' };
|
||||
return <Tag color={st.color}>{st.text}</Tag>;
|
||||
}},
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s: string) => (
|
||||
<Tag color={s === 'active' ? 'green' : 'default'}>{s === 'active' ? '启用' : '停用'}</Tag>
|
||||
)},
|
||||
{ title: '操作', key: 'action', width: 150, fixed: 'right' as const, render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEditModal(record)}>编辑</Button>
|
||||
<Button size="small" type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>停用</Button>
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small" title="充电桩管理" extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}>添加充电桩</Button>
|
||||
}>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select allowClear placeholder="所属充电站" style={{ width: 180 }}
|
||||
options={stations.map((s: any) => ({ label: s.name, value: s.id }))}
|
||||
onChange={v => handleFilterChange('station_id', v)} />
|
||||
<Select allowClear placeholder="类型" style={{ width: 120 }} options={typeOptions}
|
||||
onChange={v => handleFilterChange('type', v)} />
|
||||
<Select allowClear placeholder="工作状态" style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '空闲', value: 'idle' }, { label: '充电中', value: 'charging' },
|
||||
{ label: '故障', value: 'fault' }, { label: '离线', value: 'offline' },
|
||||
]}
|
||||
onChange={v => handleFilterChange('work_status', v)} />
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns} dataSource={data.items} rowKey="id"
|
||||
loading={loading} size="small" scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: filters.page,
|
||||
pageSize: filters.page_size,
|
||||
total: data.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 个充电桩`,
|
||||
onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑充电桩' : '添加充电桩'}
|
||||
open={showModal}
|
||||
onCancel={() => { setShowModal(false); form.resetFields(); }}
|
||||
onOk={() => form.submit()}
|
||||
okText={editing ? '保存' : '创建'}
|
||||
cancelText="取消"
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="station_id" label="所属充电站" rules={[{ required: true, message: '请选择充电站' }]}>
|
||||
<Select placeholder="选择充电站"
|
||||
options={stations.map((s: any) => ({ label: s.name, value: s.id }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="encoding" label="终端编码" rules={[{ required: true, message: '请输入终端编码' }]}>
|
||||
<Input placeholder="唯一终端编码" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input placeholder="充电桩名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="类型">
|
||||
<Select allowClear placeholder="选择类型" options={typeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="brand" label="品牌">
|
||||
<Select allowClear placeholder="选择品牌"
|
||||
options={brands.map((b: any) => ({ label: b.brand_name, value: b.brand_name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="model" label="型号">
|
||||
<Input placeholder="设备型号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="rated_power_kw" label="额定功率(kW)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} step={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="connector_type" label="接口类型">
|
||||
<Select allowClear placeholder="选择接口类型" options={connectorOptions} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber, Space, message } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, MinusCircleOutlined } from '@ant-design/icons';
|
||||
import { getChargingPricing, createChargingPricing, updateChargingPricing, deleteChargingPricing, getChargingStations } from '../../services/api';
|
||||
|
||||
const periodMarkMap: Record<string, { color: string; text: string }> = {
|
||||
sharp: { color: 'red', text: '尖峰' },
|
||||
peak: { color: 'orange', text: '高峰' },
|
||||
flat: { color: 'blue', text: '平段' },
|
||||
valley: { color: 'green', text: '低谷' },
|
||||
};
|
||||
|
||||
export default function Pricing() {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [stations, setStations] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getChargingPricing();
|
||||
setData(res as any[]);
|
||||
} catch { message.error('加载计费策略失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const loadStations = async () => {
|
||||
try {
|
||||
const res = await getChargingStations({ page_size: 100 });
|
||||
setStations((res as any).items || []);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(); loadStations(); }, []);
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ status: 'inactive', bill_model: 'tou', params: [{}] });
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (record: any) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({
|
||||
strategy_name: record.strategy_name,
|
||||
station_id: record.station_id,
|
||||
bill_model: record.bill_model,
|
||||
description: record.description,
|
||||
status: record.status,
|
||||
params: record.params?.length > 0 ? record.params : [{}],
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
if (editing) {
|
||||
await updateChargingPricing(editing.id, values);
|
||||
message.success('计费策略更新成功');
|
||||
} else {
|
||||
await createChargingPricing(values);
|
||||
message.success('计费策略创建成功');
|
||||
}
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.detail || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteChargingPricing(id);
|
||||
message.success('已停用');
|
||||
loadData();
|
||||
} catch { message.error('操作失败'); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '策略名称', dataIndex: 'strategy_name', width: 180 },
|
||||
{ title: '适用站点', dataIndex: 'station_id', width: 150, render: (id: number) => {
|
||||
const s = stations.find((st: any) => st.id === id);
|
||||
return s ? s.name : id || '全部';
|
||||
}},
|
||||
{ title: '计费模式', dataIndex: 'bill_model', width: 100, render: (v: string) => v === 'tou' ? '分时计费' : v === 'flat' ? '固定计费' : v || '-' },
|
||||
{ title: '时段数', dataIndex: 'params', width: 80, render: (p: any[]) => p?.length || 0 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => (
|
||||
<Tag color={s === 'active' ? 'green' : 'default'}>{s === 'active' ? '启用' : '停用'}</Tag>
|
||||
)},
|
||||
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
|
||||
{ title: '操作', key: 'action', width: 150, render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEditModal(record)}>编辑</Button>
|
||||
<Button size="small" type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>停用</Button>
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
const expandedRowRender = (record: any) => {
|
||||
const paramCols = [
|
||||
{ title: '开始时间', dataIndex: 'start_time', width: 100 },
|
||||
{ title: '结束时间', dataIndex: 'end_time', width: 100 },
|
||||
{ title: '时段标识', dataIndex: 'period_mark', width: 100, render: (v: string) => {
|
||||
const pm = periodMarkMap[v];
|
||||
return pm ? <Tag color={pm.color}>{pm.text}</Tag> : v || '-';
|
||||
}},
|
||||
{ title: '电费(元/kWh)', dataIndex: 'elec_price', width: 120, render: (v: number) => v?.toFixed(4) },
|
||||
{ title: '服务费(元/kWh)', dataIndex: 'service_price', width: 130, render: (v: number) => v?.toFixed(4) },
|
||||
{ title: '合计(元/kWh)', key: 'total', width: 120, render: (_: any, r: any) => ((r.elec_price || 0) + (r.service_price || 0)).toFixed(4) },
|
||||
];
|
||||
return <Table columns={paramCols} dataSource={record.params || []} rowKey="id" pagination={false} size="small" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card size="small" title="计费策略管理" extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}>新建策略</Button>
|
||||
}>
|
||||
<Table
|
||||
columns={columns} dataSource={data} rowKey="id"
|
||||
loading={loading} size="small"
|
||||
expandable={{ expandedRowRender }}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑计费策略' : '新建计费策略'}
|
||||
open={showModal}
|
||||
onCancel={() => { setShowModal(false); form.resetFields(); }}
|
||||
onOk={() => form.submit()}
|
||||
okText={editing ? '保存' : '创建'}
|
||||
cancelText="取消"
|
||||
width={800}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="strategy_name" label="策略名称" rules={[{ required: true, message: '请输入策略名称' }]}>
|
||||
<Input placeholder="请输入策略名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="station_id" label="适用站点">
|
||||
<Select allowClear placeholder="选择站点 (留空表示全部)"
|
||||
options={stations.map((s: any) => ({ label: s.name, value: s.id }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bill_model" label="计费模式">
|
||||
<Select options={[{ label: '分时计费', value: 'tou' }, { label: '固定计费', value: 'flat' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={2} placeholder="策略描述" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>时段配置</div>
|
||||
<Form.List name="params">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline" wrap>
|
||||
<Form.Item {...restField} name={[name, 'start_time']} rules={[{ required: true, message: '开始时间' }]}>
|
||||
<Input placeholder="开始 HH:MM" style={{ width: 110 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'end_time']} rules={[{ required: true, message: '结束时间' }]}>
|
||||
<Input placeholder="结束 HH:MM" style={{ width: 110 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'period_mark']}>
|
||||
<Select placeholder="时段" style={{ width: 100 }} allowClear
|
||||
options={Object.entries(periodMarkMap).map(([k, v]) => ({ label: v.text, value: k }))} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'elec_price']} rules={[{ required: true, message: '电费' }]}>
|
||||
<InputNumber placeholder="电费" style={{ width: 110 }} min={0} step={0.01} addonAfter="元" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'service_price']}>
|
||||
<InputNumber placeholder="服务费" style={{ width: 120 }} min={0} step={0.01} addonAfter="元" />
|
||||
</Form.Item>
|
||||
<MinusCircleOutlined onClick={() => remove(name)} style={{ color: '#ff4d4f' }} />
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
添加时段
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber, Space, message } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { getChargingStations, createChargingStation, updateChargingStation, deleteChargingStation, getChargingMerchants } from '../../services/api';
|
||||
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
active: { color: 'green', text: '运营中' },
|
||||
disabled: { color: 'default', text: '已停用' },
|
||||
};
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '公共充电站', value: 'public' },
|
||||
{ label: '专用充电站', value: 'private' },
|
||||
{ label: '专属充电站', value: 'dedicated' },
|
||||
];
|
||||
|
||||
export default function Stations() {
|
||||
const [data, setData] = useState<any>({ total: 0, items: [] });
|
||||
const [merchants, setMerchants] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const cleanQuery: Record<string, any> = {};
|
||||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') cleanQuery[k] = v;
|
||||
});
|
||||
const res = await getChargingStations(cleanQuery);
|
||||
setData(res as any);
|
||||
} catch { message.error('加载充电站失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [filters]);
|
||||
|
||||
const loadMerchants = async () => {
|
||||
try {
|
||||
const res = await getChargingMerchants();
|
||||
setMerchants(res as any[]);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => { loadMerchants(); }, []);
|
||||
useEffect(() => { loadData(); }, [filters, loadData]);
|
||||
|
||||
const handleFilterChange = (key: string, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value, page: 1 }));
|
||||
};
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ status: 'active' });
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (record: any) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue(record);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
if (editing) {
|
||||
await updateChargingStation(editing.id, values);
|
||||
message.success('充电站更新成功');
|
||||
} else {
|
||||
await createChargingStation(values);
|
||||
message.success('充电站创建成功');
|
||||
}
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.detail || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteChargingStation(id);
|
||||
message.success('已停用');
|
||||
loadData();
|
||||
} catch { message.error('操作失败'); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '站点名称', dataIndex: 'name', width: 180, ellipsis: true },
|
||||
{ title: '类型', dataIndex: 'type', width: 100, render: (v: string) => typeOptions.find(o => o.value === v)?.label || v || '-' },
|
||||
{ title: '地址', dataIndex: 'address', width: 200, ellipsis: true },
|
||||
{ title: '充电桩总数', dataIndex: 'total_piles', width: 100 },
|
||||
{ title: '可用桩数', dataIndex: 'available_piles', width: 100 },
|
||||
{ title: '总功率(kW)', dataIndex: 'total_power_kw', width: 110 },
|
||||
{ title: '默认电价(元/kWh)', dataIndex: 'price', width: 140, render: (v: number) => v != null ? v.toFixed(2) : '-' },
|
||||
{ title: '运营时间', dataIndex: 'operating_hours', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => {
|
||||
const st = statusMap[s] || { color: 'default', text: s || '-' };
|
||||
return <Tag color={st.color}>{st.text}</Tag>;
|
||||
}},
|
||||
{ title: '操作', key: 'action', width: 150, fixed: 'right' as const, render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEditModal(record)}>编辑</Button>
|
||||
<Button size="small" type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>停用</Button>
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small" title="充电站管理" extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}>添加充电站</Button>
|
||||
}>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select allowClear placeholder="站点类型" style={{ width: 150 }} options={typeOptions}
|
||||
onChange={v => handleFilterChange('type', v)} />
|
||||
<Select allowClear placeholder="状态" style={{ width: 120 }}
|
||||
options={[{ label: '运营中', value: 'active' }, { label: '已停用', value: 'disabled' }]}
|
||||
onChange={v => handleFilterChange('status', v)} />
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns} dataSource={data.items} rowKey="id"
|
||||
loading={loading} size="small" scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: filters.page,
|
||||
pageSize: filters.page_size,
|
||||
total: data.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 个充电站`,
|
||||
onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑充电站' : '添加充电站'}
|
||||
open={showModal}
|
||||
onCancel={() => { setShowModal(false); form.resetFields(); }}
|
||||
onOk={() => form.submit()}
|
||||
okText={editing ? '保存' : '创建'}
|
||||
cancelText="取消"
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="name" label="站点名称" rules={[{ required: true, message: '请输入站点名称' }]}>
|
||||
<Input placeholder="请输入站点名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="类型">
|
||||
<Select allowClear placeholder="选择类型" options={typeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="merchant_id" label="运营商">
|
||||
<Select allowClear placeholder="选择运营商"
|
||||
options={merchants.map((m: any) => ({ label: m.name, value: m.id }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址">
|
||||
<Input placeholder="请输入地址" />
|
||||
</Form.Item>
|
||||
<Form.Item name="price" label="默认电价(元/kWh)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} step={0.01} placeholder="默认电价" />
|
||||
</Form.Item>
|
||||
<Form.Item name="total_power_kw" label="总功率(kW)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} step={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="operating_hours" label="运营时间">
|
||||
<Input placeholder="例: 00:00-24:00" />
|
||||
</Form.Item>
|
||||
<Form.Item name="activity" label="优惠活动">
|
||||
<Input.TextArea rows={2} placeholder="优惠活动信息" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={[{ label: '运营中', value: 'active' }, { label: '已停用', value: 'disabled' }]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||