10 Commits

Author SHA1 Message Date
Du Wenbo
a05b25bcc2 fix: realtime + KPI power dedup by station prefix (v1.4.3)
Realtime endpoint was summing ALL device power readings, causing
double-counting when multiple devices share the same Sungrow station.
E.g. 10 devices × station-level power = 5x inflated total.

Fix: GROUP BY station prefix (first 3 chars of device name) and
take MAX per station. Same fix applied to KPI daily_generation.

Result: 5,550 kW → 1,931 kW (matches iSolarCloud's 2,049 kW
within the 15-min collection timing window).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:18:49 +08:00
Du Wenbo
8e5e52e8ee fix: carbon fallback, energy history parsing, generation dedup (v1.4.2)
- Carbon overview: fallback to compute from energy_data × emission_factors
  when carbon_emissions table is empty
- Energy history: parse start_time/end_time as datetime (was raw string → 500)
- Dashboard generation: dedup by station prefix to prevent inflated totals
  (93K → 14.8K kWh)
- Realtime window: already at 20min to cover 15min collector interval

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 09:55:48 +08:00
Du Wenbo
72f4269cd4 fix(models): add alembic migration 009 for missing tables (v1.4.1)
Migration adds tables that existed in models/ but were never
included in 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

Without this migration, fresh deploys would 500 on these endpoints:
- /api/v1/ai-ops/health, /ai-ops/dashboard
- /api/v1/strategy/pricing
- /api/v1/prediction/forecast
- /api/v1/weather/current

Discovered during Z-Park demo deployment on xie_openclaw1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:47:34 +08:00
Du Wenbo
56132bae32 chore: add validate_data.py for buyoff data accuracy checks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:05:56 +08:00
Du Wenbo
475313855d feat: add version API and solar KPI endpoint (v1.4.0)
New endpoints:
- GET /api/v1/version — returns VERSIONS.json (no auth required)
  For field engineers to check platform version from login page
- GET /api/v1/kpi/solar — returns PR, self-consumption rate,
  equivalent utilization hours, and daily revenue
  Handles station-level vs device-level data deduplication

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:35:08 +08:00
Du Wenbo
60e7f08d7e feat!: generic defaults, dashboard fallback, PV filter fix (v1.3.0)
- Replace all hardcoded Tianpu/天普 defaults with generic "EMS Platform"
- Add energy_today fallback: query raw energy_data when daily summary empty
- Fix PV device filter to include sungrow_inverter device type
- Update APP_NAME, CUSTOMER default, SECRET_KEY, SMTP, Celery, email templates

BREAKING: CUSTOMER default changed from "tianpu" to "default"
Existing deployments with CUSTOMER=tianpu in .env are unaffected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:02:38 +08:00
Du Wenbo
1636dea8f1 docs: add version management guide to README
Step-by-step instructions for releasing, downloading old versions,
comparing versions, and keeping version metadata in sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:08:03 +08:00
Du Wenbo
2516b8d1de docs: rewrite README with correct URLs, structure, and v1.2.0
- Fix clone URL from old Mac Studio to labmac3 Gitea
- Fix repo name from tianpu-ems to ems-core
- Remove nonexistent seed_data.py reference
- Add missing dirs: tests/, templates/, VERSION, VERSIONS.json
- Add version badge referencing VERSIONS.json

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:55:15 +08:00
Du Wenbo
4095ba0b56 fix: update VERSIONS.json to match v1.2.0 tag
VERSIONS.json was still showing 1.1.0 while the git tag and commit
message already referenced v1.2.0. Sync version metadata.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:21:15 +08:00
Du Wenbo
139ca4c128 feat!: backend-only architecture, remove frontend/nginx (v1.2.0)
- Remove frontend/ and nginx/ from core (each customer owns their frontend)
- Update docker-compose.yml to backend-only (postgres + redis + backend)
- Update docker-compose.prod.yml to backend-only
- Add CLAUDE.md with dev guidelines
- Update README.md with new architecture diagram

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:39:31 +08:00
143 changed files with 1347 additions and 21901 deletions

33
CLAUDE.md Normal file
View 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
View File

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

View File

@@ -1 +1 @@
1.1.0
1.4.1

6
VERSIONS.json Normal file
View 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"
}

View File

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

View File

@@ -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', '模拟器采集间隔(秒)'),

View File

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

View File

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

View File

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

View File

@@ -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()]

View File

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

View 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]

View File

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

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

View File

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

View File

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

View File

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

View 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) # 原始数据包

View File

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

View File

@@ -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": "电力",

View File

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

View File

@@ -1,4 +1,4 @@
"""模拟数据生成器 - 为天普园区设备生成真实感的模拟数据
"""模拟数据生成器 - 为园区设备生成真实感的模拟数据
Uses physics-based solar position, Beijing weather models, cloud transients,
temperature derating, and realistic building load patterns to produce data

View File

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

View File

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

View File

@@ -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>
天普零碳园区智慧能源管理平台 &copy; 2026
Smart Energy Management Platform &copy; 2026
</div>
</td>
</tr>

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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...
},
},
])
```

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &amp; Humidity Sensor</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "选择日期范围"
}
}

View File

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

View File

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

View File

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

View File

@@ -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="&yen;" />
</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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]} />
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More