From d8e4449f1009bc03b167c0e5667413585b2b3e53 Mon Sep 17 00:00:00 2001 From: Du Wenbo Date: Sat, 4 Apr 2026 18:16:49 +0800 Subject: [PATCH] Squashed 'core/' content from commit 92ec910 git-subtree-dir: core git-subtree-split: 92ec910a132e379a3a6e442a75bcb07cac0f0010 --- .env.example | 37 + .gitignore | 15 + README.md | 252 + backend/Dockerfile | 10 + backend/alembic.ini | 36 + backend/alembic/env.py | 49 + backend/alembic/script.py.mako | 24 + .../alembic/versions/001_initial_schema.py | 268 + .../versions/002_add_system_settings.py | 41 + .../alembic/versions/003_energy_categories.py | 37 + .../alembic/versions/004_charging_tables.py | 168 + backend/alembic/versions/005_quota_tables.py | 53 + .../alembic/versions/006_pricing_tables.py | 48 + .../versions/007_maintenance_tables.py | 88 + .../alembic/versions/008_management_tables.py | 79 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/router.py | 28 + backend/app/api/v1/__init__.py | 0 backend/app/api/v1/ai_ops.py | 590 ++ backend/app/api/v1/alarms.py | 321 + backend/app/api/v1/audit.py | 76 + backend/app/api/v1/auth.py | 53 + backend/app/api/v1/branding.py | 20 + backend/app/api/v1/carbon.py | 434 ++ backend/app/api/v1/charging.py | 716 ++ backend/app/api/v1/collectors.py | 53 + backend/app/api/v1/cost.py | 279 + backend/app/api/v1/dashboard.py | 146 + backend/app/api/v1/devices.py | 206 + backend/app/api/v1/energy.py | 763 +++ backend/app/api/v1/energy_strategy.py | 376 ++ backend/app/api/v1/maintenance.py | 489 ++ backend/app/api/v1/management.py | 385 ++ backend/app/api/v1/monitoring.py | 78 + backend/app/api/v1/prediction.py | 185 + backend/app/api/v1/quota.py | 192 + backend/app/api/v1/reports.py | 316 + backend/app/api/v1/settings.py | 84 + backend/app/api/v1/users.py | 83 + backend/app/api/v1/weather.py | 83 + backend/app/api/v1/websocket.py | 227 + backend/app/collectors/__init__.py | 5 + backend/app/collectors/base.py | 160 + backend/app/collectors/http_collector.py | 107 + backend/app/collectors/manager.py | 154 + backend/app/collectors/modbus_tcp.py | 87 + backend/app/collectors/mqtt_collector.py | 117 + backend/app/collectors/queue.py | 185 + backend/app/collectors/sungrow_collector.py | 204 + backend/app/core/__init__.py | 0 backend/app/core/cache.py | 148 + backend/app/core/config.py | 88 + backend/app/core/database.py | 27 + backend/app/core/deps.py | 34 + backend/app/core/middleware.py | 86 + backend/app/core/security.py | 29 + backend/app/main.py | 135 + backend/app/models/__init__.py | 43 + backend/app/models/ai_ops.py | 88 + backend/app/models/alarm.py | 48 + backend/app/models/carbon.py | 115 + backend/app/models/charging.py | 145 + backend/app/models/device.py | 51 + backend/app/models/energy.py | 52 + backend/app/models/energy_strategy.py | 81 + backend/app/models/maintenance.py | 69 + backend/app/models/management.py | 60 + backend/app/models/prediction.py | 48 + backend/app/models/pricing.py | 33 + backend/app/models/quota.py | 38 + backend/app/models/report.py | 38 + backend/app/models/setting.py | 13 + backend/app/models/user.py | 42 + backend/app/models/weather.py | 33 + backend/app/services/__init__.py | 0 backend/app/services/aggregation.py | 291 + backend/app/services/ai_ops.py | 1016 +++ backend/app/services/ai_prediction.py | 606 ++ backend/app/services/alarm_checker.py | 253 + backend/app/services/audit.py | 32 + backend/app/services/carbon_asset.py | 462 ++ backend/app/services/cost_calculator.py | 261 + backend/app/services/email_service.py | 105 + backend/app/services/energy_strategy.py | 419 ++ backend/app/services/quota_checker.py | 124 + backend/app/services/report_generator.py | 523 ++ backend/app/services/report_scheduler.py | 192 + backend/app/services/simulator.py | 295 + backend/app/services/weather_model.py | 739 ++ backend/app/services/weather_service.py | 229 + backend/app/tasks/__init__.py | 6 + backend/app/tasks/celery_app.py | 24 + backend/app/tasks/report_tasks.py | 157 + backend/app/templates/alarm_email.html | 98 + backend/conftest.py | 272 + backend/pytest.ini | 6 + backend/requirements.txt | 26 + backend/tests/__init__.py | 0 backend/tests/test_alarms.py | 125 + backend/tests/test_auth.py | 66 + backend/tests/test_carbon.py | 62 + backend/tests/test_dashboard.py | 47 + backend/tests/test_devices.py | 119 + backend/tests/test_energy.py | 74 + backend/tests/test_monitoring.py | 44 + backend/tests/test_reports.py | 79 + backend/tests/test_users.py | 78 + docker-compose.prod.yml | 127 + docker-compose.yml | 63 + frontend/.gitignore | 24 + frontend/Dockerfile | 11 + frontend/Dockerfile.prod | 20 + frontend/README.md | 73 + frontend/eslint.config.js | 23 + frontend/index.html | 13 + frontend/package-lock.json | 6012 +++++++++++++++++ frontend/package.json | 45 + frontend/public/devices/default.svg | 19 + frontend/public/devices/heat_meter.svg | 30 + frontend/public/devices/heat_pump.svg | 46 + frontend/public/devices/meter.svg | 41 + frontend/public/devices/pv_inverter.svg | 42 + frontend/public/devices/sensor.svg | 39 + frontend/public/devices/water_meter.svg | 41 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.tsx | 84 + frontend/src/assets/hero.png | Bin 0 -> 44919 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/contexts/ThemeContext.tsx | 33 + frontend/src/hooks/useRealtimeWebSocket.ts | 196 + frontend/src/i18n/index.ts | 18 + frontend/src/i18n/locales/en.json | 64 + frontend/src/i18n/locales/zh.json | 64 + frontend/src/index.css | 84 + frontend/src/layouts/MainLayout.tsx | 224 + frontend/src/main.tsx | 10 + frontend/src/pages/AIOperations/index.tsx | 859 +++ frontend/src/pages/Alarms/index.tsx | 314 + frontend/src/pages/Analysis/CostAnalysis.tsx | 245 + frontend/src/pages/Analysis/LossAnalysis.tsx | 107 + frontend/src/pages/Analysis/MomAnalysis.tsx | 130 + .../src/pages/Analysis/SubitemAnalysis.tsx | 222 + frontend/src/pages/Analysis/YoyAnalysis.tsx | 108 + frontend/src/pages/Analysis/index.tsx | 336 + .../pages/BigScreen/components/AlarmCard.tsx | 91 + .../BigScreen/components/AnimatedNumber.tsx | 38 + .../pages/BigScreen/components/CarbonCard.tsx | 139 + .../components/EnergyFlowDiagram.tsx | 190 + .../components/EnergyOverviewCard.tsx | 101 + .../BigScreen/components/HeatPumpCard.tsx | 96 + .../BigScreen/components/LoadCurveCard.tsx | 83 + .../src/pages/BigScreen/components/PVCard.tsx | 110 + frontend/src/pages/BigScreen/index.tsx | 195 + .../src/pages/BigScreen/styles.module.css | 658 ++ .../BigScreen3D/components/Buildings.tsx | 130 + .../BigScreen3D/components/CampusScene.tsx | 182 + .../components/DeviceDetailView.tsx | 490 ++ .../components/DeviceInfoPanel.tsx | 178 + .../components/DeviceListPanel.tsx | 85 + .../BigScreen3D/components/DeviceMarkers.tsx | 200 + .../components/EnergyParticles.tsx | 164 + .../pages/BigScreen3D/components/Ground.tsx | 22 + .../BigScreen3D/components/HUDOverlay.tsx | 93 + .../BigScreen3D/components/HeatPumps.tsx | 147 + .../pages/BigScreen3D/components/PVPanels.tsx | 107 + .../components/SceneEnvironment.tsx | 24 + frontend/src/pages/BigScreen3D/constants.ts | 120 + .../BigScreen3D/hooks/useCameraAnimation.ts | 69 + .../pages/BigScreen3D/hooks/useDeviceData.ts | 129 + .../pages/BigScreen3D/hooks/useEnergyFlow.ts | 33 + frontend/src/pages/BigScreen3D/index.tsx | 132 + .../src/pages/BigScreen3D/styles.module.css | 329 + frontend/src/pages/BigScreen3D/types.ts | 73 + frontend/src/pages/Carbon/index.tsx | 626 ++ frontend/src/pages/Charging/Dashboard.tsx | 169 + frontend/src/pages/Charging/Orders.tsx | 201 + frontend/src/pages/Charging/Piles.tsx | 203 + frontend/src/pages/Charging/Pricing.tsx | 193 + frontend/src/pages/Charging/Stations.tsx | 180 + frontend/src/pages/Charging/index.tsx | 23 + .../Dashboard/components/DeviceStatus.tsx | 26 + .../pages/Dashboard/components/EnergyFlow.tsx | 96 + .../Dashboard/components/EnergyOverview.tsx | 33 + .../pages/Dashboard/components/LoadCurve.tsx | 40 + .../Dashboard/components/PowerGeneration.tsx | 45 + frontend/src/pages/Dashboard/index.tsx | 143 + frontend/src/pages/DataQuery/index.tsx | 365 + frontend/src/pages/DeviceDetail/index.tsx | 490 ++ frontend/src/pages/Devices/Topology.tsx | 186 + frontend/src/pages/Devices/index.tsx | 312 + .../src/pages/EnergyStrategy/CostAnalysis.tsx | 130 + .../pages/EnergyStrategy/PricingConfig.tsx | 259 + .../pages/EnergyStrategy/SavingsReport.tsx | 98 + .../pages/EnergyStrategy/StrategyManager.tsx | 91 + .../EnergyStrategy/StrategySimulator.tsx | 157 + .../src/pages/EnergyStrategy/WeatherPanel.tsx | 145 + frontend/src/pages/EnergyStrategy/index.tsx | 57 + frontend/src/pages/Login/index.tsx | 88 + frontend/src/pages/Maintenance/index.tsx | 399 ++ frontend/src/pages/Management/index.tsx | 524 ++ frontend/src/pages/Monitoring/index.tsx | 79 + .../src/pages/Prediction/AccuracyMetrics.tsx | 151 + .../src/pages/Prediction/LoadForecast.tsx | 119 + .../pages/Prediction/OptimizationPanel.tsx | 212 + frontend/src/pages/Prediction/PVForecast.tsx | 132 + .../src/pages/Prediction/SavingsReport.tsx | 180 + frontend/src/pages/Prediction/index.tsx | 43 + frontend/src/pages/Quota/index.tsx | 263 + frontend/src/pages/Reports/index.tsx | 129 + frontend/src/pages/System/AuditLog.tsx | 174 + frontend/src/pages/System/Settings.tsx | 110 + frontend/src/pages/System/index.tsx | 142 + frontend/src/services/api.ts | 353 + frontend/src/utils/auth.ts | 9 + frontend/src/utils/devicePhoto.ts | 21 + frontend/tsconfig.app.json | 28 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 26 + frontend/vite.config.ts | 29 + nginx/Dockerfile | 20 + nginx/nginx.conf | 128 + scripts/backfill_data.py | 399 ++ scripts/init_db.py | 18 + scripts/quick-start.sh | 145 + 227 files changed, 39179 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/001_initial_schema.py create mode 100644 backend/alembic/versions/002_add_system_settings.py create mode 100644 backend/alembic/versions/003_energy_categories.py create mode 100644 backend/alembic/versions/004_charging_tables.py create mode 100644 backend/alembic/versions/005_quota_tables.py create mode 100644 backend/alembic/versions/006_pricing_tables.py create mode 100644 backend/alembic/versions/007_maintenance_tables.py create mode 100644 backend/alembic/versions/008_management_tables.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/router.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/ai_ops.py create mode 100644 backend/app/api/v1/alarms.py create mode 100644 backend/app/api/v1/audit.py create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/api/v1/branding.py create mode 100644 backend/app/api/v1/carbon.py create mode 100644 backend/app/api/v1/charging.py create mode 100644 backend/app/api/v1/collectors.py create mode 100644 backend/app/api/v1/cost.py create mode 100644 backend/app/api/v1/dashboard.py create mode 100644 backend/app/api/v1/devices.py create mode 100644 backend/app/api/v1/energy.py create mode 100644 backend/app/api/v1/energy_strategy.py create mode 100644 backend/app/api/v1/maintenance.py create mode 100644 backend/app/api/v1/management.py create mode 100644 backend/app/api/v1/monitoring.py create mode 100644 backend/app/api/v1/prediction.py create mode 100644 backend/app/api/v1/quota.py create mode 100644 backend/app/api/v1/reports.py create mode 100644 backend/app/api/v1/settings.py create mode 100644 backend/app/api/v1/users.py create mode 100644 backend/app/api/v1/weather.py create mode 100644 backend/app/api/v1/websocket.py create mode 100644 backend/app/collectors/__init__.py create mode 100644 backend/app/collectors/base.py create mode 100644 backend/app/collectors/http_collector.py create mode 100644 backend/app/collectors/manager.py create mode 100644 backend/app/collectors/modbus_tcp.py create mode 100644 backend/app/collectors/mqtt_collector.py create mode 100644 backend/app/collectors/queue.py create mode 100644 backend/app/collectors/sungrow_collector.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/cache.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/deps.py create mode 100644 backend/app/core/middleware.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/ai_ops.py create mode 100644 backend/app/models/alarm.py create mode 100644 backend/app/models/carbon.py create mode 100644 backend/app/models/charging.py create mode 100644 backend/app/models/device.py create mode 100644 backend/app/models/energy.py create mode 100644 backend/app/models/energy_strategy.py create mode 100644 backend/app/models/maintenance.py create mode 100644 backend/app/models/management.py create mode 100644 backend/app/models/prediction.py create mode 100644 backend/app/models/pricing.py create mode 100644 backend/app/models/quota.py create mode 100644 backend/app/models/report.py create mode 100644 backend/app/models/setting.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/models/weather.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/aggregation.py create mode 100644 backend/app/services/ai_ops.py create mode 100644 backend/app/services/ai_prediction.py create mode 100644 backend/app/services/alarm_checker.py create mode 100644 backend/app/services/audit.py create mode 100644 backend/app/services/carbon_asset.py create mode 100644 backend/app/services/cost_calculator.py create mode 100644 backend/app/services/email_service.py create mode 100644 backend/app/services/energy_strategy.py create mode 100644 backend/app/services/quota_checker.py create mode 100644 backend/app/services/report_generator.py create mode 100644 backend/app/services/report_scheduler.py create mode 100644 backend/app/services/simulator.py create mode 100644 backend/app/services/weather_model.py create mode 100644 backend/app/services/weather_service.py create mode 100644 backend/app/tasks/__init__.py create mode 100644 backend/app/tasks/celery_app.py create mode 100644 backend/app/tasks/report_tasks.py create mode 100644 backend/app/templates/alarm_email.html create mode 100644 backend/conftest.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_alarms.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_carbon.py create mode 100644 backend/tests/test_dashboard.py create mode 100644 backend/tests/test_devices.py create mode 100644 backend/tests/test_energy.py create mode 100644 backend/tests/test_monitoring.py create mode 100644 backend/tests/test_reports.py create mode 100644 backend/tests/test_users.py create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/Dockerfile.prod create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/devices/default.svg create mode 100644 frontend/public/devices/heat_meter.svg create mode 100644 frontend/public/devices/heat_pump.svg create mode 100644 frontend/public/devices/meter.svg create mode 100644 frontend/public/devices/pv_inverter.svg create mode 100644 frontend/public/devices/sensor.svg create mode 100644 frontend/public/devices/water_meter.svg create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/contexts/ThemeContext.tsx create mode 100644 frontend/src/hooks/useRealtimeWebSocket.ts create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/locales/en.json create mode 100644 frontend/src/i18n/locales/zh.json create mode 100644 frontend/src/index.css create mode 100644 frontend/src/layouts/MainLayout.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AIOperations/index.tsx create mode 100644 frontend/src/pages/Alarms/index.tsx create mode 100644 frontend/src/pages/Analysis/CostAnalysis.tsx create mode 100644 frontend/src/pages/Analysis/LossAnalysis.tsx create mode 100644 frontend/src/pages/Analysis/MomAnalysis.tsx create mode 100644 frontend/src/pages/Analysis/SubitemAnalysis.tsx create mode 100644 frontend/src/pages/Analysis/YoyAnalysis.tsx create mode 100644 frontend/src/pages/Analysis/index.tsx create mode 100644 frontend/src/pages/BigScreen/components/AlarmCard.tsx create mode 100644 frontend/src/pages/BigScreen/components/AnimatedNumber.tsx create mode 100644 frontend/src/pages/BigScreen/components/CarbonCard.tsx create mode 100644 frontend/src/pages/BigScreen/components/EnergyFlowDiagram.tsx create mode 100644 frontend/src/pages/BigScreen/components/EnergyOverviewCard.tsx create mode 100644 frontend/src/pages/BigScreen/components/HeatPumpCard.tsx create mode 100644 frontend/src/pages/BigScreen/components/LoadCurveCard.tsx create mode 100644 frontend/src/pages/BigScreen/components/PVCard.tsx create mode 100644 frontend/src/pages/BigScreen/index.tsx create mode 100644 frontend/src/pages/BigScreen/styles.module.css create mode 100644 frontend/src/pages/BigScreen3D/components/Buildings.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/CampusScene.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/DeviceDetailView.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/DeviceInfoPanel.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/DeviceListPanel.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/DeviceMarkers.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/EnergyParticles.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/Ground.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/HUDOverlay.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/HeatPumps.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/PVPanels.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/SceneEnvironment.tsx create mode 100644 frontend/src/pages/BigScreen3D/constants.ts create mode 100644 frontend/src/pages/BigScreen3D/hooks/useCameraAnimation.ts create mode 100644 frontend/src/pages/BigScreen3D/hooks/useDeviceData.ts create mode 100644 frontend/src/pages/BigScreen3D/hooks/useEnergyFlow.ts create mode 100644 frontend/src/pages/BigScreen3D/index.tsx create mode 100644 frontend/src/pages/BigScreen3D/styles.module.css create mode 100644 frontend/src/pages/BigScreen3D/types.ts create mode 100644 frontend/src/pages/Carbon/index.tsx create mode 100644 frontend/src/pages/Charging/Dashboard.tsx create mode 100644 frontend/src/pages/Charging/Orders.tsx create mode 100644 frontend/src/pages/Charging/Piles.tsx create mode 100644 frontend/src/pages/Charging/Pricing.tsx create mode 100644 frontend/src/pages/Charging/Stations.tsx create mode 100644 frontend/src/pages/Charging/index.tsx create mode 100644 frontend/src/pages/Dashboard/components/DeviceStatus.tsx create mode 100644 frontend/src/pages/Dashboard/components/EnergyFlow.tsx create mode 100644 frontend/src/pages/Dashboard/components/EnergyOverview.tsx create mode 100644 frontend/src/pages/Dashboard/components/LoadCurve.tsx create mode 100644 frontend/src/pages/Dashboard/components/PowerGeneration.tsx create mode 100644 frontend/src/pages/Dashboard/index.tsx create mode 100644 frontend/src/pages/DataQuery/index.tsx create mode 100644 frontend/src/pages/DeviceDetail/index.tsx create mode 100644 frontend/src/pages/Devices/Topology.tsx create mode 100644 frontend/src/pages/Devices/index.tsx create mode 100644 frontend/src/pages/EnergyStrategy/CostAnalysis.tsx create mode 100644 frontend/src/pages/EnergyStrategy/PricingConfig.tsx create mode 100644 frontend/src/pages/EnergyStrategy/SavingsReport.tsx create mode 100644 frontend/src/pages/EnergyStrategy/StrategyManager.tsx create mode 100644 frontend/src/pages/EnergyStrategy/StrategySimulator.tsx create mode 100644 frontend/src/pages/EnergyStrategy/WeatherPanel.tsx create mode 100644 frontend/src/pages/EnergyStrategy/index.tsx create mode 100644 frontend/src/pages/Login/index.tsx create mode 100644 frontend/src/pages/Maintenance/index.tsx create mode 100644 frontend/src/pages/Management/index.tsx create mode 100644 frontend/src/pages/Monitoring/index.tsx create mode 100644 frontend/src/pages/Prediction/AccuracyMetrics.tsx create mode 100644 frontend/src/pages/Prediction/LoadForecast.tsx create mode 100644 frontend/src/pages/Prediction/OptimizationPanel.tsx create mode 100644 frontend/src/pages/Prediction/PVForecast.tsx create mode 100644 frontend/src/pages/Prediction/SavingsReport.tsx create mode 100644 frontend/src/pages/Prediction/index.tsx create mode 100644 frontend/src/pages/Quota/index.tsx create mode 100644 frontend/src/pages/Reports/index.tsx create mode 100644 frontend/src/pages/System/AuditLog.tsx create mode 100644 frontend/src/pages/System/Settings.tsx create mode 100644 frontend/src/pages/System/index.tsx create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/utils/auth.ts create mode 100644 frontend/src/utils/devicePhoto.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 nginx/Dockerfile create mode 100644 nginx/nginx.conf create mode 100644 scripts/backfill_data.py create mode 100644 scripts/init_db.py create mode 100644 scripts/quick-start.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e782a89 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# ============================================= +# 天普零碳园区智慧能源管理平台 - 环境变量配置 +# ============================================= +# 复制此文件为 .env 并修改为实际配置值 +# cp .env.example .env + +# ----- 数据库 (必填) ----- +POSTGRES_DB=tianpu_ems +POSTGRES_USER=tianpu +POSTGRES_PASSWORD=your-secure-password-here + +# Docker 内部连接地址 (容器间通信) +DATABASE_URL=postgresql+asyncpg://tianpu:your-secure-password-here@postgres:5432/tianpu_ems +DATABASE_URL_SYNC=postgresql://tianpu:your-secure-password-here@postgres:5432/tianpu_ems + +# 本地开发连接地址 (宿主机直连) +DATABASE_URL_LOCAL=postgresql+asyncpg://tianpu:your-secure-password-here@localhost:5432/tianpu_ems +DATABASE_URL_LOCAL_SYNC=postgresql://tianpu:your-secure-password-here@localhost:5432/tianpu_ems + +# ----- Redis (必填) ----- +# Docker 内部连接 +REDIS_URL=redis://redis:6379/0 +# 本地开发连接 +REDIS_URL_LOCAL=redis://localhost:6379/0 + +# ----- JWT 认证 (必填) ----- +# 生产环境请使用强随机密钥: python -c "import secrets; print(secrets.token_urlsafe(64))" +SECRET_KEY=change-this-to-a-random-secret-key +ALGORITHM=HS256 +# 令牌过期时间 (分钟),默认 480 分钟 (8 小时) +ACCESS_TOKEN_EXPIRE_MINUTES=480 + +# ----- 应用配置 (可选) ----- +APP_NAME=TianpuEMS +# 生产环境设为 false +DEBUG=false +API_V1_PREFIX=/api/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a61e37b --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +__pycache__/ +*.pyc +.env.local +.env +backend/.env +node_modules/ +dist/ +.next/ +*.egg-info/ +.venv/ +venv/ +*.log +.DS_Store +*.db +backend/reports/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c21e5da --- /dev/null +++ b/README.md @@ -0,0 +1,252 @@ +# 天普零碳园区智慧能源管理平台 + +> Tianpu Zero-Carbon Park Smart Energy Management System + +天普零碳园区智慧能源管理平台是面向工业园区业主的一站式能源管理解决方案。通过实时数据采集、智能分析和可视化大屏,帮助园区实现能源消耗透明化、碳排放精准核算和运营效率全面提升。 + +--- + +## 核心功能 + +- **实时监控大屏** — 3D 园区可视化 + 多维度能源数据实时展示 +- **设备管理** — 园区设备台账、运行状态监控、故障告警 +- **能耗分析** — 多维度能耗统计、同比环比分析、能耗排名 +- **碳排放管理** — 碳排放核算、碳足迹追踪、减排目标管理 +- **告警中心** — 实时告警推送、告警分级处理、历史告警查询 +- **报表中心** — 自动生成日/周/月报表、支持 Excel 导出 +- **系统管理** — 用户权限管理、操作日志、系统配置 + +--- + +## 系统架构 + +``` + ┌──────────────┐ + │ Nginx │ + │ 反向代理 │ + │ :80 / :443 │ + └──────┬───────┘ + │ + ┌───────────────┼───────────────┐ + │ │ + ┌──────▼──────┐ ┌────────▼────────┐ + │ Frontend │ │ Backend │ + │ React 19 │ │ FastAPI │ + │ Ant Design │ │ :8000 │ + │ ECharts │ │ │ + │ Three.js │ │ /api/v1/* │ + └─────────────┘ └───────┬──────────┘ + │ + ┌──────────────┼──────────────┐ + │ │ + ┌──────▼──────┐ ┌───────▼───────┐ + │ TimescaleDB │ │ Redis │ + │ PostgreSQL │ │ 缓存/队列 │ + │ :5432 │ │ :6379 │ + └─────────────┘ └───────────────┘ +``` + +--- + +## 技术栈 + +| 层级 | 技术 | +|------|------| +| 前端框架 | React 19 + TypeScript | +| UI 组件库 | Ant Design 5 + ProComponents | +| 数据可视化 | ECharts 6 | +| 3D 渲染 | Three.js + React Three Fiber | +| 后端框架 | FastAPI (Python 3.11) | +| ORM | SQLAlchemy 2.0 (async) | +| 数据库 | TimescaleDB (PostgreSQL 16) | +| 缓存 | Redis 7 | +| 任务队列 | Celery + APScheduler | +| 数据库迁移 | Alembic | +| 容器化 | Docker + Docker Compose | + +--- + +## 快速开始 + +### 前置要求 + +- Docker 20.10+ +- Docker Compose 2.0+ + +### 一键启动 + +```bash +# 克隆项目 +git clone http://100.108.180.60:3300/tianpu/tianpu-ems.git +cd tianpu-ems + +# 复制环境变量 +cp .env.example .env + +# 启动所有服务 +docker-compose up -d + +# 初始化数据库 & 写入种子数据 +docker exec tianpu_backend python scripts/init_db.py +docker exec tianpu_backend python scripts/seed_data.py +``` + +或使用快速启动脚本: + +```bash +bash scripts/quick-start.sh +``` + +### 访问地址 + +| 服务 | 地址 | +|------|------| +| 前端页面 | http://localhost:3000 | +| 后端 API | http://localhost:8000 | +| API 文档 (Swagger) | http://localhost:8000/docs | +| 健康检查 | http://localhost:8000/health | + +### 默认账号 + +| 角色 | 用户名 | 密码 | +|------|--------|------| +| 管理员 | admin | admin123 | + +> 请在首次登录后立即修改默认密码。 + +--- + +## 本地开发 + +### 后端开发 + +```bash +cd backend + +# 创建虚拟环境 +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# 安装依赖 +pip install -r requirements.txt + +# 启动开发服务器 (需先启动 PostgreSQL 和 Redis) +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +### 前端开发 + +```bash +cd frontend + +# 安装依赖 +npm install + +# 启动开发服务器 +npm run dev +``` + +### 仅启动基础设施 + +```bash +# 只启动数据库和 Redis +docker-compose up -d postgres redis +``` + +--- + +## 生产部署 + +```bash +# 使用生产配置 +docker-compose -f docker-compose.prod.yml up -d +``` + +生产环境使用 Nginx 反向代理,前端编译为静态文件,后端使用 Gunicorn + Uvicorn workers。 + +--- + +## 项目结构 + +``` +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 +``` + +--- + +## API 文档 + +启动后端服务后访问 [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 园区 | 三维可视化园区模型 | +| 仪表盘 | 关键能耗指标看板 | +| 设备监控 | 设备运行状态实时监控 | + +--- + +## License + +Copyright 2026 天普集团. All rights reserved. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..307714f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..24f9877 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = postgresql://tianpu:tianpu2026@localhost:5432/tianpu_ems + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..7df504c --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,49 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config, pool +from alembic import context +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from app.core.config import get_settings +from app.core.database import Base +from app.models import * # noqa + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Override sqlalchemy.url from app settings (supports .env override) +app_settings = get_settings() +config.set_main_option("sqlalchemy.url", app_settings.DATABASE_URL_SYNC) + +target_metadata = Base.metadata + + +def run_migrations_offline(): + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..a4074ba --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/001_initial_schema.py b/backend/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..0b82c30 --- /dev/null +++ b/backend/alembic/versions/001_initial_schema.py @@ -0,0 +1,268 @@ +"""Initial schema - all 14 tables + +Revision ID: 001_initial +Revises: +Create Date: 2026-04-01 +""" +from alembic import op +import sqlalchemy as sa + +revision = "001_initial" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- roles --- + op.create_table( + "roles", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(50), unique=True, nullable=False), + sa.Column("display_name", sa.String(100), nullable=False), + sa.Column("description", sa.Text), + sa.Column("permissions", sa.Text), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- users --- + op.create_table( + "users", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("username", sa.String(50), unique=True, nullable=False), + sa.Column("email", sa.String(100), unique=True), + sa.Column("hashed_password", sa.String(200), nullable=False), + sa.Column("full_name", sa.String(100)), + sa.Column("phone", sa.String(20)), + sa.Column("role", sa.String(50), sa.ForeignKey("roles.name"), default="visitor"), + sa.Column("is_active", sa.Boolean, default=True), + sa.Column("last_login", sa.DateTime(timezone=True)), + 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_index("ix_users_username", "users", ["username"]) + + # --- audit_logs --- + op.create_table( + "audit_logs", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id")), + sa.Column("action", sa.String(50), nullable=False), + sa.Column("resource", sa.String(100)), + sa.Column("detail", sa.Text), + sa.Column("ip_address", sa.String(50)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- device_types --- + op.create_table( + "device_types", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("code", sa.String(50), unique=True, nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("icon", sa.String(100)), + sa.Column("data_fields", sa.JSON), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- device_groups --- + op.create_table( + "device_groups", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("parent_id", sa.Integer, sa.ForeignKey("device_groups.id")), + sa.Column("location", sa.String(200)), + sa.Column("description", sa.Text), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- devices --- + op.create_table( + "devices", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("code", sa.String(100), unique=True, nullable=False), + sa.Column("device_type", sa.String(50), sa.ForeignKey("device_types.code"), nullable=False), + sa.Column("group_id", sa.Integer, sa.ForeignKey("device_groups.id")), + sa.Column("model", sa.String(100)), + sa.Column("manufacturer", sa.String(100)), + sa.Column("serial_number", sa.String(100)), + sa.Column("rated_power", sa.Float), + sa.Column("install_date", sa.DateTime(timezone=True)), + sa.Column("location", sa.String(200)), + sa.Column("protocol", sa.String(50)), + sa.Column("connection_params", sa.JSON), + sa.Column("collect_interval", sa.Integer, default=15), + sa.Column("status", sa.String(20), default="offline"), + sa.Column("is_active", sa.Boolean, default=True), + sa.Column("metadata", sa.JSON), + sa.Column("last_data_time", sa.DateTime(timezone=True)), + 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()), + ) + + # --- energy_data --- + op.create_table( + "energy_data", + 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), nullable=False), + sa.Column("data_type", sa.String(50), nullable=False), + sa.Column("value", sa.Float, nullable=False), + sa.Column("unit", sa.String(20)), + sa.Column("quality", sa.Integer, default=0), + sa.Column("raw_data", sa.JSON), + ) + op.create_index("ix_energy_data_device_id", "energy_data", ["device_id"]) + op.create_index("ix_energy_data_timestamp", "energy_data", ["timestamp"]) + + # --- energy_daily_summary --- + op.create_table( + "energy_daily_summary", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id"), nullable=False), + sa.Column("date", sa.DateTime(timezone=True), nullable=False), + sa.Column("energy_type", sa.String(50), nullable=False), + sa.Column("total_consumption", sa.Float, default=0), + sa.Column("total_generation", sa.Float, default=0), + sa.Column("peak_power", sa.Float), + sa.Column("min_power", sa.Float), + sa.Column("avg_power", sa.Float), + sa.Column("operating_hours", sa.Float), + sa.Column("avg_cop", sa.Float), + sa.Column("avg_temperature", sa.Float), + sa.Column("cost", sa.Float), + sa.Column("carbon_emission", sa.Float), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_energy_daily_summary_device_id", "energy_daily_summary", ["device_id"]) + op.create_index("ix_energy_daily_summary_date", "energy_daily_summary", ["date"]) + + # --- alarm_rules --- + op.create_table( + "alarm_rules", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id")), + sa.Column("device_type", sa.String(50)), + sa.Column("data_type", sa.String(50), nullable=False), + sa.Column("condition", sa.String(20), nullable=False), + sa.Column("threshold", sa.Float), + sa.Column("threshold_high", sa.Float), + sa.Column("threshold_low", sa.Float), + sa.Column("duration", sa.Integer, default=0), + sa.Column("severity", sa.String(20), default="warning"), + sa.Column("notify_channels", sa.JSON), + sa.Column("notify_targets", sa.JSON), + sa.Column("auto_action", sa.JSON), + sa.Column("silence_start", sa.String(10)), + sa.Column("silence_end", sa.String(10)), + 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()), + ) + + # --- alarm_events --- + op.create_table( + "alarm_events", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("rule_id", sa.Integer, sa.ForeignKey("alarm_rules.id")), + sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id"), nullable=False), + sa.Column("severity", sa.String(20), nullable=False), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("description", sa.Text), + sa.Column("value", sa.Float), + sa.Column("threshold", sa.Float), + sa.Column("status", sa.String(20), default="active"), + sa.Column("acknowledged_by", sa.Integer, sa.ForeignKey("users.id")), + sa.Column("acknowledged_at", sa.DateTime(timezone=True)), + sa.Column("resolved_at", sa.DateTime(timezone=True)), + sa.Column("resolve_note", sa.Text), + sa.Column("triggered_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- emission_factors --- + op.create_table( + "emission_factors", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("energy_type", sa.String(50), nullable=False), + sa.Column("factor", sa.Float, nullable=False), + sa.Column("unit", sa.String(20), nullable=False), + sa.Column("region", sa.String(50), default="north_china"), + sa.Column("scope", sa.Integer, nullable=False), + sa.Column("source", sa.String(200)), + sa.Column("year", sa.Integer), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- carbon_emissions --- + op.create_table( + "carbon_emissions", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("date", sa.DateTime(timezone=True), nullable=False), + sa.Column("scope", sa.Integer, nullable=False), + sa.Column("category", sa.String(50), nullable=False), + sa.Column("emission", sa.Float, nullable=False), + sa.Column("reduction", sa.Float, default=0), + sa.Column("energy_consumption", sa.Float), + sa.Column("energy_unit", sa.String(20)), + sa.Column("emission_factor_id", sa.Integer), + sa.Column("note", sa.Text), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_carbon_emissions_date", "carbon_emissions", ["date"]) + + # --- report_templates --- + op.create_table( + "report_templates", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("report_type", sa.String(50), nullable=False), + sa.Column("description", sa.Text), + sa.Column("fields", sa.JSON, nullable=False), + sa.Column("filters", sa.JSON), + sa.Column("aggregation", sa.String(20), default="sum"), + sa.Column("time_granularity", sa.String(20), default="hour"), + sa.Column("format_config", sa.JSON), + sa.Column("is_system", sa.Boolean, default=False), + sa.Column("created_by", sa.Integer, sa.ForeignKey("users.id")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- report_tasks --- + op.create_table( + "report_tasks", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("template_id", sa.Integer, sa.ForeignKey("report_templates.id"), nullable=False), + sa.Column("name", sa.String(200)), + sa.Column("schedule", sa.String(50)), + sa.Column("next_run", sa.DateTime(timezone=True)), + sa.Column("last_run", sa.DateTime(timezone=True)), + sa.Column("recipients", sa.JSON), + sa.Column("export_format", sa.String(20), default="xlsx"), + sa.Column("file_path", sa.String(500)), + sa.Column("status", sa.String(20), default="pending"), + 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()), + ) + + +def downgrade() -> None: + op.drop_table("report_tasks") + op.drop_table("report_templates") + op.drop_table("carbon_emissions") + op.drop_table("emission_factors") + op.drop_table("alarm_events") + op.drop_table("alarm_rules") + op.drop_table("energy_daily_summary") + op.drop_table("energy_data") + op.drop_table("devices") + op.drop_table("device_groups") + op.drop_table("device_types") + op.drop_table("audit_logs") + op.drop_table("users") + op.drop_table("roles") diff --git a/backend/alembic/versions/002_add_system_settings.py b/backend/alembic/versions/002_add_system_settings.py new file mode 100644 index 0000000..850e03a --- /dev/null +++ b/backend/alembic/versions/002_add_system_settings.py @@ -0,0 +1,41 @@ +"""Add system_settings table + +Revision ID: 002_system_settings +Revises: 001_initial +Create Date: 2026-04-02 +""" +from alembic import op +import sqlalchemy as sa + +revision = "002_system_settings" +down_revision = "001_initial" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "system_settings", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("key", sa.String(100), unique=True, nullable=False, index=True), + sa.Column("value", sa.Text, nullable=False, server_default=""), + sa.Column("description", sa.String(255)), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # Seed default settings + op.execute(""" + INSERT INTO system_settings (key, value, description) VALUES + ('platform_name', '天普零碳园区智慧能源管理平台', '平台名称'), + ('data_retention_days', '365', '数据保留天数'), + ('alarm_auto_resolve_minutes', '30', '告警自动解除时间(分钟)'), + ('simulator_interval_seconds', '15', '模拟器采集间隔(秒)'), + ('notification_email_enabled', 'false', '是否启用邮件通知'), + ('notification_email_smtp', '', 'SMTP服务器地址'), + ('report_auto_schedule_enabled', 'false', '是否启用自动报表'), + ('timezone', 'Asia/Shanghai', '系统时区') + """) + + +def downgrade() -> None: + op.drop_table("system_settings") diff --git a/backend/alembic/versions/003_energy_categories.py b/backend/alembic/versions/003_energy_categories.py new file mode 100644 index 0000000..649a534 --- /dev/null +++ b/backend/alembic/versions/003_energy_categories.py @@ -0,0 +1,37 @@ +"""Add energy_categories table and devices.category_id + +Revision ID: 003_energy_categories +Revises: 002_system_settings +Create Date: 2026-04-03 +""" +from alembic import op +import sqlalchemy as sa + +revision = "003_energy_categories" +down_revision = "002_system_settings" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- energy_categories --- + op.create_table( + "energy_categories", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("code", sa.String(50), unique=True, nullable=False), + sa.Column("parent_id", sa.Integer, sa.ForeignKey("energy_categories.id")), + sa.Column("sort_order", sa.Integer, default=0), + sa.Column("icon", sa.String(100)), + sa.Column("color", sa.String(20)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # Add category_id column to devices table (batch mode for SQLite compat) + with op.batch_alter_table("devices") as batch_op: + batch_op.add_column(sa.Column("category_id", sa.Integer, nullable=True)) + + +def downgrade() -> None: + op.drop_column("devices", "category_id") + op.drop_table("energy_categories") diff --git a/backend/alembic/versions/004_charging_tables.py b/backend/alembic/versions/004_charging_tables.py new file mode 100644 index 0000000..48c4f26 --- /dev/null +++ b/backend/alembic/versions/004_charging_tables.py @@ -0,0 +1,168 @@ +"""Add charging tables + +Revision ID: 004_charging +Revises: 003_energy_categories +Create Date: 2026-04-03 +""" +from alembic import op +import sqlalchemy as sa + +revision = "004_charging" +down_revision = "003_energy_categories" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- charging_merchants --- + op.create_table( + "charging_merchants", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("contact_person", sa.String(100)), + sa.Column("phone", sa.String(20)), + sa.Column("email", sa.String(100)), + sa.Column("address", sa.String(500)), + sa.Column("business_license", sa.String(100)), + sa.Column("status", sa.String(20), default="active"), + sa.Column("settlement_type", sa.String(20)), + 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()), + ) + + # --- charging_brands --- + op.create_table( + "charging_brands", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("brand_name", sa.String(100), nullable=False), + sa.Column("logo_url", sa.String(500)), + sa.Column("country", sa.String(50)), + sa.Column("description", sa.Text), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- charging_stations --- + op.create_table( + "charging_stations", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("merchant_id", sa.Integer, sa.ForeignKey("charging_merchants.id")), + sa.Column("type", sa.String(50)), + sa.Column("address", sa.String(500)), + sa.Column("latitude", sa.Float), + sa.Column("longitude", sa.Float), + sa.Column("price", sa.Float), + sa.Column("activity", sa.Text), + sa.Column("status", sa.String(20), default="active"), + sa.Column("total_piles", sa.Integer, default=0), + sa.Column("available_piles", sa.Integer, default=0), + sa.Column("total_power_kw", sa.Float, default=0), + sa.Column("photo_url", sa.String(500)), + sa.Column("operating_hours", sa.String(100)), + 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()), + ) + + # --- charging_piles --- + op.create_table( + "charging_piles", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("station_id", sa.Integer, sa.ForeignKey("charging_stations.id"), nullable=False), + sa.Column("encoding", sa.String(100), unique=True), + sa.Column("name", sa.String(200)), + sa.Column("type", sa.String(50)), + sa.Column("brand", sa.String(100)), + sa.Column("model", sa.String(100)), + sa.Column("rated_power_kw", sa.Float), + sa.Column("connector_type", sa.String(50)), + sa.Column("status", sa.String(20), default="active"), + sa.Column("work_status", sa.String(20), default="offline"), + 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()), + ) + + # --- charging_price_strategies --- + op.create_table( + "charging_price_strategies", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("strategy_name", sa.String(200), nullable=False), + sa.Column("station_id", sa.Integer, sa.ForeignKey("charging_stations.id")), + sa.Column("bill_model", sa.String(20)), + sa.Column("description", sa.Text), + sa.Column("status", sa.String(20), default="inactive"), + 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()), + ) + + # --- charging_price_params --- + op.create_table( + "charging_price_params", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("strategy_id", sa.Integer, sa.ForeignKey("charging_price_strategies.id"), nullable=False), + sa.Column("start_time", sa.String(10), nullable=False), + sa.Column("end_time", sa.String(10), nullable=False), + sa.Column("period_mark", sa.String(20)), + sa.Column("elec_price", sa.Float, nullable=False), + sa.Column("service_price", sa.Float, default=0), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- charging_orders --- + op.create_table( + "charging_orders", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("order_no", sa.String(50), unique=True, nullable=False), + sa.Column("user_id", sa.Integer), + sa.Column("user_name", sa.String(100)), + sa.Column("phone", sa.String(20)), + sa.Column("station_id", sa.Integer, sa.ForeignKey("charging_stations.id")), + sa.Column("station_name", sa.String(200)), + sa.Column("pile_id", sa.Integer, sa.ForeignKey("charging_piles.id")), + sa.Column("pile_name", sa.String(200)), + sa.Column("start_time", sa.DateTime(timezone=True)), + sa.Column("end_time", sa.DateTime(timezone=True)), + sa.Column("car_no", sa.String(20)), + sa.Column("car_vin", sa.String(50)), + sa.Column("charge_method", sa.String(20)), + sa.Column("settle_type", sa.String(20)), + sa.Column("pay_type", sa.String(20)), + sa.Column("settle_time", sa.DateTime(timezone=True)), + sa.Column("settle_price", sa.Float), + sa.Column("paid_price", sa.Float), + sa.Column("discount_amt", sa.Float, default=0), + sa.Column("elec_amt", sa.Float), + sa.Column("serve_amt", sa.Float), + sa.Column("order_status", sa.String(20), default="charging"), + sa.Column("charge_duration", sa.Integer), + sa.Column("energy", sa.Float), + sa.Column("start_soc", sa.Float), + sa.Column("end_soc", sa.Float), + sa.Column("abno_cause", sa.Text), + sa.Column("order_source", sa.String(20)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- occupancy_orders --- + op.create_table( + "occupancy_orders", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("order_id", sa.Integer, sa.ForeignKey("charging_orders.id")), + sa.Column("pile_id", sa.Integer, sa.ForeignKey("charging_piles.id")), + sa.Column("start_time", sa.DateTime(timezone=True)), + sa.Column("end_time", sa.DateTime(timezone=True)), + sa.Column("occupancy_fee", sa.Float, default=0), + sa.Column("status", sa.String(20), default="active"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("occupancy_orders") + op.drop_table("charging_orders") + op.drop_table("charging_price_params") + op.drop_table("charging_price_strategies") + op.drop_table("charging_piles") + op.drop_table("charging_stations") + op.drop_table("charging_brands") + op.drop_table("charging_merchants") diff --git a/backend/alembic/versions/005_quota_tables.py b/backend/alembic/versions/005_quota_tables.py new file mode 100644 index 0000000..01fce87 --- /dev/null +++ b/backend/alembic/versions/005_quota_tables.py @@ -0,0 +1,53 @@ +"""Add quota tables + +Revision ID: 005_quota +Revises: 004_charging +Create Date: 2026-04-03 +""" +from alembic import op +import sqlalchemy as sa + +revision = "005_quota" +down_revision = "004_charging" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- energy_quotas --- + op.create_table( + "energy_quotas", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("target_type", sa.String(50), nullable=False), + sa.Column("target_id", sa.Integer, nullable=False), + sa.Column("energy_type", sa.String(50), nullable=False), + sa.Column("period", sa.String(20), nullable=False), + sa.Column("quota_value", sa.Float, nullable=False), + sa.Column("unit", sa.String(20), default="kWh"), + sa.Column("warning_threshold_pct", sa.Float, default=80), + sa.Column("alert_threshold_pct", sa.Float, default=95), + 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()), + ) + + # --- quota_usage --- + op.create_table( + "quota_usage", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("quota_id", sa.Integer, sa.ForeignKey("energy_quotas.id"), nullable=False), + sa.Column("period_start", sa.DateTime(timezone=True), nullable=False), + sa.Column("period_end", sa.DateTime(timezone=True), nullable=False), + sa.Column("actual_value", sa.Float, default=0), + sa.Column("quota_value", sa.Float, nullable=False), + sa.Column("usage_rate_pct", sa.Float, default=0), + sa.Column("status", sa.String(20), default="normal"), + sa.Column("calculated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("quota_usage") + op.drop_table("energy_quotas") diff --git a/backend/alembic/versions/006_pricing_tables.py b/backend/alembic/versions/006_pricing_tables.py new file mode 100644 index 0000000..c1cb4ef --- /dev/null +++ b/backend/alembic/versions/006_pricing_tables.py @@ -0,0 +1,48 @@ +"""Add pricing tables + +Revision ID: 006_pricing +Revises: 005_quota +Create Date: 2026-04-03 +""" +from alembic import op +import sqlalchemy as sa + +revision = "006_pricing" +down_revision = "005_quota" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- electricity_pricing --- + op.create_table( + "electricity_pricing", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("energy_type", sa.String(50), default="electricity"), + sa.Column("pricing_type", sa.String(20), nullable=False), + sa.Column("effective_from", sa.DateTime(timezone=True)), + sa.Column("effective_to", sa.DateTime(timezone=True)), + 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()), + ) + + # --- pricing_periods --- + op.create_table( + "pricing_periods", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("pricing_id", sa.Integer, sa.ForeignKey("electricity_pricing.id"), nullable=False), + sa.Column("period_name", sa.String(50), nullable=False), + sa.Column("start_time", sa.String(10), nullable=False), + sa.Column("end_time", sa.String(10), nullable=False), + sa.Column("price_per_unit", sa.Float, nullable=False), + sa.Column("applicable_months", sa.JSON), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("pricing_periods") + op.drop_table("electricity_pricing") diff --git a/backend/alembic/versions/007_maintenance_tables.py b/backend/alembic/versions/007_maintenance_tables.py new file mode 100644 index 0000000..d79fc55 --- /dev/null +++ b/backend/alembic/versions/007_maintenance_tables.py @@ -0,0 +1,88 @@ +"""Add maintenance tables + +Revision ID: 007_maintenance +Revises: 006_pricing +Create Date: 2026-04-03 +""" +from alembic import op +import sqlalchemy as sa + +revision = "007_maintenance" +down_revision = "006_pricing" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- inspection_plans --- + op.create_table( + "inspection_plans", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("description", sa.Text), + sa.Column("device_group_id", sa.Integer, sa.ForeignKey("device_groups.id")), + sa.Column("device_ids", sa.JSON), + sa.Column("schedule_type", sa.String(20)), + sa.Column("schedule_cron", sa.String(100)), + sa.Column("inspector_id", sa.Integer, sa.ForeignKey("users.id")), + sa.Column("checklist", sa.JSON), + sa.Column("is_active", sa.Boolean, default=True), + sa.Column("next_run_at", sa.DateTime(timezone=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()), + ) + + # --- inspection_records --- + op.create_table( + "inspection_records", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("plan_id", sa.Integer, sa.ForeignKey("inspection_plans.id"), nullable=False), + sa.Column("inspector_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False), + sa.Column("status", sa.String(20), default="pending"), + sa.Column("findings", sa.JSON), + sa.Column("started_at", sa.DateTime(timezone=True)), + sa.Column("completed_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- repair_orders --- + op.create_table( + "repair_orders", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("code", sa.String(50), unique=True, nullable=False), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("description", sa.Text), + sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id")), + sa.Column("alarm_event_id", sa.Integer, sa.ForeignKey("alarm_events.id")), + sa.Column("priority", sa.String(20), default="medium"), + sa.Column("status", sa.String(20), default="open"), + sa.Column("assigned_to", sa.Integer, sa.ForeignKey("users.id")), + sa.Column("resolution", sa.Text), + sa.Column("cost_estimate", sa.Float), + sa.Column("actual_cost", sa.Float), + 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("assigned_at", sa.DateTime(timezone=True)), + sa.Column("completed_at", sa.DateTime(timezone=True)), + sa.Column("closed_at", sa.DateTime(timezone=True)), + ) + + # --- duty_schedules --- + op.create_table( + "duty_schedules", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False), + sa.Column("duty_date", sa.DateTime(timezone=True), nullable=False), + sa.Column("shift", sa.String(20)), + sa.Column("area_id", sa.Integer, sa.ForeignKey("device_groups.id")), + sa.Column("notes", sa.Text), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("duty_schedules") + op.drop_table("repair_orders") + op.drop_table("inspection_records") + op.drop_table("inspection_plans") diff --git a/backend/alembic/versions/008_management_tables.py b/backend/alembic/versions/008_management_tables.py new file mode 100644 index 0000000..201ae40 --- /dev/null +++ b/backend/alembic/versions/008_management_tables.py @@ -0,0 +1,79 @@ +"""Add management tables + +Revision ID: 008_management +Revises: 007_maintenance +Create Date: 2026-04-03 +""" +from alembic import op +import sqlalchemy as sa + +revision = "008_management" +down_revision = "007_maintenance" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- regulations --- + op.create_table( + "regulations", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("category", sa.String(50)), + sa.Column("content", sa.Text), + sa.Column("effective_date", sa.DateTime(timezone=True)), + sa.Column("status", sa.String(20), default="active"), + sa.Column("attachment_url", sa.String(500)), + 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()), + ) + + # --- standards --- + op.create_table( + "standards", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("code", sa.String(100)), + sa.Column("type", sa.String(50)), + sa.Column("description", sa.Text), + sa.Column("compliance_status", sa.String(20), default="pending"), + sa.Column("review_date", sa.DateTime(timezone=True)), + 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()), + ) + + # --- process_docs --- + op.create_table( + "process_docs", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("category", sa.String(50)), + sa.Column("content", sa.Text), + sa.Column("version", sa.String(20), default="1.0"), + sa.Column("approved_by", sa.String(100)), + sa.Column("effective_date", sa.DateTime(timezone=True)), + 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()), + ) + + # --- emergency_plans --- + op.create_table( + "emergency_plans", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("scenario", sa.String(100)), + sa.Column("steps", sa.JSON), + sa.Column("responsible_person", sa.String(100)), + sa.Column("review_date", sa.DateTime(timezone=True)), + sa.Column("is_active", sa.Boolean, default=True), + 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()), + ) + + +def downgrade() -> None: + op.drop_table("emergency_plans") + op.drop_table("process_docs") + op.drop_table("standards") + op.drop_table("regulations") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 0000000..366f61b --- /dev/null +++ b/backend/app/api/router.py @@ -0,0 +1,28 @@ +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 + +api_router = APIRouter(prefix="/api/v1") + +api_router.include_router(auth.router) +api_router.include_router(users.router) +api_router.include_router(devices.router) +api_router.include_router(energy.router) +api_router.include_router(monitoring.router) +api_router.include_router(alarms.router) +api_router.include_router(reports.router) +api_router.include_router(carbon.router) +api_router.include_router(dashboard.router) +api_router.include_router(collectors.router) +api_router.include_router(websocket.router) +api_router.include_router(audit.router) +api_router.include_router(settings.router) +api_router.include_router(charging.router) +api_router.include_router(quota.router) +api_router.include_router(cost.router) +api_router.include_router(maintenance.router) +api_router.include_router(management.router) +api_router.include_router(prediction.router) +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) diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/ai_ops.py b/backend/app/api/v1/ai_ops.py new file mode 100644 index 0000000..dae73ca --- /dev/null +++ b/backend/app/api/v1/ai_ops.py @@ -0,0 +1,590 @@ +"""AI运维智能体 API - 设备健康、异常检测、诊断、预测维护、洞察""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from datetime import datetime, timezone, timedelta +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.user import User +from app.models.device import Device +from app.models.ai_ops import ( + DeviceHealthScore, AnomalyDetection, DiagnosticReport, + MaintenancePrediction, OpsInsight, +) +from app.services.ai_ops import ( + calculate_device_health, scan_anomalies, run_diagnostics, + generate_maintenance_predictions, generate_insights, get_dashboard_data, +) + +router = APIRouter(prefix="/ai-ops", tags=["AI运维智能体"]) + + +# ── Device Health ─────────────────────────────────────────────────── + +@router.get("/health") +async def get_all_health( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取所有设备最新健康评分""" + subq = ( + select( + DeviceHealthScore.device_id, + func.max(DeviceHealthScore.timestamp).label("max_ts"), + ).group_by(DeviceHealthScore.device_id).subquery() + ) + result = await db.execute( + select(DeviceHealthScore).join( + subq, and_( + DeviceHealthScore.device_id == subq.c.device_id, + DeviceHealthScore.timestamp == subq.c.max_ts, + ) + ) + ) + scores = result.scalars().all() + + # Get device info + device_ids = [s.device_id for s in scores] + dev_map = {} + if device_ids: + dev_result = await db.execute( + select(Device.id, Device.name, Device.device_type, Device.code) + .where(Device.id.in_(device_ids)) + ) + dev_map = {r.id: {"name": r.name, "type": r.device_type, "code": r.code} for r in dev_result.all()} + + return [{ + "device_id": s.device_id, + "device_name": dev_map.get(s.device_id, {}).get("name", f"#{s.device_id}"), + "device_type": dev_map.get(s.device_id, {}).get("type", "unknown"), + "device_code": dev_map.get(s.device_id, {}).get("code", ""), + "health_score": s.health_score, + "status": s.status, + "trend": s.trend, + "factors": s.factors, + "timestamp": str(s.timestamp), + } for s in scores] + + +@router.get("/health/{device_id}") +async def get_device_health( + device_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取单设备健康详情""" + result = await db.execute( + select(DeviceHealthScore).where( + DeviceHealthScore.device_id == device_id + ).order_by(DeviceHealthScore.timestamp.desc()).limit(1) + ) + score = result.scalar_one_or_none() + if not score: + raise HTTPException(status_code=404, detail="暂无该设备健康数据") + + dev_result = await db.execute(select(Device).where(Device.id == device_id)) + device = dev_result.scalar_one_or_none() + + return { + "device_id": score.device_id, + "device_name": device.name if device else f"#{device_id}", + "device_type": device.device_type if device else "unknown", + "health_score": score.health_score, + "status": score.status, + "trend": score.trend, + "factors": score.factors, + "timestamp": str(score.timestamp), + } + + +@router.get("/health/{device_id}/history") +async def get_health_history( + device_id: int, + days: int = Query(7, ge=1, le=90), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取设备健康评分历史""" + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + result = await db.execute( + select(DeviceHealthScore).where(and_( + DeviceHealthScore.device_id == device_id, + DeviceHealthScore.timestamp >= cutoff, + )).order_by(DeviceHealthScore.timestamp.asc()) + ) + scores = result.scalars().all() + return [{ + "timestamp": str(s.timestamp), + "health_score": s.health_score, + "status": s.status, + "trend": s.trend, + "factors": s.factors, + } for s in scores] + + +@router.post("/health/calculate") +async def trigger_health_calculation( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """触发全部设备健康评分计算""" + result = await db.execute(select(Device).where(Device.is_active == True)) + devices = result.scalars().all() + scores = [] + for device in devices: + try: + score = await calculate_device_health(db, device) + scores.append({ + "device_id": score.device_id, + "health_score": score.health_score, + "status": score.status, + }) + except Exception as e: + scores.append({"device_id": device.id, "error": str(e)}) + return {"calculated": len(scores), "results": scores} + + +# ── Anomaly Detection ─────────────────────────────────────────────── + +@router.get("/anomalies") +async def list_anomalies( + device_id: int | None = None, + severity: str | None = None, + status: str | None = None, + days: int = Query(7, ge=1, le=90), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """列出异常检测记录""" + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + query = select(AnomalyDetection).where(AnomalyDetection.detected_at >= cutoff) + if device_id: + query = query.where(AnomalyDetection.device_id == device_id) + if severity: + query = query.where(AnomalyDetection.severity == severity) + if status: + query = query.where(AnomalyDetection.status == status) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(AnomalyDetection.detected_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + anomalies = result.scalars().all() + + # Get device names + dev_ids = list(set(a.device_id for a in anomalies)) + dev_map = {} + if dev_ids: + dev_result = await db.execute(select(Device.id, Device.name).where(Device.id.in_(dev_ids))) + dev_map = {r.id: r.name for r in dev_result.all()} + + return { + "total": total, + "items": [{ + "id": a.id, + "device_id": a.device_id, + "device_name": dev_map.get(a.device_id, f"#{a.device_id}"), + "detected_at": str(a.detected_at), + "anomaly_type": a.anomaly_type, + "severity": a.severity, + "description": a.description, + "metric_name": a.metric_name, + "expected_value": a.expected_value, + "actual_value": a.actual_value, + "deviation_percent": a.deviation_percent, + "status": a.status, + "resolution_notes": a.resolution_notes, + } for a in anomalies], + } + + +@router.get("/anomalies/{device_id}") +async def get_device_anomalies( + device_id: int, + days: int = Query(7, ge=1, le=90), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取设备异常记录""" + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + result = await db.execute( + select(AnomalyDetection).where(and_( + AnomalyDetection.device_id == device_id, + AnomalyDetection.detected_at >= cutoff, + )).order_by(AnomalyDetection.detected_at.desc()) + ) + anomalies = result.scalars().all() + return [{ + "id": a.id, + "detected_at": str(a.detected_at), + "anomaly_type": a.anomaly_type, + "severity": a.severity, + "description": a.description, + "metric_name": a.metric_name, + "expected_value": a.expected_value, + "actual_value": a.actual_value, + "deviation_percent": a.deviation_percent, + "status": a.status, + } for a in anomalies] + + +class AnomalyStatusUpdate(BaseModel): + status: str # investigating, resolved, false_positive + resolution_notes: str | None = None + + +@router.put("/anomalies/{anomaly_id}/status") +async def update_anomaly_status( + anomaly_id: int, + data: AnomalyStatusUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """更新异常状态""" + result = await db.execute(select(AnomalyDetection).where(AnomalyDetection.id == anomaly_id)) + anomaly = result.scalar_one_or_none() + if not anomaly: + raise HTTPException(status_code=404, detail="异常记录不存在") + anomaly.status = data.status + if data.resolution_notes: + anomaly.resolution_notes = data.resolution_notes + return {"message": "已更新", "id": anomaly.id, "status": anomaly.status} + + +@router.post("/anomalies/scan") +async def trigger_anomaly_scan( + device_id: int | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """触发异常扫描""" + anomalies = await scan_anomalies(db, device_id) + return { + "scanned_at": str(datetime.now(timezone.utc)), + "anomalies_found": len(anomalies), + "anomalies": [{ + "device_id": a.device_id, + "anomaly_type": a.anomaly_type, + "severity": a.severity, + "description": a.description, + } for a in anomalies], + } + + +# ── Diagnostics ───────────────────────────────────────────────────── + +@router.get("/diagnostics") +async def list_diagnostics( + device_id: int | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """列出诊断报告""" + query = select(DiagnosticReport) + if device_id: + query = query.where(DiagnosticReport.device_id == device_id) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(DiagnosticReport.generated_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + reports = result.scalars().all() + + dev_ids = list(set(r.device_id for r in reports)) + dev_map = {} + if dev_ids: + dev_result = await db.execute(select(Device.id, Device.name).where(Device.id.in_(dev_ids))) + dev_map = {r.id: r.name for r in dev_result.all()} + + return { + "total": total, + "items": [{ + "id": r.id, + "device_id": r.device_id, + "device_name": dev_map.get(r.device_id, f"#{r.device_id}"), + "generated_at": str(r.generated_at), + "report_type": r.report_type, + "findings": r.findings, + "recommendations": r.recommendations, + "estimated_impact": r.estimated_impact, + "status": r.status, + } for r in reports], + } + + +@router.post("/diagnostics/{device_id}/run") +async def trigger_diagnostics( + device_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """对指定设备运行诊断""" + try: + report = await run_diagnostics(db, device_id) + return { + "id": report.id, + "device_id": report.device_id, + "report_type": report.report_type, + "findings": report.findings, + "recommendations": report.recommendations, + "estimated_impact": report.estimated_impact, + "status": report.status, + "generated_at": str(report.generated_at), + } + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.get("/diagnostics/{report_id}") +async def get_diagnostic_detail( + report_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取诊断报告详情""" + result = await db.execute(select(DiagnosticReport).where(DiagnosticReport.id == report_id)) + report = result.scalar_one_or_none() + if not report: + raise HTTPException(status_code=404, detail="诊断报告不存在") + + dev_result = await db.execute(select(Device.name).where(Device.id == report.device_id)) + device_name = dev_result.scalar() or f"#{report.device_id}" + + return { + "id": report.id, + "device_id": report.device_id, + "device_name": device_name, + "generated_at": str(report.generated_at), + "report_type": report.report_type, + "findings": report.findings, + "recommendations": report.recommendations, + "estimated_impact": report.estimated_impact, + "status": report.status, + } + + +# ── Predictive Maintenance ────────────────────────────────────────── + +@router.get("/maintenance/predictions") +async def list_predictions( + status: str | None = None, + urgency: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """列出维护预测""" + query = select(MaintenancePrediction) + if status: + query = query.where(MaintenancePrediction.status == status) + if urgency: + query = query.where(MaintenancePrediction.urgency == urgency) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(MaintenancePrediction.predicted_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + predictions = result.scalars().all() + + dev_ids = list(set(p.device_id for p in predictions)) + dev_map = {} + if dev_ids: + dev_result = await db.execute(select(Device.id, Device.name).where(Device.id.in_(dev_ids))) + dev_map = {r.id: r.name for r in dev_result.all()} + + return { + "total": total, + "items": [{ + "id": p.id, + "device_id": p.device_id, + "device_name": dev_map.get(p.device_id, f"#{p.device_id}"), + "predicted_at": str(p.predicted_at), + "component": p.component, + "failure_mode": p.failure_mode, + "probability": p.probability, + "predicted_failure_date": str(p.predicted_failure_date) if p.predicted_failure_date else None, + "recommended_action": p.recommended_action, + "urgency": p.urgency, + "estimated_downtime_hours": p.estimated_downtime_hours, + "estimated_repair_cost": p.estimated_repair_cost, + "status": p.status, + } for p in predictions], + } + + +@router.get("/maintenance/schedule") +async def get_maintenance_schedule( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取推荐维护计划""" + result = await db.execute( + select(MaintenancePrediction).where( + MaintenancePrediction.status.in_(["predicted", "scheduled"]) + ).order_by(MaintenancePrediction.predicted_failure_date.asc()) + ) + predictions = result.scalars().all() + + dev_ids = list(set(p.device_id for p in predictions)) + dev_map = {} + if dev_ids: + dev_result = await db.execute(select(Device.id, Device.name).where(Device.id.in_(dev_ids))) + dev_map = {r.id: r.name for r in dev_result.all()} + + return [{ + "id": p.id, + "device_id": p.device_id, + "device_name": dev_map.get(p.device_id, f"#{p.device_id}"), + "component": p.component, + "failure_mode": p.failure_mode, + "probability": p.probability, + "predicted_failure_date": str(p.predicted_failure_date) if p.predicted_failure_date else None, + "recommended_action": p.recommended_action, + "urgency": p.urgency, + "estimated_downtime_hours": p.estimated_downtime_hours, + "estimated_repair_cost": p.estimated_repair_cost, + "status": p.status, + } for p in predictions] + + +class PredictionStatusUpdate(BaseModel): + status: str # scheduled, completed, false_alarm + + +@router.put("/maintenance/predictions/{prediction_id}") +async def update_prediction( + prediction_id: int, + data: PredictionStatusUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """更新预测状态""" + result = await db.execute(select(MaintenancePrediction).where(MaintenancePrediction.id == prediction_id)) + pred = result.scalar_one_or_none() + if not pred: + raise HTTPException(status_code=404, detail="预测记录不存在") + pred.status = data.status + return {"message": "已更新", "id": pred.id, "status": pred.status} + + +@router.post("/maintenance/predict") +async def trigger_predictions( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """触发维护预测生成""" + predictions = await generate_maintenance_predictions(db) + return { + "generated": len(predictions), + "predictions": [{ + "device_id": p.device_id, + "component": p.component, + "urgency": p.urgency, + "probability": p.probability, + } for p in predictions], + } + + +# ── Insights ──────────────────────────────────────────────────────── + +@router.get("/insights") +async def list_insights( + insight_type: str | None = None, + impact_level: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """列出运营洞察""" + query = select(OpsInsight) + if insight_type: + query = query.where(OpsInsight.insight_type == insight_type) + if impact_level: + query = query.where(OpsInsight.impact_level == impact_level) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(OpsInsight.generated_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + insights = result.scalars().all() + + return { + "total": total, + "items": [{ + "id": i.id, + "insight_type": i.insight_type, + "title": i.title, + "description": i.description, + "data": i.data, + "impact_level": i.impact_level, + "actionable": i.actionable, + "recommended_action": i.recommended_action, + "generated_at": str(i.generated_at), + "valid_until": str(i.valid_until) if i.valid_until else None, + } for i in insights], + } + + +@router.get("/insights/latest") +async def get_latest_insights( + limit: int = Query(5, ge=1, le=20), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取最新洞察""" + now = datetime.now(timezone.utc) + result = await db.execute( + select(OpsInsight).where( + OpsInsight.valid_until >= now + ).order_by(OpsInsight.generated_at.desc()).limit(limit) + ) + insights = result.scalars().all() + return [{ + "id": i.id, + "insight_type": i.insight_type, + "title": i.title, + "description": i.description, + "impact_level": i.impact_level, + "actionable": i.actionable, + "recommended_action": i.recommended_action, + "generated_at": str(i.generated_at), + } for i in insights] + + +@router.post("/insights/generate") +async def trigger_insights( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """触发洞察生成""" + insights = await generate_insights(db) + return { + "generated": len(insights), + "insights": [{ + "title": i.title, + "insight_type": i.insight_type, + "impact_level": i.impact_level, + } for i in insights], + } + + +# ── Dashboard ─────────────────────────────────────────────────────── + +@router.get("/dashboard") +async def ai_ops_dashboard( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """AI运维总览仪表盘""" + return await get_dashboard_data(db) diff --git a/backend/app/api/v1/alarms.py b/backend/app/api/v1/alarms.py new file mode 100644 index 0000000..18d77fc --- /dev/null +++ b/backend/app/api/v1/alarms.py @@ -0,0 +1,321 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, String +from datetime import datetime, timezone +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user, require_roles +from app.models.alarm import AlarmRule, AlarmEvent +from app.models.user import User +from app.services.audit import log_audit + +router = APIRouter(prefix="/alarms", tags=["告警管理"]) + + +class AlarmRuleCreate(BaseModel): + name: str + device_id: int | None = None + device_type: str | None = None + data_type: str + condition: str + threshold: float | None = None + threshold_high: float | None = None + threshold_low: float | None = None + duration: int = 0 + severity: str = "warning" + notify_channels: list[str] | None = None + notify_targets: list[str] | None = None + silence_start: str | None = None + silence_end: str | None = None + + +@router.get("/rules") +async def list_rules(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute(select(AlarmRule).order_by(AlarmRule.id.desc())) + return [_rule_to_dict(r) for r in result.scalars().all()] + + +@router.post("/rules") +async def create_rule(data: AlarmRuleCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + rule = AlarmRule(**data.model_dump(), created_by=user.id) + db.add(rule) + await db.flush() + return _rule_to_dict(rule) + + +@router.put("/rules/{rule_id}") +async def update_rule(rule_id: int, data: AlarmRuleCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + result = await db.execute(select(AlarmRule).where(AlarmRule.id == rule_id)) + rule = result.scalar_one_or_none() + if not rule: + raise HTTPException(status_code=404, detail="规则不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(rule, k, v) + return _rule_to_dict(rule) + + +@router.delete("/rules/{rule_id}") +async def delete_rule(rule_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + result = await db.execute(select(AlarmRule).where(AlarmRule.id == rule_id)) + rule = result.scalar_one_or_none() + if not rule: + raise HTTPException(status_code=404, detail="规则不存在") + rule.is_active = False + return {"message": "已删除"} + + +@router.get("/events") +async def list_events( + status: str | None = None, + severity: str | None = None, + device_id: int | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(AlarmEvent) + if status: + query = query.where(AlarmEvent.status == status) + if severity: + query = query.where(AlarmEvent.severity == severity) + if device_id: + query = query.where(AlarmEvent.device_id == device_id) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(AlarmEvent.triggered_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [{ + "id": e.id, "rule_id": e.rule_id, "device_id": e.device_id, "severity": e.severity, + "title": e.title, "description": e.description, "value": e.value, "threshold": e.threshold, + "status": e.status, "triggered_at": str(e.triggered_at), + "acknowledged_at": str(e.acknowledged_at) if e.acknowledged_at else None, + "resolved_at": str(e.resolved_at) if e.resolved_at else None, + } for e in result.scalars().all()] + } + + +@router.post("/events/{event_id}/acknowledge") +async def acknowledge_event(event_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute(select(AlarmEvent).where(AlarmEvent.id == event_id)) + event = result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="告警不存在") + event.status = "acknowledged" + event.acknowledged_by = user.id + event.acknowledged_at = datetime.now(timezone.utc) + await log_audit(db, user.id, "acknowledge", "alarm", detail=f"确认告警 #{event_id}") + return {"message": "已确认"} + + +@router.post("/events/{event_id}/resolve") +async def resolve_event(event_id: int, note: str = "", db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute(select(AlarmEvent).where(AlarmEvent.id == event_id)) + event = result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="告警不存在") + event.status = "resolved" + event.resolved_at = datetime.now(timezone.utc) + event.resolve_note = note + await log_audit(db, user.id, "resolve", "alarm", detail=f"解决告警 #{event_id}") + return {"message": "已解决"} + + +@router.get("/stats") +async def alarm_stats(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute( + select(AlarmEvent.severity, AlarmEvent.status, func.count(AlarmEvent.id)) + .group_by(AlarmEvent.severity, AlarmEvent.status) + ) + stats = {} + for severity, status, count in result.all(): + if severity not in stats: + stats[severity] = {} + stats[severity][status] = count + return stats + + +@router.get("/analytics") +async def get_alarm_analytics( + start_date: str | None = None, + end_date: str | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """告警分析 - Alarm trends and patterns""" + from datetime import timedelta + if not end_date: + end_dt = datetime.now(timezone.utc) + else: + end_dt = datetime.fromisoformat(end_date) + if not start_date: + start_dt = end_dt - timedelta(days=30) + else: + start_dt = datetime.fromisoformat(start_date) + + # Daily alarm count by severity + from app.core.config import get_settings + settings = get_settings() + if settings.is_sqlite: + date_col = func.strftime('%Y-%m-%d', AlarmEvent.triggered_at).label('date') + else: + date_col = func.date_trunc('day', AlarmEvent.triggered_at).cast(String).label('date') + + query = select( + date_col, + AlarmEvent.severity, + func.count(AlarmEvent.id).label('count'), + ).where( + and_(AlarmEvent.triggered_at >= start_dt, AlarmEvent.triggered_at <= end_dt) + ).group_by('date', AlarmEvent.severity).order_by('date') + + result = await db.execute(query) + rows = result.all() + + daily_map: dict[str, dict] = {} + totals = {"critical": 0, "major": 0, "warning": 0} + for date_val, severity, count in rows: + d = str(date_val)[:10] + if d not in daily_map: + daily_map[d] = {"date": d, "critical": 0, "major": 0, "warning": 0} + daily_map[d][severity] = count + if severity in totals: + totals[severity] += count + + return { + "daily_trend": list(daily_map.values()), + "totals": totals, + } + + +@router.get("/top-devices") +async def get_top_alarm_devices( + limit: int = 10, + start_date: str | None = None, + end_date: str | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """告警设备排名 - Devices with most alarms""" + from app.models.device import Device + + query = select( + AlarmEvent.device_id, + Device.name.label('device_name'), + func.count(AlarmEvent.id).label('alarm_count'), + func.max(AlarmEvent.triggered_at).label('last_alarm_time'), + ).join(Device, AlarmEvent.device_id == Device.id) + + if start_date: + query = query.where(AlarmEvent.triggered_at >= start_date) + if end_date: + query = query.where(AlarmEvent.triggered_at <= end_date) + + query = query.group_by(AlarmEvent.device_id, Device.name).order_by( + func.count(AlarmEvent.id).desc() + ).limit(limit) + + result = await db.execute(query) + return [{ + "device_id": r.device_id, + "device_name": r.device_name, + "alarm_count": r.alarm_count, + "last_alarm_time": str(r.last_alarm_time) if r.last_alarm_time else None, + } for r in result.all()] + + +@router.get("/mttr") +async def get_alarm_mttr( + start_date: str | None = None, + end_date: str | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """平均修复时间 - Mean Time To Resolve by severity""" + from app.core.config import get_settings + settings = get_settings() + + base_query = select(AlarmEvent).where( + and_(AlarmEvent.status == "resolved", AlarmEvent.resolved_at.isnot(None)) + ) + if start_date: + base_query = base_query.where(AlarmEvent.triggered_at >= start_date) + if end_date: + base_query = base_query.where(AlarmEvent.triggered_at <= end_date) + + result = await db.execute(base_query) + events = result.scalars().all() + + mttr_data: dict[str, dict] = {} + for e in events: + if not e.resolved_at or not e.triggered_at: + continue + hours = (e.resolved_at - e.triggered_at).total_seconds() / 3600 + sev = e.severity + if sev not in mttr_data: + mttr_data[sev] = {"total_hours": 0, "count": 0} + mttr_data[sev]["total_hours"] += hours + mttr_data[sev]["count"] += 1 + + return { + sev: { + "avg_hours": round(d["total_hours"] / d["count"], 2) if d["count"] > 0 else 0, + "count": d["count"], + } + for sev, d in mttr_data.items() + } + + +@router.put("/rules/{rule_id}/toggle") +async def toggle_rule( + rule_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + """快速启用/禁用规则""" + result = await db.execute(select(AlarmRule).where(AlarmRule.id == rule_id)) + rule = result.scalar_one_or_none() + if not rule: + raise HTTPException(status_code=404, detail="规则不存在") + rule.is_active = not rule.is_active + return {"id": rule.id, "is_active": rule.is_active} + + +@router.get("/rules/{rule_id}/history") +async def get_rule_history( + rule_id: int, + page: int = 1, + page_size: int = 20, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """规则触发历史""" + query = select(AlarmEvent).where(AlarmEvent.rule_id == rule_id) + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(AlarmEvent.triggered_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [{ + "id": e.id, "device_id": e.device_id, "severity": e.severity, + "title": e.title, "value": e.value, "threshold": e.threshold, + "status": e.status, "triggered_at": str(e.triggered_at), + "resolved_at": str(e.resolved_at) if e.resolved_at else None, + } for e in result.scalars().all()] + } + + +def _rule_to_dict(r: AlarmRule) -> dict: + return { + "id": r.id, "name": r.name, "device_id": r.device_id, "device_type": r.device_type, + "data_type": r.data_type, "condition": r.condition, "threshold": r.threshold, + "threshold_high": r.threshold_high, "threshold_low": r.threshold_low, + "duration": r.duration, "severity": r.severity, "is_active": r.is_active, + "notify_channels": r.notify_channels, "silence_start": r.silence_start, "silence_end": r.silence_end, + } diff --git a/backend/app/api/v1/audit.py b/backend/app/api/v1/audit.py new file mode 100644 index 0000000..7a13178 --- /dev/null +++ b/backend/app/api/v1/audit.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from datetime import datetime +from app.core.database import get_db +from app.core.deps import get_current_user, require_roles +from app.models.user import User, AuditLog + +router = APIRouter(prefix="/audit", tags=["审计日志"]) + + +@router.get("/logs") +async def list_audit_logs( + user_id: int | None = None, + action: str | None = None, + resource: str | None = None, + start_time: str | None = None, + end_time: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_roles("admin", "energy_manager")), +): + """Return paginated audit logs with optional filters.""" + query = select( + AuditLog.id, + AuditLog.user_id, + User.username, + AuditLog.action, + AuditLog.resource, + AuditLog.detail, + AuditLog.ip_address, + AuditLog.created_at, + ).outerjoin(User, AuditLog.user_id == User.id) + + if user_id is not None: + query = query.where(AuditLog.user_id == user_id) + if action: + query = query.where(AuditLog.action == action) + if resource: + query = query.where(AuditLog.resource == resource) + if start_time: + try: + st = datetime.fromisoformat(start_time) + query = query.where(AuditLog.created_at >= st) + except ValueError: + pass + if end_time: + try: + et = datetime.fromisoformat(end_time) + query = query.where(AuditLog.created_at <= et) + except ValueError: + pass + + # Count + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + # Paginate + query = query.order_by(AuditLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + + items = [] + for row in result.all(): + items.append({ + "id": row.id, + "user_id": row.user_id, + "username": row.username or "-", + "action": row.action, + "resource": row.resource, + "detail": row.detail, + "ip_address": row.ip_address, + "created_at": str(row.created_at) if row.created_at else None, + }) + + return {"total": total, "items": items} diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..38bdf90 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,53 @@ +from datetime import datetime, timezone +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel +from app.core.database import get_db +from app.core.security import verify_password, create_access_token, hash_password +from app.core.deps import get_current_user +from app.models.user import User +from app.services.audit import log_audit + +router = APIRouter(prefix="/auth", tags=["认证"]) + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + user: dict + + +class RegisterRequest(BaseModel): + username: str + password: str + full_name: str | None = None + email: str | None = None + phone: str | None = None + + +@router.post("/login", response_model=Token) +async def login(request: Request, form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.username == form.username)) + user = result.scalar_one_or_none() + if not user or not verify_password(form.password, user.hashed_password): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误") + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用") + user.last_login = datetime.now(timezone.utc) + token = create_access_token({"sub": str(user.id), "role": user.role}) + client_ip = request.client.host if request.client else None + await log_audit(db, user.id, "login", "auth", detail=f"用户 {user.username} 登录", ip_address=client_ip) + return Token( + access_token=token, + user={"id": user.id, "username": user.username, "full_name": user.full_name, "role": user.role} + ) + + +@router.get("/me") +async def get_me(user: User = Depends(get_current_user)): + return { + "id": user.id, "username": user.username, "full_name": user.full_name, + "email": user.email, "phone": user.phone, "role": user.role, "is_active": user.is_active, + } diff --git a/backend/app/api/v1/branding.py b/backend/app/api/v1/branding.py new file mode 100644 index 0000000..b13fafa --- /dev/null +++ b/backend/app/api/v1/branding.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter +from app.core.config import get_settings + +router = APIRouter(prefix="/branding", tags=["品牌配置"]) + + +@router.get("") +async def get_branding(): + """Return customer-specific branding configuration""" + settings = get_settings() + customer_config = settings.load_customer_config() + return { + "customer": settings.CUSTOMER, + "customer_name": customer_config.get("customer_name", settings.CUSTOMER), + "platform_name": customer_config.get("platform_name", settings.APP_NAME), + "platform_name_en": customer_config.get("platform_name_en", "Smart EMS"), + "logo_url": customer_config.get("logo_url", ""), + "theme_color": customer_config.get("theme_color", "#1890ff"), + "features": customer_config.get("features", {}), + } diff --git a/backend/app/api/v1/carbon.py b/backend/app/api/v1/carbon.py new file mode 100644 index 0000000..1a780fb --- /dev/null +++ b/backend/app/api/v1/carbon.py @@ -0,0 +1,434 @@ +from datetime import date, datetime, timedelta, timezone +from typing import Optional +from fastapi import APIRouter, Depends, Query, HTTPException, Body +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, text +from app.core.database import get_db +from app.core.config import get_settings +from app.core.deps import get_current_user +from app.models.carbon import ( + CarbonEmission, EmissionFactor, CarbonTarget, CarbonReduction, + GreenCertificate, CarbonReport, CarbonBenchmark, +) +from app.models.user import User +from app.services import carbon_asset + +router = APIRouter(prefix="/carbon", tags=["碳排放管理"]) + + +# --------------- Pydantic Schemas --------------- + +class TargetCreate(BaseModel): + year: int + month: Optional[int] = None + target_emission_tons: float + +class TargetUpdate(BaseModel): + target_emission_tons: Optional[float] = None + status: Optional[str] = None + +class CertificateCreate(BaseModel): + certificate_type: str + certificate_number: str + issue_date: date + expiry_date: Optional[date] = None + energy_mwh: float + price_yuan: float = 0 + status: str = "active" + source_device_id: Optional[int] = None + notes: Optional[str] = None + +class CertificateUpdate(BaseModel): + status: Optional[str] = None + price_yuan: Optional[float] = None + notes: Optional[str] = None + +class ReportGenerate(BaseModel): + report_type: str = Field(..., pattern="^(monthly|quarterly|annual)$") + period_start: date + period_end: date + + +@router.get("/overview") +async def carbon_overview(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + """碳排放总览""" + 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) + year_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) + + async def sum_carbon(start, end): + r = await db.execute( + select(func.sum(CarbonEmission.emission), func.sum(CarbonEmission.reduction)) + .where(and_(CarbonEmission.date >= start, CarbonEmission.date < end)) + ) + row = r.first() + return {"emission": row[0] or 0, "reduction": row[1] or 0} + + today = await sum_carbon(today_start, now) + month = await sum_carbon(month_start, now) + year = await sum_carbon(year_start, now) + + # 各scope分布 + scope_q = await db.execute( + select(CarbonEmission.scope, func.sum(CarbonEmission.emission)) + .where(CarbonEmission.date >= year_start) + .group_by(CarbonEmission.scope) + ) + by_scope = {row[0]: round(row[1], 2) for row in scope_q.all()} + + return { + "today": {"emission": round(today["emission"], 2), "reduction": round(today["reduction"], 2)}, + "month": {"emission": round(month["emission"], 2), "reduction": round(month["reduction"], 2)}, + "year": {"emission": round(year["emission"], 2), "reduction": round(year["reduction"], 2)}, + "by_scope": by_scope, + } + + +@router.get("/trend") +async def carbon_trend( + days: int = Query(30, ge=1, le=365), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """碳排放趋势""" + start = datetime.now(timezone.utc) - timedelta(days=days) + settings = get_settings() + if settings.is_sqlite: + day_expr = func.strftime('%Y-%m-%d', CarbonEmission.date).label('day') + else: + day_expr = func.date_trunc('day', CarbonEmission.date).label('day') + + result = await db.execute( + select( + day_expr, + func.sum(CarbonEmission.emission), + func.sum(CarbonEmission.reduction), + ).where(CarbonEmission.date >= start) + .group_by(text('day')).order_by(text('day')) + ) + return [{"date": str(r[0]), "emission": round(r[1], 2), "reduction": round(r[2], 2)} for r in result.all()] + + +@router.get("/factors") +async def list_factors(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute(select(EmissionFactor).order_by(EmissionFactor.id)) + return [{ + "id": f.id, "name": f.name, "energy_type": f.energy_type, "factor": f.factor, + "unit": f.unit, "scope": f.scope, "region": f.region, "source": f.source, + } for f in result.scalars().all()] + + +# =============== Carbon Dashboard =============== + +@router.get("/dashboard") +async def carbon_dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + """综合碳资产仪表盘""" + return await carbon_asset.get_carbon_dashboard(db) + + +# =============== Carbon Targets =============== + +@router.get("/targets") +async def list_targets( + year: int = Query(None), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """碳减排目标列表""" + q = select(CarbonTarget).order_by(CarbonTarget.year.desc(), CarbonTarget.month) + if year: + q = q.where(CarbonTarget.year == year) + result = await db.execute(q) + targets = result.scalars().all() + return [{ + "id": t.id, "year": t.year, "month": t.month, + "target_emission_tons": t.target_emission_tons, + "actual_emission_tons": t.actual_emission_tons, + "status": t.status, + } for t in targets] + + +@router.post("/targets") +async def create_target( + data: TargetCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """创建碳减排目标""" + target = CarbonTarget( + year=data.year, + month=data.month, + target_emission_tons=data.target_emission_tons, + ) + db.add(target) + await db.flush() + return {"id": target.id, "message": "目标创建成功"} + + +@router.put("/targets/{target_id}") +async def update_target( + target_id: int, + data: TargetUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """更新碳减排目标""" + result = await db.execute(select(CarbonTarget).where(CarbonTarget.id == target_id)) + target = result.scalar_one_or_none() + if not target: + raise HTTPException(404, "目标不存在") + if data.target_emission_tons is not None: + target.target_emission_tons = data.target_emission_tons + if data.status is not None: + target.status = data.status + return {"message": "更新成功"} + + +@router.get("/targets/progress") +async def target_progress( + year: int = Query(None), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """碳目标进度""" + if year is None: + year = datetime.now(timezone.utc).year + return await carbon_asset.get_target_progress(db, year) + + +# =============== Carbon Reductions =============== + +@router.get("/reductions") +async def list_reductions( + start: date = Query(None), + end: date = Query(None), + source_type: str = Query(None), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """碳减排活动列表""" + q = select(CarbonReduction).order_by(CarbonReduction.date.desc()) + if start: + q = q.where(CarbonReduction.date >= start) + if end: + q = q.where(CarbonReduction.date <= end) + if source_type: + q = q.where(CarbonReduction.source_type == source_type) + result = await db.execute(q.limit(500)) + items = result.scalars().all() + return [{ + "id": r.id, "source_type": r.source_type, "date": str(r.date), + "reduction_tons": r.reduction_tons, "equivalent_trees": r.equivalent_trees, + "methodology": r.methodology, "verified": r.verified, + } for r in items] + + +@router.get("/reductions/summary") +async def reduction_summary( + start: date = Query(None), + end: date = Query(None), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """减排汇总(按来源类型)""" + if not start: + start = date(datetime.now(timezone.utc).year, 1, 1) + if not end: + end = datetime.now(timezone.utc).date() + return await carbon_asset.get_reduction_summary(db, start, end) + + +@router.post("/reductions/calculate") +async def calculate_reductions( + start: date = Query(None), + end: date = Query(None), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """触发减排量计算""" + if not start: + start = date(datetime.now(timezone.utc).year, 1, 1) + if not end: + end = datetime.now(timezone.utc).date() + return await carbon_asset.trigger_reduction_calculation(db, start, end) + + +# =============== Green Certificates =============== + +@router.get("/certificates") +async def list_certificates( + status: str = Query(None), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """绿证列表""" + q = select(GreenCertificate).order_by(GreenCertificate.issue_date.desc()) + if status: + q = q.where(GreenCertificate.status == status) + result = await db.execute(q) + certs = result.scalars().all() + return [{ + "id": c.id, "certificate_type": c.certificate_type, + "certificate_number": c.certificate_number, + "issue_date": str(c.issue_date), "expiry_date": str(c.expiry_date) if c.expiry_date else None, + "energy_mwh": c.energy_mwh, "price_yuan": c.price_yuan, + "status": c.status, "notes": c.notes, + } for c in certs] + + +@router.post("/certificates") +async def create_certificate( + data: CertificateCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """登记绿证""" + cert = GreenCertificate(**data.model_dump()) + db.add(cert) + await db.flush() + return {"id": cert.id, "message": "绿证登记成功"} + + +@router.put("/certificates/{cert_id}") +async def update_certificate( + cert_id: int, + data: CertificateUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """更新绿证""" + result = await db.execute(select(GreenCertificate).where(GreenCertificate.id == cert_id)) + cert = result.scalar_one_or_none() + if not cert: + raise HTTPException(404, "绿证不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(cert, k, v) + return {"message": "更新成功"} + + +@router.get("/certificates/value") +async def certificate_portfolio_value( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """绿证组合价值""" + return await carbon_asset.get_certificate_portfolio_value(db) + + +# =============== Carbon Reports =============== + +@router.get("/reports") +async def list_reports( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """报告列表""" + result = await db.execute( + select(CarbonReport).order_by(CarbonReport.generated_at.desc()).limit(50) + ) + reports = result.scalars().all() + return [{ + "id": r.id, "report_type": r.report_type, + "period_start": str(r.period_start), "period_end": str(r.period_end), + "generated_at": str(r.generated_at), + "total_tons": r.total_tons, "reduction_tons": r.reduction_tons, + "net_tons": r.net_tons, + } for r in reports] + + +@router.post("/reports/generate") +async def generate_report( + data: ReportGenerate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """生成碳报告""" + report = await carbon_asset.generate_carbon_report( + db, data.report_type, data.period_start, data.period_end, + ) + await db.flush() + return { + "id": report.id, + "total_tons": report.total_tons, + "reduction_tons": report.reduction_tons, + "net_tons": report.net_tons, + "message": "报告生成成功", + } + + +@router.get("/reports/{report_id}") +async def get_report( + report_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """报告详情""" + result = await db.execute(select(CarbonReport).where(CarbonReport.id == report_id)) + r = result.scalar_one_or_none() + if not r: + raise HTTPException(404, "报告不存在") + return { + "id": r.id, "report_type": r.report_type, + "period_start": str(r.period_start), "period_end": str(r.period_end), + "generated_at": str(r.generated_at), + "scope1_tons": r.scope1_tons, "scope2_tons": r.scope2_tons, + "scope3_tons": r.scope3_tons, + "total_tons": r.total_tons, "reduction_tons": r.reduction_tons, + "net_tons": r.net_tons, "report_data": r.report_data, + } + + +@router.get("/reports/{report_id}/download") +async def download_report( + report_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """下载报告数据""" + result = await db.execute(select(CarbonReport).where(CarbonReport.id == report_id)) + r = result.scalar_one_or_none() + if not r: + raise HTTPException(404, "报告不存在") + return { + "report_type": r.report_type, + "period": f"{r.period_start} ~ {r.period_end}", + "scope1_tons": r.scope1_tons, "scope2_tons": r.scope2_tons, + "total_tons": r.total_tons, "reduction_tons": r.reduction_tons, + "net_tons": r.net_tons, + "detail": r.report_data, + } + + +# =============== Carbon Benchmarks =============== + +@router.get("/benchmarks") +async def list_benchmarks( + year: int = Query(None), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """行业基准列表""" + q = select(CarbonBenchmark).order_by(CarbonBenchmark.year.desc()) + if year: + q = q.where(CarbonBenchmark.year == year) + result = await db.execute(q) + items = result.scalars().all() + return [{ + "id": b.id, "industry": b.industry, "metric_name": b.metric_name, + "benchmark_value": b.benchmark_value, "unit": b.unit, + "year": b.year, "source": b.source, "notes": b.notes, + } for b in items] + + +@router.get("/benchmarks/comparison") +async def benchmark_comparison( + year: int = Query(None), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """行业对标比较""" + if year is None: + year = datetime.now(timezone.utc).year + return await carbon_asset.compare_with_benchmarks(db, year) diff --git a/backend/app/api/v1/charging.py b/backend/app/api/v1/charging.py new file mode 100644 index 0000000..0d92018 --- /dev/null +++ b/backend/app/api/v1/charging.py @@ -0,0 +1,716 @@ +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user, require_roles +from app.models.charging import ( + ChargingStation, ChargingPile, ChargingPriceStrategy, ChargingPriceParam, + ChargingOrder, OccupancyOrder, ChargingBrand, ChargingMerchant, +) +from app.models.user import User +from app.services.audit import log_audit + +router = APIRouter(prefix="/charging", tags=["充电管理"]) + + +# ─── Pydantic Schemas ─────────────────────────────────────────────── + +class StationCreate(BaseModel): + name: str + merchant_id: int | None = None + type: str | None = None + address: str | None = None + latitude: float | None = None + longitude: float | None = None + price: float | None = None + activity: str | None = None + status: str = "active" + total_piles: int = 0 + available_piles: int = 0 + total_power_kw: float = 0 + photo_url: str | None = None + operating_hours: str | None = None + + +class StationUpdate(BaseModel): + name: str | None = None + merchant_id: int | None = None + type: str | None = None + address: str | None = None + latitude: float | None = None + longitude: float | None = None + price: float | None = None + activity: str | None = None + status: str | None = None + total_piles: int | None = None + available_piles: int | None = None + total_power_kw: float | None = None + photo_url: str | None = None + operating_hours: str | None = None + + +class PileCreate(BaseModel): + station_id: int + encoding: str + name: str | None = None + type: str | None = None + brand: str | None = None + model: str | None = None + rated_power_kw: float | None = None + connector_type: str | None = None + status: str = "active" + work_status: str = "offline" + + +class PileUpdate(BaseModel): + station_id: int | None = None + encoding: str | None = None + name: str | None = None + type: str | None = None + brand: str | None = None + model: str | None = None + rated_power_kw: float | None = None + connector_type: str | None = None + status: str | None = None + work_status: str | None = None + + +class PriceParamCreate(BaseModel): + start_time: str + end_time: str + period_mark: str | None = None + elec_price: float + service_price: float = 0 + + +class PriceStrategyCreate(BaseModel): + strategy_name: str + station_id: int | None = None + bill_model: str | None = None + description: str | None = None + status: str = "inactive" + params: list[PriceParamCreate] = [] + + +class PriceStrategyUpdate(BaseModel): + strategy_name: str | None = None + station_id: int | None = None + bill_model: str | None = None + description: str | None = None + status: str | None = None + params: list[PriceParamCreate] | None = None + + +class MerchantCreate(BaseModel): + name: str + contact_person: str | None = None + phone: str | None = None + email: str | None = None + address: str | None = None + business_license: str | None = None + status: str = "active" + settlement_type: str | None = None + + +class BrandCreate(BaseModel): + brand_name: str + logo_url: str | None = None + country: str | None = None + description: str | None = None + + +# ─── Station Endpoints ─────────────────────────────────────────────── + +@router.get("/stations") +async def list_stations( + status: str | None = None, + type: str | None = None, + merchant_id: int | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(ChargingStation) + if status: + query = query.where(ChargingStation.status == status) + if type: + query = query.where(ChargingStation.type == type) + if merchant_id: + query = query.where(ChargingStation.merchant_id == merchant_id) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(ChargingStation.id.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + stations = result.scalars().all() + return {"total": total, "items": [_station_to_dict(s) for s in stations]} + + +@router.post("/stations") +async def create_station( + data: StationCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + station = ChargingStation(**data.model_dump(), created_by=user.id) + db.add(station) + await db.flush() + await log_audit(db, user.id, "create", "charging", detail=f"创建充电站 {data.name}") + return _station_to_dict(station) + + +@router.put("/stations/{station_id}") +async def update_station( + station_id: int, + data: StationUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ChargingStation).where(ChargingStation.id == station_id)) + station = result.scalar_one_or_none() + if not station: + raise HTTPException(status_code=404, detail="充电站不存在") + updates = data.model_dump(exclude_unset=True) + for k, v in updates.items(): + setattr(station, k, v) + await log_audit(db, user.id, "update", "charging", detail=f"更新充电站 {station.name}") + return _station_to_dict(station) + + +@router.delete("/stations/{station_id}") +async def delete_station( + station_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ChargingStation).where(ChargingStation.id == station_id)) + station = result.scalar_one_or_none() + if not station: + raise HTTPException(status_code=404, detail="充电站不存在") + station.status = "disabled" + await log_audit(db, user.id, "delete", "charging", detail=f"禁用充电站 {station.name}") + return {"message": "已禁用"} + + +@router.get("/stations/{station_id}/piles") +async def list_station_piles( + station_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute( + select(ChargingPile).where(ChargingPile.station_id == station_id).order_by(ChargingPile.id) + ) + return [_pile_to_dict(p) for p in result.scalars().all()] + + +# ─── Pile Endpoints ────────────────────────────────────────────────── + +@router.get("/piles") +async def list_piles( + station_id: int | None = None, + status: str | None = None, + work_status: str | None = None, + type: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(ChargingPile) + if station_id: + query = query.where(ChargingPile.station_id == station_id) + if status: + query = query.where(ChargingPile.status == status) + if work_status: + query = query.where(ChargingPile.work_status == work_status) + if type: + query = query.where(ChargingPile.type == type) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(ChargingPile.id.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + piles = result.scalars().all() + return {"total": total, "items": [_pile_to_dict(p) for p in piles]} + + +@router.post("/piles") +async def create_pile( + data: PileCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + pile = ChargingPile(**data.model_dump()) + db.add(pile) + await db.flush() + await log_audit(db, user.id, "create", "charging", detail=f"创建充电桩 {data.encoding}") + return _pile_to_dict(pile) + + +@router.put("/piles/{pile_id}") +async def update_pile( + pile_id: int, + data: PileUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ChargingPile).where(ChargingPile.id == pile_id)) + pile = result.scalar_one_or_none() + if not pile: + raise HTTPException(status_code=404, detail="充电桩不存在") + updates = data.model_dump(exclude_unset=True) + for k, v in updates.items(): + setattr(pile, k, v) + await log_audit(db, user.id, "update", "charging", detail=f"更新充电桩 {pile.encoding}") + return _pile_to_dict(pile) + + +@router.delete("/piles/{pile_id}") +async def delete_pile( + pile_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ChargingPile).where(ChargingPile.id == pile_id)) + pile = result.scalar_one_or_none() + if not pile: + raise HTTPException(status_code=404, detail="充电桩不存在") + pile.status = "disabled" + await log_audit(db, user.id, "delete", "charging", detail=f"禁用充电桩 {pile.encoding}") + return {"message": "已禁用"} + + +# ─── Pricing Endpoints ─────────────────────────────────────────────── + +@router.get("/pricing") +async def list_pricing( + station_id: int | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(ChargingPriceStrategy) + if station_id: + query = query.where(ChargingPriceStrategy.station_id == station_id) + result = await db.execute(query.order_by(ChargingPriceStrategy.id.desc())) + strategies = result.scalars().all() + + items = [] + for s in strategies: + params_q = await db.execute( + select(ChargingPriceParam).where(ChargingPriceParam.strategy_id == s.id).order_by(ChargingPriceParam.start_time) + ) + params = [_param_to_dict(p) for p in params_q.scalars().all()] + d = _strategy_to_dict(s) + d["params"] = params + items.append(d) + return items + + +@router.post("/pricing") +async def create_pricing( + data: PriceStrategyCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + strategy = ChargingPriceStrategy( + strategy_name=data.strategy_name, + station_id=data.station_id, + bill_model=data.bill_model, + description=data.description, + status=data.status, + ) + db.add(strategy) + await db.flush() + + for p in data.params: + param = ChargingPriceParam(strategy_id=strategy.id, **p.model_dump()) + db.add(param) + await db.flush() + + await log_audit(db, user.id, "create", "charging", detail=f"创建计费策略 {data.strategy_name}") + return _strategy_to_dict(strategy) + + +@router.put("/pricing/{strategy_id}") +async def update_pricing( + strategy_id: int, + data: PriceStrategyUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ChargingPriceStrategy).where(ChargingPriceStrategy.id == strategy_id)) + strategy = result.scalar_one_or_none() + if not strategy: + raise HTTPException(status_code=404, detail="计费策略不存在") + + updates = data.model_dump(exclude_unset=True, exclude={"params"}) + for k, v in updates.items(): + setattr(strategy, k, v) + + if data.params is not None: + # Delete old params and recreate + old_params = await db.execute( + select(ChargingPriceParam).where(ChargingPriceParam.strategy_id == strategy_id) + ) + for old in old_params.scalars().all(): + await db.delete(old) + await db.flush() + + for p in data.params: + param = ChargingPriceParam(strategy_id=strategy_id, **p.model_dump()) + db.add(param) + await db.flush() + + await log_audit(db, user.id, "update", "charging", detail=f"更新计费策略 {strategy.strategy_name}") + return _strategy_to_dict(strategy) + + +@router.delete("/pricing/{strategy_id}") +async def delete_pricing( + strategy_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ChargingPriceStrategy).where(ChargingPriceStrategy.id == strategy_id)) + strategy = result.scalar_one_or_none() + if not strategy: + raise HTTPException(status_code=404, detail="计费策略不存在") + strategy.status = "inactive" + await log_audit(db, user.id, "delete", "charging", detail=f"停用计费策略 {strategy.strategy_name}") + return {"message": "已停用"} + + +# ─── Order Endpoints ───────────────────────────────────────────────── + +@router.get("/orders") +async def list_orders( + order_status: str | None = None, + station_id: int | None = None, + start_date: str | None = None, + end_date: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(ChargingOrder) + if order_status: + query = query.where(ChargingOrder.order_status == order_status) + if station_id: + query = query.where(ChargingOrder.station_id == station_id) + if start_date: + query = query.where(ChargingOrder.created_at >= start_date) + if end_date: + query = query.where(ChargingOrder.created_at <= end_date) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(ChargingOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + orders = result.scalars().all() + return {"total": total, "items": [_order_to_dict(o) for o in orders]} + + +@router.get("/orders/realtime") +async def realtime_orders( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute( + select(ChargingOrder).where(ChargingOrder.order_status == "charging").order_by(ChargingOrder.start_time.desc()) + ) + return [_order_to_dict(o) for o in result.scalars().all()] + + +@router.get("/orders/abnormal") +async def abnormal_orders( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(ChargingOrder).where(ChargingOrder.order_status.in_(["failed", "refunded"])) + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(ChargingOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return {"total": total, "items": [_order_to_dict(o) for o in result.scalars().all()]} + + +@router.get("/orders/{order_id}") +async def get_order( + order_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute(select(ChargingOrder).where(ChargingOrder.id == order_id)) + order = result.scalar_one_or_none() + if not order: + raise HTTPException(status_code=404, detail="订单不存在") + return _order_to_dict(order) + + +@router.post("/orders/{order_id}/settle") +async def settle_order( + order_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ChargingOrder).where(ChargingOrder.id == order_id)) + order = result.scalar_one_or_none() + if not order: + raise HTTPException(status_code=404, detail="订单不存在") + order.settle_type = "manual" + order.settle_time = datetime.now(timezone.utc) + order.order_status = "completed" + await log_audit(db, user.id, "update", "charging", detail=f"手动结算订单 {order.order_no}") + return {"message": "已结算"} + + +# ─── Dashboard ─────────────────────────────────────────────────────── + +@router.get("/dashboard") +async def charging_dashboard( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + now = datetime.now(timezone.utc) + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + # Total revenue (completed orders) + rev_q = await db.execute( + select(func.sum(ChargingOrder.paid_price)).where(ChargingOrder.order_status == "completed") + ) + total_revenue = rev_q.scalar() or 0 + + # Total energy delivered + energy_q = await db.execute( + select(func.sum(ChargingOrder.energy)).where(ChargingOrder.order_status == "completed") + ) + total_energy = energy_q.scalar() or 0 + + # Active sessions + active_q = await db.execute( + select(func.count(ChargingOrder.id)).where(ChargingOrder.order_status == "charging") + ) + active_sessions = active_q.scalar() or 0 + + # Utilization rate: charging piles / total active piles + total_piles_q = await db.execute( + select(func.count(ChargingPile.id)).where(ChargingPile.status == "active") + ) + total_piles = total_piles_q.scalar() or 0 + charging_piles_q = await db.execute( + select(func.count(ChargingPile.id)).where(ChargingPile.work_status == "charging") + ) + charging_piles = charging_piles_q.scalar() or 0 + utilization_rate = round(charging_piles / total_piles * 100, 1) if total_piles > 0 else 0 + + # Revenue trend (last 30 days) + thirty_days_ago = now - timedelta(days=30) + trend_q = await db.execute( + select( + func.date(ChargingOrder.created_at).label("date"), + func.sum(ChargingOrder.paid_price).label("revenue"), + func.sum(ChargingOrder.energy).label("energy"), + ).where( + and_(ChargingOrder.order_status == "completed", ChargingOrder.created_at >= thirty_days_ago) + ).group_by(func.date(ChargingOrder.created_at)).order_by(func.date(ChargingOrder.created_at)) + ) + revenue_trend = [{"date": str(r[0]), "revenue": round(r[1] or 0, 2), "energy": round(r[2] or 0, 2)} for r in trend_q.all()] + + # Station ranking by revenue + ranking_q = await db.execute( + select( + ChargingOrder.station_name, + func.sum(ChargingOrder.paid_price).label("revenue"), + func.count(ChargingOrder.id).label("orders"), + ).where(ChargingOrder.order_status == "completed") + .group_by(ChargingOrder.station_name) + .order_by(func.sum(ChargingOrder.paid_price).desc()) + .limit(10) + ) + station_ranking = [{"station": r[0] or "未知", "revenue": round(r[1] or 0, 2), "orders": r[2]} for r in ranking_q.all()] + + # Pile status distribution + pile_status_q = await db.execute( + select(ChargingPile.work_status, func.count(ChargingPile.id)) + .where(ChargingPile.status == "active") + .group_by(ChargingPile.work_status) + ) + pile_status = {row[0]: row[1] for row in pile_status_q.all()} + + return { + "total_revenue": round(total_revenue, 2), + "total_energy": round(total_energy, 2), + "active_sessions": active_sessions, + "utilization_rate": utilization_rate, + "revenue_trend": revenue_trend, + "station_ranking": station_ranking, + "pile_status": { + "idle": pile_status.get("idle", 0), + "charging": pile_status.get("charging", 0), + "fault": pile_status.get("fault", 0), + "offline": pile_status.get("offline", 0), + }, + } + + +# ─── Merchant CRUD ─────────────────────────────────────────────────── + +@router.get("/merchants") +async def list_merchants(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute(select(ChargingMerchant).order_by(ChargingMerchant.id.desc())) + return [_merchant_to_dict(m) for m in result.scalars().all()] + + +@router.post("/merchants") +async def create_merchant(data: MerchantCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + merchant = ChargingMerchant(**data.model_dump()) + db.add(merchant) + await db.flush() + return _merchant_to_dict(merchant) + + +@router.put("/merchants/{merchant_id}") +async def update_merchant(merchant_id: int, data: MerchantCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + result = await db.execute(select(ChargingMerchant).where(ChargingMerchant.id == merchant_id)) + merchant = result.scalar_one_or_none() + if not merchant: + raise HTTPException(status_code=404, detail="运营商不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(merchant, k, v) + return _merchant_to_dict(merchant) + + +@router.delete("/merchants/{merchant_id}") +async def delete_merchant(merchant_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + result = await db.execute(select(ChargingMerchant).where(ChargingMerchant.id == merchant_id)) + merchant = result.scalar_one_or_none() + if not merchant: + raise HTTPException(status_code=404, detail="运营商不存在") + merchant.status = "disabled" + return {"message": "已禁用"} + + +# ─── Brand CRUD ────────────────────────────────────────────────────── + +@router.get("/brands") +async def list_brands(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute(select(ChargingBrand).order_by(ChargingBrand.id.desc())) + return [_brand_to_dict(b) for b in result.scalars().all()] + + +@router.post("/brands") +async def create_brand(data: BrandCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + brand = ChargingBrand(**data.model_dump()) + db.add(brand) + await db.flush() + return _brand_to_dict(brand) + + +@router.put("/brands/{brand_id}") +async def update_brand(brand_id: int, data: BrandCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + result = await db.execute(select(ChargingBrand).where(ChargingBrand.id == brand_id)) + brand = result.scalar_one_or_none() + if not brand: + raise HTTPException(status_code=404, detail="品牌不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(brand, k, v) + return _brand_to_dict(brand) + + +@router.delete("/brands/{brand_id}") +async def delete_brand(brand_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + result = await db.execute(select(ChargingBrand).where(ChargingBrand.id == brand_id)) + brand = result.scalar_one_or_none() + if not brand: + raise HTTPException(status_code=404, detail="品牌不存在") + await db.delete(brand) + return {"message": "已删除"} + + +# ─── Dict Helpers ──────────────────────────────────────────────────── + +def _station_to_dict(s: ChargingStation) -> dict: + return { + "id": s.id, "name": s.name, "merchant_id": s.merchant_id, "type": s.type, + "address": s.address, "latitude": s.latitude, "longitude": s.longitude, + "price": s.price, "activity": s.activity, "status": s.status, + "total_piles": s.total_piles, "available_piles": s.available_piles, + "total_power_kw": s.total_power_kw, "photo_url": s.photo_url, + "operating_hours": s.operating_hours, "created_by": s.created_by, + "created_at": str(s.created_at) if s.created_at else None, + } + + +def _pile_to_dict(p: ChargingPile) -> dict: + return { + "id": p.id, "station_id": p.station_id, "encoding": p.encoding, + "name": p.name, "type": p.type, "brand": p.brand, "model": p.model, + "rated_power_kw": p.rated_power_kw, "connector_type": p.connector_type, + "status": p.status, "work_status": p.work_status, + "created_at": str(p.created_at) if p.created_at else None, + } + + +def _strategy_to_dict(s: ChargingPriceStrategy) -> dict: + return { + "id": s.id, "strategy_name": s.strategy_name, "station_id": s.station_id, + "bill_model": s.bill_model, "description": s.description, "status": s.status, + "created_at": str(s.created_at) if s.created_at else None, + } + + +def _param_to_dict(p: ChargingPriceParam) -> dict: + return { + "id": p.id, "strategy_id": p.strategy_id, "start_time": p.start_time, + "end_time": p.end_time, "period_mark": p.period_mark, + "elec_price": p.elec_price, "service_price": p.service_price, + } + + +def _order_to_dict(o: ChargingOrder) -> dict: + return { + "id": o.id, "order_no": o.order_no, "user_id": o.user_id, + "user_name": o.user_name, "phone": o.phone, + "station_id": o.station_id, "station_name": o.station_name, + "pile_id": o.pile_id, "pile_name": o.pile_name, + "start_time": str(o.start_time) if o.start_time else None, + "end_time": str(o.end_time) if o.end_time else None, + "car_no": o.car_no, "car_vin": o.car_vin, + "charge_method": o.charge_method, "settle_type": o.settle_type, + "pay_type": o.pay_type, + "settle_time": str(o.settle_time) if o.settle_time else None, + "settle_price": o.settle_price, "paid_price": o.paid_price, + "discount_amt": o.discount_amt, "elec_amt": o.elec_amt, + "serve_amt": o.serve_amt, "order_status": o.order_status, + "charge_duration": o.charge_duration, "energy": o.energy, + "start_soc": o.start_soc, "end_soc": o.end_soc, + "abno_cause": o.abno_cause, "order_source": o.order_source, + "created_at": str(o.created_at) if o.created_at else None, + } + + +def _merchant_to_dict(m: ChargingMerchant) -> dict: + return { + "id": m.id, "name": m.name, "contact_person": m.contact_person, + "phone": m.phone, "email": m.email, "address": m.address, + "business_license": m.business_license, "status": m.status, + "settlement_type": m.settlement_type, + } + + +def _brand_to_dict(b: ChargingBrand) -> dict: + return { + "id": b.id, "brand_name": b.brand_name, "logo_url": b.logo_url, + "country": b.country, "description": b.description, + } diff --git a/backend/app/api/v1/collectors.py b/backend/app/api/v1/collectors.py new file mode 100644 index 0000000..1b938f8 --- /dev/null +++ b/backend/app/api/v1/collectors.py @@ -0,0 +1,53 @@ +"""API endpoints for collector management and status.""" +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/collectors", tags=["collectors"]) + + +def _get_manager(): + """Get the global CollectorManager instance.""" + from app.main import collector_manager + if collector_manager is None: + raise HTTPException(status_code=503, detail="Collector manager not active (simulator mode)") + return collector_manager + + +@router.get("/status") +async def get_collectors_status(): + """Get status of all active collectors.""" + manager = _get_manager() + return { + "running": manager.is_running, + "collector_count": manager.collector_count, + "collectors": manager.get_all_status(), + } + + +@router.get("/status/{device_id}") +async def get_collector_status(device_id: int): + """Get status of a specific collector.""" + manager = _get_manager() + collector = manager.get_collector(device_id) + if not collector: + raise HTTPException(status_code=404, detail="No collector for this device") + return collector.get_status() + + +@router.post("/{device_id}/restart") +async def restart_collector(device_id: int): + """Restart a specific device collector.""" + manager = _get_manager() + success = await manager.restart_collector(device_id) + if not success: + raise HTTPException(status_code=400, detail="Failed to restart collector") + return {"message": f"Collector for device {device_id} restarted"} + + +@router.post("/{device_id}/stop") +async def stop_collector(device_id: int): + """Stop a specific device collector.""" + manager = _get_manager() + success = await manager.stop_collector(device_id) + if not success: + raise HTTPException(status_code=404, detail="No running collector for this device") + return {"message": f"Collector for device {device_id} stopped"} diff --git a/backend/app/api/v1/cost.py b/backend/app/api/v1/cost.py new file mode 100644 index 0000000..e4b8400 --- /dev/null +++ b/backend/app/api/v1/cost.py @@ -0,0 +1,279 @@ +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.pricing import ElectricityPricing, PricingPeriod +from app.models.energy import EnergyDailySummary +from app.models.user import User +from app.services.cost_calculator import get_cost_summary, get_cost_breakdown + +router = APIRouter(prefix="/cost", tags=["费用分析"]) + + +# ---- Schemas ---- + +class PricingPeriodCreate(BaseModel): + period_name: str + start_time: str + end_time: str + price_per_unit: float + applicable_months: list[int] | None = None + + +class PricingCreate(BaseModel): + name: str + energy_type: str = "electricity" + pricing_type: str # flat, tou, tiered + effective_from: str | None = None + effective_to: str | None = None + periods: list[PricingPeriodCreate] = [] + + +class PricingUpdate(BaseModel): + name: str | None = None + energy_type: str | None = None + pricing_type: str | None = None + effective_from: str | None = None + effective_to: str | None = None + is_active: bool | None = None + + +# ---- Pricing CRUD ---- + +@router.get("/pricing") +async def list_pricing( + energy_type: str | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取电价配置列表""" + q = select(ElectricityPricing).order_by(ElectricityPricing.created_at.desc()) + if energy_type: + q = q.where(ElectricityPricing.energy_type == energy_type) + result = await db.execute(q) + pricings = result.scalars().all() + items = [] + for p in pricings: + # Load periods + pq = await db.execute(select(PricingPeriod).where(PricingPeriod.pricing_id == p.id)) + periods = pq.scalars().all() + items.append({ + "id": p.id, "name": p.name, "energy_type": p.energy_type, + "pricing_type": p.pricing_type, "is_active": p.is_active, + "effective_from": str(p.effective_from) if p.effective_from else None, + "effective_to": str(p.effective_to) if p.effective_to else None, + "created_at": str(p.created_at), + "periods": [ + {"id": pp.id, "period_name": pp.period_name, "start_time": pp.start_time, + "end_time": pp.end_time, "price_per_unit": pp.price_per_unit, + "applicable_months": pp.applicable_months} + for pp in periods + ], + }) + return items + + +@router.post("/pricing") +async def create_pricing( + data: PricingCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """创建电价配置""" + pricing = ElectricityPricing( + name=data.name, + energy_type=data.energy_type, + pricing_type=data.pricing_type, + effective_from=datetime.fromisoformat(data.effective_from) if data.effective_from else None, + effective_to=datetime.fromisoformat(data.effective_to) if data.effective_to else None, + created_by=user.id, + ) + db.add(pricing) + await db.flush() + + for period in data.periods: + pp = PricingPeriod( + pricing_id=pricing.id, + period_name=period.period_name, + start_time=period.start_time, + end_time=period.end_time, + price_per_unit=period.price_per_unit, + applicable_months=period.applicable_months, + ) + db.add(pp) + + return {"id": pricing.id, "message": "电价配置创建成功"} + + +@router.put("/pricing/{pricing_id}") +async def update_pricing( + pricing_id: int, + data: PricingUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """更新电价配置""" + result = await db.execute(select(ElectricityPricing).where(ElectricityPricing.id == pricing_id)) + pricing = result.scalar_one_or_none() + if not pricing: + raise HTTPException(status_code=404, detail="电价配置不存在") + + if data.name is not None: + pricing.name = data.name + if data.energy_type is not None: + pricing.energy_type = data.energy_type + if data.pricing_type is not None: + pricing.pricing_type = data.pricing_type + if data.effective_from is not None: + pricing.effective_from = datetime.fromisoformat(data.effective_from) + if data.effective_to is not None: + pricing.effective_to = datetime.fromisoformat(data.effective_to) + if data.is_active is not None: + pricing.is_active = data.is_active + + return {"message": "电价配置更新成功"} + + +@router.delete("/pricing/{pricing_id}") +async def deactivate_pricing( + pricing_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """停用电价配置""" + result = await db.execute(select(ElectricityPricing).where(ElectricityPricing.id == pricing_id)) + pricing = result.scalar_one_or_none() + if not pricing: + raise HTTPException(status_code=404, detail="电价配置不存在") + pricing.is_active = False + return {"message": "电价配置已停用"} + + +# ---- Pricing Periods ---- + +@router.get("/pricing/{pricing_id}/periods") +async def list_periods( + pricing_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取电价时段列表""" + result = await db.execute(select(PricingPeriod).where(PricingPeriod.pricing_id == pricing_id)) + periods = result.scalars().all() + return [ + {"id": p.id, "period_name": p.period_name, "start_time": p.start_time, + "end_time": p.end_time, "price_per_unit": p.price_per_unit, + "applicable_months": p.applicable_months} + for p in periods + ] + + +@router.post("/pricing/{pricing_id}/periods") +async def add_period( + pricing_id: int, + data: PricingPeriodCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """添加电价时段""" + result = await db.execute(select(ElectricityPricing).where(ElectricityPricing.id == pricing_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="电价配置不存在") + + period = PricingPeriod( + pricing_id=pricing_id, + period_name=data.period_name, + start_time=data.start_time, + end_time=data.end_time, + price_per_unit=data.price_per_unit, + applicable_months=data.applicable_months, + ) + db.add(period) + await db.flush() + return {"id": period.id, "message": "时段添加成功"} + + +# ---- Cost Analysis ---- + +@router.get("/summary") +async def cost_summary( + start_date: str = Query(..., description="开始日期, e.g. 2026-01-01"), + end_date: str = Query(..., description="结束日期, e.g. 2026-03-31"), + group_by: str = Query("day", pattern="^(day|month|device)$"), + energy_type: str = Query("electricity"), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """费用汇总""" + start_dt = datetime.fromisoformat(start_date) + end_dt = datetime.fromisoformat(end_date) + return await get_cost_summary(db, start_dt, end_dt, group_by, energy_type) + + +@router.get("/comparison") +async def cost_comparison( + energy_type: str = "electricity", + period: str = Query("month", pattern="^(day|week|month|year)$"), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """费用同比环比""" + now = datetime.now(timezone.utc) + + if period == "day": + current_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + prev_start = current_start - timedelta(days=1) + yoy_start = current_start.replace(year=current_start.year - 1) + elif period == "week": + current_start = now - timedelta(days=now.weekday()) + current_start = current_start.replace(hour=0, minute=0, second=0, microsecond=0) + prev_start = current_start - timedelta(weeks=1) + yoy_start = current_start.replace(year=current_start.year - 1) + elif period == "month": + current_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + prev_start = (current_start - timedelta(days=1)).replace(day=1) + yoy_start = current_start.replace(year=current_start.year - 1) + else: # year + current_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) + prev_start = current_start.replace(year=current_start.year - 1) + yoy_start = prev_start + + async def sum_cost(start, end): + q = select(func.sum(EnergyDailySummary.cost)).where( + and_( + EnergyDailySummary.date >= start, + EnergyDailySummary.date < end, + EnergyDailySummary.energy_type == energy_type, + ) + ) + r = await db.execute(q) + return r.scalar() or 0 + + current = await sum_cost(current_start, now) + previous = await sum_cost(prev_start, current_start) + yoy = await sum_cost(yoy_start, yoy_start.replace(year=yoy_start.year + 1)) + + return { + "current": round(current, 2), + "previous": round(previous, 2), + "yoy": round(yoy, 2), + "mom_change": round((current - previous) / previous * 100, 1) if previous else 0, + "yoy_change": round((current - yoy) / yoy * 100, 1) if yoy else 0, + } + + +@router.get("/breakdown") +async def cost_breakdown_api( + start_date: str = Query(..., description="开始日期"), + end_date: str = Query(..., description="结束日期"), + energy_type: str = Query("electricity"), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """峰谷平费用分布""" + start_dt = datetime.fromisoformat(start_date) + end_dt = datetime.fromisoformat(end_date) + return await get_cost_breakdown(db, start_dt, end_dt, energy_type) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py new file mode 100644 index 0000000..c46fd64 --- /dev/null +++ b/backend/app/api/v1/dashboard.py @@ -0,0 +1,146 @@ +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, text, case, literal_column +from app.core.database import get_db +from app.core.config import get_settings +from app.core.deps import get_current_user +from app.models.device import Device +from app.models.energy import EnergyData, EnergyDailySummary +from app.models.alarm import AlarmEvent +from app.models.carbon import CarbonEmission +from app.models.user import User + +router = APIRouter(prefix="/dashboard", tags=["大屏数据"]) + + +@router.get("/overview") +async def get_overview(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + """能源总览大屏核心数据""" + now = datetime.now(timezone.utc) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + # 设备状态统计 + device_stats_q = await db.execute( + select(Device.status, func.count(Device.id)).where(Device.is_active == True).group_by(Device.status) + ) + device_stats = {row[0]: row[1] for row in device_stats_q.all()} + + # 今日能耗汇总 + daily_q = await db.execute( + select( + EnergyDailySummary.energy_type, + func.sum(EnergyDailySummary.total_consumption), + func.sum(EnergyDailySummary.total_generation), + ).where(EnergyDailySummary.date >= today_start).group_by(EnergyDailySummary.energy_type) + ) + energy_summary = {} + for row in daily_q.all(): + energy_summary[row[0]] = {"consumption": row[1] or 0, "generation": row[2] or 0} + + # 今日碳排放 + carbon_q = await db.execute( + select(func.sum(CarbonEmission.emission), func.sum(CarbonEmission.reduction)) + .where(CarbonEmission.date >= today_start) + ) + carbon_row = carbon_q.first() + + # 活跃告警数 + alarm_count_q = await db.execute( + select(func.count(AlarmEvent.id)).where(AlarmEvent.status == "active") + ) + active_alarms = alarm_count_q.scalar() or 0 + + # 最近告警 + recent_alarms_q = await db.execute( + select(AlarmEvent).where(AlarmEvent.status == "active").order_by(AlarmEvent.triggered_at.desc()).limit(10) + ) + recent_alarms = [ + {"id": a.id, "title": a.title, "severity": a.severity, "device_id": a.device_id, + "triggered_at": str(a.triggered_at)} + for a in recent_alarms_q.scalars().all() + ] + + return { + "device_stats": { + "online": device_stats.get("online", 0), + "offline": device_stats.get("offline", 0), + "alarm": device_stats.get("alarm", 0), + "total": sum(device_stats.values()), + }, + "energy_today": energy_summary, + "carbon": { + "emission": carbon_row[0] or 0 if carbon_row else 0, + "reduction": carbon_row[1] or 0 if carbon_row else 0, + }, + "active_alarms": active_alarms, + "recent_alarms": recent_alarms, + } + + +@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() + + 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) + + return { + "timestamp": str(now), + "pv_power": round(pv_power, 2), + "heatpump_power": round(heatpump_power, 2), + "total_load": round(pv_power + heatpump_power, 2), + "grid_power": round(max(0, heatpump_power - pv_power), 2), + } + + +@router.get("/load-curve") +async def get_load_curve( + hours: int = 24, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """负荷曲线数据""" + now = datetime.now(timezone.utc) + start = now - timedelta(hours=hours) + + settings = get_settings() + if settings.is_sqlite: + hour_expr = func.strftime('%Y-%m-%d %H:00:00', EnergyData.timestamp).label('hour') + else: + hour_expr = func.date_trunc('hour', EnergyData.timestamp).label('hour') + + result = await db.execute( + select( + hour_expr, + func.avg(EnergyData.value).label('avg_power'), + ).where( + and_(EnergyData.timestamp >= start, EnergyData.data_type == "power") + ).group_by(text('hour')).order_by(text('hour')) + ) + return [{"time": str(row[0]), "power": round(row[1], 2)} for row in result.all()] + + +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) + ) + return [r[0] for r in result.fetchall()] + + +async def _get_hp_device_ids(db: AsyncSession): + result = await db.execute( + select(Device.id).where(Device.device_type == "heat_pump", Device.is_active == True) + ) + return [r[0] for r in result.fetchall()] diff --git a/backend/app/api/v1/devices.py b/backend/app/api/v1/devices.py new file mode 100644 index 0000000..655b147 --- /dev/null +++ b/backend/app/api/v1/devices.py @@ -0,0 +1,206 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user, require_roles +from app.models.device import Device, DeviceType, DeviceGroup +from app.models.user import User +from app.services.audit import log_audit + +router = APIRouter(prefix="/devices", tags=["设备管理"]) + + +class DeviceCreate(BaseModel): + name: str + code: str + device_type: str + group_id: int | None = None + model: str | None = None + manufacturer: str | None = None + serial_number: str | None = None + rated_power: float | None = None + location: str | None = None + protocol: str | None = None + connection_params: dict | None = None + collect_interval: int = 15 + + +class DeviceUpdate(BaseModel): + name: str | None = None + group_id: int | None = None + location: str | None = None + protocol: str | None = None + connection_params: dict | None = None + collect_interval: int | None = None + status: str | None = None + is_active: bool | None = None + + +@router.get("") +async def list_devices( + device_type: str | None = None, + group_id: int | None = None, + status: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(Device).where(Device.is_active == True) + if device_type: + query = query.where(Device.device_type == device_type) + if group_id: + query = query.where(Device.group_id == group_id) + if status: + query = query.where(Device.status == status) + + count_query = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_query)).scalar() + + query = query.offset((page - 1) * page_size).limit(page_size).order_by(Device.id) + result = await db.execute(query) + devices = result.scalars().all() + return {"total": total, "items": [_device_to_dict(d) for d in devices]} + + +@router.get("/types") +async def list_device_types(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute(select(DeviceType).order_by(DeviceType.id)) + return [{"id": t.id, "code": t.code, "name": t.name, "icon": t.icon} for t in result.scalars().all()] + + +@router.get("/groups") +async def list_device_groups(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute(select(DeviceGroup).order_by(DeviceGroup.id)) + return [{"id": g.id, "name": g.name, "parent_id": g.parent_id, "location": g.location} for g in result.scalars().all()] + + +@router.get("/stats") +async def device_stats(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute( + select(Device.status, func.count(Device.id)).where(Device.is_active == True).group_by(Device.status) + ) + stats = {row[0]: row[1] for row in result.all()} + return {"online": stats.get("online", 0), "offline": stats.get("offline", 0), "alarm": stats.get("alarm", 0), "maintenance": stats.get("maintenance", 0)} + + +@router.get("/{device_id}") +async def get_device(device_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute(select(Device).where(Device.id == device_id)) + device = result.scalar_one_or_none() + if not device: + raise HTTPException(status_code=404, detail="设备不存在") + return _device_to_dict(device) + + +@router.post("") +async def create_device(data: DeviceCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + device = Device(**data.model_dump()) + db.add(device) + await db.flush() + await log_audit(db, user.id, "create", "device", detail=f"创建设备 {data.name} ({data.code})") + return _device_to_dict(device) + + +@router.put("/{device_id}") +async def update_device(device_id: int, data: DeviceUpdate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + result = await db.execute(select(Device).where(Device.id == device_id)) + device = result.scalar_one_or_none() + if not device: + raise HTTPException(status_code=404, detail="设备不存在") + updates = data.model_dump(exclude_unset=True) + for k, v in updates.items(): + setattr(device, k, v) + await log_audit(db, user.id, "update", "device", detail=f"更新设备 {device.name}: {', '.join(updates.keys())}") + return _device_to_dict(device) + + +@router.get("/topology") +async def get_device_topology( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """设备拓扑树 - Full device tree with counts and status""" + # Get all groups + group_result = await db.execute(select(DeviceGroup).order_by(DeviceGroup.id)) + groups = group_result.scalars().all() + + # Get device counts and status per group + status_query = ( + select( + Device.group_id, + Device.status, + func.count(Device.id).label('cnt'), + ) + .where(Device.is_active == True) + .group_by(Device.group_id, Device.status) + ) + status_result = await db.execute(status_query) + status_rows = status_result.all() + + # Build status map: group_id -> {status: count} + group_stats: dict[int | None, dict[str, int]] = {} + for group_id, status, cnt in status_rows: + if group_id not in group_stats: + group_stats[group_id] = {} + group_stats[group_id][status] = cnt + + # Build group nodes + group_map: dict[int, dict] = {} + for g in groups: + stats = group_stats.get(g.id, {}) + device_count = sum(stats.values()) + group_map[g.id] = { + "id": g.id, + "name": g.name, + "location": g.location, + "parent_id": g.parent_id, + "children": [], + "device_count": device_count, + "online_count": stats.get("online", 0), + "offline_count": stats.get("offline", 0), + "alarm_count": stats.get("alarm", 0), + } + + # Build tree + roots = [] + for gid, node in group_map.items(): + pid = node["parent_id"] + if pid and pid in group_map: + group_map[pid]["children"].append(node) + else: + roots.append(node) + + # Propagate child counts up + def propagate(node: dict) -> tuple[int, int, int, int]: + total = node["device_count"] + online = node["online_count"] + offline = node["offline_count"] + alarm = node["alarm_count"] + for child in node["children"]: + ct, co, coff, ca = propagate(child) + total += ct + online += co + offline += coff + alarm += ca + node["total_device_count"] = total + node["total_online"] = online + node["total_offline"] = offline + node["total_alarm"] = alarm + return total, online, offline, alarm + + for root in roots: + propagate(root) + + return roots + + +def _device_to_dict(d: Device) -> dict: + return { + "id": d.id, "name": d.name, "code": d.code, "device_type": d.device_type, + "group_id": d.group_id, "model": d.model, "manufacturer": d.manufacturer, + "serial_number": d.serial_number, "rated_power": d.rated_power, + "location": d.location, "protocol": d.protocol, "collect_interval": d.collect_interval, + "status": d.status, "is_active": d.is_active, "last_data_time": str(d.last_data_time) if d.last_data_time else None, + } diff --git a/backend/app/api/v1/energy.py b/backend/app/api/v1/energy.py new file mode 100644 index 0000000..21d345d --- /dev/null +++ b/backend/app/api/v1/energy.py @@ -0,0 +1,763 @@ +import csv +import io +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, Query, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, text, Integer +from sqlalchemy.orm import joinedload +from pydantic import BaseModel +from app.core.database import get_db +from app.core.config import get_settings +from app.core.deps import get_current_user +from app.models.energy import EnergyData, EnergyDailySummary, EnergyCategory +from app.models.device import Device +from app.models.user import User +from app.core.deps import require_roles + +router = APIRouter(prefix="/energy", tags=["能耗数据"]) + + +@router.get("/history") +async def query_history( + device_id: int | None = None, + data_type: str = "power", + start_time: str | None = None, + end_time: str | None = None, + granularity: str = Query("hour", pattern="^(raw|5min|hour|day)$"), + page: int = Query(1, ge=1), + page_size: int = Query(100, ge=1, le=1000), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """历史数据查询""" + 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 granularity == "raw": + query = query.order_by(EnergyData.timestamp.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return [{"timestamp": str(d.timestamp), "value": d.value, "unit": d.unit, "device_id": d.device_id} + for d in result.scalars().all()] + else: + settings = get_settings() + if granularity == "5min": + if settings.is_sqlite: + time_bucket = func.strftime('%Y-%m-%d %H:', EnergyData.timestamp).op('||')( + func.printf('%02d:00', (func.cast(func.strftime('%M', EnergyData.timestamp), Integer) / 5) * 5) + ).label('time_bucket') + else: + time_bucket = func.to_timestamp( + func.floor(func.extract('epoch', EnergyData.timestamp) / 300) * 300 + ).label('time_bucket') + elif granularity == "hour": + if settings.is_sqlite: + time_bucket = func.strftime('%Y-%m-%d %H:00:00', EnergyData.timestamp).label('time_bucket') + else: + time_bucket = func.date_trunc('hour', EnergyData.timestamp).label('time_bucket') + else: # day + if settings.is_sqlite: + time_bucket = func.strftime('%Y-%m-%d', EnergyData.timestamp).label('time_bucket') + else: + time_bucket = func.date_trunc('day', EnergyData.timestamp).label('time_bucket') + agg_query = select( + time_bucket, + func.avg(EnergyData.value).label('avg_value'), + func.max(EnergyData.value).label('max_value'), + func.min(EnergyData.value).label('min_value'), + ).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) + 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)} + for r in result.all()] + + +@router.get("/params") +async def query_electrical_params( + device_id: int = Query(..., description="设备ID"), + params: str = Query("power", description="参数列表(逗号分隔): power,voltage,current,power_factor,temperature,frequency,cop"), + start_time: str | None = None, + end_time: str | None = None, + granularity: str = Query("raw", pattern="^(raw|5min|hour|day)$"), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """电参量查询 - Query multiple electrical parameters for a device""" + param_list = [p.strip() for p in params.split(",") if p.strip()] + result_data = {} + settings = get_settings() + + for param in param_list: + base_filter = and_( + EnergyData.device_id == device_id, + EnergyData.data_type == param, + ) + conditions = [base_filter] + if start_time: + conditions.append(EnergyData.timestamp >= start_time) + if end_time: + conditions.append(EnergyData.timestamp <= end_time) + combined = and_(*conditions) + + if granularity == "raw": + query = ( + select(EnergyData.timestamp, EnergyData.value, EnergyData.unit) + .where(combined) + .order_by(EnergyData.timestamp) + .limit(5000) + ) + rows = await db.execute(query) + result_data[param] = [ + {"timestamp": str(r[0]), "value": r[1], "unit": r[2]} + for r in rows.all() + ] + else: + if granularity == "5min": + if settings.is_sqlite: + time_bucket = func.strftime('%Y-%m-%d %H:', EnergyData.timestamp).op('||')( + func.printf('%02d:00', (func.cast(func.strftime('%M', EnergyData.timestamp), Integer) / 5) * 5) + ).label('time_bucket') + else: + time_bucket = func.to_timestamp( + func.floor(func.extract('epoch', EnergyData.timestamp) / 300) * 300 + ).label('time_bucket') + elif granularity == "hour": + if settings.is_sqlite: + time_bucket = func.strftime('%Y-%m-%d %H:00:00', EnergyData.timestamp).label('time_bucket') + else: + time_bucket = func.date_trunc('hour', EnergyData.timestamp).label('time_bucket') + else: # day + if settings.is_sqlite: + time_bucket = func.strftime('%Y-%m-%d', EnergyData.timestamp).label('time_bucket') + else: + time_bucket = func.date_trunc('day', EnergyData.timestamp).label('time_bucket') + + agg_query = ( + select(time_bucket, func.avg(EnergyData.value).label('avg_value')) + .where(combined) + .group_by(text('time_bucket')) + .order_by(text('time_bucket')) + ) + rows = await db.execute(agg_query) + result_data[param] = [ + {"timestamp": str(r[0]), "value": round(r[1], 2)} + for r in rows.all() + ] + + return result_data + + +@router.get("/daily-summary") +async def daily_summary( + start_date: str | None = None, + end_date: str | None = None, + energy_type: str | None = None, + device_id: int | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """每日能耗汇总""" + query = select(EnergyDailySummary) + if start_date: + query = query.where(EnergyDailySummary.date >= start_date) + if end_date: + query = query.where(EnergyDailySummary.date <= end_date) + if energy_type: + query = query.where(EnergyDailySummary.energy_type == energy_type) + if device_id: + query = query.where(EnergyDailySummary.device_id == device_id) + query = query.order_by(EnergyDailySummary.date.desc()).limit(365) + result = await db.execute(query) + return [{ + "date": str(s.date), "device_id": s.device_id, "energy_type": s.energy_type, + "consumption": s.total_consumption, "generation": s.total_generation, + "peak_power": s.peak_power, "avg_power": s.avg_power, + "operating_hours": s.operating_hours, "cost": s.cost, "carbon_emission": s.carbon_emission, + } for s in result.scalars().all()] + + +@router.get("/comparison") +async def energy_comparison( + device_id: int | None = None, + energy_type: str = "electricity", + period: str = Query("month", pattern="^(day|week|month|year)$"), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """能耗同比环比分析""" + now = datetime.now(timezone.utc) + if period == "day": + current_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + prev_start = current_start - timedelta(days=1) + yoy_start = current_start.replace(year=current_start.year - 1) + elif period == "week": + current_start = now - timedelta(days=now.weekday()) + current_start = current_start.replace(hour=0, minute=0, second=0, microsecond=0) + prev_start = current_start - timedelta(weeks=1) + yoy_start = current_start.replace(year=current_start.year - 1) + elif period == "month": + current_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + prev_start = (current_start - timedelta(days=1)).replace(day=1) + yoy_start = current_start.replace(year=current_start.year - 1) + else: + current_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) + prev_start = current_start.replace(year=current_start.year - 1) + yoy_start = prev_start + + async def sum_consumption(start, end): + q = select(func.sum(EnergyDailySummary.total_consumption)).where( + and_(EnergyDailySummary.date >= start, EnergyDailySummary.date < end, + EnergyDailySummary.energy_type == energy_type) + ) + if device_id: + q = q.where(EnergyDailySummary.device_id == device_id) + r = await db.execute(q) + return r.scalar() or 0 + + current = await sum_consumption(current_start, now) + previous = await sum_consumption(prev_start, current_start) + yoy = await sum_consumption(yoy_start, yoy_start.replace(year=yoy_start.year + 1)) + + return { + "current": round(current, 2), + "previous": round(previous, 2), + "yoy": round(yoy, 2), + "mom_change": round((current - previous) / previous * 100, 1) if previous else 0, + "yoy_change": round((current - yoy) / yoy * 100, 1) if yoy else 0, + } + + +@router.get("/export") +async def export_energy_data( + start_time: str = Query(..., description="开始时间, e.g. 2026-03-01"), + end_time: str = Query(..., description="结束时间, e.g. 2026-03-31"), + device_id: int | None = Query(None, description="设备ID (可选)"), + data_type: str | None = Query(None, description="数据类型 (可选, e.g. power, energy)"), + format: str = Query("csv", pattern="^(csv|xlsx)$", description="导出格式: csv 或 xlsx"), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """导出能耗数据为CSV或Excel文件""" + # Parse date strings to datetime for proper PostgreSQL comparison + try: + start_dt = datetime.fromisoformat(start_time) + except ValueError: + start_dt = datetime.strptime(start_time, "%Y-%m-%d") + 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) + + # If end_time was just a date (no time component), set to end of day + if end_dt.hour == 0 and end_dt.minute == 0 and end_dt.second == 0 and "T" not in end_time: + end_dt = end_dt.replace(hour=23, minute=59, second=59) + + # Query energy data with device names + query = ( + select(EnergyData, Device.name.label("device_name")) + .join(Device, EnergyData.device_id == Device.id, isouter=True) + .where( + and_( + EnergyData.timestamp >= start_dt, + EnergyData.timestamp <= end_dt, + ) + ) + ) + if device_id: + query = query.where(EnergyData.device_id == device_id) + if data_type: + query = query.where(EnergyData.data_type == data_type) + query = query.order_by(EnergyData.timestamp) + + result = await db.execute(query) + rows = result.all() + + headers = ["timestamp", "device_name", "data_type", "value", "unit"] + data_rows = [] + for row in rows: + energy = row[0] # EnergyData object + device_name = row[1] or f"Device#{energy.device_id}" + data_rows.append([ + str(energy.timestamp) if energy.timestamp else "", + device_name, + energy.data_type or "", + energy.value, + energy.unit or "", + ]) + + date_suffix = f"{start_time}_{end_time}".replace("-", "") + if format == "xlsx": + return _export_xlsx(headers, data_rows, f"energy_export_{date_suffix}.xlsx") + else: + return _export_csv(headers, data_rows, f"energy_export_{date_suffix}.csv") + + +def _export_csv(headers: list[str], rows: list[list], filename: str) -> StreamingResponse: + """Generate CSV streaming response.""" + output = io.StringIO() + # Add BOM for Excel compatibility with Chinese characters + output.write('\ufeff') + writer = csv.writer(output) + writer.writerow(headers) + writer.writerows(rows) + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +def _export_xlsx(headers: list[str], rows: list[list], filename: str) -> StreamingResponse: + """Generate XLSX streaming response.""" + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + + wb = Workbook() + ws = wb.active + ws.title = "能耗数据" + + header_font = Font(bold=True, color="FFFFFF", size=11) + header_fill = PatternFill(start_color="336699", end_color="336699", fill_type="solid") + header_align = Alignment(horizontal="center", vertical="center") + thin_border = Border( + left=Side(style="thin", color="CCCCCC"), + right=Side(style="thin", color="CCCCCC"), + top=Side(style="thin", color="CCCCCC"), + bottom=Side(style="thin", color="CCCCCC"), + ) + + # Write headers + for col_idx, h in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_idx, value=h) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_align + cell.border = thin_border + + # Write data + for row_idx, row_data in enumerate(rows, 2): + for col_idx, val in enumerate(row_data, 1): + cell = ws.cell(row=row_idx, column=col_idx, value=val) + cell.border = thin_border + if isinstance(val, float): + cell.number_format = "#,##0.00" + + # Auto-width + for col_idx in range(1, len(headers) + 1): + max_len = len(str(headers[col_idx - 1])) + for row_idx in range(2, min(len(rows) + 2, 102)): + val = ws.cell(row=row_idx, column=col_idx).value + if val: + max_len = max(max_len, len(str(val))) + ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = min(max_len + 4, 40) + + ws.freeze_panes = "A2" + if rows: + ws.auto_filter.ref = f"A1:{ws.cell(row=1, column=len(headers)).column_letter}{len(rows) + 1}" + + output = io.BytesIO() + wb.save(output) + output.seek(0) + + return StreamingResponse( + output, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# ── Energy Category (分项能耗) ────────────────────────────────────── + +class CategoryCreate(BaseModel): + name: str + code: str + parent_id: int | None = None + sort_order: int = 0 + icon: str | None = None + color: str | None = None + + +def _category_to_dict(c: EnergyCategory) -> dict: + return { + "id": c.id, "name": c.name, "code": c.code, + "parent_id": c.parent_id, "sort_order": c.sort_order, + "icon": c.icon, "color": c.color, + "created_at": str(c.created_at) if c.created_at else None, + } + + +def _build_category_tree(items: list[dict], parent_id: int | None = None) -> list[dict]: + tree = [] + for item in items: + if item["parent_id"] == parent_id: + children = _build_category_tree(items, item["id"]) + if children: + item["children"] = children + tree.append(item) + return tree + + +@router.get("/categories") +async def list_categories( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取能耗分项类别(树结构)""" + result = await db.execute( + select(EnergyCategory).order_by(EnergyCategory.sort_order, EnergyCategory.id) + ) + items = [_category_to_dict(c) for c in result.scalars().all()] + return _build_category_tree(items) + + +@router.post("/categories") +async def create_category( + data: CategoryCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + """创建能耗分项类别""" + cat = EnergyCategory(**data.model_dump()) + db.add(cat) + await db.flush() + return _category_to_dict(cat) + + +@router.put("/categories/{cat_id}") +async def update_category( + cat_id: int, + data: CategoryCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + """更新能耗分项类别""" + result = await db.execute(select(EnergyCategory).where(EnergyCategory.id == cat_id)) + cat = result.scalar_one_or_none() + if not cat: + raise HTTPException(status_code=404, detail="分项类别不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(cat, k, v) + return _category_to_dict(cat) + + +@router.get("/by-category") +async def energy_by_category( + start_date: str | None = None, + end_date: str | None = None, + energy_type: str = "electricity", + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """按分项类别统计能耗""" + query = ( + select( + EnergyCategory.id, + EnergyCategory.name, + EnergyCategory.code, + EnergyCategory.color, + func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0).label("consumption"), + ) + .select_from(EnergyCategory) + .outerjoin(Device, Device.category_id == EnergyCategory.id) + .outerjoin( + EnergyDailySummary, + and_( + EnergyDailySummary.device_id == Device.id, + EnergyDailySummary.energy_type == energy_type, + ), + ) + ) + if start_date: + query = query.where(EnergyDailySummary.date >= start_date) + if end_date: + query = query.where(EnergyDailySummary.date <= end_date) + query = query.group_by(EnergyCategory.id, EnergyCategory.name, EnergyCategory.code, EnergyCategory.color) + result = await db.execute(query) + rows = result.all() + total = sum(r.consumption for r in rows) or 1 + return [ + { + "id": r.id, "name": r.name, "code": r.code, "color": r.color, + "consumption": round(r.consumption, 2), + "percentage": round(r.consumption / total * 100, 1), + } + for r in rows + ] + + +@router.get("/category-ranking") +async def category_ranking( + start_date: str | None = None, + end_date: str | None = None, + energy_type: str = "electricity", + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """分项能耗排名""" + query = ( + select( + EnergyCategory.name, + EnergyCategory.color, + func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0).label("consumption"), + ) + .select_from(EnergyCategory) + .outerjoin(Device, Device.category_id == EnergyCategory.id) + .outerjoin( + EnergyDailySummary, + and_( + EnergyDailySummary.device_id == Device.id, + EnergyDailySummary.energy_type == energy_type, + ), + ) + ) + if start_date: + query = query.where(EnergyDailySummary.date >= start_date) + if end_date: + query = query.where(EnergyDailySummary.date <= end_date) + query = query.group_by(EnergyCategory.name, EnergyCategory.color).order_by(text("consumption DESC")) + result = await db.execute(query) + return [{"name": r.name, "color": r.color, "consumption": round(r.consumption, 2)} for r in result.all()] + + +@router.get("/category-trend") +async def category_trend( + start_date: str | None = None, + end_date: str | None = None, + energy_type: str = "electricity", + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """分项能耗每日趋势""" + query = ( + select( + EnergyDailySummary.date, + EnergyCategory.name, + EnergyCategory.color, + func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0).label("consumption"), + ) + .select_from(EnergyDailySummary) + .join(Device, EnergyDailySummary.device_id == Device.id) + .join(EnergyCategory, Device.category_id == EnergyCategory.id) + .where(EnergyDailySummary.energy_type == energy_type) + ) + if start_date: + query = query.where(EnergyDailySummary.date >= start_date) + if end_date: + query = query.where(EnergyDailySummary.date <= end_date) + query = query.group_by(EnergyDailySummary.date, EnergyCategory.name, EnergyCategory.color) + query = query.order_by(EnergyDailySummary.date) + result = await db.execute(query) + return [ + {"date": str(r.date), "category": r.name, "color": r.color, "consumption": round(r.consumption, 2)} + for r in result.all() + ] + + +# ── Loss / YoY / MoM Analysis ───────────────────────────────────── + +from app.models.device import DeviceGroup + + +@router.get("/loss") +async def get_energy_loss( + start_date: str, + end_date: str, + energy_type: str = "electricity", + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """能耗损耗分析 - Compare parent meter vs sum of sub-meters""" + # Get all groups that have children + groups_result = await db.execute(select(DeviceGroup)) + all_groups = groups_result.scalars().all() + group_map = {g.id: g for g in all_groups} + parent_ids = {g.parent_id for g in all_groups if g.parent_id is not None} + + results = [] + for gid in parent_ids: + group = group_map.get(gid) + if not group: + continue + child_group_ids = [g.id for g in all_groups if g.parent_id == gid] + + # Parent consumption: devices directly in this group + parent_q = select(func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0)).select_from( + EnergyDailySummary + ).join(Device, EnergyDailySummary.device_id == Device.id).where( + and_( + Device.group_id == gid, + EnergyDailySummary.energy_type == energy_type, + EnergyDailySummary.date >= start_date, + EnergyDailySummary.date <= end_date, + ) + ) + parent_consumption = (await db.execute(parent_q)).scalar() or 0 + + # Children consumption: devices in child groups + if child_group_ids: + children_q = select(func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0)).select_from( + EnergyDailySummary + ).join(Device, EnergyDailySummary.device_id == Device.id).where( + and_( + Device.group_id.in_(child_group_ids), + EnergyDailySummary.energy_type == energy_type, + EnergyDailySummary.date >= start_date, + EnergyDailySummary.date <= end_date, + ) + ) + children_consumption = (await db.execute(children_q)).scalar() or 0 + else: + children_consumption = 0 + + loss = parent_consumption - children_consumption + loss_rate = (loss / parent_consumption * 100) if parent_consumption > 0 else 0 + + results.append({ + "group_name": group.name, + "parent_consumption": round(parent_consumption, 2), + "children_consumption": round(children_consumption, 2), + "loss": round(loss, 2), + "loss_rate_pct": round(loss_rate, 1), + }) + + return results + + +@router.get("/yoy") +async def get_yoy_comparison( + year: int | None = None, + energy_type: str = "electricity", + group_id: int | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """同比分析 - Current year vs previous year, month by month""" + current_year = year or datetime.now(timezone.utc).year + prev_year = current_year - 1 + settings = get_settings() + + results = [] + for month in range(1, 13): + for yr, label in [(current_year, "current_year"), (prev_year, "previous_year")]: + month_start = f"{yr}-{month:02d}-01" + if month == 12: + month_end = f"{yr + 1}-01-01" + else: + month_end = f"{yr}-{month + 1:02d}-01" + + q = select(func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0)).where( + and_( + EnergyDailySummary.energy_type == energy_type, + EnergyDailySummary.date >= month_start, + EnergyDailySummary.date < month_end, + ) + ) + if group_id: + q = q.select_from(EnergyDailySummary).join( + Device, EnergyDailySummary.device_id == Device.id + ).where(Device.group_id == group_id) + + val = (await db.execute(q)).scalar() or 0 + # Find or create month entry + existing = next((r for r in results if r["month"] == month), None) + if not existing: + existing = {"month": month, "current_year": 0, "previous_year": 0, "change_pct": 0} + results.append(existing) + existing[label] = round(val, 2) + + # Calculate change percentages + for r in results: + if r["previous_year"] > 0: + r["change_pct"] = round((r["current_year"] - r["previous_year"]) / r["previous_year"] * 100, 1) + + return results + + +@router.get("/mom") +async def get_mom_comparison( + period: str = "month", + energy_type: str = "electricity", + group_id: int | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """环比分析 - Current period vs previous period""" + now = datetime.now(timezone.utc) + + if period == "month": + current_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + prev_start = (current_start - timedelta(days=1)).replace(day=1) + # Generate daily labels + days_in_month = (now - current_start).days + 1 + prev_end = current_start + labels = [f"{i + 1}日" for i in range(31)] + elif period == "week": + current_start = (now - timedelta(days=now.weekday())).replace(hour=0, minute=0, second=0, microsecond=0) + prev_start = current_start - timedelta(weeks=1) + prev_end = current_start + labels = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + else: # day + current_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + prev_start = current_start - timedelta(days=1) + prev_end = current_start + labels = [f"{i}:00" for i in range(24)] + + async def get_period_data(start, end): + q = select( + EnergyDailySummary.date, + func.sum(EnergyDailySummary.total_consumption).label("consumption"), + ).where( + and_( + EnergyDailySummary.energy_type == energy_type, + EnergyDailySummary.date >= str(start)[:10], + EnergyDailySummary.date < str(end)[:10], + ) + ) + if group_id: + q = q.select_from(EnergyDailySummary).join( + Device, EnergyDailySummary.device_id == Device.id + ).where(Device.group_id == group_id) + q = q.group_by(EnergyDailySummary.date).order_by(EnergyDailySummary.date) + result = await db.execute(q) + return [{"date": str(r.date), "consumption": round(r.consumption, 2)} for r in result.all()] + + current_data = await get_period_data(current_start, now) + previous_data = await get_period_data(prev_start, prev_end) + + # Build comparison items + max_len = max(len(current_data), len(previous_data), 1) + items = [] + for i in range(max_len): + cur_val = current_data[i]["consumption"] if i < len(current_data) else 0 + prev_val = previous_data[i]["consumption"] if i < len(previous_data) else 0 + change_pct = round((cur_val - prev_val) / prev_val * 100, 1) if prev_val > 0 else 0 + items.append({ + "label": labels[i] if i < len(labels) else str(i + 1), + "current_period": cur_val, + "previous_period": prev_val, + "change_pct": change_pct, + }) + + total_current = sum(d["consumption"] for d in current_data) + total_previous = sum(d["consumption"] for d in previous_data) + total_change = round((total_current - total_previous) / total_previous * 100, 1) if total_previous > 0 else 0 + + return { + "items": items, + "total_current": round(total_current, 2), + "total_previous": round(total_previous, 2), + "total_change_pct": total_change, + } diff --git a/backend/app/api/v1/energy_strategy.py b/backend/app/api/v1/energy_strategy.py new file mode 100644 index 0000000..dc3b062 --- /dev/null +++ b/backend/app/api/v1/energy_strategy.py @@ -0,0 +1,376 @@ +from datetime import date, datetime +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.energy_strategy import ( + TouPricing, TouPricingPeriod, EnergyStrategy, StrategyExecution, +) +from app.models.user import User +from app.services.energy_strategy import ( + get_active_tou_pricing, get_tou_periods, + calculate_monthly_cost_breakdown, get_recommendations, + get_savings_report, simulate_strategy_impact, DEFAULT_PERIODS, PERIOD_LABELS, +) + +router = APIRouter(prefix="/strategy", tags=["策略优化"]) + + +# ---- Schemas ---- + +class TouPricingCreate(BaseModel): + name: str + region: str = "北京" + effective_date: str | None = None + end_date: str | None = None + + +class TouPricingPeriodCreate(BaseModel): + period_type: str # sharp_peak, peak, flat, valley + start_time: str # HH:MM + end_time: str # HH:MM + price_yuan_per_kwh: float + month_range: str | None = None + + +class TouPricingPeriodsSet(BaseModel): + periods: list[TouPricingPeriodCreate] + + +class EnergyStrategyCreate(BaseModel): + name: str + strategy_type: str # heat_storage, load_shift, pv_priority + description: str | None = None + parameters: dict | None = None + priority: int = 0 + + +class EnergyStrategyUpdate(BaseModel): + name: str | None = None + description: str | None = None + parameters: dict | None = None + is_enabled: bool | None = None + priority: int | None = None + + +class SimulateRequest(BaseModel): + daily_consumption_kwh: float = 2000 + pv_daily_kwh: float = 800 + strategies: list[str] = ["heat_storage", "pv_priority", "load_shift"] + + +# ---- TOU Pricing ---- + +@router.get("/pricing") +async def list_tou_pricing( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取分时电价配置列表""" + result = await db.execute( + select(TouPricing).order_by(TouPricing.created_at.desc()) + ) + pricings = result.scalars().all() + items = [] + for p in pricings: + periods = await get_tou_periods(db, p.id) + items.append({ + "id": p.id, "name": p.name, "region": p.region, + "effective_date": str(p.effective_date) if p.effective_date else None, + "end_date": str(p.end_date) if p.end_date else None, + "is_active": p.is_active, + "created_at": str(p.created_at), + "periods": [ + { + "id": pp.id, + "period_type": pp.period_type, + "period_label": PERIOD_LABELS.get(pp.period_type, pp.period_type), + "start_time": pp.start_time, + "end_time": pp.end_time, + "price_yuan_per_kwh": pp.price_yuan_per_kwh, + "month_range": pp.month_range, + } + for pp in periods + ], + }) + return items + + +@router.post("/pricing") +async def create_tou_pricing( + data: TouPricingCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """创建分时电价配置""" + pricing = TouPricing( + name=data.name, + region=data.region, + effective_date=date.fromisoformat(data.effective_date) if data.effective_date else None, + end_date=date.fromisoformat(data.end_date) if data.end_date else None, + created_by=user.id, + ) + db.add(pricing) + await db.flush() + return {"id": pricing.id, "message": "分时电价配置创建成功"} + + +@router.put("/pricing/{pricing_id}") +async def update_tou_pricing( + pricing_id: int, + data: TouPricingCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """更新分时电价配置""" + result = await db.execute(select(TouPricing).where(TouPricing.id == pricing_id)) + pricing = result.scalar_one_or_none() + if not pricing: + raise HTTPException(status_code=404, detail="电价配置不存在") + pricing.name = data.name + pricing.region = data.region + pricing.effective_date = date.fromisoformat(data.effective_date) if data.effective_date else None + pricing.end_date = date.fromisoformat(data.end_date) if data.end_date else None + return {"message": "电价配置更新成功"} + + +@router.get("/pricing/{pricing_id}/periods") +async def get_pricing_periods( + pricing_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取电价时段""" + periods = await get_tou_periods(db, pricing_id) + return [ + { + "id": p.id, + "period_type": p.period_type, + "period_label": PERIOD_LABELS.get(p.period_type, p.period_type), + "start_time": p.start_time, + "end_time": p.end_time, + "price_yuan_per_kwh": p.price_yuan_per_kwh, + "month_range": p.month_range, + } + for p in periods + ] + + +@router.post("/pricing/{pricing_id}/periods") +async def set_pricing_periods( + pricing_id: int, + data: TouPricingPeriodsSet, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """设置电价时段(替换所有现有时段)""" + result = await db.execute(select(TouPricing).where(TouPricing.id == pricing_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="电价配置不存在") + + # Delete existing periods + existing = await db.execute( + select(TouPricingPeriod).where(TouPricingPeriod.pricing_id == pricing_id) + ) + for p in existing.scalars().all(): + await db.delete(p) + + # Create new periods + for period in data.periods: + pp = TouPricingPeriod( + pricing_id=pricing_id, + period_type=period.period_type, + start_time=period.start_time, + end_time=period.end_time, + price_yuan_per_kwh=period.price_yuan_per_kwh, + month_range=period.month_range, + ) + db.add(pp) + + return {"message": f"已设置{len(data.periods)}个时段"} + + +# ---- Strategies ---- + +@router.get("/strategies") +async def list_strategies( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取优化策略列表""" + result = await db.execute( + select(EnergyStrategy).order_by(EnergyStrategy.priority.desc()) + ) + strategies = result.scalars().all() + + if not strategies: + # Return defaults + return [ + { + "id": None, "name": "谷电蓄热", "strategy_type": "heat_storage", + "description": "在低谷电价时段(23:00-7:00)预热水箱,减少尖峰时段热泵运行", + "is_enabled": False, "priority": 3, + "parameters": {"shift_ratio": 0.3, "valley_start": "23:00", "valley_end": "07:00"}, + }, + { + "id": None, "name": "光伏自消纳优先", "strategy_type": "pv_priority", + "description": "优先使用光伏发电供给园区负荷,减少向电网购电", + "is_enabled": True, "priority": 2, + "parameters": {"min_self_consumption_ratio": 0.7}, + }, + { + "id": None, "name": "负荷转移", "strategy_type": "load_shift", + "description": "将可调负荷从尖峰时段转移至平段或低谷时段", + "is_enabled": False, "priority": 1, + "parameters": {"max_shift_ratio": 0.15, "target_periods": ["flat", "valley"]}, + }, + ] + + return [ + { + "id": s.id, "name": s.name, "strategy_type": s.strategy_type, + "description": s.description, "is_enabled": s.is_enabled, + "priority": s.priority, "parameters": s.parameters or {}, + } + for s in strategies + ] + + +@router.post("/strategies") +async def create_strategy( + data: EnergyStrategyCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """创建优化策略""" + strategy = EnergyStrategy( + name=data.name, + strategy_type=data.strategy_type, + description=data.description, + parameters=data.parameters or {}, + priority=data.priority, + ) + db.add(strategy) + await db.flush() + return {"id": strategy.id, "message": "策略创建成功"} + + +@router.put("/strategies/{strategy_id}") +async def update_strategy( + strategy_id: int, + data: EnergyStrategyUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """更新优化策略""" + result = await db.execute(select(EnergyStrategy).where(EnergyStrategy.id == strategy_id)) + strategy = result.scalar_one_or_none() + if not strategy: + raise HTTPException(status_code=404, detail="策略不存在") + + if data.name is not None: + strategy.name = data.name + if data.description is not None: + strategy.description = data.description + if data.parameters is not None: + strategy.parameters = data.parameters + if data.is_enabled is not None: + strategy.is_enabled = data.is_enabled + if data.priority is not None: + strategy.priority = data.priority + + return {"message": "策略更新成功"} + + +@router.put("/strategies/{strategy_id}/toggle") +async def toggle_strategy( + strategy_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """启用/停用策略""" + result = await db.execute(select(EnergyStrategy).where(EnergyStrategy.id == strategy_id)) + strategy = result.scalar_one_or_none() + if not strategy: + raise HTTPException(status_code=404, detail="策略不存在") + strategy.is_enabled = not strategy.is_enabled + return {"is_enabled": strategy.is_enabled, "message": f"策略已{'启用' if strategy.is_enabled else '停用'}"} + + +# ---- Analysis ---- + +@router.get("/cost-analysis") +async def cost_analysis( + year: int = Query(default=2026), + month: int = Query(default=4, ge=1, le=12), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """月度费用分析""" + return await calculate_monthly_cost_breakdown(db, year, month) + + +@router.get("/savings-report") +async def savings_report( + year: int = Query(default=2026), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """节约报告""" + return await get_savings_report(db, year) + + +@router.get("/recommendations") +async def strategy_recommendations( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取当前推荐策略""" + return await get_recommendations(db) + + +@router.post("/simulate") +async def simulate( + data: SimulateRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """模拟策略影响""" + pricing = await get_active_tou_pricing(db) + if pricing: + periods = await get_tou_periods(db, pricing.id) + else: + # Use default periods + periods = [ + TouPricingPeriod( + period_type=p["period_type"], + start_time=p["start_time"], + end_time=p["end_time"], + price_yuan_per_kwh=p["price"], + ) + for p in DEFAULT_PERIODS + ] + + return simulate_strategy_impact( + daily_consumption_kwh=data.daily_consumption_kwh, + pv_daily_kwh=data.pv_daily_kwh, + periods=periods, + strategies=data.strategies, + ) + + +@router.get("/default-pricing") +async def get_default_pricing( + user: User = Depends(get_current_user), +): + """获取北京工业默认电价""" + return { + "region": "北京", + "type": "工业用电", + "periods": [ + {**p, "period_label": PERIOD_LABELS.get(p["period_type"], p["period_type"])} + for p in DEFAULT_PERIODS + ], + } diff --git a/backend/app/api/v1/maintenance.py b/backend/app/api/v1/maintenance.py new file mode 100644 index 0000000..fd6874c --- /dev/null +++ b/backend/app/api/v1/maintenance.py @@ -0,0 +1,489 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user, require_roles +from app.models.maintenance import InspectionPlan, InspectionRecord, RepairOrder, DutySchedule +from app.models.user import User + +router = APIRouter(prefix="/maintenance", tags=["运维管理"]) + + +# ── Pydantic Schemas ──────────────────────────────────────────────── + +class PlanCreate(BaseModel): + name: str + description: str | None = None + device_group_id: int | None = None + device_ids: list[int] | None = None + schedule_type: str | None = None + schedule_cron: str | None = None + inspector_id: int | None = None + checklist: list[dict] | None = None + is_active: bool = True + next_run_at: str | None = None + + +class RecordCreate(BaseModel): + plan_id: int + inspector_id: int + status: str = "pending" + findings: list[dict] | None = None + started_at: str | None = None + + +class RecordUpdate(BaseModel): + status: str | None = None + findings: list[dict] | None = None + completed_at: str | None = None + + +class OrderCreate(BaseModel): + title: str + description: str | None = None + device_id: int | None = None + alarm_event_id: int | None = None + priority: str = "medium" + cost_estimate: float | None = None + + +class OrderUpdate(BaseModel): + title: str | None = None + description: str | None = None + priority: str | None = None + status: str | None = None + resolution: str | None = None + actual_cost: float | None = None + + +class DutyCreate(BaseModel): + user_id: int + duty_date: str + shift: str | None = None + area_id: int | None = None + notes: str | None = None + + +# ── Helpers ───────────────────────────────────────────────────────── + +def _plan_to_dict(p: InspectionPlan) -> dict: + return { + "id": p.id, "name": p.name, "description": p.description, + "device_group_id": p.device_group_id, "device_ids": p.device_ids, + "schedule_type": p.schedule_type, "schedule_cron": p.schedule_cron, + "inspector_id": p.inspector_id, "checklist": p.checklist, + "is_active": p.is_active, + "next_run_at": str(p.next_run_at) if p.next_run_at else None, + "created_by": p.created_by, + "created_at": str(p.created_at) if p.created_at else None, + "updated_at": str(p.updated_at) if p.updated_at else None, + } + + +def _record_to_dict(r: InspectionRecord) -> dict: + return { + "id": r.id, "plan_id": r.plan_id, "inspector_id": r.inspector_id, + "status": r.status, "findings": r.findings, + "started_at": str(r.started_at) if r.started_at else None, + "completed_at": str(r.completed_at) if r.completed_at else None, + "created_at": str(r.created_at) if r.created_at else None, + } + + +def _order_to_dict(o: RepairOrder) -> dict: + return { + "id": o.id, "code": o.code, "title": o.title, "description": o.description, + "device_id": o.device_id, "alarm_event_id": o.alarm_event_id, + "priority": o.priority, "status": o.status, "assigned_to": o.assigned_to, + "resolution": o.resolution, "cost_estimate": o.cost_estimate, + "actual_cost": o.actual_cost, "created_by": o.created_by, + "created_at": str(o.created_at) if o.created_at else None, + "assigned_at": str(o.assigned_at) if o.assigned_at else None, + "completed_at": str(o.completed_at) if o.completed_at else None, + "closed_at": str(o.closed_at) if o.closed_at else None, + } + + +def _duty_to_dict(d: DutySchedule) -> dict: + return { + "id": d.id, "user_id": d.user_id, + "duty_date": str(d.duty_date) if d.duty_date else None, + "shift": d.shift, "area_id": d.area_id, "notes": d.notes, + "created_at": str(d.created_at) if d.created_at else None, + } + + +def _generate_order_code() -> str: + now = datetime.now(timezone.utc) + return f"WO-{now.strftime('%Y%m%d')}-{now.strftime('%H%M%S')}" + + +# ── Inspection Plans ──────────────────────────────────────────────── + +@router.get("/plans") +async def list_plans( + is_active: bool | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(InspectionPlan).order_by(InspectionPlan.id.desc()) + if is_active is not None: + query = query.where(InspectionPlan.is_active == is_active) + result = await db.execute(query) + return [_plan_to_dict(p) for p in result.scalars().all()] + + +@router.post("/plans") +async def create_plan( + data: PlanCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + plan = InspectionPlan(**data.model_dump(exclude={"next_run_at"}), created_by=user.id) + if data.next_run_at: + plan.next_run_at = datetime.fromisoformat(data.next_run_at) + db.add(plan) + await db.flush() + return _plan_to_dict(plan) + + +@router.put("/plans/{plan_id}") +async def update_plan( + plan_id: int, + data: PlanCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(InspectionPlan).where(InspectionPlan.id == plan_id)) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(status_code=404, detail="巡检计划不存在") + for k, v in data.model_dump(exclude_unset=True, exclude={"next_run_at"}).items(): + setattr(plan, k, v) + if data.next_run_at: + plan.next_run_at = datetime.fromisoformat(data.next_run_at) + return _plan_to_dict(plan) + + +@router.delete("/plans/{plan_id}") +async def delete_plan( + plan_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(InspectionPlan).where(InspectionPlan.id == plan_id)) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(status_code=404, detail="巡检计划不存在") + plan.is_active = False + return {"message": "已删除"} + + +@router.post("/plans/{plan_id}/trigger") +async def trigger_plan( + plan_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + """手动触发巡检计划,生成巡检记录""" + result = await db.execute(select(InspectionPlan).where(InspectionPlan.id == plan_id)) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(status_code=404, detail="巡检计划不存在") + record = InspectionRecord( + plan_id=plan.id, + inspector_id=plan.inspector_id or user.id, + status="pending", + ) + db.add(record) + await db.flush() + return _record_to_dict(record) + + +# ── Inspection Records ────────────────────────────────────────────── + +@router.get("/records") +async def list_records( + plan_id: int | None = None, + status: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(InspectionRecord) + if plan_id: + query = query.where(InspectionRecord.plan_id == plan_id) + if status: + query = query.where(InspectionRecord.status == status) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(InspectionRecord.id.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [_record_to_dict(r) for r in result.scalars().all()], + } + + +@router.post("/records") +async def create_record( + data: RecordCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + record = InspectionRecord(**data.model_dump(exclude={"started_at"})) + if data.started_at: + record.started_at = datetime.fromisoformat(data.started_at) + db.add(record) + await db.flush() + return _record_to_dict(record) + + +@router.put("/records/{record_id}") +async def update_record( + record_id: int, + data: RecordUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute(select(InspectionRecord).where(InspectionRecord.id == record_id)) + record = result.scalar_one_or_none() + if not record: + raise HTTPException(status_code=404, detail="巡检记录不存在") + for k, v in data.model_dump(exclude_unset=True, exclude={"completed_at"}).items(): + setattr(record, k, v) + if data.completed_at: + record.completed_at = datetime.fromisoformat(data.completed_at) + elif data.status == "completed" or data.status == "issues_found": + record.completed_at = datetime.now(timezone.utc) + return _record_to_dict(record) + + +# ── Repair Orders ─────────────────────────────────────────────────── + +@router.get("/orders") +async def list_orders( + status: str | None = None, + priority: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(RepairOrder) + if status: + query = query.where(RepairOrder.status == status) + if priority: + query = query.where(RepairOrder.priority == priority) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(RepairOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [_order_to_dict(o) for o in result.scalars().all()], + } + + +@router.post("/orders") +async def create_order( + data: OrderCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + order = RepairOrder( + **data.model_dump(), + code=_generate_order_code(), + created_by=user.id, + ) + db.add(order) + await db.flush() + return _order_to_dict(order) + + +@router.put("/orders/{order_id}") +async def update_order( + order_id: int, + data: OrderUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute(select(RepairOrder).where(RepairOrder.id == order_id)) + order = result.scalar_one_or_none() + if not order: + raise HTTPException(status_code=404, detail="工单不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(order, k, v) + return _order_to_dict(order) + + +@router.put("/orders/{order_id}/assign") +async def assign_order( + order_id: int, + assigned_to: int = Query(..., description="指派的用户ID"), + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(RepairOrder).where(RepairOrder.id == order_id)) + order = result.scalar_one_or_none() + if not order: + raise HTTPException(status_code=404, detail="工单不存在") + order.assigned_to = assigned_to + order.status = "assigned" + order.assigned_at = datetime.now(timezone.utc) + return _order_to_dict(order) + + +@router.put("/orders/{order_id}/complete") +async def complete_order( + order_id: int, + resolution: str = "", + actual_cost: float | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute(select(RepairOrder).where(RepairOrder.id == order_id)) + order = result.scalar_one_or_none() + if not order: + raise HTTPException(status_code=404, detail="工单不存在") + order.status = "completed" + order.resolution = resolution + if actual_cost is not None: + order.actual_cost = actual_cost + order.completed_at = datetime.now(timezone.utc) + return _order_to_dict(order) + + +# ── Duty Schedule ─────────────────────────────────────────────────── + +@router.get("/duty") +async def list_duty( + start_date: str | None = None, + end_date: str | None = None, + user_id: int | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(DutySchedule) + if start_date: + query = query.where(DutySchedule.duty_date >= start_date) + if end_date: + query = query.where(DutySchedule.duty_date <= end_date) + if user_id: + query = query.where(DutySchedule.user_id == user_id) + query = query.order_by(DutySchedule.duty_date) + result = await db.execute(query) + return [_duty_to_dict(d) for d in result.scalars().all()] + + +@router.post("/duty") +async def create_duty( + data: DutyCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + duty = DutySchedule( + user_id=data.user_id, + duty_date=datetime.fromisoformat(data.duty_date), + shift=data.shift, + area_id=data.area_id, + notes=data.notes, + ) + db.add(duty) + await db.flush() + return _duty_to_dict(duty) + + +@router.put("/duty/{duty_id}") +async def update_duty( + duty_id: int, + data: DutyCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(DutySchedule).where(DutySchedule.id == duty_id)) + duty = result.scalar_one_or_none() + if not duty: + raise HTTPException(status_code=404, detail="值班记录不存在") + duty.user_id = data.user_id + duty.duty_date = datetime.fromisoformat(data.duty_date) + duty.shift = data.shift + duty.area_id = data.area_id + duty.notes = data.notes + return _duty_to_dict(duty) + + +@router.delete("/duty/{duty_id}") +async def delete_duty( + duty_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(DutySchedule).where(DutySchedule.id == duty_id)) + duty = result.scalar_one_or_none() + if not duty: + raise HTTPException(status_code=404, detail="值班记录不存在") + await db.delete(duty) + return {"message": "已删除"} + + +# ── Dashboard ─────────────────────────────────────────────────────── + +@router.get("/dashboard") +async def maintenance_dashboard( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + now = datetime.now(timezone.utc) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + # Open orders count + open_q = select(func.count()).select_from(RepairOrder).where( + RepairOrder.status.in_(["open", "assigned", "in_progress"]) + ) + open_orders = (await db.execute(open_q)).scalar() or 0 + + # Overdue: assigned but not completed, assigned_at > 7 days ago + from datetime import timedelta + overdue_cutoff = now - timedelta(days=7) + overdue_q = select(func.count()).select_from(RepairOrder).where( + and_( + RepairOrder.status.in_(["assigned", "in_progress"]), + RepairOrder.assigned_at < overdue_cutoff, + ) + ) + overdue_count = (await db.execute(overdue_q)).scalar() or 0 + + # Today's inspections + inspect_q = select(func.count()).select_from(InspectionRecord).where( + InspectionRecord.created_at >= today_start, + ) + todays_inspections = (await db.execute(inspect_q)).scalar() or 0 + + # Upcoming duties (next 7 days) + duty_end = now + timedelta(days=7) + duty_q = select(func.count()).select_from(DutySchedule).where( + and_(DutySchedule.duty_date >= today_start, DutySchedule.duty_date <= duty_end) + ) + upcoming_duties = (await db.execute(duty_q)).scalar() or 0 + + # Recent orders (latest 10) + recent_q = select(RepairOrder).order_by(RepairOrder.created_at.desc()).limit(10) + recent_result = await db.execute(recent_q) + recent_orders = [_order_to_dict(o) for o in recent_result.scalars().all()] + + return { + "open_orders": open_orders, + "overdue_count": overdue_count, + "todays_inspections": todays_inspections, + "upcoming_duties": upcoming_duties, + "recent_orders": recent_orders, + } diff --git a/backend/app/api/v1/management.py b/backend/app/api/v1/management.py new file mode 100644 index 0000000..b3f3563 --- /dev/null +++ b/backend/app/api/v1/management.py @@ -0,0 +1,385 @@ +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user, require_roles +from app.models.management import Regulation, Standard, ProcessDoc, EmergencyPlan +from app.models.user import User + +router = APIRouter(prefix="/management", tags=["管理体系"]) + + +# ── Pydantic Schemas ────────────────────────────────────────────────── + +class RegulationCreate(BaseModel): + title: str + category: str | None = None + content: str | None = None + effective_date: datetime | None = None + status: str = "active" + attachment_url: str | None = None + + +class StandardCreate(BaseModel): + name: str + code: str | None = None + type: str | None = None + description: str | None = None + compliance_status: str = "pending" + review_date: datetime | None = None + + +class ProcessDocCreate(BaseModel): + title: str + category: str | None = None + content: str | None = None + version: str = "1.0" + approved_by: str | None = None + effective_date: datetime | None = None + + +class EmergencyPlanCreate(BaseModel): + title: str + scenario: str | None = None + steps: list[dict] | None = None + responsible_person: str | None = None + review_date: datetime | None = None + is_active: bool = True + + +# ── Regulations (规章制度) ──────────────────────────────────────────── + +@router.get("/regulations") +async def list_regulations( + category: str | None = None, + status: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(Regulation) + if category: + query = query.where(Regulation.category == category) + if status: + query = query.where(Regulation.status == status) + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + query = query.order_by(Regulation.id.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [_regulation_to_dict(r) for r in result.scalars().all()], + } + + +@router.post("/regulations") +async def create_regulation( + data: RegulationCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + reg = Regulation(**data.model_dump(), created_by=user.id) + db.add(reg) + await db.flush() + return _regulation_to_dict(reg) + + +@router.put("/regulations/{reg_id}") +async def update_regulation( + reg_id: int, + data: RegulationCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(Regulation).where(Regulation.id == reg_id)) + reg = result.scalar_one_or_none() + if not reg: + raise HTTPException(status_code=404, detail="规章制度不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(reg, k, v) + return _regulation_to_dict(reg) + + +@router.delete("/regulations/{reg_id}") +async def delete_regulation( + reg_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(Regulation).where(Regulation.id == reg_id)) + reg = result.scalar_one_or_none() + if not reg: + raise HTTPException(status_code=404, detail="规章制度不存在") + await db.delete(reg) + return {"message": "已删除"} + + +# ── Standards (标准规范) ────────────────────────────────────────────── + +@router.get("/standards") +async def list_standards( + type: str | None = None, + compliance_status: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(Standard) + if type: + query = query.where(Standard.type == type) + if compliance_status: + query = query.where(Standard.compliance_status == compliance_status) + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + query = query.order_by(Standard.id.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [_standard_to_dict(s) for s in result.scalars().all()], + } + + +@router.post("/standards") +async def create_standard( + data: StandardCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + std = Standard(**data.model_dump()) + db.add(std) + await db.flush() + return _standard_to_dict(std) + + +@router.put("/standards/{std_id}") +async def update_standard( + std_id: int, + data: StandardCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(Standard).where(Standard.id == std_id)) + std = result.scalar_one_or_none() + if not std: + raise HTTPException(status_code=404, detail="标准规范不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(std, k, v) + return _standard_to_dict(std) + + +@router.delete("/standards/{std_id}") +async def delete_standard( + std_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(Standard).where(Standard.id == std_id)) + std = result.scalar_one_or_none() + if not std: + raise HTTPException(status_code=404, detail="标准规范不存在") + await db.delete(std) + return {"message": "已删除"} + + +# ── Process Docs (管理流程) ─────────────────────────────────────────── + +@router.get("/process-docs") +async def list_process_docs( + category: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(ProcessDoc) + if category: + query = query.where(ProcessDoc.category == category) + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + query = query.order_by(ProcessDoc.id.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [_process_doc_to_dict(d) for d in result.scalars().all()], + } + + +@router.post("/process-docs") +async def create_process_doc( + data: ProcessDocCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + doc = ProcessDoc(**data.model_dump()) + db.add(doc) + await db.flush() + return _process_doc_to_dict(doc) + + +@router.put("/process-docs/{doc_id}") +async def update_process_doc( + doc_id: int, + data: ProcessDocCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ProcessDoc).where(ProcessDoc.id == doc_id)) + doc = result.scalar_one_or_none() + if not doc: + raise HTTPException(status_code=404, detail="管理流程文档不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(doc, k, v) + return _process_doc_to_dict(doc) + + +@router.delete("/process-docs/{doc_id}") +async def delete_process_doc( + doc_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ProcessDoc).where(ProcessDoc.id == doc_id)) + doc = result.scalar_one_or_none() + if not doc: + raise HTTPException(status_code=404, detail="管理流程文档不存在") + await db.delete(doc) + return {"message": "已删除"} + + +# ── Emergency Plans (应急预案) ──────────────────────────────────────── + +@router.get("/emergency-plans") +async def list_emergency_plans( + scenario: str | None = None, + is_active: bool | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(EmergencyPlan) + if scenario: + query = query.where(EmergencyPlan.scenario == scenario) + if is_active is not None: + query = query.where(EmergencyPlan.is_active == is_active) + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + query = query.order_by(EmergencyPlan.id.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [_emergency_plan_to_dict(p) for p in result.scalars().all()], + } + + +@router.post("/emergency-plans") +async def create_emergency_plan( + data: EmergencyPlanCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + plan = EmergencyPlan(**data.model_dump()) + db.add(plan) + await db.flush() + return _emergency_plan_to_dict(plan) + + +@router.put("/emergency-plans/{plan_id}") +async def update_emergency_plan( + plan_id: int, + data: EmergencyPlanCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(EmergencyPlan).where(EmergencyPlan.id == plan_id)) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(status_code=404, detail="应急预案不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(plan, k, v) + return _emergency_plan_to_dict(plan) + + +@router.delete("/emergency-plans/{plan_id}") +async def delete_emergency_plan( + plan_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(EmergencyPlan).where(EmergencyPlan.id == plan_id)) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(status_code=404, detail="应急预案不存在") + await db.delete(plan) + return {"message": "已删除"} + + +# ── Compliance Overview ─────────────────────────────────────────────── + +@router.get("/compliance") +async def compliance_overview( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """合规概览 - count by compliance_status for standards""" + result = await db.execute( + select(Standard.compliance_status, func.count(Standard.id)) + .group_by(Standard.compliance_status) + ) + stats = {row[0]: row[1] for row in result.all()} + return { + "compliant": stats.get("compliant", 0), + "non_compliant": stats.get("non_compliant", 0), + "pending": stats.get("pending", 0), + "in_progress": stats.get("in_progress", 0), + "total": sum(stats.values()), + } + + +# ── Serializers ─────────────────────────────────────────────────────── + +def _regulation_to_dict(r: Regulation) -> dict: + return { + "id": r.id, "title": r.title, "category": r.category, + "content": r.content, "effective_date": str(r.effective_date) if r.effective_date else None, + "status": r.status, "attachment_url": r.attachment_url, + "created_by": r.created_by, + "created_at": str(r.created_at) if r.created_at else None, + "updated_at": str(r.updated_at) if r.updated_at else None, + } + + +def _standard_to_dict(s: Standard) -> dict: + return { + "id": s.id, "name": s.name, "code": s.code, "type": s.type, + "description": s.description, "compliance_status": s.compliance_status, + "review_date": str(s.review_date) if s.review_date else None, + "created_at": str(s.created_at) if s.created_at else None, + "updated_at": str(s.updated_at) if s.updated_at else None, + } + + +def _process_doc_to_dict(d: ProcessDoc) -> dict: + return { + "id": d.id, "title": d.title, "category": d.category, + "content": d.content, "version": d.version, + "approved_by": d.approved_by, + "effective_date": str(d.effective_date) if d.effective_date else None, + "created_at": str(d.created_at) if d.created_at else None, + "updated_at": str(d.updated_at) if d.updated_at else None, + } + + +def _emergency_plan_to_dict(p: EmergencyPlan) -> dict: + return { + "id": p.id, "title": p.title, "scenario": p.scenario, + "steps": p.steps, "responsible_person": p.responsible_person, + "review_date": str(p.review_date) if p.review_date else None, + "is_active": p.is_active, + "created_at": str(p.created_at) if p.created_at else None, + "updated_at": str(p.updated_at) if p.updated_at else None, + } diff --git a/backend/app/api/v1/monitoring.py b/backend/app/api/v1/monitoring.py new file mode 100644 index 0000000..072ac88 --- /dev/null +++ b/backend/app/api/v1/monitoring.py @@ -0,0 +1,78 @@ +from datetime import datetime, timedelta, 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="/monitoring", tags=["实时监控"]) + + +@router.get("/devices/{device_id}/realtime") +async def device_realtime(device_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + """获取单台设备的最新实时数据""" + now = datetime.now(timezone.utc) + five_min_ago = now - timedelta(minutes=5) + + result = await db.execute( + select(EnergyData).where( + and_(EnergyData.device_id == device_id, EnergyData.timestamp >= five_min_ago) + ).order_by(EnergyData.timestamp.desc()).limit(20) + ) + data_points = result.scalars().all() + latest = {} + for d in data_points: + if d.data_type not in latest: + latest[d.data_type] = {"value": d.value, "unit": d.unit, "timestamp": str(d.timestamp)} + + device_q = await db.execute(select(Device).where(Device.id == device_id)) + device = device_q.scalar_one_or_none() + + return { + "device": { + "id": device.id, "name": device.name, "code": device.code, + "device_type": device.device_type, "status": device.status, + "model": device.model, "manufacturer": device.manufacturer, + } if device else None, + "data": latest, + } + + +@router.get("/energy-flow") +async def energy_flow(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + """能流图数据 - 展示能量流向""" + now = datetime.now(timezone.utc) + five_min_ago = now - timedelta(minutes=5) + + # 获取各类设备最新功率 + result = await db.execute( + select(Device.device_type, func.sum(EnergyData.value)) + .join(EnergyData, EnergyData.device_id == Device.id) + .where(and_(EnergyData.timestamp >= five_min_ago, EnergyData.data_type == "power")) + .group_by(Device.device_type) + ) + power_by_type = {row[0]: round(row[1], 2) for row in result.all()} + + pv_power = power_by_type.get("pv_inverter", 0) + hp_power = power_by_type.get("heat_pump", 0) + total_load = hp_power + power_by_type.get("meter", 0) + grid_import = max(0, total_load - pv_power) + grid_export = max(0, pv_power - total_load) + + return { + "nodes": [ + {"id": "pv", "name": "光伏发电", "power": pv_power, "unit": "kW"}, + {"id": "grid", "name": "电网", "power": grid_import - grid_export, "unit": "kW"}, + {"id": "heatpump", "name": "热泵系统", "power": hp_power, "unit": "kW"}, + {"id": "building", "name": "建筑负荷", "power": total_load, "unit": "kW"}, + ], + "links": [ + {"source": "pv", "target": "building", "value": min(pv_power, total_load)}, + {"source": "pv", "target": "grid", "value": grid_export}, + {"source": "grid", "target": "building", "value": grid_import}, + {"source": "grid", "target": "heatpump", "value": hp_power}, + ] + } diff --git a/backend/app/api/v1/prediction.py b/backend/app/api/v1/prediction.py new file mode 100644 index 0000000..0ad0192 --- /dev/null +++ b/backend/app/api/v1/prediction.py @@ -0,0 +1,185 @@ +"""AI预测引擎 API - 光伏/负荷/热泵预测 & 自发自用优化""" + +from datetime import datetime, timezone, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, Query, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ + +from app.core.database import get_db +from app.core.deps import get_current_user, require_roles +from app.models.user import User +from app.models.prediction import PredictionTask, PredictionResult, OptimizationSchedule +from app.services.ai_prediction import ( + forecast_pv, forecast_load, forecast_heatpump_cop, + optimize_self_consumption, get_prediction_accuracy, run_prediction, +) + +router = APIRouter(prefix="/prediction", tags=["AI预测"]) + + +# ── Schemas ──────────────────────────────────────────────────────────── + +class RunPredictionRequest(BaseModel): + device_id: Optional[int] = None + prediction_type: str # pv, load, heatpump, optimization + horizon_hours: int = 24 + parameters: Optional[dict] = None + + +# ── Endpoints ────────────────────────────────────────────────────────── + +@router.get("/forecast") +async def get_forecast( + device_id: Optional[int] = None, + type: str = Query("pv", pattern="^(pv|load|heatpump)$"), + horizon: int = Query(24, ge=1, le=168), + building_type: str = Query("office", pattern="^(office|factory)$"), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取预测结果 - PV发电/负荷/热泵COP""" + if type == "pv": + if not device_id: + raise HTTPException(400, "光伏预测需要指定device_id") + return await forecast_pv(db, device_id, horizon) + elif type == "load": + return await forecast_load(db, device_id, building_type, horizon) + elif type == "heatpump": + if not device_id: + raise HTTPException(400, "热泵预测需要指定device_id") + return await forecast_heatpump_cop(db, device_id, horizon) + + +@router.post("/run") +async def trigger_prediction( + req: RunPredictionRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """触发新的预测任务""" + task = await run_prediction( + db, req.device_id, req.prediction_type, + req.horizon_hours, req.parameters, + ) + return { + "task_id": task.id, + "status": task.status, + "prediction_type": task.prediction_type, + "horizon_hours": task.horizon_hours, + "error_message": task.error_message, + } + + +@router.get("/accuracy") +async def prediction_accuracy( + type: Optional[str] = Query(None, pattern="^(pv|load|heatpump|optimization)$"), + days: int = Query(7, ge=1, le=90), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取预测精度指标 (MAE, RMSE, MAPE)""" + return await get_prediction_accuracy(db, type, days) + + +@router.get("/optimization") +async def get_optimization( + horizon: int = Query(24, ge=1, le=72), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取自发自用优化建议""" + return await optimize_self_consumption(db, horizon) + + +@router.post("/optimization/{schedule_id}/approve") +async def approve_optimization( + schedule_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + """审批优化调度方案""" + result = await db.execute( + select(OptimizationSchedule).where(OptimizationSchedule.id == schedule_id) + ) + schedule = result.scalar_one_or_none() + if not schedule: + raise HTTPException(404, "优化方案不存在") + if schedule.status != "pending": + raise HTTPException(400, f"方案状态为 {schedule.status},无法审批") + + schedule.status = "approved" + schedule.approved_by = user.id + schedule.approved_at = datetime.now(timezone.utc) + return {"id": schedule.id, "status": "approved"} + + +@router.get("/history") +async def prediction_history( + type: Optional[str] = Query(None), + days: int = Query(7, ge=1, le=30), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """历史预测任务列表""" + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + conditions = [PredictionTask.created_at >= cutoff] + if type: + conditions.append(PredictionTask.prediction_type == type) + + query = ( + select(PredictionTask) + .where(and_(*conditions)) + .order_by(PredictionTask.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + result = await db.execute(query) + tasks = result.scalars().all() + + return [{ + "id": t.id, + "device_id": t.device_id, + "prediction_type": t.prediction_type, + "horizon_hours": t.horizon_hours, + "status": t.status, + "created_at": str(t.created_at) if t.created_at else None, + "completed_at": str(t.completed_at) if t.completed_at else None, + "error_message": t.error_message, + } for t in tasks] + + +@router.get("/schedules") +async def list_schedules( + status: Optional[str] = Query(None, pattern="^(pending|approved|executed|rejected)$"), + days: int = Query(7, ge=1, le=30), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取优化调度方案列表""" + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + conditions = [OptimizationSchedule.created_at >= cutoff] + if status: + conditions.append(OptimizationSchedule.status == status) + + result = await db.execute( + select(OptimizationSchedule) + .where(and_(*conditions)) + .order_by(OptimizationSchedule.created_at.desc()) + ) + schedules = result.scalars().all() + + return [{ + "id": s.id, + "device_id": s.device_id, + "date": str(s.date) if s.date else None, + "expected_savings_kwh": s.expected_savings_kwh, + "expected_savings_yuan": s.expected_savings_yuan, + "status": s.status, + "schedule_data": s.schedule_data, + "created_at": str(s.created_at) if s.created_at else None, + } for s in schedules] diff --git a/backend/app/api/v1/quota.py b/backend/app/api/v1/quota.py new file mode 100644 index 0000000..8e9a885 --- /dev/null +++ b/backend/app/api/v1/quota.py @@ -0,0 +1,192 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from datetime import datetime, timezone +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user, require_roles +from app.models.quota import EnergyQuota, QuotaUsage +from app.models.user import User + +router = APIRouter(prefix="/quota", tags=["配额管理"]) + + +class QuotaCreate(BaseModel): + name: str + target_type: str + target_id: int + energy_type: str + period: str + quota_value: float + unit: str = "kWh" + warning_threshold_pct: float = 80 + alert_threshold_pct: float = 95 + + +@router.get("") +async def list_quotas( + target_type: str | None = None, + energy_type: str | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """列出所有配额,附带当前使用率""" + query = select(EnergyQuota).where(EnergyQuota.is_active == True) + if target_type: + query = query.where(EnergyQuota.target_type == target_type) + if energy_type: + query = query.where(EnergyQuota.energy_type == energy_type) + query = query.order_by(EnergyQuota.id.desc()) + result = await db.execute(query) + quotas = result.scalars().all() + + items = [] + for q in quotas: + # 获取最新使用记录 + usage_result = await db.execute( + select(QuotaUsage) + .where(QuotaUsage.quota_id == q.id) + .order_by(QuotaUsage.calculated_at.desc()) + .limit(1) + ) + usage = usage_result.scalar_one_or_none() + items.append({ + **_quota_to_dict(q), + "current_usage": usage.actual_value if usage else 0, + "usage_rate_pct": usage.usage_rate_pct if usage else 0, + "usage_status": usage.status if usage else "normal", + }) + return items + + +@router.post("") +async def create_quota( + data: QuotaCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + quota = EnergyQuota(**data.model_dump(), created_by=user.id) + db.add(quota) + await db.flush() + return _quota_to_dict(quota) + + +@router.put("/{quota_id}") +async def update_quota( + quota_id: int, + data: QuotaCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(EnergyQuota).where(EnergyQuota.id == quota_id)) + quota = result.scalar_one_or_none() + if not quota: + raise HTTPException(status_code=404, detail="配额不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(quota, k, v) + return _quota_to_dict(quota) + + +@router.delete("/{quota_id}") +async def delete_quota( + quota_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(EnergyQuota).where(EnergyQuota.id == quota_id)) + quota = result.scalar_one_or_none() + if not quota: + raise HTTPException(status_code=404, detail="配额不存在") + quota.is_active = False + return {"message": "已删除"} + + +@router.get("/usage") +async def quota_usage( + target_type: str | None = None, + energy_type: str | None = None, + period: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """配额使用统计,支持筛选和分页""" + query = ( + select(QuotaUsage) + .join(EnergyQuota, QuotaUsage.quota_id == EnergyQuota.id) + .where(EnergyQuota.is_active == True) + ) + if target_type: + query = query.where(EnergyQuota.target_type == target_type) + if energy_type: + query = query.where(EnergyQuota.energy_type == energy_type) + if period: + query = query.where(EnergyQuota.period == period) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(QuotaUsage.calculated_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [{ + "id": u.id, "quota_id": u.quota_id, + "period_start": str(u.period_start), "period_end": str(u.period_end), + "actual_value": u.actual_value, "quota_value": u.quota_value, + "usage_rate_pct": u.usage_rate_pct, "status": u.status, + "calculated_at": str(u.calculated_at), + } for u in result.scalars().all()] + } + + +@router.get("/compliance") +async def quota_compliance( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """配额合规概览:统计各状态数量""" + # 每个活跃配额的最新使用记录 + quotas_result = await db.execute( + select(EnergyQuota).where(EnergyQuota.is_active == True) + ) + quotas = quotas_result.scalars().all() + + summary = {"total": 0, "normal": 0, "warning": 0, "exceeded": 0} + details = [] + + for q in quotas: + usage_result = await db.execute( + select(QuotaUsage) + .where(QuotaUsage.quota_id == q.id) + .order_by(QuotaUsage.calculated_at.desc()) + .limit(1) + ) + usage = usage_result.scalar_one_or_none() + status = usage.status if usage else "normal" + summary["total"] += 1 + summary[status] = summary.get(status, 0) + 1 + details.append({ + "quota_id": q.id, + "name": q.name, + "target_type": q.target_type, + "energy_type": q.energy_type, + "quota_value": q.quota_value, + "actual_value": usage.actual_value if usage else 0, + "usage_rate_pct": usage.usage_rate_pct if usage else 0, + "status": status, + }) + + return {"summary": summary, "details": details} + + +def _quota_to_dict(q: EnergyQuota) -> dict: + return { + "id": q.id, "name": q.name, "target_type": q.target_type, + "target_id": q.target_id, "energy_type": q.energy_type, + "period": q.period, "quota_value": q.quota_value, "unit": q.unit, + "warning_threshold_pct": q.warning_threshold_pct, + "alert_threshold_pct": q.alert_threshold_pct, + "is_active": q.is_active, + } diff --git a/backend/app/api/v1/reports.py b/backend/app/api/v1/reports.py new file mode 100644 index 0000000..411b7ca --- /dev/null +++ b/backend/app/api/v1/reports.py @@ -0,0 +1,316 @@ +from datetime import date, datetime +from pathlib import Path +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel + +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.report import ReportTemplate, ReportTask +from app.models.user import User +from app.services.report_generator import ReportGenerator, REPORTS_DIR +from app.services.audit import log_audit + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/reports", tags=["报表管理"]) + + +class TemplateCreate(BaseModel): + name: str + report_type: str + description: str | None = None + fields: list[dict] + filters: dict | None = None + aggregation: str = "sum" + time_granularity: str = "hour" + + +class TaskCreate(BaseModel): + template_id: int + name: str | None = None + schedule: str | None = None + recipients: list[str] | None = None + export_format: str = "xlsx" + + +class QuickReportRequest(BaseModel): + report_type: str # daily, monthly, device_status, alarm, carbon + export_format: str = "xlsx" + start_date: date | None = None + end_date: date | None = None + month: int | None = None + year: int | None = None + device_ids: list[int] | None = None + + +# ------------------------------------------------------------------ # +# Templates +# ------------------------------------------------------------------ # + +@router.get("/templates") +async def list_templates(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute(select(ReportTemplate).order_by(ReportTemplate.id)) + return [{ + "id": t.id, "name": t.name, "report_type": t.report_type, "description": t.description, + "fields": t.fields, "is_system": t.is_system, "aggregation": t.aggregation, + "time_granularity": t.time_granularity, + } for t in result.scalars().all()] + + +@router.post("/templates") +async def create_template(data: TemplateCreate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + template = ReportTemplate(**data.model_dump(), created_by=user.id) + db.add(template) + await db.flush() + return {"id": template.id, "name": template.name} + + +# ------------------------------------------------------------------ # +# Tasks CRUD +# ------------------------------------------------------------------ # + +@router.get("/tasks") +async def list_tasks(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute(select(ReportTask).order_by(ReportTask.id.desc())) + return [{ + "id": t.id, "template_id": t.template_id, "name": t.name, "schedule": t.schedule, + "status": t.status, "export_format": t.export_format, "file_path": t.file_path, + "last_run": str(t.last_run) if t.last_run else None, + } for t in result.scalars().all()] + + +@router.post("/tasks") +async def create_task(data: TaskCreate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + task = ReportTask(**data.model_dump(), created_by=user.id) + db.add(task) + await db.flush() + return {"id": task.id} + + +# ------------------------------------------------------------------ # +# Run / Status / Download +# ------------------------------------------------------------------ # + +REPORT_TYPE_METHODS = { + "daily": "generate_energy_daily_report", + "monthly": "generate_monthly_summary", + "device_status": "generate_device_status_report", + "alarm": "generate_alarm_report", + "carbon": "generate_carbon_report", +} + + +def _parse_date(val, default: date) -> date: + if not val: + return default + if isinstance(val, date): + return val + try: + return date.fromisoformat(str(val)) + except (ValueError, TypeError): + return default + + +@router.post("/tasks/{task_id}/run") +async def run_task( + task_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute(select(ReportTask).where(ReportTask.id == task_id)) + task = result.scalar_one_or_none() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + # Try Celery first + try: + from app.core.config import get_settings + from app.tasks.report_tasks import CELERY_AVAILABLE + if CELERY_AVAILABLE and get_settings().CELERY_ENABLED: + task.status = "running" + await db.flush() + from app.tasks.report_tasks import generate_report_task + generate_report_task.delay(task_id) + return {"message": "报表生成任务已提交(异步)", "task_id": task.id, "mode": "async"} + except Exception: + pass + + # Inline async generation (avoids event loop issues with BackgroundTasks) + task.status = "running" + await db.flush() + + template = (await db.execute( + select(ReportTemplate).where(ReportTemplate.id == task.template_id) + )).scalar_one_or_none() + if not template: + task.status = "failed" + await db.flush() + raise HTTPException(status_code=400, detail=f"模板 {task.template_id} 不存在") + + filters = template.filters or {} + today = date.today() + start_date = _parse_date(filters.get("start_date"), default=today.replace(day=1)) + end_date = _parse_date(filters.get("end_date"), default=today) + device_ids = filters.get("device_ids") + export_format = task.export_format or "xlsx" + report_type = template.report_type + + method_name = REPORT_TYPE_METHODS.get(report_type) + if not method_name: + task.status = "failed" + await db.flush() + raise HTTPException(status_code=400, detail=f"未知报表类型: {report_type}") + + try: + gen = ReportGenerator(db) + method = getattr(gen, method_name) + if report_type == "monthly": + month = filters.get("month", today.month) + year = filters.get("year", today.year) + filepath = await method(month=month, year=year, export_format=export_format) + elif report_type == "device_status": + filepath = await method(export_format=export_format) + else: + kwargs = {"start_date": start_date, "end_date": end_date, "export_format": export_format} + if device_ids and report_type == "daily": + kwargs["device_ids"] = device_ids + filepath = await method(**kwargs) + + task.status = "completed" + task.file_path = filepath + task.last_run = datetime.now() + await db.flush() + await log_audit(db, user.id, "export", "report", detail=f"运行报表任务 #{task_id}") + logger.info(f"Report task {task_id} completed: {filepath}") + return {"message": "报表生成完成", "task_id": task.id, "mode": "sync", "status": "completed"} + except Exception as e: + logger.error(f"Report task {task_id} failed: {e}") + task.status = "failed" + await db.flush() + raise HTTPException(status_code=500, detail=f"报表生成失败: {str(e)}") + + +@router.get("/tasks/{task_id}/status") +async def task_status( + task_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute(select(ReportTask).where(ReportTask.id == task_id)) + task = result.scalar_one_or_none() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + return { + "id": task.id, + "status": task.status, + "file_path": task.file_path, + "last_run": str(task.last_run) if task.last_run else None, + } + + +@router.get("/tasks/{task_id}/download") +async def download_report( + task_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute(select(ReportTask).where(ReportTask.id == task_id)) + task = result.scalar_one_or_none() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + if task.status != "completed" or not task.file_path: + raise HTTPException(status_code=400, detail="报表尚未生成完成") + if not Path(task.file_path).exists(): + raise HTTPException(status_code=404, detail="报表文件不存在") + + filename = Path(task.file_path).name + media_type = ( + "application/pdf" if filename.endswith(".pdf") + else "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + return FileResponse(task.file_path, filename=filename, media_type=media_type) + + +# ------------------------------------------------------------------ # +# Quick report (synchronous, no task record needed) +# ------------------------------------------------------------------ # + +@router.post("/generate") +async def generate_quick_report( + req: QuickReportRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """Generate a report synchronously and return the download URL. + Useful for demo and quick one-off reports without creating a task record. + """ + gen = ReportGenerator(db) + today = date.today() + + try: + if req.report_type == "daily": + filepath = await gen.generate_energy_daily_report( + start_date=req.start_date or today.replace(day=1), + end_date=req.end_date or today, + device_ids=req.device_ids, + export_format=req.export_format, + ) + elif req.report_type == "monthly": + filepath = await gen.generate_monthly_summary( + month=req.month or today.month, + year=req.year or today.year, + export_format=req.export_format, + ) + elif req.report_type == "device_status": + filepath = await gen.generate_device_status_report( + export_format=req.export_format, + ) + elif req.report_type == "alarm": + filepath = await gen.generate_alarm_report( + start_date=req.start_date or today.replace(day=1), + end_date=req.end_date or today, + export_format=req.export_format, + ) + elif req.report_type == "carbon": + filepath = await gen.generate_carbon_report( + start_date=req.start_date or today.replace(day=1), + end_date=req.end_date or today, + export_format=req.export_format, + ) + else: + raise HTTPException(status_code=400, detail=f"未知的报表类型: {req.report_type}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"报表生成失败: {str(e)}") + + filename = Path(filepath).name + await log_audit(db, user.id, "export", "report", detail=f"生成报表: {req.report_type} ({req.export_format})") + return { + "message": "报表生成成功", + "filename": filename, + "download_url": f"/api/v1/reports/download/{filename}", + } + + +@router.get("/download/{filename}") +async def download_by_filename( + filename: str, + user: User = Depends(get_current_user), +): + """Download a generated report file by filename.""" + filepath = REPORTS_DIR / filename + if not filepath.exists(): + raise HTTPException(status_code=404, detail="文件不存在") + # Prevent path traversal + if not filepath.resolve().parent == REPORTS_DIR.resolve(): + raise HTTPException(status_code=400, detail="非法文件路径") + + media_type = ( + "application/pdf" if filename.endswith(".pdf") + else "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + return FileResponse(str(filepath), filename=filename, media_type=media_type) diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py new file mode 100644 index 0000000..fcede7e --- /dev/null +++ b/backend/app/api/v1/settings.py @@ -0,0 +1,84 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user, require_roles +from app.models.user import User +from app.models.setting import SystemSetting +from app.services.audit import log_audit + +router = APIRouter(prefix="/settings", tags=["系统设置"]) + +# Default settings — used when keys are missing from DB +DEFAULTS: dict[str, str] = { + "platform_name": "天普零碳园区智慧能源管理平台", + "data_retention_days": "365", + "alarm_auto_resolve_minutes": "30", + "simulator_interval_seconds": "15", + "notification_email_enabled": "false", + "notification_email_smtp": "", + "report_auto_schedule_enabled": "false", + "timezone": "Asia/Shanghai", +} + + +class SettingsUpdate(BaseModel): + platform_name: str | None = None + data_retention_days: int | None = None + alarm_auto_resolve_minutes: int | None = None + simulator_interval_seconds: int | None = None + notification_email_enabled: bool | None = None + notification_email_smtp: str | None = None + report_auto_schedule_enabled: bool | None = None + timezone: str | None = None + + +@router.get("") +async def get_settings( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """Return all platform settings as a flat dict.""" + result = await db.execute(select(SystemSetting)) + db_settings = {s.key: s.value for s in result.scalars().all()} + # Merge defaults with DB values + merged = {**DEFAULTS, **db_settings} + # Cast types for frontend + return { + "platform_name": merged["platform_name"], + "data_retention_days": int(merged["data_retention_days"]), + "alarm_auto_resolve_minutes": int(merged["alarm_auto_resolve_minutes"]), + "simulator_interval_seconds": int(merged["simulator_interval_seconds"]), + "notification_email_enabled": merged["notification_email_enabled"] == "true", + "notification_email_smtp": merged["notification_email_smtp"], + "report_auto_schedule_enabled": merged["report_auto_schedule_enabled"] == "true", + "timezone": merged["timezone"], + } + + +@router.put("") +async def update_settings( + data: SettingsUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin")), +): + """Update platform settings (admin only).""" + updates = data.model_dump(exclude_unset=True) + changed_keys = [] + for key, value in updates.items(): + str_value = str(value).lower() if isinstance(value, bool) else str(value) + result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) + setting = result.scalar_one_or_none() + if setting: + setting.value = str_value + else: + db.add(SystemSetting(key=key, value=str_value)) + changed_keys.append(key) + + await log_audit( + db, user.id, "update", "system", + detail=f"更新系统设置: {', '.join(changed_keys)}", + ) + + return {"message": "设置已更新"} diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..1cb6b39 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,83 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user, require_roles +from app.core.security import hash_password +from app.models.user import User, Role +from app.services.audit import log_audit + +router = APIRouter(prefix="/users", tags=["用户管理"]) + + +class UserCreate(BaseModel): + username: str + password: str + full_name: str | None = None + email: str | None = None + phone: str | None = None + role: str = "visitor" + + +class UserUpdate(BaseModel): + full_name: str | None = None + email: str | None = None + phone: str | None = None + role: str | None = None + is_active: bool | None = None + + +@router.get("") +async def list_users( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + count_q = select(func.count(User.id)) + total = (await db.execute(count_q)).scalar() + result = await db.execute(select(User).offset((page - 1) * page_size).limit(page_size).order_by(User.id)) + return { + "total": total, + "items": [{ + "id": u.id, "username": u.username, "full_name": u.full_name, + "email": u.email, "phone": u.phone, "role": u.role, + "is_active": u.is_active, "last_login": str(u.last_login) if u.last_login else None, + } for u in result.scalars().all()] + } + + +@router.post("") +async def create_user(data: UserCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin"))): + existing = await db.execute(select(User).where(User.username == data.username)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="用户名已存在") + new_user = User( + username=data.username, hashed_password=hash_password(data.password), + full_name=data.full_name, email=data.email, phone=data.phone, role=data.role, + ) + db.add(new_user) + await db.flush() + await log_audit(db, user.id, "create", "user", detail=f"创建用户 {data.username}") + return {"id": new_user.id, "username": new_user.username} + + +@router.put("/{user_id}") +async def update_user(user_id: int, data: UserUpdate, db: AsyncSession = Depends(get_db), admin: User = Depends(require_roles("admin"))): + result = await db.execute(select(User).where(User.id == user_id)) + target = result.scalar_one_or_none() + if not target: + raise HTTPException(status_code=404, detail="用户不存在") + updates = data.model_dump(exclude_unset=True) + for k, v in updates.items(): + setattr(target, k, v) + await log_audit(db, admin.id, "update", "user", detail=f"更新用户 {target.username}: {', '.join(updates.keys())}") + return {"message": "已更新"} + + +@router.get("/roles") +async def list_roles(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute(select(Role).order_by(Role.id)) + return [{"id": r.id, "name": r.name, "display_name": r.display_name, "description": r.description} + for r in result.scalars().all()] diff --git a/backend/app/api/v1/weather.py b/backend/app/api/v1/weather.py new file mode 100644 index 0000000..948005e --- /dev/null +++ b/backend/app/api/v1/weather.py @@ -0,0 +1,83 @@ +from datetime import datetime +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.user import User +from app.services.weather_service import ( + get_current_weather, get_forecast, get_weather_history, + get_weather_impact, get_weather_config, update_weather_config, +) + +router = APIRouter(prefix="/weather", tags=["气象数据"]) + + +class WeatherConfigUpdate(BaseModel): + api_provider: str | None = None + api_key: str | None = None + location_lat: float | None = None + location_lon: float | None = None + fetch_interval_minutes: int | None = None + is_enabled: bool | None = None + + +@router.get("/current") +async def current_weather( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取当前天气""" + return await get_current_weather(db) + + +@router.get("/forecast") +async def weather_forecast( + hours: int = Query(default=72, ge=1, le=168), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取72h天气预报""" + return await get_forecast(db, hours) + + +@router.get("/history") +async def weather_history( + start_date: str = Query(..., description="开始日期 e.g. 2026-03-01"), + end_date: str = Query(..., description="结束日期 e.g. 2026-04-01"), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取历史天气数据""" + start = datetime.fromisoformat(start_date) + end = datetime.fromisoformat(end_date) + return await get_weather_history(db, start, end) + + +@router.get("/impact") +async def weather_impact( + days: int = Query(default=30, ge=1, le=365), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """天气对能源的影响分析""" + return await get_weather_impact(db, days) + + +@router.get("/config") +async def get_config( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取气象API配置""" + return await get_weather_config(db) + + +@router.put("/config") +async def set_config( + data: WeatherConfigUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """更新气象API配置""" + return await update_weather_config(db, data.model_dump(exclude_none=True)) diff --git a/backend/app/api/v1/websocket.py b/backend/app/api/v1/websocket.py new file mode 100644 index 0000000..d81493a --- /dev/null +++ b/backend/app/api/v1/websocket.py @@ -0,0 +1,227 @@ +""" +WebSocket endpoint for real-time data push. + +Provides instant updates to connected clients (BigScreen, dashboards) +instead of relying solely on polling. +""" + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from typing import Optional + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query +from sqlalchemy import select, func, and_ + +from app.core.security import decode_access_token +from app.core.database import async_session +from app.models.device import Device +from app.models.energy import EnergyData +from app.models.alarm import AlarmEvent + +logger = logging.getLogger("app.websocket") + +router = APIRouter(tags=["WebSocket"]) + + +class ConnectionManager: + """Manages active WebSocket connections.""" + + def __init__(self): + self.active_connections: list[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + logger.info(f"WebSocket connected. Total: {len(self.active_connections)}") + + def disconnect(self, websocket: WebSocket): + if websocket in self.active_connections: + self.active_connections.remove(websocket) + logger.info(f"WebSocket disconnected. Total: {len(self.active_connections)}") + + async def broadcast(self, message: dict): + """Send message to all connected clients, removing dead connections.""" + disconnected = [] + for connection in self.active_connections: + try: + await connection.send_json(message) + except Exception: + disconnected.append(connection) + for conn in disconnected: + self.disconnect(conn) + + +manager = ConnectionManager() + +# Background task reference +_broadcast_task: Optional[asyncio.Task] = None + + +async def get_realtime_snapshot() -> dict: + """Fetch latest realtime data from the database. + + Mirrors the logic in dashboard.get_realtime_data: + - Query recent power data points (last 5 min) + - Aggregate by device type (PV inverters vs heat pumps) + """ + try: + async with async_session() as db: + now = datetime.now(timezone.utc) + five_min_ago = now - timedelta(minutes=5) + + # Get recent power data points + 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() + + # Get PV and heat pump device IDs + pv_q = await db.execute( + select(Device.id).where( + Device.device_type == "pv_inverter", + Device.is_active == True, + ) + ) + pv_ids = {r[0] for r in pv_q.fetchall()} + + hp_q = await db.execute( + select(Device.id).where( + Device.device_type == "heat_pump", + Device.is_active == True, + ) + ) + hp_ids = {r[0] for r in hp_q.fetchall()} + + 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) + total_load = pv_power + heatpump_power + grid_power = max(0, heatpump_power - pv_power) + + # Count active alarms + alarm_count_q = await db.execute( + select(func.count(AlarmEvent.id)).where( + AlarmEvent.status == 'active' + ) + ) + active_alarms = alarm_count_q.scalar() or 0 + + return { + "pv_power": round(pv_power, 1), + "heatpump_power": round(heatpump_power, 1), + "total_load": round(total_load, 1), + "grid_power": round(grid_power, 1), + "active_alarms": active_alarms, + "timestamp": now.isoformat(), + } + except Exception as e: + logger.error(f"Error fetching realtime snapshot: {e}") + return { + "pv_power": 0, + "heatpump_power": 0, + "total_load": 0, + "grid_power": 0, + "active_alarms": 0, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + +async def broadcast_loop(): + """Background task: broadcast realtime data every 15 seconds.""" + while True: + try: + await asyncio.sleep(15) + if manager.active_connections: + data = await get_realtime_snapshot() + await manager.broadcast({ + "type": "realtime_update", + "data": data, + }) + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Broadcast loop error: {e}") + await asyncio.sleep(5) + + +async def broadcast_alarm_event(alarm_data: dict): + """Called externally when a new alarm is triggered.""" + if manager.active_connections: + await manager.broadcast({ + "type": "alarm_event", + "data": alarm_data, + }) + + +def start_broadcast_task(): + """Start the background broadcast loop. Call during app startup.""" + global _broadcast_task + if _broadcast_task is None or _broadcast_task.done(): + _broadcast_task = asyncio.create_task(broadcast_loop()) + logger.info("WebSocket broadcast task started") + + +def stop_broadcast_task(): + """Stop the background broadcast loop. Call during app shutdown.""" + global _broadcast_task + if _broadcast_task and not _broadcast_task.done(): + _broadcast_task.cancel() + logger.info("WebSocket broadcast task stopped") + + +@router.websocket("/ws/realtime") +async def websocket_realtime( + websocket: WebSocket, + token: str = Query(default=""), +): + """ + WebSocket endpoint for real-time energy data. + + Connect with: ws://host/api/v1/ws/realtime?token= + + Messages sent to clients: + - type: "realtime_update" - periodic snapshot every 15s + - type: "alarm_event" - when a new alarm triggers + """ + # Authenticate + if not token: + await websocket.close(code=4001, reason="Missing token") + return + + payload = decode_access_token(token) + if payload is None: + await websocket.close(code=4001, reason="Invalid token") + return + + await manager.connect(websocket) + + # Ensure broadcast task is running + start_broadcast_task() + + # Send initial data immediately + try: + initial_data = await get_realtime_snapshot() + await websocket.send_json({ + "type": "realtime_update", + "data": initial_data, + }) + except Exception as e: + logger.error(f"Error sending initial data: {e}") + + # Keep connection alive and handle incoming messages + try: + while True: + # Wait for any client message (ping/pong, or just keep alive) + data = await websocket.receive_text() + # Client can send "ping" to keep alive + if data == "ping": + await websocket.send_json({"type": "pong"}) + except WebSocketDisconnect: + manager.disconnect(websocket) + except Exception: + manager.disconnect(websocket) diff --git a/backend/app/collectors/__init__.py b/backend/app/collectors/__init__.py new file mode 100644 index 0000000..34a8b69 --- /dev/null +++ b/backend/app/collectors/__init__.py @@ -0,0 +1,5 @@ +"""IoT data collection framework with protocol-specific collectors.""" +from app.collectors.base import BaseCollector +from app.collectors.manager import CollectorManager, COLLECTOR_REGISTRY + +__all__ = ["BaseCollector", "CollectorManager", "COLLECTOR_REGISTRY"] diff --git a/backend/app/collectors/base.py b/backend/app/collectors/base.py new file mode 100644 index 0000000..37bfc10 --- /dev/null +++ b/backend/app/collectors/base.py @@ -0,0 +1,160 @@ +"""Base collector abstract class for IoT data collection.""" +import asyncio +import logging +from abc import ABC, abstractmethod +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import select + +from app.core.database import async_session +from app.models.device import Device +from app.models.energy import EnergyData + + +class BaseCollector(ABC): + """Abstract base class for all protocol collectors.""" + + MAX_BACKOFF = 300 # 5 minutes max backoff + + def __init__( + self, + device_id: int, + device_code: str, + connection_params: dict, + collect_interval: int = 15, + ): + self.device_id = device_id + self.device_code = device_code + self.connection_params = connection_params or {} + self.collect_interval = collect_interval + self.status = "disconnected" + self.last_error: Optional[str] = None + self.last_collect_time: Optional[datetime] = None + self._task: Optional[asyncio.Task] = None + self._running = False + self._backoff = 1 + self.logger = logging.getLogger(f"collector.{device_code}") + + @abstractmethod + async def connect(self): + """Establish connection to the device.""" + + @abstractmethod + async def disconnect(self): + """Clean up connection resources.""" + + @abstractmethod + async def collect(self) -> dict: + """Collect data points from the device. + + Returns a dict mapping data_type -> (value, unit), e.g.: + {"power": (105.3, "kW"), "voltage": (220.1, "V")} + """ + + async def start(self): + """Start the collector loop.""" + self._running = True + self._task = asyncio.create_task(self._run(), name=f"collector-{self.device_code}") + self.logger.info("Collector started for %s", self.device_code) + + async def stop(self): + """Stop the collector loop and disconnect.""" + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + try: + await self.disconnect() + except Exception as e: + self.logger.warning("Error during disconnect: %s", e) + self.status = "disconnected" + self.logger.info("Collector stopped for %s", self.device_code) + + async def _run(self): + """Main loop: connect, collect at interval, save to DB.""" + while self._running: + # Connect phase + if self.status != "connected": + try: + await self.connect() + self.status = "connected" + self.last_error = None + self._backoff = 1 + self.logger.info("Connected to %s", self.device_code) + except Exception as e: + self.status = "error" + self.last_error = str(e) + self.logger.error("Connection failed for %s: %s", self.device_code, e) + await self._wait_backoff() + continue + + # Collect phase + try: + data = await self.collect() + if data: + await self._save_data(data) + self.last_collect_time = datetime.now(timezone.utc) + self._backoff = 1 + except Exception as e: + self.status = "error" + self.last_error = str(e) + self.logger.error("Collect error for %s: %s", self.device_code, e) + try: + await self.disconnect() + except Exception: + pass + self.status = "disconnected" + await self._wait_backoff() + continue + + await asyncio.sleep(self.collect_interval) + + async def _wait_backoff(self): + """Wait with exponential backoff.""" + wait_time = min(self._backoff, self.MAX_BACKOFF) + self.logger.debug("Backing off %ds for %s", wait_time, self.device_code) + await asyncio.sleep(wait_time) + self._backoff = min(self._backoff * 2, self.MAX_BACKOFF) + + async def _save_data(self, data: dict): + """Save collected data points to the database.""" + now = datetime.now(timezone.utc) + async with async_session() as session: + points = [] + for data_type, (value, unit) in data.items(): + points.append( + EnergyData( + device_id=self.device_id, + timestamp=now, + data_type=data_type, + value=float(value), + unit=unit, + ) + ) + # Update device status + result = await session.execute( + select(Device).where(Device.id == self.device_id) + ) + device = result.scalar_one_or_none() + if device: + device.status = "online" + device.last_data_time = now + + session.add_all(points) + await session.commit() + self.logger.debug("Saved %d points for %s", len(points), self.device_code) + + def get_status(self) -> dict: + """Return collector status info.""" + return { + "device_id": self.device_id, + "device_code": self.device_code, + "status": self.status, + "last_error": self.last_error, + "last_collect_time": self.last_collect_time.isoformat() if self.last_collect_time else None, + "collect_interval": self.collect_interval, + } diff --git a/backend/app/collectors/http_collector.py b/backend/app/collectors/http_collector.py new file mode 100644 index 0000000..4a9267e --- /dev/null +++ b/backend/app/collectors/http_collector.py @@ -0,0 +1,107 @@ +"""HTTP API protocol collector.""" +from typing import Optional + +import httpx + +from app.collectors.base import BaseCollector + + +class HttpCollector(BaseCollector): + """Collect data by polling HTTP API endpoints. + + connection_params example: + { + "url": "http://api.example.com/device/data", + "method": "GET", + "headers": {"X-API-Key": "abc123"}, + "auth": {"type": "basic", "username": "user", "password": "pass"}, + "data_mapping": { + "active_power": {"key": "data.power", "unit": "kW"}, + "voltage": {"key": "data.voltage", "unit": "V"} + }, + "timeout": 10 + } + """ + + def __init__(self, device_id, device_code, connection_params, collect_interval=15): + super().__init__(device_id, device_code, connection_params, collect_interval) + self._url = connection_params.get("url", "") + self._method = connection_params.get("method", "GET").upper() + self._headers = connection_params.get("headers", {}) + self._auth_config = connection_params.get("auth", {}) + self._data_mapping = connection_params.get("data_mapping", {}) + self._timeout = connection_params.get("timeout", 10) + self._client: Optional[httpx.AsyncClient] = None + + async def connect(self): + auth = None + auth_type = self._auth_config.get("type", "") + if auth_type == "basic": + auth = httpx.BasicAuth( + self._auth_config.get("username", ""), + self._auth_config.get("password", ""), + ) + + headers = dict(self._headers) + if auth_type == "token": + token = self._auth_config.get("token", "") + headers["Authorization"] = f"Bearer {token}" + + self._client = httpx.AsyncClient( + headers=headers, + auth=auth, + timeout=self._timeout, + ) + # Verify connectivity with a test request + response = await self._client.request(self._method, self._url) + response.raise_for_status() + + async def disconnect(self): + if self._client: + await self._client.aclose() + self._client = None + + async def collect(self) -> dict: + if not self._client: + raise ConnectionError("HTTP client not initialized") + + response = await self._client.request(self._method, self._url) + response.raise_for_status() + payload = response.json() + + return self._parse_response(payload) + + def _parse_response(self, payload: dict) -> dict: + """Parse HTTP JSON response into data points. + + Supports dotted key paths like "data.power" to navigate nested JSON. + """ + data = {} + if self._data_mapping: + for data_type, mapping in self._data_mapping.items(): + key_path = mapping.get("key", data_type) + unit = mapping.get("unit", "") + value = self._resolve_path(payload, key_path) + if value is not None: + try: + data[data_type] = (float(value), unit) + except (TypeError, ValueError): + pass + else: + # Auto-detect numeric fields at top level + for key, value in payload.items(): + if isinstance(value, (int, float)): + data[key] = (float(value), "") + return data + + @staticmethod + def _resolve_path(obj: dict, path: str): + """Resolve a dotted path like 'data.power' in a nested dict.""" + parts = path.split(".") + current = obj + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current diff --git a/backend/app/collectors/manager.py b/backend/app/collectors/manager.py new file mode 100644 index 0000000..5707869 --- /dev/null +++ b/backend/app/collectors/manager.py @@ -0,0 +1,154 @@ +"""Collector Manager - orchestrates all device collectors.""" +import logging +from typing import Optional + +from sqlalchemy import select + +from app.core.config import get_settings +from app.core.database import async_session +from app.models.device import Device +from app.collectors.base import BaseCollector +from app.collectors.modbus_tcp import ModbusTcpCollector +from app.collectors.mqtt_collector import MqttCollector +from app.collectors.http_collector import HttpCollector + +logger = logging.getLogger("collector.manager") + +# Full registry mapping protocol names to collector classes +COLLECTOR_REGISTRY: dict[str, type[BaseCollector]] = { + "modbus_tcp": ModbusTcpCollector, + "mqtt": MqttCollector, + "http_api": HttpCollector, +} + + +def get_enabled_collectors() -> dict[str, type[BaseCollector]]: + """Return collector registry filtered by customer config. + + If the customer config specifies a 'collectors' list, only those + protocols are enabled. Otherwise fall back to the full registry. + """ + settings = get_settings() + customer_config = settings.load_customer_config() + enabled_list = customer_config.get("collectors") + if enabled_list is None: + return COLLECTOR_REGISTRY + enabled = {} + for name in enabled_list: + if name in COLLECTOR_REGISTRY: + enabled[name] = COLLECTOR_REGISTRY[name] + else: + logger.warning("Customer config references unknown collector '%s', skipping", name) + return enabled + + +class CollectorManager: + """Manages lifecycle of all device collectors.""" + + def __init__(self): + self._collectors: dict[int, BaseCollector] = {} # device_id -> collector + self._running = False + + async def start(self): + """Load active devices from DB and start their collectors.""" + self._running = True + await self._load_and_start_collectors() + logger.info("CollectorManager started with %d collectors", len(self._collectors)) + + async def stop(self): + """Stop all collectors.""" + self._running = False + for device_id, collector in self._collectors.items(): + try: + await collector.stop() + except Exception as e: + logger.error("Error stopping collector for device %d: %s", device_id, e) + self._collectors.clear() + logger.info("CollectorManager stopped") + + async def _load_and_start_collectors(self): + """Load active devices with supported protocols and start collectors.""" + enabled = get_enabled_collectors() + logger.info("Enabled collectors: %s", list(enabled.keys())) + async with async_session() as session: + result = await session.execute( + select(Device).where( + Device.is_active == True, + Device.protocol.in_(list(enabled.keys())), + ) + ) + devices = result.scalars().all() + + for device in devices: + await self.start_collector( + device.id, + device.code, + device.protocol, + device.connection_params or {}, + device.collect_interval or 15, + ) + + async def start_collector( + self, + device_id: int, + device_code: str, + protocol: str, + connection_params: dict, + collect_interval: int, + ) -> bool: + """Start a single collector for a device.""" + if device_id in self._collectors: + logger.warning("Collector already running for device %d", device_id) + return False + + collector_cls = COLLECTOR_REGISTRY.get(protocol) + if not collector_cls: + logger.warning("No collector for protocol '%s' (device %s)", protocol, device_code) + return False + + collector = collector_cls(device_id, device_code, connection_params, collect_interval) + self._collectors[device_id] = collector + await collector.start() + logger.info("Started %s collector for %s", protocol, device_code) + return True + + async def stop_collector(self, device_id: int) -> bool: + """Stop collector for a specific device.""" + collector = self._collectors.pop(device_id, None) + if not collector: + return False + await collector.stop() + return True + + async def restart_collector(self, device_id: int) -> bool: + """Restart collector for a device by reloading its config from DB.""" + await self.stop_collector(device_id) + async with async_session() as session: + result = await session.execute( + select(Device).where(Device.id == device_id) + ) + device = result.scalar_one_or_none() + if not device or not device.is_active: + return False + return await self.start_collector( + device.id, + device.code, + device.protocol, + device.connection_params or {}, + device.collect_interval or 15, + ) + + def get_collector(self, device_id: int) -> Optional[BaseCollector]: + return self._collectors.get(device_id) + + def get_all_status(self) -> list[dict]: + """Return status of all collectors.""" + return [c.get_status() for c in self._collectors.values()] + + @property + def collector_count(self) -> int: + return len(self._collectors) + + @property + def is_running(self) -> bool: + return self._running diff --git a/backend/app/collectors/modbus_tcp.py b/backend/app/collectors/modbus_tcp.py new file mode 100644 index 0000000..73f1118 --- /dev/null +++ b/backend/app/collectors/modbus_tcp.py @@ -0,0 +1,87 @@ +"""Modbus TCP protocol collector.""" +import struct +from typing import Optional + +from pymodbus.client import AsyncModbusTcpClient + +from app.collectors.base import BaseCollector + + +class ModbusTcpCollector(BaseCollector): + """Collect data from devices via Modbus TCP. + + connection_params example: + { + "host": "192.168.1.100", + "port": 502, + "slave_id": 1, + "registers": [ + {"address": 0, "count": 2, "data_type": "active_power", "scale": 0.1, "unit": "kW"}, + {"address": 2, "count": 2, "data_type": "voltage", "scale": 0.1, "unit": "V"} + ] + } + """ + + def __init__(self, device_id, device_code, connection_params, collect_interval=15): + super().__init__(device_id, device_code, connection_params, collect_interval) + self._client: Optional[AsyncModbusTcpClient] = None + self._host = connection_params.get("host", "127.0.0.1") + self._port = connection_params.get("port", 502) + self._slave_id = connection_params.get("slave_id", 1) + self._registers = connection_params.get("registers", []) + + async def connect(self): + self._client = AsyncModbusTcpClient( + self._host, + port=self._port, + timeout=5, + ) + connected = await self._client.connect() + if not connected: + raise ConnectionError(f"Cannot connect to Modbus TCP {self._host}:{self._port}") + + async def disconnect(self): + if self._client: + self._client.close() + self._client = None + + async def collect(self) -> dict: + if not self._client or not self._client.connected: + raise ConnectionError("Modbus client not connected") + + data = {} + for reg in self._registers: + address = reg["address"] + count = reg.get("count", 1) + data_type = reg["data_type"] + scale = reg.get("scale", 1.0) + unit = reg.get("unit", "") + + result = await self._client.read_holding_registers( + address, count=count, slave=self._slave_id + ) + if result.isError(): + self.logger.warning( + "Modbus read error at address %d for %s: %s", + address, self.device_code, result, + ) + continue + + raw_value = self._decode_registers(result.registers, count) + value = round(raw_value * scale, 4) + data[data_type] = (value, unit) + + return data + + @staticmethod + def _decode_registers(registers: list, count: int) -> float: + """Decode register values to a numeric value.""" + if count == 1: + return float(registers[0]) + elif count == 2: + # Two 16-bit registers -> 32-bit float (big-endian) + raw = struct.pack(">HH", registers[0], registers[1]) + return struct.unpack(">f", raw)[0] + else: + # Fallback: treat as concatenated 16-bit values + return float(registers[0]) diff --git a/backend/app/collectors/mqtt_collector.py b/backend/app/collectors/mqtt_collector.py new file mode 100644 index 0000000..f45e7bc --- /dev/null +++ b/backend/app/collectors/mqtt_collector.py @@ -0,0 +1,117 @@ +"""MQTT protocol collector.""" +import json +from typing import Optional + +import aiomqtt + +from app.collectors.base import BaseCollector + + +class MqttCollector(BaseCollector): + """Collect data from devices via MQTT subscription. + + connection_params example: + { + "broker": "localhost", + "port": 1883, + "topic": "device/INV-001/data", + "username": "", + "password": "", + "data_mapping": { + "active_power": {"key": "power", "unit": "kW"}, + "voltage": {"key": "voltage", "unit": "V"} + } + } + """ + + def __init__(self, device_id, device_code, connection_params, collect_interval=15): + super().__init__(device_id, device_code, connection_params, collect_interval) + self._broker = connection_params.get("broker", "localhost") + self._port = connection_params.get("port", 1883) + self._topic = connection_params.get("topic", f"device/{device_code}/data") + self._username = connection_params.get("username", "") or None + self._password = connection_params.get("password", "") or None + self._data_mapping = connection_params.get("data_mapping", {}) + self._client: Optional[aiomqtt.Client] = None + self._latest_data: dict = {} + + async def connect(self): + # Connection is established in the run loop via context manager + pass + + async def disconnect(self): + self._client = None + + async def collect(self) -> dict: + # Return latest received data; cleared after read + data = self._latest_data.copy() + self._latest_data.clear() + return data + + async def _run(self): + """Override run loop to use MQTT's push-based model.""" + while self._running: + try: + async with aiomqtt.Client( + self._broker, + port=self._port, + username=self._username, + password=self._password, + ) as client: + self._client = client + self.status = "connected" + self.last_error = None + self._backoff = 1 + self.logger.info("MQTT connected to %s:%d", self._broker, self._port) + + await client.subscribe(self._topic) + self.logger.info("Subscribed to %s", self._topic) + + async for message in client.messages: + if not self._running: + break + try: + payload = json.loads(message.payload.decode()) + data = self._parse_payload(payload) + if data: + self._latest_data.update(data) + await self._save_data(data) + from datetime import datetime, timezone + self.last_collect_time = datetime.now(timezone.utc) + except (json.JSONDecodeError, ValueError) as e: + self.logger.warning("Bad MQTT payload on %s: %s", message.topic, e) + + except aiomqtt.MqttError as e: + self.status = "error" + self.last_error = str(e) + self.logger.error("MQTT error for %s: %s", self.device_code, e) + await self._wait_backoff() + except Exception as e: + self.status = "error" + self.last_error = str(e) + self.logger.error("Unexpected MQTT error for %s: %s", self.device_code, e) + await self._wait_backoff() + + self.status = "disconnected" + + def _parse_payload(self, payload: dict) -> dict: + """Parse MQTT JSON payload into data points. + + If data_mapping is configured, use it. Otherwise, treat all + numeric top-level keys as data points with empty units. + """ + data = {} + if self._data_mapping: + for data_type, mapping in self._data_mapping.items(): + key = mapping.get("key", data_type) + unit = mapping.get("unit", "") + if key in payload: + try: + data[data_type] = (float(payload[key]), unit) + except (TypeError, ValueError): + pass + else: + for key, value in payload.items(): + if isinstance(value, (int, float)): + data[key] = (float(value), "") + return data diff --git a/backend/app/collectors/queue.py b/backend/app/collectors/queue.py new file mode 100644 index 0000000..3b3648b --- /dev/null +++ b/backend/app/collectors/queue.py @@ -0,0 +1,185 @@ +"""Redis Streams-based data ingestion buffer for high-throughput device data.""" +import asyncio +import json +import logging +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import select + +from app.core.cache import get_redis +from app.core.config import get_settings +from app.core.database import async_session +from app.models.energy import EnergyData + +logger = logging.getLogger("ingestion.queue") + +STREAM_KEY = "ems:ingestion:stream" +CONSUMER_GROUP = "ems:ingestion:workers" +CONSUMER_NAME = "worker-1" + + +class IngestionQueue: + """Push device data into a Redis Stream for buffered ingestion.""" + + async def push( + self, + device_id: int, + data_type: str, + value: float, + unit: str, + timestamp: Optional[str] = None, + raw_data: Optional[dict] = None, + ) -> Optional[str]: + """Add a data point to the ingestion stream. + + Returns the message ID on success, None on failure. + """ + redis = await get_redis() + if not redis: + return None + try: + fields = { + "device_id": str(device_id), + "data_type": data_type, + "value": str(value), + "unit": unit, + "timestamp": timestamp or datetime.now(timezone.utc).isoformat(), + } + if raw_data: + fields["raw_data"] = json.dumps(raw_data, ensure_ascii=False, default=str) + msg_id = await redis.xadd(STREAM_KEY, fields) + return msg_id + except Exception as e: + logger.error("Failed to push to ingestion stream: %s", e) + return None + + async def consume_batch(self, count: int = 100) -> list[tuple[str, dict]]: + """Read up to `count` messages from the stream via consumer group. + + Returns list of (message_id, fields) tuples. + """ + redis = await get_redis() + if not redis: + return [] + try: + # Ensure consumer group exists + try: + await redis.xgroup_create(STREAM_KEY, CONSUMER_GROUP, id="0", mkstream=True) + except Exception: + # Group already exists + pass + + messages = await redis.xreadgroup( + CONSUMER_GROUP, + CONSUMER_NAME, + {STREAM_KEY: ">"}, + count=count, + block=1000, + ) + if not messages: + return [] + # messages format: [(stream_key, [(msg_id, fields), ...])] + return messages[0][1] + except Exception as e: + logger.error("Failed to consume from ingestion stream: %s", e) + return [] + + async def ack(self, message_ids: list[str]) -> int: + """Acknowledge processed messages. + + Returns number of successfully acknowledged messages. + """ + redis = await get_redis() + if not redis or not message_ids: + return 0 + try: + return await redis.xack(STREAM_KEY, CONSUMER_GROUP, *message_ids) + except Exception as e: + logger.error("Failed to ack messages: %s", e) + return 0 + + +class IngestionWorker: + """Background worker that drains the ingestion stream and bulk-inserts to DB.""" + + def __init__(self, batch_size: int = 100, interval: float = 2.0): + self.batch_size = batch_size + self.interval = interval + self._queue = IngestionQueue() + self._running = False + self._task: Optional[asyncio.Task] = None + + async def start(self): + """Start the background ingestion worker.""" + self._running = True + self._task = asyncio.create_task(self._run(), name="ingestion-worker") + logger.info( + "IngestionWorker started (batch_size=%d, interval=%.1fs)", + self.batch_size, + self.interval, + ) + + async def stop(self): + """Stop the ingestion worker gracefully.""" + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("IngestionWorker stopped.") + + async def _run(self): + """Main loop: consume batches from stream and insert to DB.""" + while self._running: + try: + messages = await self._queue.consume_batch(count=self.batch_size) + if messages: + await self._process_batch(messages) + else: + await asyncio.sleep(self.interval) + except asyncio.CancelledError: + raise + except Exception as e: + logger.error("IngestionWorker error: %s", e, exc_info=True) + await asyncio.sleep(self.interval) + + async def _process_batch(self, messages: list[tuple[str, dict]]): + """Parse messages and bulk-insert EnergyData rows.""" + msg_ids = [] + rows = [] + for msg_id, fields in messages: + msg_ids.append(msg_id) + try: + ts_str = fields.get("timestamp", "") + timestamp = datetime.fromisoformat(ts_str) if ts_str else datetime.now(timezone.utc) + raw = None + if "raw_data" in fields: + try: + raw = json.loads(fields["raw_data"]) + except (json.JSONDecodeError, TypeError): + raw = None + rows.append( + EnergyData( + device_id=int(fields["device_id"]), + timestamp=timestamp, + data_type=fields["data_type"], + value=float(fields["value"]), + unit=fields.get("unit", ""), + raw_data=raw, + ) + ) + except (KeyError, ValueError) as e: + logger.warning("Skipping malformed message %s: %s", msg_id, e) + + if rows: + async with async_session() as session: + session.add_all(rows) + await session.commit() + logger.debug("Bulk-inserted %d rows from ingestion stream.", len(rows)) + + # Acknowledge all messages (including malformed ones to avoid reprocessing) + if msg_ids: + await self._queue.ack(msg_ids) diff --git a/backend/app/collectors/sungrow_collector.py b/backend/app/collectors/sungrow_collector.py new file mode 100644 index 0000000..5dc885e --- /dev/null +++ b/backend/app/collectors/sungrow_collector.py @@ -0,0 +1,204 @@ +"""阳光电源 iSolarCloud API 数据采集器""" +import time +from datetime import datetime, timezone +from typing import Optional + +import httpx + +from app.collectors.base import BaseCollector + + +class SungrowCollector(BaseCollector): + """Collect data from Sungrow inverters via iSolarCloud OpenAPI. + + connection_params example: + { + "api_base": "https://gateway.isolarcloud.com", + "app_key": "1BF313B6A9F919A6FB6A90BD43D23395", + "sys_code": "901", + "x_access_key": "qpthtsf287zvtmr6t3q9hsc0k70f3tay", + "user_account": "13911211695", + "user_password": "123456#ABC", + "ps_id": "power_station_id", + "device_sn": "optional_device_serial" + } + """ + + TOKEN_LIFETIME = 23 * 3600 # Refresh before 24h expiry + + def __init__(self, device_id, device_code, connection_params, collect_interval=900): + super().__init__(device_id, device_code, connection_params, collect_interval) + self._api_base = connection_params.get("api_base", "https://gateway.isolarcloud.com").rstrip("/") + self._app_key = connection_params.get("app_key", "") + self._sys_code = connection_params.get("sys_code", "901") + self._x_access_key = connection_params.get("x_access_key", "") + self._user_account = connection_params.get("user_account", "") + self._user_password = connection_params.get("user_password", "") + self._ps_id = connection_params.get("ps_id", "") + self._device_sn = connection_params.get("device_sn", "") + self._client: Optional[httpx.AsyncClient] = None + self._token: Optional[str] = None + self._token_obtained_at: float = 0 + + async def connect(self): + """Establish HTTP client and authenticate with iSolarCloud.""" + self._client = httpx.AsyncClient(timeout=30) + await self._login() + self.logger.info("Authenticated with iSolarCloud for %s", self.device_code) + + async def disconnect(self): + """Close HTTP client.""" + if self._client: + await self._client.aclose() + self._client = None + self._token = None + + async def collect(self) -> dict: + """Collect real-time data from the Sungrow inverter. + + Returns a dict mapping data_type -> (value, unit). + """ + if not self._client: + raise ConnectionError("HTTP client not initialized") + + # Refresh token if close to expiry + if self._token_needs_refresh(): + await self._login() + + data = {} + + # Fetch power station overview for power/energy data + if self._ps_id: + ps_data = await self._get_station_data() + if ps_data: + data.update(ps_data) + + # Fetch device list for per-device metrics + if self._ps_id: + dev_data = await self._get_device_data() + if dev_data: + data.update(dev_data) + + return data + + # ------------------------------------------------------------------ + # Internal API methods + # ------------------------------------------------------------------ + + async def _login(self): + """POST /openapi/login to obtain access token.""" + payload = { + "appkey": self._app_key, + "sys_code": self._sys_code, + "user_account": self._user_account, + "user_password": self._user_password, + } + result = await self._api_call("/openapi/login", payload, auth=False) + + token = result.get("token") + if not token: + raise ConnectionError(f"Login failed: {result.get('msg', 'no token returned')}") + + self._token = token + self._token_obtained_at = time.monotonic() + self.logger.info("iSolarCloud login successful for account %s", self._user_account) + + async def _get_station_data(self) -> dict: + """Fetch power station real-time data.""" + payload = {"ps_id": self._ps_id} + result = await self._api_call("/openapi/getPowerStationList", payload) + + data = {} + stations = result.get("pageList", []) + for station in stations: + if str(station.get("ps_id")) == str(self._ps_id): + # Map station-level fields + if "curr_power" in station: + data["power"] = (float(station["curr_power"]), "kW") + if "today_energy" in station: + data["daily_energy"] = (float(station["today_energy"]), "kWh") + if "total_energy" in station: + data["total_energy"] = (float(station["total_energy"]), "kWh") + break + + return data + + async def _get_device_data(self) -> dict: + """Fetch device-level real-time data for the target inverter.""" + payload = {"ps_id": self._ps_id} + result = await self._api_call("/openapi/getDeviceList", payload) + + data = {} + devices = result.get("pageList", []) + for device in devices: + # Match by serial number if specified, otherwise use first inverter + if self._device_sn and device.get("device_sn") != self._device_sn: + continue + + device_type = device.get("device_type", 0) + # device_type 1 = inverter in Sungrow API + if device_type in (1, "1") or not self._device_sn: + if "device_power" in device: + data["power"] = (float(device["device_power"]), "kW") + if "today_energy" in device: + data["daily_energy"] = (float(device["today_energy"]), "kWh") + if "total_energy" in device: + data["total_energy"] = (float(device["total_energy"]), "kWh") + if "temperature" in device: + data["temperature"] = (float(device["temperature"]), "°C") + if "dc_voltage" in device: + data["voltage"] = (float(device["dc_voltage"]), "V") + if "ac_current" in device: + data["current"] = (float(device["ac_current"]), "A") + if "frequency" in device: + data["frequency"] = (float(device["frequency"]), "Hz") + if self._device_sn: + break + + return data + + async def _api_call(self, path: str, payload: dict, auth: bool = True) -> dict: + """Make an API call to iSolarCloud. + + Args: + path: API endpoint path (e.g. /openapi/login). + payload: Request body parameters. + auth: Whether to include the auth token. + + Returns: + The 'result_data' dict from the response, or raises on error. + """ + url = f"{self._api_base}{path}" + headers = { + "Content-Type": "application/json", + "x-access-key": self._x_access_key, + "sys_code": self._sys_code, + } + if auth and self._token: + headers["token"] = self._token + + body = { + "appkey": self._app_key, + "lang": "_zh_CN", + **payload, + } + + self.logger.debug("API call: %s %s", "POST", url) + response = await self._client.post(url, json=body, headers=headers) + response.raise_for_status() + + resp_json = response.json() + result_code = resp_json.get("result_code", -1) + if result_code != 1 and str(result_code) != "1": + msg = resp_json.get("result_msg", "Unknown error") + self.logger.error("API error on %s: code=%s msg=%s", path, result_code, msg) + raise RuntimeError(f"iSolarCloud API error: {msg} (code={result_code})") + + return resp_json.get("result_data", {}) + + def _token_needs_refresh(self) -> bool: + """Check if the token is close to expiry.""" + if not self._token: + return True + elapsed = time.monotonic() - self._token_obtained_at + return elapsed >= self.TOKEN_LIFETIME diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/cache.py b/backend/app/core/cache.py new file mode 100644 index 0000000..1d31317 --- /dev/null +++ b/backend/app/core/cache.py @@ -0,0 +1,148 @@ +"""Redis caching layer with graceful fallback when Redis is unavailable.""" +import json +import logging +from functools import wraps +from typing import Any, Optional + +import redis.asyncio as aioredis + +from app.core.config import get_settings + +logger = logging.getLogger("cache") + +_redis_pool: Optional[aioredis.Redis] = None + + +async def get_redis() -> Optional[aioredis.Redis]: + """Get or create a global Redis connection pool. + + Returns None if Redis is disabled or connection fails. + """ + global _redis_pool + settings = get_settings() + if not settings.REDIS_ENABLED: + return None + if _redis_pool is not None: + return _redis_pool + try: + _redis_pool = aioredis.from_url( + settings.REDIS_URL, + decode_responses=True, + max_connections=20, + ) + # Verify connectivity + await _redis_pool.ping() + logger.info("Redis connection established: %s", settings.REDIS_URL) + return _redis_pool + except Exception as e: + logger.warning("Redis unavailable, caching disabled: %s", e) + _redis_pool = None + return None + + +async def close_redis(): + """Close the global Redis connection pool.""" + global _redis_pool + if _redis_pool: + await _redis_pool.close() + _redis_pool = None + logger.info("Redis connection closed.") + + +class RedisCache: + """Async Redis cache with JSON serialization and graceful fallback.""" + + def __init__(self, redis_client: Optional[aioredis.Redis] = None): + self._redis = redis_client + + async def _get_client(self) -> Optional[aioredis.Redis]: + if self._redis is not None: + return self._redis + return await get_redis() + + async def get(self, key: str) -> Optional[Any]: + """Get a value from cache. Returns None on miss or error.""" + client = await self._get_client() + if not client: + return None + try: + raw = await client.get(key) + if raw is None: + return None + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + return raw + except Exception as e: + logger.warning("Cache get error for key=%s: %s", key, e) + return None + + async def set(self, key: str, value: Any, ttl: int = 300) -> bool: + """Set a value in cache with TTL in seconds.""" + client = await self._get_client() + if not client: + return False + try: + serialized = json.dumps(value, ensure_ascii=False, default=str) + await client.set(key, serialized, ex=ttl) + return True + except Exception as e: + logger.warning("Cache set error for key=%s: %s", key, e) + return False + + async def delete(self, key: str) -> bool: + """Delete a key from cache.""" + client = await self._get_client() + if not client: + return False + try: + await client.delete(key) + return True + except Exception as e: + logger.warning("Cache delete error for key=%s: %s", key, e) + return False + + async def exists(self, key: str) -> bool: + """Check if a key exists in cache.""" + client = await self._get_client() + if not client: + return False + try: + return bool(await client.exists(key)) + except Exception as e: + logger.warning("Cache exists error for key=%s: %s", key, e) + return False + + +def cache_response(prefix: str, ttl_seconds: int = 300): + """Decorator to cache FastAPI endpoint responses in Redis. + + Builds cache key from prefix + sorted query params. + Falls through to the endpoint when Redis is unavailable. + """ + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + # Build cache key from all keyword arguments + sorted_params = "&".join( + f"{k}={v}" for k, v in sorted(kwargs.items()) + if v is not None and k != "db" and k != "user" + ) + cache_key = f"{prefix}:{sorted_params}" if sorted_params else prefix + + cache = RedisCache() + # Try cache hit + cached = await cache.get(cache_key) + if cached is not None: + return cached + + # Call the actual endpoint + result = await func(*args, **kwargs) + + # Store result in cache + await cache.set(cache_key, result, ttl=ttl_seconds) + return result + + return wrapper + + return decorator diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..154309a --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,88 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache +import os + +import yaml + + +class Settings(BaseSettings): + APP_NAME: str = "TianpuEMS" + DEBUG: bool = True + API_V1_PREFIX: str = "/api/v1" + + # Customer configuration + CUSTOMER: str = "tianpu" # 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" + REDIS_URL: str = "redis://localhost:6379/0" + + SECRET_KEY: str = "tianpu-ems-secret-key-change-in-production-2026" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 + + CELERY_ENABLED: bool = False # Set True when Celery worker is running + USE_SIMULATOR: bool = True # True=simulator mode, False=real IoT collectors + + # Infrastructure flags + TIMESCALE_ENABLED: bool = False + REDIS_ENABLED: bool = True + INGESTION_QUEUE_ENABLED: bool = False + AGGREGATION_ENABLED: bool = True + + # SMTP Email settings + SMTP_HOST: str = "" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + SMTP_FROM: str = "noreply@tianpu-ems.com" + SMTP_ENABLED: bool = False + + # Platform URL for links in emails + PLATFORM_URL: str = "http://localhost:3000" + + @property + def DATABASE_URL_SYNC(self) -> str: + """Derive synchronous URL from async DATABASE_URL for Alembic.""" + url = self.DATABASE_URL + return url.replace("+aiosqlite", "").replace("+asyncpg", "+psycopg2") + + @property + def is_sqlite(self) -> bool: + return "sqlite" in self.DATABASE_URL + + @property + def customer_config_path(self) -> str: + """Search for customer config in multiple locations.""" + backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + # Standalone: project_root/customers/{CUSTOMER}/ + path1 = os.path.join(backend_dir, "..", "customers", self.CUSTOMER) + if os.path.isdir(path1): + return os.path.abspath(path1) + # Subtree: customer_project_root/customers/{CUSTOMER}/ (core is 2 levels up) + path2 = os.path.join(backend_dir, "..", "..", "customers", self.CUSTOMER) + if os.path.isdir(path2): + return os.path.abspath(path2) + return os.path.abspath(path1) # Default fallback + + def load_customer_config(self) -> dict: + """Load customer-specific config from customers/{CUSTOMER}/config.yaml""" + config_file = os.path.join(self.customer_config_path, "config.yaml") + if os.path.exists(config_file): + with open(config_file, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) or {} + return {} + + class Config: + env_file = ".env" + extra = "ignore" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..a35d8ea --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,27 @@ +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.orm import DeclarativeBase +from app.core.config import get_settings + +settings = get_settings() + +engine_kwargs = {"echo": settings.DEBUG} +if not settings.is_sqlite: + engine_kwargs["pool_size"] = 20 + engine_kwargs["max_overflow"] = 10 + +engine = create_async_engine(settings.DATABASE_URL, **engine_kwargs) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db(): + async with async_session() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..3fce1c0 --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,34 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.core.database import get_db +from app.core.security import decode_access_token +from app.models.user import User + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db), +) -> User: + payload = decode_access_token(token) + if payload is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据") + user_id = payload.get("sub") + if user_id is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据") + result = await db.execute(select(User).where(User.id == int(user_id))) + user = result.scalar_one_or_none() + if user is None or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已禁用") + return user + + +def require_roles(*roles: str): + async def checker(user: User = Depends(get_current_user)): + if user.role not in roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") + return user + return checker diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py new file mode 100644 index 0000000..32b2647 --- /dev/null +++ b/backend/app/core/middleware.py @@ -0,0 +1,86 @@ +"""Custom middleware for request tracking and rate limiting.""" +import logging +import time +import uuid +from typing import Optional + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + +from app.core.config import get_settings + +logger = logging.getLogger("middleware") + + +class RequestIdMiddleware(BaseHTTPMiddleware): + """Adds X-Request-ID header to every response.""" + + async def dispatch(self, request: Request, call_next): + request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) + request.state.request_id = request_id + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + return response + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """Redis-based rate limiting middleware. + + Default: 100 requests/minute per user. + Auth endpoints: 10 requests/minute per IP. + Graceful fallback when Redis is unavailable (allows all requests). + """ + + DEFAULT_LIMIT = 100 # requests per minute + AUTH_LIMIT = 10 # requests per minute for auth endpoints + WINDOW_SECONDS = 60 + + async def dispatch(self, request: Request, call_next): + settings = get_settings() + if not settings.REDIS_ENABLED: + return await call_next(request) + + try: + from app.core.cache import get_redis + redis = await get_redis() + except Exception: + redis = None + + if not redis: + return await call_next(request) + + try: + is_auth = request.url.path.startswith("/api/v1/auth") + limit = self.AUTH_LIMIT if is_auth else self.DEFAULT_LIMIT + + if is_auth: + client_ip = request.client.host if request.client else "unknown" + key = f"rl:auth:{client_ip}" + else: + # Use user token hash or client IP for rate limiting + auth_header = request.headers.get("Authorization", "") + if auth_header: + key = f"rl:user:{hash(auth_header)}" + else: + client_ip = request.client.host if request.client else "unknown" + key = f"rl:anon:{client_ip}" + + current = await redis.incr(key) + if current == 1: + await redis.expire(key, self.WINDOW_SECONDS) + + if current > limit: + ttl = await redis.ttl(key) + return JSONResponse( + status_code=429, + content={ + "detail": "Too many requests", + "retry_after": max(ttl, 1), + }, + headers={"Retry-After": str(max(ttl, 1))}, + ) + except Exception as e: + logger.warning("Rate limiting error (allowing request): %s", e) + + return await call_next(request) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..1b9c0b4 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,29 @@ +from datetime import datetime, timedelta, timezone +from jose import jwt, JWTError +from passlib.context import CryptContext +from app.core.config import get_settings + +settings = get_settings() +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def decode_access_token(token: str) -> dict | None: + try: + return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + except JWTError: + return None diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..db6a6ab --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,135 @@ +import logging +import uuid +from contextlib import asynccontextmanager +from typing import Optional + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from app.api.router import api_router +from app.api.v1.websocket import start_broadcast_task, stop_broadcast_task +from app.core.config import get_settings +from app.core.cache import get_redis, close_redis +from app.services.simulator import DataSimulator +from app.services.report_scheduler import start_scheduler, stop_scheduler +from app.services.aggregation import start_aggregation_scheduler, stop_aggregation_scheduler +from app.collectors.manager import CollectorManager +from app.collectors.queue import IngestionWorker + +settings = get_settings() +customer_config = settings.load_customer_config() +simulator = DataSimulator() +collector_manager: Optional[CollectorManager] = None +ingestion_worker: Optional[IngestionWorker] = None + +logger = logging.getLogger("app") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global collector_manager, ingestion_worker + + logger.info("Loading customer: %s (%s)", settings.CUSTOMER, + customer_config.get("customer_name", settings.CUSTOMER)) + + # Initialize Redis cache + if settings.REDIS_ENABLED: + redis = await get_redis() + if redis: + logger.info("Redis cache initialized") + + # Start aggregation scheduler + if settings.AGGREGATION_ENABLED: + await start_aggregation_scheduler() + logger.info("Aggregation scheduler started") + + # Start ingestion worker + if settings.INGESTION_QUEUE_ENABLED: + ingestion_worker = IngestionWorker() + await ingestion_worker.start() + logger.info("Ingestion worker started") + + if settings.USE_SIMULATOR: + logger.info("Starting in SIMULATOR mode") + await simulator.start() + else: + logger.info("Starting in COLLECTOR mode (real IoT devices)") + collector_manager = CollectorManager() + await collector_manager.start() + start_broadcast_task() + await start_scheduler() + yield + await stop_scheduler() + stop_broadcast_task() + if settings.USE_SIMULATOR: + await simulator.stop() + else: + if collector_manager: + await collector_manager.stop() + collector_manager = None + + # Stop ingestion worker + if ingestion_worker: + await ingestion_worker.stop() + ingestion_worker = None + + # Stop aggregation scheduler + if settings.AGGREGATION_ENABLED: + await stop_aggregation_scheduler() + + # Close Redis + if settings.REDIS_ENABLED: + await close_redis() + logger.info("Redis cache closed") + + +app = FastAPI( + title=customer_config.get("platform_name", "天普零碳园区智慧能源管理平台"), + description=customer_config.get("platform_name_en", "Tianpu Zero-Carbon Park Smart Energy Management System"), + version="1.0.0", + lifespan=lifespan, +) + +_default_origins = ["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"] +_customer_origins = customer_config.get("cors_origins", []) +_cors_origins = list(set(_default_origins + _customer_origins)) + +app.add_middleware( + CORSMiddleware, + allow_origins=_cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.middleware("http") +async def request_id_middleware(request: Request, call_next): + """Add a unique X-Request-ID header to every response.""" + request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) + request.state.request_id = request_id + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + return response + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + """Global exception handler for consistent error responses.""" + request_id = getattr(request.state, "request_id", "unknown") + logger.error("Unhandled exception [request_id=%s]: %s", request_id, exc, exc_info=True) + return JSONResponse( + status_code=500, + content={ + "detail": "Internal server error", + "request_id": request_id, + }, + ) + + +app.include_router(api_router) + + +@app.get("/health") +async def health(): + return {"status": "ok", "app": settings.APP_NAME} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..4f96241 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,43 @@ +from app.models.user import User, Role, AuditLog +from app.models.device import Device, DeviceGroup, DeviceType +from app.models.energy import EnergyData, EnergyDailySummary, EnergyCategory +from app.models.alarm import AlarmRule, AlarmEvent +from app.models.carbon import ( + CarbonEmission, EmissionFactor, CarbonTarget, CarbonReduction, + GreenCertificate, CarbonReport, CarbonBenchmark, +) +from app.models.report import ReportTemplate, ReportTask +from app.models.setting import SystemSetting +from app.models.charging import ( + ChargingStation, ChargingPile, ChargingPriceStrategy, ChargingPriceParam, + ChargingOrder, OccupancyOrder, ChargingBrand, ChargingMerchant, +) +from app.models.quota import EnergyQuota, QuotaUsage +from app.models.pricing import ElectricityPricing, PricingPeriod +from app.models.maintenance import InspectionPlan, InspectionRecord, RepairOrder, DutySchedule +from app.models.management import Regulation, Standard, ProcessDoc, EmergencyPlan +from app.models.prediction import PredictionTask, PredictionResult, OptimizationSchedule +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 + +__all__ = [ + "User", "Role", "AuditLog", + "Device", "DeviceGroup", "DeviceType", + "EnergyData", "EnergyDailySummary", "EnergyCategory", + "AlarmRule", "AlarmEvent", + "CarbonEmission", "EmissionFactor", "CarbonTarget", "CarbonReduction", + "GreenCertificate", "CarbonReport", "CarbonBenchmark", + "ReportTemplate", "ReportTask", + "SystemSetting", + "ChargingStation", "ChargingPile", "ChargingPriceStrategy", "ChargingPriceParam", + "ChargingOrder", "OccupancyOrder", "ChargingBrand", "ChargingMerchant", + "EnergyQuota", "QuotaUsage", + "ElectricityPricing", "PricingPeriod", + "InspectionPlan", "InspectionRecord", "RepairOrder", "DutySchedule", + "Regulation", "Standard", "ProcessDoc", "EmergencyPlan", + "PredictionTask", "PredictionResult", "OptimizationSchedule", + "TouPricing", "TouPricingPeriod", "EnergyStrategy", "StrategyExecution", "MonthlyCostReport", + "WeatherData", "WeatherConfig", + "DeviceHealthScore", "AnomalyDetection", "DiagnosticReport", "MaintenancePrediction", "OpsInsight", +] diff --git a/backend/app/models/ai_ops.py b/backend/app/models/ai_ops.py new file mode 100644 index 0000000..75a75ec --- /dev/null +++ b/backend/app/models/ai_ops.py @@ -0,0 +1,88 @@ +"""AI运维智能体数据模型 - 设备健康评分、异常检测、诊断报告、预测性维护、运营洞察""" +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, JSON +from sqlalchemy.sql import func +from app.core.database import Base + + +class DeviceHealthScore(Base): + """设备健康评分""" + __tablename__ = "device_health_scores" + + id = Column(Integer, primary_key=True, autoincrement=True) + device_id = Column(Integer, ForeignKey("devices.id"), nullable=False, index=True) + timestamp = Column(DateTime(timezone=True), server_default=func.now(), index=True) + health_score = Column(Float, nullable=False) # 0-100 + status = Column(String(20), default="healthy") # healthy, warning, critical + factors = Column(JSON) # {power_stability, efficiency, alarm_frequency, uptime, temperature} + trend = Column(String(20), default="stable") # improving, stable, degrading + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class AnomalyDetection(Base): + """异常检测记录""" + __tablename__ = "anomaly_detections" + + id = Column(Integer, primary_key=True, autoincrement=True) + device_id = Column(Integer, ForeignKey("devices.id"), nullable=False, index=True) + detected_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + anomaly_type = Column(String(50), nullable=False) # power_drop, efficiency_loss, abnormal_temperature, communication_loss, pattern_deviation + severity = Column(String(20), default="warning") # info, warning, critical + description = Column(Text) + metric_name = Column(String(50)) + expected_value = Column(Float) + actual_value = Column(Float) + deviation_percent = Column(Float) + status = Column(String(20), default="detected") # detected, investigating, resolved, false_positive + resolution_notes = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class DiagnosticReport(Base): + """AI诊断报告""" + __tablename__ = "diagnostic_reports" + + id = Column(Integer, primary_key=True, autoincrement=True) + device_id = Column(Integer, ForeignKey("devices.id"), nullable=False, index=True) + generated_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + report_type = Column(String(20), default="routine") # routine, triggered, comprehensive + findings = Column(JSON) # [{finding, severity, detail}] + recommendations = Column(JSON) # [{action, priority, detail}] + estimated_impact = Column(JSON) # {energy_loss_kwh, cost_impact_yuan} + status = Column(String(20), default="generated") # generated, reviewed, action_taken + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class MaintenancePrediction(Base): + """预测性维护""" + __tablename__ = "maintenance_predictions" + + id = Column(Integer, primary_key=True, autoincrement=True) + device_id = Column(Integer, ForeignKey("devices.id"), nullable=False, index=True) + predicted_at = Column(DateTime(timezone=True), server_default=func.now()) + component = Column(String(100)) + failure_mode = Column(String(200)) + probability = Column(Float) # 0-1 + predicted_failure_date = Column(DateTime(timezone=True)) + recommended_action = Column(Text) + urgency = Column(String(20), default="medium") # low, medium, high, critical + estimated_downtime_hours = Column(Float) + estimated_repair_cost = Column(Float) + status = Column(String(20), default="predicted") # predicted, scheduled, completed, false_alarm + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class OpsInsight(Base): + """运营洞察""" + __tablename__ = "ops_insights" + + id = Column(Integer, primary_key=True, autoincrement=True) + insight_type = Column(String(50), nullable=False) # efficiency_trend, cost_anomaly, performance_comparison, seasonal_pattern + title = Column(String(200), nullable=False) + description = Column(Text) + data = Column(JSON) + impact_level = Column(String(20), default="medium") # low, medium, high + actionable = Column(Boolean, default=False) + recommended_action = Column(Text) + generated_at = Column(DateTime(timezone=True), server_default=func.now()) + valid_until = Column(DateTime(timezone=True)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/alarm.py b/backend/app/models/alarm.py new file mode 100644 index 0000000..83ce04f --- /dev/null +++ b/backend/app/models/alarm.py @@ -0,0 +1,48 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, JSON +from sqlalchemy.sql import func +from app.core.database import Base + + +class AlarmRule(Base): + __tablename__ = "alarm_rules" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + device_id = Column(Integer, ForeignKey("devices.id")) + device_type = Column(String(50)) # 按设备类型的通用规则 + data_type = Column(String(50), nullable=False) # 监控的数据类型 + condition = Column(String(20), nullable=False) # gt, lt, eq, neq, range_out, rate_of_change + threshold = Column(Float) + threshold_high = Column(Float) # 范围上限 + threshold_low = Column(Float) # 范围下限 + duration = Column(Integer, default=0) # 持续时间(秒) + severity = Column(String(20), default="warning") # critical, major, warning + notify_channels = Column(JSON) # ["sms", "email", "app", "wechat"] + notify_targets = Column(JSON) # 通知对象 + auto_action = Column(JSON) # 联动动作 + silence_start = Column(String(10)) # 静默开始时间 HH:MM + silence_end = Column(String(10)) # 静默结束时间 + is_active = Column(Boolean, default=True) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class AlarmEvent(Base): + __tablename__ = "alarm_events" + + id = Column(Integer, primary_key=True, autoincrement=True) + rule_id = Column(Integer, ForeignKey("alarm_rules.id")) + device_id = Column(Integer, ForeignKey("devices.id"), nullable=False) + severity = Column(String(20), nullable=False) + title = Column(String(200), nullable=False) + description = Column(Text) + value = Column(Float) # 触发时的数值 + threshold = Column(Float) # 阈值 + status = Column(String(20), default="active") # active, acknowledged, resolved + acknowledged_by = Column(Integer, ForeignKey("users.id")) + acknowledged_at = Column(DateTime(timezone=True)) + resolved_at = Column(DateTime(timezone=True)) + resolve_note = Column(Text) + triggered_at = Column(DateTime(timezone=True), server_default=func.now()) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/carbon.py b/backend/app/models/carbon.py new file mode 100644 index 0000000..75aa9b1 --- /dev/null +++ b/backend/app/models/carbon.py @@ -0,0 +1,115 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime, Text, Boolean, Date, JSON, ForeignKey +from sqlalchemy.sql import func +from app.core.database import Base + + +class EmissionFactor(Base): + """碳排放因子""" + __tablename__ = "emission_factors" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + energy_type = Column(String(50), nullable=False) # electricity, natural_gas, diesel, etc. + factor = Column(Float, nullable=False) # kgCO2/单位 + unit = Column(String(20), nullable=False) # kWh, m³, L, etc. + region = Column(String(50), default="north_china") # 区域电网 + scope = Column(Integer, nullable=False) # 1, 2, 3 + source = Column(String(200)) # 数据来源 + year = Column(Integer) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class CarbonTarget(Base): + """碳减排目标""" + __tablename__ = "carbon_targets" + + id = Column(Integer, primary_key=True, autoincrement=True) + year = Column(Integer, nullable=False) + month = Column(Integer, nullable=True) # NULL for annual target + target_emission_tons = Column(Float, nullable=False) + actual_emission_tons = Column(Float, default=0) + status = Column(String(20), default="on_track") # on_track / warning / exceeded + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class CarbonReduction(Base): + """碳减排活动""" + __tablename__ = "carbon_reductions" + + id = Column(Integer, primary_key=True, autoincrement=True) + source_type = Column(String(50), nullable=False) # pv_generation / heat_pump_cop / energy_saving + date = Column(Date, nullable=False, index=True) + reduction_tons = Column(Float, nullable=False) + equivalent_trees = Column(Float, default=0) + methodology = Column(String(200)) + verified = Column(Boolean, default=False) + verification_date = Column(DateTime(timezone=True), nullable=True) + device_id = Column(Integer, ForeignKey("devices.id"), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class GreenCertificate(Base): + """绿证管理""" + __tablename__ = "green_certificates" + + id = Column(Integer, primary_key=True, autoincrement=True) + certificate_type = Column(String(20), nullable=False) # GEC / IREC / CCER + certificate_number = Column(String(100), unique=True, nullable=False) + issue_date = Column(Date, nullable=False) + expiry_date = Column(Date, nullable=True) + energy_mwh = Column(Float, nullable=False) + price_yuan = Column(Float, default=0) + status = Column(String(20), default="active") # active / used / expired / traded + source_device_id = Column(Integer, ForeignKey("devices.id"), nullable=True) + notes = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class CarbonReport(Base): + """碳排放报告""" + __tablename__ = "carbon_reports" + + id = Column(Integer, primary_key=True, autoincrement=True) + report_type = Column(String(20), nullable=False) # monthly / quarterly / annual + period_start = Column(Date, nullable=False) + period_end = Column(Date, nullable=False) + generated_at = Column(DateTime(timezone=True), server_default=func.now()) + scope1_tons = Column(Float, default=0) + scope2_tons = Column(Float, default=0) + scope3_tons = Column(Float, nullable=True) + total_tons = Column(Float, default=0) + reduction_tons = Column(Float, default=0) + net_tons = Column(Float, default=0) + report_data = Column(JSON, nullable=True) + file_path = Column(String(500), nullable=True) + + +class CarbonBenchmark(Base): + """行业碳排放基准""" + __tablename__ = "carbon_benchmarks" + + id = Column(Integer, primary_key=True, autoincrement=True) + industry = Column(String(100), nullable=False) + metric_name = Column(String(100), nullable=False) + benchmark_value = Column(Float, nullable=False) + unit = Column(String(50), nullable=False) + year = Column(Integer) + source = Column(String(200)) + notes = Column(Text) + + +class CarbonEmission(Base): + """碳排放记录""" + __tablename__ = "carbon_emissions" + + id = Column(Integer, primary_key=True, autoincrement=True) + date = Column(DateTime(timezone=True), nullable=False, index=True) + scope = Column(Integer, nullable=False) # 1, 2, 3 + category = Column(String(50), nullable=False) # electricity, gas, heat, etc. + emission = Column(Float, nullable=False) # kgCO2e + reduction = Column(Float, default=0) # 减排量 kgCO2e (光伏、热泵节能等) + energy_consumption = Column(Float) # 对应能耗量 + energy_unit = Column(String(20)) + emission_factor_id = Column(Integer) + note = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/charging.py b/backend/app/models/charging.py new file mode 100644 index 0000000..1509d60 --- /dev/null +++ b/backend/app/models/charging.py @@ -0,0 +1,145 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, JSON, BigInteger +from sqlalchemy.sql import func +from app.core.database import Base + + +class ChargingStation(Base): + __tablename__ = "charging_stations" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + merchant_id = Column(Integer, ForeignKey("charging_merchants.id")) + type = Column(String(50)) # public, private, dedicated + address = Column(String(500)) + latitude = Column(Float) + longitude = Column(Float) + price = Column(Float) # default price yuan/kWh + activity = Column(Text) # promotions text + status = Column(String(20), default="active") # active, disabled + total_piles = Column(Integer, default=0) + available_piles = Column(Integer, default=0) + total_power_kw = Column(Float, default=0) + photo_url = Column(String(500)) + operating_hours = Column(String(100)) # e.g. "00:00-24:00" + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class ChargingPile(Base): + __tablename__ = "charging_piles" + + id = Column(Integer, primary_key=True, autoincrement=True) + station_id = Column(Integer, ForeignKey("charging_stations.id"), nullable=False) + encoding = Column(String(100), unique=True) # terminal code + name = Column(String(200)) + type = Column(String(50)) # AC_slow, DC_fast, DC_superfast + brand = Column(String(100)) + model = Column(String(100)) + rated_power_kw = Column(Float) + connector_type = Column(String(50)) # GB_T, CCS, CHAdeMO + status = Column(String(20), default="active") # active, disabled + work_status = Column(String(20), default="offline") # idle, charging, fault, offline + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class ChargingPriceStrategy(Base): + __tablename__ = "charging_price_strategies" + + id = Column(Integer, primary_key=True, autoincrement=True) + strategy_name = Column(String(200), nullable=False) + station_id = Column(Integer, ForeignKey("charging_stations.id")) + bill_model = Column(String(20)) # tou, flat + description = Column(Text) + status = Column(String(20), default="inactive") # active, inactive + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class ChargingPriceParam(Base): + __tablename__ = "charging_price_params" + + id = Column(Integer, primary_key=True, autoincrement=True) + strategy_id = Column(Integer, ForeignKey("charging_price_strategies.id"), nullable=False) + start_time = Column(String(10), nullable=False) # HH:MM + end_time = Column(String(10), nullable=False) + period_mark = Column(String(20)) # sharp, peak, flat, valley + elec_price = Column(Float, nullable=False) # yuan/kWh + service_price = Column(Float, default=0) # yuan/kWh + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class ChargingOrder(Base): + __tablename__ = "charging_orders" + + id = Column(Integer, primary_key=True, autoincrement=True) + order_no = Column(String(50), unique=True, nullable=False) + user_id = Column(Integer) + user_name = Column(String(100)) + phone = Column(String(20)) + station_id = Column(Integer, ForeignKey("charging_stations.id")) + station_name = Column(String(200)) + pile_id = Column(Integer, ForeignKey("charging_piles.id")) + pile_name = Column(String(200)) + start_time = Column(DateTime(timezone=True)) + end_time = Column(DateTime(timezone=True)) + car_no = Column(String(20)) # license plate + car_vin = Column(String(50)) + charge_method = Column(String(20)) # plug_and_charge, app, card + settle_type = Column(String(20)) # normal, manual, delayed, abnormal, offline + pay_type = Column(String(20)) # balance, wechat, alipay + settle_time = Column(DateTime(timezone=True)) + settle_price = Column(Float) # settlement amount + paid_price = Column(Float) # actual paid + discount_amt = Column(Float, default=0) + elec_amt = Column(Float) # electricity fee + serve_amt = Column(Float) # service fee + order_status = Column(String(20), default="charging") # charging, pending_pay, completed, failed, refunded + charge_duration = Column(Integer) # seconds + energy = Column(Float) # kWh delivered + start_soc = Column(Float) # battery start % + end_soc = Column(Float) # battery end % + abno_cause = Column(Text) # abnormal reason + order_source = Column(String(20)) # miniprogram, pc, app + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class OccupancyOrder(Base): + __tablename__ = "occupancy_orders" + + id = Column(Integer, primary_key=True, autoincrement=True) + order_id = Column(Integer, ForeignKey("charging_orders.id")) + pile_id = Column(Integer, ForeignKey("charging_piles.id")) + start_time = Column(DateTime(timezone=True)) + end_time = Column(DateTime(timezone=True)) + occupancy_fee = Column(Float, default=0) + status = Column(String(20), default="active") + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class ChargingBrand(Base): + __tablename__ = "charging_brands" + + id = Column(Integer, primary_key=True, autoincrement=True) + brand_name = Column(String(100), nullable=False) + logo_url = Column(String(500)) + country = Column(String(50)) + description = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class ChargingMerchant(Base): + __tablename__ = "charging_merchants" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + contact_person = Column(String(100)) + phone = Column(String(20)) + email = Column(String(100)) + address = Column(String(500)) + business_license = Column(String(100)) + status = Column(String(20), default="active") + settlement_type = Column(String(20)) # prepaid, postpaid + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/device.py b/backend/app/models/device.py new file mode 100644 index 0000000..7b960dd --- /dev/null +++ b/backend/app/models/device.py @@ -0,0 +1,51 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, JSON +from sqlalchemy.sql import func +from app.core.database import Base + + +class DeviceType(Base): + __tablename__ = "device_types" + + id = Column(Integer, primary_key=True, autoincrement=True) + code = Column(String(50), unique=True, nullable=False) # pv_inverter, heat_pump, solar_thermal, battery, meter, sensor + name = Column(String(100), nullable=False) + icon = Column(String(100)) + data_fields = Column(JSON) # 该类型设备的数据字段定义 + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class DeviceGroup(Base): + __tablename__ = "device_groups" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + parent_id = Column(Integer, ForeignKey("device_groups.id")) + location = Column(String(200)) + description = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class Device(Base): + __tablename__ = "devices" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + code = Column(String(100), unique=True, nullable=False) # 设备编号 + device_type = Column(String(50), ForeignKey("device_types.code"), nullable=False) + group_id = Column(Integer, ForeignKey("device_groups.id")) + model = Column(String(100)) # 型号 + manufacturer = Column(String(100)) # 厂商 + serial_number = Column(String(100)) # 序列号 + rated_power = Column(Float) # 额定功率 kW + install_date = Column(DateTime(timezone=True)) + location = Column(String(200)) + protocol = Column(String(50)) # modbus_tcp, modbus_rtu, opc_ua, mqtt, http_api + connection_params = Column(JSON) # 连接参数 (IP, port, slave_id, etc.) + collect_interval = Column(Integer, default=15) # 采集间隔(秒) + category_id = Column(Integer, ForeignKey("energy_categories.id")) # 分项类别 + status = Column(String(20), default="offline") # online, offline, alarm, maintenance + is_active = Column(Boolean, default=True) + metadata_ = Column("metadata", JSON) # 扩展元数据 + last_data_time = Column(DateTime(timezone=True)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/energy.py b/backend/app/models/energy.py new file mode 100644 index 0000000..38fb5f6 --- /dev/null +++ b/backend/app/models/energy.py @@ -0,0 +1,52 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, JSON +from sqlalchemy.sql import func +from app.core.database import Base + + +class EnergyCategory(Base): + """能耗分项类别""" + __tablename__ = "energy_categories" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) # HVAC, 照明, 动力, 特殊 + code = Column(String(50), unique=True, nullable=False) # hvac, lighting, power, special + parent_id = Column(Integer, ForeignKey("energy_categories.id")) + sort_order = Column(Integer, default=0) + icon = Column(String(100)) + color = Column(String(20)) # hex color for charts + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class EnergyData(Base): + """时序能耗采集数据 - 使用TimescaleDB hypertable""" + __tablename__ = "energy_data" + + id = Column(Integer, primary_key=True, autoincrement=True) + device_id = Column(Integer, ForeignKey("devices.id"), nullable=False, index=True) + timestamp = Column(DateTime(timezone=True), nullable=False, index=True) + data_type = Column(String(50), nullable=False) # power, energy, temperature, flow, etc. + value = Column(Float, nullable=False) + unit = Column(String(20)) # kW, kWh, ℃, m³/h, etc. + quality = Column(Integer, default=0) # 0=good, 1=interpolated, 2=suspect + raw_data = Column(JSON) # 原始完整数据包 + + +class EnergyDailySummary(Base): + """每日能耗汇总""" + __tablename__ = "energy_daily_summary" + + id = Column(Integer, primary_key=True, autoincrement=True) + device_id = Column(Integer, ForeignKey("devices.id"), nullable=False, index=True) + date = Column(DateTime(timezone=True), nullable=False, index=True) + energy_type = Column(String(50), nullable=False) # electricity, heat, water, gas + total_consumption = Column(Float, default=0) # 总消耗 + total_generation = Column(Float, default=0) # 总产出 + peak_power = Column(Float) # 最大功率 + min_power = Column(Float) # 最小功率 + avg_power = Column(Float) # 平均功率 + operating_hours = Column(Float) # 运行小时数 + avg_cop = Column(Float) # 平均COP (热泵) + avg_temperature = Column(Float) # 平均温度 + cost = Column(Float) # 费用 + carbon_emission = Column(Float) # 碳排放 kgCO2 + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/energy_strategy.py b/backend/app/models/energy_strategy.py new file mode 100644 index 0000000..66263b4 --- /dev/null +++ b/backend/app/models/energy_strategy.py @@ -0,0 +1,81 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, JSON, Date +from sqlalchemy.sql import func +from app.core.database import Base + + +class TouPricing(Base): + """分时电价配置 (Time-of-Use pricing)""" + __tablename__ = "tou_pricing" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + region = Column(String(100), default="北京") + effective_date = Column(Date) + end_date = Column(Date) + is_active = Column(Boolean, default=True) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class TouPricingPeriod(Base): + """分时电价时段 (TOU pricing periods)""" + __tablename__ = "tou_pricing_periods" + + id = Column(Integer, primary_key=True, autoincrement=True) + pricing_id = Column(Integer, ForeignKey("tou_pricing.id", ondelete="CASCADE"), nullable=False) + period_type = Column(String(20), nullable=False) # sharp_peak, peak, flat, valley + start_time = Column(String(10), nullable=False) # HH:MM + end_time = Column(String(10), nullable=False) # HH:MM + price_yuan_per_kwh = Column(Float, nullable=False) + month_range = Column(String(50)) # e.g. "1-3,11-12" for winter, null=all + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class EnergyStrategy(Base): + """能源优化策略""" + __tablename__ = "energy_strategies" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + strategy_type = Column(String(50), nullable=False) # heat_storage, load_shift, pv_priority + description = Column(String(500)) + parameters = Column(JSON, default=dict) + is_enabled = Column(Boolean, default=False) + priority = Column(Integer, default=0) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class StrategyExecution(Base): + """策略执行记录""" + __tablename__ = "strategy_executions" + + id = Column(Integer, primary_key=True, autoincrement=True) + strategy_id = Column(Integer, ForeignKey("energy_strategies.id"), nullable=False) + date = Column(Date, nullable=False) + actions_taken = Column(JSON, default=list) + savings_kwh = Column(Float, default=0) + savings_yuan = Column(Float, default=0) + status = Column(String(20), default="planned") # planned, executing, completed + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class MonthlyCostReport(Base): + """月度电费分析报告""" + __tablename__ = "monthly_cost_reports" + + id = Column(Integer, primary_key=True, autoincrement=True) + year_month = Column(String(7), nullable=False, unique=True) # YYYY-MM + total_consumption_kwh = Column(Float, default=0) + total_cost_yuan = Column(Float, default=0) + peak_consumption = Column(Float, default=0) + valley_consumption = Column(Float, default=0) + flat_consumption = Column(Float, default=0) + sharp_peak_consumption = Column(Float, default=0) + pv_self_consumption = Column(Float, default=0) + pv_feed_in = Column(Float, default=0) + optimized_cost = Column(Float, default=0) + baseline_cost = Column(Float, default=0) + savings_yuan = Column(Float, default=0) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/maintenance.py b/backend/app/models/maintenance.py new file mode 100644 index 0000000..660c974 --- /dev/null +++ b/backend/app/models/maintenance.py @@ -0,0 +1,69 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, JSON +from sqlalchemy.sql import func +from app.core.database import Base + + +class InspectionPlan(Base): + __tablename__ = "inspection_plans" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + description = Column(Text) + device_group_id = Column(Integer, ForeignKey("device_groups.id")) + device_ids = Column(JSON) # specific devices to inspect + schedule_type = Column(String(20)) # daily, weekly, monthly, custom + schedule_cron = Column(String(100)) # cron expression for custom + inspector_id = Column(Integer, ForeignKey("users.id")) + checklist = Column(JSON) # [{item: "检查外观", required: true, type: "checkbox"}] + is_active = Column(Boolean, default=True) + next_run_at = Column(DateTime(timezone=True)) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class InspectionRecord(Base): + __tablename__ = "inspection_records" + + id = Column(Integer, primary_key=True, autoincrement=True) + plan_id = Column(Integer, ForeignKey("inspection_plans.id"), nullable=False) + inspector_id = Column(Integer, ForeignKey("users.id"), nullable=False) + status = Column(String(20), default="pending") # pending, in_progress, completed, issues_found + findings = Column(JSON) # [{item, result, note, photo_url}] + started_at = Column(DateTime(timezone=True)) + completed_at = Column(DateTime(timezone=True)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class RepairOrder(Base): + __tablename__ = "repair_orders" + + id = Column(Integer, primary_key=True, autoincrement=True) + code = Column(String(50), unique=True, nullable=False) # WO-20260402-001 + title = Column(String(200), nullable=False) + description = Column(Text) + device_id = Column(Integer, ForeignKey("devices.id")) + alarm_event_id = Column(Integer, ForeignKey("alarm_events.id")) + priority = Column(String(20), default="medium") # critical, high, medium, low + status = Column(String(20), default="open") # open, assigned, in_progress, completed, verified, closed + assigned_to = Column(Integer, ForeignKey("users.id")) + resolution = Column(Text) + cost_estimate = Column(Float) + actual_cost = Column(Float) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + assigned_at = Column(DateTime(timezone=True)) + completed_at = Column(DateTime(timezone=True)) + closed_at = Column(DateTime(timezone=True)) + + +class DutySchedule(Base): + __tablename__ = "duty_schedules" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + duty_date = Column(DateTime(timezone=True), nullable=False) + shift = Column(String(20)) # day, night, on_call + area_id = Column(Integer, ForeignKey("device_groups.id")) + notes = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/management.py b/backend/app/models/management.py new file mode 100644 index 0000000..13942fe --- /dev/null +++ b/backend/app/models/management.py @@ -0,0 +1,60 @@ +from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, ForeignKey, JSON +from sqlalchemy.sql import func +from app.core.database import Base + + +class Regulation(Base): + __tablename__ = "regulations" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200), nullable=False) + category = Column(String(50)) # safety, operation, quality, environment + content = Column(Text) + effective_date = Column(DateTime(timezone=True)) + status = Column(String(20), default="active") # active, archived, draft + attachment_url = Column(String(500)) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class Standard(Base): + __tablename__ = "standards" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + code = Column(String(100)) # e.g. ISO 50001, GB/T 23331 + type = Column(String(50)) # national, industry, enterprise + description = Column(Text) + compliance_status = Column(String(20), default="pending") # compliant, non_compliant, pending, in_progress + review_date = Column(DateTime(timezone=True)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class ProcessDoc(Base): + __tablename__ = "process_docs" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200), nullable=False) + category = Column(String(50)) # operation, maintenance, emergency, training + content = Column(Text) + version = Column(String(20), default="1.0") + approved_by = Column(String(100)) + effective_date = Column(DateTime(timezone=True)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class EmergencyPlan(Base): + __tablename__ = "emergency_plans" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200), nullable=False) + scenario = Column(String(100)) # fire, power_outage, equipment_failure, chemical_leak + steps = Column(JSON) # [{step_number, action, responsible_person, contact}] + responsible_person = Column(String(100)) + review_date = Column(DateTime(timezone=True)) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/prediction.py b/backend/app/models/prediction.py new file mode 100644 index 0000000..d738382 --- /dev/null +++ b/backend/app/models/prediction.py @@ -0,0 +1,48 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, JSON, Text +from sqlalchemy.sql import func +from app.core.database import Base + + +class PredictionTask(Base): + """AI预测任务元数据""" + __tablename__ = "prediction_tasks" + + id = Column(Integer, primary_key=True, autoincrement=True) + device_id = Column(Integer, ForeignKey("devices.id")) + prediction_type = Column(String(50), nullable=False) # pv, load, heatpump, optimization + horizon_hours = Column(Integer, default=24) + status = Column(String(20), default="pending") # pending, running, completed, failed + parameters = Column(JSON) # extra config for the prediction run + error_message = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + completed_at = Column(DateTime(timezone=True)) + + +class PredictionResult(Base): + """AI预测结果时序数据""" + __tablename__ = "prediction_results" + + id = Column(Integer, primary_key=True, autoincrement=True) + task_id = Column(Integer, ForeignKey("prediction_tasks.id"), nullable=False, index=True) + timestamp = Column(DateTime(timezone=True), nullable=False, index=True) + predicted_value = Column(Float, nullable=False) + confidence_lower = Column(Float) + confidence_upper = Column(Float) + actual_value = Column(Float) # filled later for accuracy tracking + unit = Column(String(20)) + + +class OptimizationSchedule(Base): + """AI优化调度建议""" + __tablename__ = "optimization_schedules" + + id = Column(Integer, primary_key=True, autoincrement=True) + device_id = Column(Integer, ForeignKey("devices.id")) + date = Column(DateTime(timezone=True), nullable=False, index=True) + schedule_data = Column(JSON) # hourly on/off + setpoints + expected_savings_kwh = Column(Float, default=0) + expected_savings_yuan = Column(Float, default=0) + status = Column(String(20), default="pending") # pending, approved, executed, rejected + approved_by = Column(Integer, ForeignKey("users.id")) + approved_at = Column(DateTime(timezone=True)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/pricing.py b/backend/app/models/pricing.py new file mode 100644 index 0000000..f50261a --- /dev/null +++ b/backend/app/models/pricing.py @@ -0,0 +1,33 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, JSON +from sqlalchemy.sql import func +from app.core.database import Base + + +class ElectricityPricing(Base): + """电价配置""" + __tablename__ = "electricity_pricing" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + energy_type = Column(String(50), default="electricity") # electricity, heat, water, gas + pricing_type = Column(String(20), nullable=False) # flat, tou, tiered + effective_from = Column(DateTime(timezone=True)) + effective_to = Column(DateTime(timezone=True)) + is_active = Column(Boolean, default=True) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class PricingPeriod(Base): + """分时电价时段""" + __tablename__ = "pricing_periods" + + id = Column(Integer, primary_key=True, autoincrement=True) + pricing_id = Column(Integer, ForeignKey("electricity_pricing.id"), nullable=False) + period_name = Column(String(50), nullable=False) # peak, valley, flat, shoulder, sharp + start_time = Column(String(10), nullable=False) # HH:MM format + end_time = Column(String(10), nullable=False) # HH:MM format + price_per_unit = Column(Float, nullable=False) # yuan per kWh + applicable_months = Column(JSON) # [1,2,3,...12] or null for all months + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/quota.py b/backend/app/models/quota.py new file mode 100644 index 0000000..84b77d0 --- /dev/null +++ b/backend/app/models/quota.py @@ -0,0 +1,38 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey +from sqlalchemy.sql import func +from app.core.database import Base + + +class EnergyQuota(Base): + """能源配额管理""" + __tablename__ = "energy_quotas" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + target_type = Column(String(50), nullable=False) # building, department, device_group + target_id = Column(Integer, nullable=False) # FK to device_groups.id + energy_type = Column(String(50), nullable=False) # electricity, heat, water, gas + period = Column(String(20), nullable=False) # monthly, yearly + quota_value = Column(Float, nullable=False) # 目标消耗值 + unit = Column(String(20), default="kWh") + warning_threshold_pct = Column(Float, default=80) # 80%预警 + alert_threshold_pct = Column(Float, default=95) # 95%告警 + is_active = Column(Boolean, default=True) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class QuotaUsage(Base): + """配额使用记录""" + __tablename__ = "quota_usage" + + id = Column(Integer, primary_key=True, autoincrement=True) + quota_id = Column(Integer, ForeignKey("energy_quotas.id"), nullable=False) + period_start = Column(DateTime(timezone=True), nullable=False) + period_end = Column(DateTime(timezone=True), nullable=False) + actual_value = Column(Float, default=0) + quota_value = Column(Float, nullable=False) + usage_rate_pct = Column(Float, default=0) + status = Column(String(20), default="normal") # normal, warning, exceeded + calculated_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/report.py b/backend/app/models/report.py new file mode 100644 index 0000000..1cc48e6 --- /dev/null +++ b/backend/app/models/report.py @@ -0,0 +1,38 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, JSON +from sqlalchemy.sql import func +from app.core.database import Base + + +class ReportTemplate(Base): + __tablename__ = "report_templates" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + report_type = Column(String(50), nullable=False) # daily, weekly, monthly, yearly, custom + description = Column(Text) + fields = Column(JSON, nullable=False) # 报表字段配置 + filters = Column(JSON) # 默认筛选条件 + aggregation = Column(String(20), default="sum") # sum, avg, max, min + time_granularity = Column(String(20), default="hour") # hour, day, month + format_config = Column(JSON) # 展示格式配置 + is_system = Column(Boolean, default=False) # 系统预置 + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class ReportTask(Base): + __tablename__ = "report_tasks" + + id = Column(Integer, primary_key=True, autoincrement=True) + template_id = Column(Integer, ForeignKey("report_templates.id"), nullable=False) + name = Column(String(200)) + schedule = Column(String(50)) # cron expression or null for manual + next_run = Column(DateTime(timezone=True)) + last_run = Column(DateTime(timezone=True)) + recipients = Column(JSON) # 接收人 + export_format = Column(String(20), default="xlsx") # xlsx, csv, pdf + file_path = Column(String(500)) # 最新生成的文件路径 + status = Column(String(20), default="pending") # pending, running, completed, failed + is_active = Column(Boolean, default=True) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/setting.py b/backend/app/models/setting.py new file mode 100644 index 0000000..ac0c89e --- /dev/null +++ b/backend/app/models/setting.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime +from sqlalchemy.sql import func +from app.core.database import Base + + +class SystemSetting(Base): + __tablename__ = "system_settings" + + id = Column(Integer, primary_key=True, autoincrement=True) + key = Column(String(100), unique=True, nullable=False, index=True) + value = Column(Text, nullable=False, default="") + description = Column(String(255)) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..f2a3a31 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,42 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text +from sqlalchemy.sql import func +from app.core.database import Base + + +class Role(Base): + __tablename__ = "roles" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(50), unique=True, nullable=False) # admin, energy_manager, area_manager, operator, analyst, visitor + display_name = Column(String(100), nullable=False) + description = Column(Text) + permissions = Column(Text) # JSON string of permission list + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String(50), unique=True, nullable=False, index=True) + email = Column(String(100), unique=True) + hashed_password = Column(String(200), nullable=False) + full_name = Column(String(100)) + phone = Column(String(20)) + role = Column(String(50), ForeignKey("roles.name"), default="visitor") + is_active = Column(Boolean, default=True) + last_login = Column(DateTime(timezone=True)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id")) + action = Column(String(50), nullable=False) + resource = Column(String(100)) + detail = Column(Text) + ip_address = Column(String(50)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/weather.py b/backend/app/models/weather.py new file mode 100644 index 0000000..c5feedf --- /dev/null +++ b/backend/app/models/weather.py @@ -0,0 +1,33 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime +from sqlalchemy.sql import func +from app.core.database import Base + + +class WeatherData(Base): + """气象数据缓存""" + __tablename__ = "weather_data" + + id = Column(Integer, primary_key=True, autoincrement=True) + timestamp = Column(DateTime(timezone=True), nullable=False, index=True) + data_type = Column(String(20), nullable=False) # observation, forecast + temperature = Column(Float) + humidity = Column(Float) + solar_radiation = Column(Float) # W/m2 + cloud_cover = Column(Float) # 0-100 % + wind_speed = Column(Float) # m/s + source = Column(String(20), default="mock") # api, mock + fetched_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class WeatherConfig(Base): + """气象API配置""" + __tablename__ = "weather_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + api_provider = Column(String(50), default="mock") + api_key = Column(String(200)) + location_lat = Column(Float, default=39.9) + location_lon = Column(Float, default=116.4) + fetch_interval_minutes = Column(Integer, default=30) + is_enabled = Column(Boolean, default=True) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/aggregation.py b/backend/app/services/aggregation.py new file mode 100644 index 0000000..287f536 --- /dev/null +++ b/backend/app/services/aggregation.py @@ -0,0 +1,291 @@ +"""Scheduled aggregation engine for energy data rollups. + +Computes hourly, daily, and monthly aggregations from raw EnergyData +and populates EnergyDailySummary. Follows the APScheduler pattern +established in report_scheduler.py. +""" +import logging +from datetime import datetime, timedelta, timezone + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from sqlalchemy import select, func, and_, text, Integer + +from app.core.config import get_settings +from app.core.database import async_session +from app.models.energy import EnergyData, EnergyDailySummary + +logger = logging.getLogger("aggregation") + +_scheduler: AsyncIOScheduler | None = None + + +async def aggregate_hourly(): + """Aggregate raw energy_data into hourly avg/min/max per device+data_type. + + Processes data from the previous hour. Results are logged but not + stored separately — the primary use is for cache warming and monitoring. + Daily aggregation (which writes to EnergyDailySummary) is the persistent rollup. + """ + now = datetime.now(timezone.utc) + hour_start = now.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) + hour_end = hour_start + timedelta(hours=1) + + logger.info("Running hourly aggregation for %s", hour_start.isoformat()) + + async with async_session() as session: + settings = get_settings() + query = ( + select( + EnergyData.device_id, + EnergyData.data_type, + func.avg(EnergyData.value).label("avg_value"), + func.min(EnergyData.value).label("min_value"), + func.max(EnergyData.value).label("max_value"), + func.count(EnergyData.id).label("sample_count"), + ) + .where( + and_( + EnergyData.timestamp >= hour_start, + EnergyData.timestamp < hour_end, + ) + ) + .group_by(EnergyData.device_id, EnergyData.data_type) + ) + result = await session.execute(query) + rows = result.all() + + logger.info( + "Hourly aggregation complete: %d device/type groups for %s", + len(rows), + hour_start.isoformat(), + ) + return rows + + +async def aggregate_daily(): + """Compute daily summaries and populate EnergyDailySummary. + + Processes yesterday's data. Groups by device_id and maps data_type + to energy_type for the summary table. + """ + now = datetime.now(timezone.utc) + day_start = (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + day_end = day_start + timedelta(days=1) + + logger.info("Running daily aggregation for %s", day_start.date().isoformat()) + + # Map data_type -> energy_type for summary grouping + data_type_to_energy_type = { + "power": "electricity", + "energy": "electricity", + "voltage": "electricity", + "current": "electricity", + "heat_power": "heat", + "heat_energy": "heat", + "temperature": "heat", + "water_flow": "water", + "water_volume": "water", + "gas_flow": "gas", + "gas_volume": "gas", + } + + async with async_session() as session: + # Fetch per-device aggregated stats for the day + query = ( + select( + EnergyData.device_id, + EnergyData.data_type, + func.avg(EnergyData.value).label("avg_value"), + func.min(EnergyData.value).label("min_value"), + func.max(EnergyData.value).label("max_value"), + func.sum(EnergyData.value).label("total_value"), + func.count(EnergyData.id).label("sample_count"), + ) + .where( + and_( + EnergyData.timestamp >= day_start, + EnergyData.timestamp < day_end, + ) + ) + .group_by(EnergyData.device_id, EnergyData.data_type) + ) + result = await session.execute(query) + rows = result.all() + + # Group results by (device_id, energy_type) + device_summaries: dict[tuple[int, str], dict] = {} + for row in rows: + energy_type = data_type_to_energy_type.get(row.data_type, "electricity") + key = (row.device_id, energy_type) + if key not in device_summaries: + device_summaries[key] = { + "peak_power": None, + "min_power": None, + "avg_power": None, + "total_consumption": 0.0, + "total_generation": 0.0, + "avg_temperature": None, + "sample_count": 0, + } + + summary = device_summaries[key] + summary["sample_count"] += row.sample_count + + # Power-type metrics + if row.data_type in ("power", "heat_power"): + summary["peak_power"] = max( + summary["peak_power"] or 0, row.max_value or 0 + ) + summary["min_power"] = min( + summary["min_power"] if summary["min_power"] is not None else float("inf"), + row.min_value or 0, + ) + summary["avg_power"] = row.avg_value + + # Consumption (energy, volume) + if row.data_type in ("energy", "heat_energy", "water_volume", "gas_volume"): + summary["total_consumption"] += row.total_value or 0 + + # Temperature + if row.data_type == "temperature": + summary["avg_temperature"] = row.avg_value + + # Delete existing summaries for the same date to allow re-runs + await session.execute( + EnergyDailySummary.__table__.delete().where( + EnergyDailySummary.date == day_start + ) + ) + + # Insert new summaries + summaries = [] + for (device_id, energy_type), stats in device_summaries.items(): + summaries.append( + EnergyDailySummary( + device_id=device_id, + date=day_start, + energy_type=energy_type, + total_consumption=round(stats["total_consumption"], 4), + total_generation=0.0, + peak_power=round(stats["peak_power"], 4) if stats["peak_power"] else None, + min_power=round(stats["min_power"], 4) if stats["min_power"] is not None and stats["min_power"] != float("inf") else None, + avg_power=round(stats["avg_power"], 4) if stats["avg_power"] else None, + avg_temperature=round(stats["avg_temperature"], 2) if stats["avg_temperature"] else None, + ) + ) + + if summaries: + session.add_all(summaries) + await session.commit() + + logger.info( + "Daily aggregation complete: %d summaries for %s", + len(summaries) if device_summaries else 0, + day_start.date().isoformat(), + ) + + +async def aggregate_monthly(): + """Compute monthly rollups from EnergyDailySummary. + + Aggregates the previous month's daily summaries. Results are logged + for monitoring — monthly reports use ReportGenerator for output. + """ + now = datetime.now(timezone.utc) + first_of_current = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + last_month_end = first_of_current - timedelta(days=1) + month_start = last_month_end.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + logger.info( + "Running monthly aggregation for %s-%02d", + month_start.year, + month_start.month, + ) + + async with async_session() as session: + query = ( + select( + EnergyDailySummary.device_id, + EnergyDailySummary.energy_type, + func.sum(EnergyDailySummary.total_consumption).label("total_consumption"), + func.sum(EnergyDailySummary.total_generation).label("total_generation"), + func.max(EnergyDailySummary.peak_power).label("peak_power"), + func.min(EnergyDailySummary.min_power).label("min_power"), + func.avg(EnergyDailySummary.avg_power).label("avg_power"), + func.sum(EnergyDailySummary.operating_hours).label("total_operating_hours"), + func.sum(EnergyDailySummary.cost).label("total_cost"), + func.sum(EnergyDailySummary.carbon_emission).label("total_carbon"), + ) + .where( + and_( + EnergyDailySummary.date >= month_start, + EnergyDailySummary.date < first_of_current, + ) + ) + .group_by(EnergyDailySummary.device_id, EnergyDailySummary.energy_type) + ) + result = await session.execute(query) + rows = result.all() + + logger.info( + "Monthly aggregation complete: %d device/type groups for %s-%02d", + len(rows), + month_start.year, + month_start.month, + ) + return rows + + +async def start_aggregation_scheduler(): + """Start the APScheduler-based aggregation scheduler.""" + global _scheduler + settings = get_settings() + if not settings.AGGREGATION_ENABLED: + logger.info("Aggregation scheduler disabled by config.") + return + + if _scheduler and _scheduler.running: + logger.warning("Aggregation scheduler is already running.") + return + + _scheduler = AsyncIOScheduler(timezone="Asia/Shanghai") + + # Hourly aggregation: every hour at :05 + _scheduler.add_job( + aggregate_hourly, + CronTrigger(minute=5), + id="aggregate_hourly", + replace_existing=True, + misfire_grace_time=600, + ) + + # Daily aggregation: every day at 00:30 + _scheduler.add_job( + aggregate_daily, + CronTrigger(hour=0, minute=30), + id="aggregate_daily", + replace_existing=True, + misfire_grace_time=3600, + ) + + # Monthly aggregation: 1st of each month at 01:00 + _scheduler.add_job( + aggregate_monthly, + CronTrigger(day=1, hour=1, minute=0), + id="aggregate_monthly", + replace_existing=True, + misfire_grace_time=7200, + ) + + _scheduler.start() + logger.info("Aggregation scheduler started (hourly @:05, daily @00:30, monthly @1st 01:00).") + + +async def stop_aggregation_scheduler(): + """Stop the aggregation scheduler gracefully.""" + global _scheduler + if _scheduler and _scheduler.running: + _scheduler.shutdown(wait=False) + logger.info("Aggregation scheduler stopped.") + _scheduler = None diff --git a/backend/app/services/ai_ops.py b/backend/app/services/ai_ops.py new file mode 100644 index 0000000..7e62a66 --- /dev/null +++ b/backend/app/services/ai_ops.py @@ -0,0 +1,1016 @@ +"""AI运维智能体服务 - 设备健康评分、异常检测、诊断智能、预测性维护、运营洞察 + +Inspired by Envision's "构网智能体" concept: self-sensing, self-adapting, self-evolving +intelligent agents for energy asset management. +""" +import logging +import random +import math +from datetime import datetime, timezone, timedelta +from sqlalchemy import select, func, and_, desc +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.device import Device +from app.models.energy import EnergyData, EnergyDailySummary +from app.models.alarm import AlarmEvent +from app.models.ai_ops import ( + DeviceHealthScore, AnomalyDetection, DiagnosticReport, + MaintenancePrediction, OpsInsight, +) + +logger = logging.getLogger("ai_ops") + +# ── Device type configurations ────────────────────────────────────── + +DEVICE_RATED_EFFICIENCY = { + "pv_inverter": {"metric": "power", "rated_cop": None, "temp_range": (20, 60)}, + "heat_pump": {"metric": "cop", "rated_cop": 3.5, "temp_range": (30, 55)}, + "meter": {"metric": "power_factor", "rated_cop": None, "temp_range": None}, + "sensor": {"metric": "temperature", "rated_cop": None, "temp_range": (18, 28)}, + "heat_meter": {"metric": "heat_power", "rated_cop": None, "temp_range": None}, +} + +HEALTH_WEIGHTS = { + "power_stability": 0.20, + "efficiency": 0.25, + "alarm_frequency": 0.20, + "uptime": 0.20, + "temperature": 0.15, +} + + +# ── Health Score Calculation ──────────────────────────────────────── + +async def calculate_device_health( + session: AsyncSession, device: Device, now: datetime | None = None +) -> DeviceHealthScore: + """Calculate health score (0-100) for a single device based on weighted factors.""" + now = now or datetime.now(timezone.utc) + factors = {} + + # Factor 1: Power output stability (std_dev of power over last 24h) + factors["power_stability"] = await _calc_power_stability(session, device.id, now) + + # Factor 2: Efficiency / COP + factors["efficiency"] = await _calc_efficiency_score(session, device, now) + + # Factor 3: Alarm frequency (last 7 days) + factors["alarm_frequency"] = await _calc_alarm_frequency_score(session, device.id, now) + + # Factor 4: Uptime (last 30 days) + factors["uptime"] = await _calc_uptime_score(session, device, now) + + # Factor 5: Temperature + factors["temperature"] = await _calc_temperature_score(session, device, now) + + # Weighted average + health_score = sum( + factors[k] * HEALTH_WEIGHTS[k] for k in HEALTH_WEIGHTS + ) + health_score = max(0, min(100, round(health_score, 1))) + + # Status + if health_score >= 80: + status = "healthy" + elif health_score >= 60: + status = "warning" + else: + status = "critical" + + # Trend: compare with last score + trend = await _calc_trend(session, device.id, health_score) + + score = DeviceHealthScore( + device_id=device.id, + timestamp=now, + health_score=health_score, + status=status, + factors=factors, + trend=trend, + ) + session.add(score) + return score + + +async def _calc_power_stability(session: AsyncSession, device_id: int, now: datetime) -> float: + """Power stability score: low std_dev = high score.""" + cutoff = now - timedelta(hours=24) + result = await session.execute( + select( + func.avg(EnergyData.value).label("avg_val"), + func.stddev(EnergyData.value).label("std_val"), + ).where(and_( + EnergyData.device_id == device_id, + EnergyData.data_type == "power", + EnergyData.timestamp >= cutoff, + )) + ) + row = result.one_or_none() + if not row or row.avg_val is None or row.avg_val == 0: + return 85.0 # no data = assume OK + avg_val = float(row.avg_val) + std_val = float(row.std_val or 0) + cv = std_val / avg_val if avg_val > 0 else 0 # coefficient of variation + # cv < 0.1 = 100, cv > 0.5 = 40 + score = max(40, min(100, 100 - (cv - 0.1) * 150)) + return round(score, 1) + + +async def _calc_efficiency_score(session: AsyncSession, device: Device, now: datetime) -> float: + """Efficiency score based on device type.""" + cutoff = now - timedelta(hours=24) + if device.device_type == "heat_pump": + result = await session.execute( + select(func.avg(EnergyData.value)).where(and_( + EnergyData.device_id == device.id, + EnergyData.data_type == "cop", + EnergyData.timestamp >= cutoff, + )) + ) + avg_cop = result.scalar() + if avg_cop is None: + return 85.0 + rated_cop = 3.5 + ratio = float(avg_cop) / rated_cop + return round(max(30, min(100, ratio * 100)), 1) + elif device.device_type == "pv_inverter": + rated_power = device.rated_power or 110.0 + result = await session.execute( + select(func.max(EnergyData.value)).where(and_( + EnergyData.device_id == device.id, + EnergyData.data_type == "power", + EnergyData.timestamp >= cutoff, + )) + ) + max_power = result.scalar() + if max_power is None: + return 85.0 + ratio = float(max_power) / rated_power + return round(max(30, min(100, ratio * 110)), 1) + elif device.device_type == "meter": + result = await session.execute( + select(func.avg(EnergyData.value)).where(and_( + EnergyData.device_id == device.id, + EnergyData.data_type == "power_factor", + EnergyData.timestamp >= cutoff, + )) + ) + avg_pf = result.scalar() + if avg_pf is None: + return 85.0 + return round(max(40, min(100, float(avg_pf) * 105)), 1) + return 85.0 + + +async def _calc_alarm_frequency_score(session: AsyncSession, device_id: int, now: datetime) -> float: + """Fewer alarms = higher score. 0 alarms = 100, 10+ = 30.""" + cutoff = now - timedelta(days=7) + result = await session.execute( + select(func.count(AlarmEvent.id)).where(and_( + AlarmEvent.device_id == device_id, + AlarmEvent.triggered_at >= cutoff, + )) + ) + count = result.scalar() or 0 + score = max(30, 100 - count * 7) + return float(score) + + +async def _calc_uptime_score(session: AsyncSession, device: Device, now: datetime) -> float: + """Uptime based on device status and data availability.""" + cutoff = now - timedelta(days=7) + result = await session.execute( + select(func.count(EnergyData.id)).where(and_( + EnergyData.device_id == device.id, + EnergyData.data_type == "power", + EnergyData.timestamp >= cutoff, + )) + ) + data_count = result.scalar() or 0 + # Expected ~4 readings/min * 60 * 24 * 7 = ~40320 (at 15s interval) + expected = 7 * 24 * 60 * 4 + ratio = min(1.0, data_count / max(1, expected)) + # Also check current status + status_penalty = 0 + if device.status == "offline": + status_penalty = 15 + elif device.status == "alarm": + status_penalty = 5 + return round(max(30, ratio * 100 - status_penalty), 1) + + +async def _calc_temperature_score(session: AsyncSession, device: Device, now: datetime) -> float: + """Temperature within normal range = high score.""" + cfg = DEVICE_RATED_EFFICIENCY.get(device.device_type, {}) + temp_range = cfg.get("temp_range") + if not temp_range: + return 90.0 # N/A for this device type + + cutoff = now - timedelta(hours=6) + result = await session.execute( + select(func.avg(EnergyData.value)).where(and_( + EnergyData.device_id == device.id, + EnergyData.data_type == "temperature", + EnergyData.timestamp >= cutoff, + )) + ) + avg_temp = result.scalar() + if avg_temp is None: + return 85.0 + avg_temp = float(avg_temp) + low, high = temp_range + if low <= avg_temp <= high: + return 100.0 + deviation = max(0, avg_temp - high, low - avg_temp) + return round(max(20, 100 - deviation * 5), 1) + + +async def _calc_trend(session: AsyncSession, device_id: int, current_score: float) -> str: + """Compare with recent scores to determine trend.""" + result = await session.execute( + select(DeviceHealthScore.health_score) + .where(DeviceHealthScore.device_id == device_id) + .order_by(DeviceHealthScore.timestamp.desc()) + .limit(5) + ) + scores = [float(r) for r in result.scalars().all()] + if len(scores) < 2: + return "stable" + avg_prev = sum(scores) / len(scores) + diff = current_score - avg_prev + if diff > 3: + return "improving" + elif diff < -3: + return "degrading" + return "stable" + + +# ── Anomaly Detection ─────────────────────────────────────────────── + +async def scan_anomalies(session: AsyncSession, device_id: int | None = None) -> list[AnomalyDetection]: + """Scan for anomalies across devices using Z-score and pattern-based methods.""" + now = datetime.now(timezone.utc) + anomalies = [] + + query = select(Device).where(Device.is_active == True) + if device_id: + query = query.where(Device.id == device_id) + result = await session.execute(query) + devices = result.scalars().all() + + for device in devices: + # Z-score based detection + device_anomalies = await _zscore_detection(session, device, now) + anomalies.extend(device_anomalies) + + # Pattern-based detection + pattern_anomalies = await _pattern_detection(session, device, now) + anomalies.extend(pattern_anomalies) + + for a in anomalies: + session.add(a) + + return anomalies + + +async def _zscore_detection( + session: AsyncSession, device: Device, now: datetime +) -> list[AnomalyDetection]: + """Detect anomalies using Z-score method (> 3 sigma).""" + anomalies = [] + metrics = ["power"] + if device.device_type == "heat_pump": + metrics.append("cop") + if device.device_type == "sensor": + metrics = ["temperature"] + + for metric in metrics: + # Get rolling stats from last 24h + cutoff_stats = now - timedelta(hours=24) + stats_result = await session.execute( + select( + func.avg(EnergyData.value).label("avg_val"), + func.stddev(EnergyData.value).label("std_val"), + ).where(and_( + EnergyData.device_id == device.id, + EnergyData.data_type == metric, + EnergyData.timestamp >= cutoff_stats, + )) + ) + stats = stats_result.one_or_none() + if not stats or stats.avg_val is None or stats.std_val is None or float(stats.std_val) == 0: + continue + + avg_val = float(stats.avg_val) + std_val = float(stats.std_val) + + # Check latest value + latest_result = await session.execute( + select(EnergyData.value).where(and_( + EnergyData.device_id == device.id, + EnergyData.data_type == metric, + )).order_by(EnergyData.timestamp.desc()).limit(1) + ) + latest = latest_result.scalar() + if latest is None: + continue + latest = float(latest) + + z_score = abs(latest - avg_val) / std_val + if z_score > 3: + deviation_pct = round(abs(latest - avg_val) / avg_val * 100, 1) if avg_val != 0 else 0 + severity = "critical" if z_score > 5 else "warning" + anomaly_type = _classify_anomaly(device.device_type, metric, latest, avg_val) + + anomalies.append(AnomalyDetection( + device_id=device.id, + detected_at=now, + anomaly_type=anomaly_type, + severity=severity, + description=f"{metric} 异常: 当前值 {latest:.2f}, 均值 {avg_val:.2f}, Z-score {z_score:.1f}", + metric_name=metric, + expected_value=round(avg_val, 2), + actual_value=round(latest, 2), + deviation_percent=deviation_pct, + status="detected", + )) + + return anomalies + + +def _classify_anomaly(device_type: str, metric: str, actual: float, expected: float) -> str: + """Classify anomaly type based on device type and metric.""" + if metric == "power" and actual < expected: + return "power_drop" + if metric == "cop" and actual < expected: + return "efficiency_loss" + if metric == "temperature": + return "abnormal_temperature" + return "pattern_deviation" + + +async def _pattern_detection( + session: AsyncSession, device: Device, now: datetime +) -> list[AnomalyDetection]: + """Pattern-based anomaly detection for specific device types.""" + anomalies = [] + + # Check for communication loss (no data in last 5 minutes) + cutoff = now - timedelta(minutes=5) + result = await session.execute( + select(func.count(EnergyData.id)).where(and_( + EnergyData.device_id == device.id, + EnergyData.timestamp >= cutoff, + )) + ) + count = result.scalar() or 0 + if count == 0 and device.status == "online": + anomalies.append(AnomalyDetection( + device_id=device.id, + detected_at=now, + anomaly_type="communication_loss", + severity="warning", + description=f"设备 {device.name} 超过5分钟无数据上报", + metric_name="data_availability", + expected_value=1.0, + actual_value=0.0, + deviation_percent=100.0, + status="detected", + )) + + # PV specific: power drop during daytime (8:00-17:00 Beijing time) + if device.device_type == "pv_inverter": + beijing_hour = (now + timedelta(hours=8)).hour + if 8 <= beijing_hour <= 17: + latest_result = await session.execute( + select(EnergyData.value).where(and_( + EnergyData.device_id == device.id, + EnergyData.data_type == "power", + )).order_by(EnergyData.timestamp.desc()).limit(1) + ) + power = latest_result.scalar() + rated = device.rated_power or 110.0 + if power is not None and float(power) < rated * 0.05 and beijing_hour >= 9 and beijing_hour <= 16: + anomalies.append(AnomalyDetection( + device_id=device.id, + detected_at=now, + anomaly_type="power_drop", + severity="warning", + description=f"光伏 {device.name} 在日照时段功率异常偏低: {power:.1f} kW", + metric_name="power", + expected_value=round(rated * 0.3, 2), + actual_value=round(float(power), 2), + deviation_percent=round((1 - float(power) / (rated * 0.3)) * 100, 1), + status="detected", + )) + + # Heat pump: COP degradation + if device.device_type == "heat_pump": + latest_result = await session.execute( + select(EnergyData.value).where(and_( + EnergyData.device_id == device.id, + EnergyData.data_type == "cop", + )).order_by(EnergyData.timestamp.desc()).limit(1) + ) + cop = latest_result.scalar() + if cop is not None and float(cop) < 2.0: + anomalies.append(AnomalyDetection( + device_id=device.id, + detected_at=now, + anomaly_type="efficiency_loss", + severity="warning" if float(cop) >= 1.5 else "critical", + description=f"热泵 {device.name} COP降至 {cop:.2f},低于正常水平", + metric_name="cop", + expected_value=3.5, + actual_value=round(float(cop), 2), + deviation_percent=round((1 - float(cop) / 3.5) * 100, 1), + status="detected", + )) + + return anomalies + + +# ── Diagnostic Intelligence ───────────────────────────────────────── + +async def run_diagnostics( + session: AsyncSession, device_id: int, report_type: str = "triggered" +) -> DiagnosticReport: + """Run diagnostic analysis for a device and generate report.""" + now = datetime.now(timezone.utc) + + result = await session.execute(select(Device).where(Device.id == device_id)) + device = result.scalar_one_or_none() + if not device: + raise ValueError(f"Device {device_id} not found") + + findings = [] + recommendations = [] + energy_loss_kwh = 0.0 + cost_impact_yuan = 0.0 + + # Check recent anomalies + cutoff = now - timedelta(days=7) + anomaly_result = await session.execute( + select(AnomalyDetection).where(and_( + AnomalyDetection.device_id == device_id, + AnomalyDetection.detected_at >= cutoff, + )).order_by(AnomalyDetection.detected_at.desc()) + ) + anomalies = anomaly_result.scalars().all() + + # Check alarm history + alarm_result = await session.execute( + select(AlarmEvent).where(and_( + AlarmEvent.device_id == device_id, + AlarmEvent.triggered_at >= cutoff, + )) + ) + alarms = alarm_result.scalars().all() + + # Get current health + health_result = await session.execute( + select(DeviceHealthScore).where( + DeviceHealthScore.device_id == device_id + ).order_by(DeviceHealthScore.timestamp.desc()).limit(1) + ) + health = health_result.scalar_one_or_none() + + # Generate findings based on device type + if device.device_type == "pv_inverter": + findings, recommendations, energy_loss_kwh, cost_impact_yuan = \ + await _diagnose_pv(session, device, anomalies, alarms, health, now) + elif device.device_type == "heat_pump": + findings, recommendations, energy_loss_kwh, cost_impact_yuan = \ + await _diagnose_heat_pump(session, device, anomalies, alarms, health, now) + else: + findings, recommendations, energy_loss_kwh, cost_impact_yuan = \ + _diagnose_general(device, anomalies, alarms, health) + + report = DiagnosticReport( + device_id=device_id, + generated_at=now, + report_type=report_type, + findings=findings, + recommendations=recommendations, + estimated_impact={"energy_loss_kwh": round(energy_loss_kwh, 2), "cost_impact_yuan": round(cost_impact_yuan, 2)}, + status="generated", + ) + session.add(report) + return report + + +async def _diagnose_pv(session, device, anomalies, alarms, health, now): + """Diagnostic logic for PV inverters.""" + findings = [] + recommendations = [] + energy_loss = 0.0 + cost_impact = 0.0 + + # Check temperature + cutoff = now - timedelta(hours=6) + temp_result = await session.execute( + select(func.avg(EnergyData.value)).where(and_( + EnergyData.device_id == device.id, + EnergyData.data_type == "temperature", + EnergyData.timestamp >= cutoff, + )) + ) + avg_temp = temp_result.scalar() + + power_anomalies = [a for a in anomalies if a.anomaly_type == "power_drop"] + temp_anomalies = [a for a in anomalies if a.anomaly_type == "abnormal_temperature"] + + if power_anomalies and avg_temp and float(avg_temp) > 55: + findings.append({ + "finding": "光伏逆变器功率下降伴随高温", + "severity": "warning", + "detail": f"平均温度 {float(avg_temp):.1f}°C,高于正常范围。功率异常 {len(power_anomalies)} 次", + }) + recommendations.append({ + "action": "清洁光伏面板并检查通风散热", + "priority": "high", + "detail": "高温导致逆变器降额运行,建议清洁面板、检查散热风扇", + }) + energy_loss = len(power_anomalies) * 5.0 + cost_impact = energy_loss * 0.6 + + elif power_anomalies: + findings.append({ + "finding": "光伏功率间歇性下降", + "severity": "info", + "detail": f"近7天检测到 {len(power_anomalies)} 次功率下降", + }) + recommendations.append({ + "action": "检查光伏面板遮挡和接线", + "priority": "medium", + "detail": "排除树木遮挡、面板污染或接线松动", + }) + + if alarms: + findings.append({ + "finding": f"近7天触发 {len(alarms)} 条告警", + "severity": "warning" if len(alarms) > 3 else "info", + "detail": "频繁告警可能指示设备劣化", + }) + + if health and health.health_score < 70: + findings.append({ + "finding": f"设备健康评分偏低: {health.health_score}", + "severity": "warning", + "detail": f"当前趋势: {health.trend}", + }) + recommendations.append({ + "action": "安排专项巡检", + "priority": "high", + "detail": "建议安排技术人员进行全面检查", + }) + + if not findings: + findings.append({ + "finding": "设备运行状态良好", + "severity": "info", + "detail": "未发现明显异常", + }) + + return findings, recommendations, energy_loss, cost_impact + + +async def _diagnose_heat_pump(session, device, anomalies, alarms, health, now): + """Diagnostic logic for heat pumps.""" + findings = [] + recommendations = [] + energy_loss = 0.0 + cost_impact = 0.0 + + # Check outdoor temperature for context + cutoff = now - timedelta(hours=6) + cop_anomalies = [a for a in anomalies if a.anomaly_type == "efficiency_loss"] + + # Check outdoor temp via sensors + outdoor_result = await session.execute( + select(func.avg(EnergyData.value)).where(and_( + EnergyData.data_type == "outdoor_temp", + EnergyData.timestamp >= cutoff, + )) + ) + outdoor_temp = outdoor_result.scalar() + + if cop_anomalies and outdoor_temp and float(outdoor_temp) < -5: + findings.append({ + "finding": "热泵COP下降,与低温天气相关", + "severity": "info", + "detail": f"室外温度 {float(outdoor_temp):.1f}°C,COP降额属正常现象", + }) + recommendations.append({ + "action": "调整运行策略以适应低温", + "priority": "low", + "detail": "低温环境下COP降低属正常特性,可适当调整运行时段", + }) + elif cop_anomalies: + findings.append({ + "finding": "热泵能效异常下降", + "severity": "warning", + "detail": f"检测到 {len(cop_anomalies)} 次COP异常", + }) + recommendations.append({ + "action": "检查冷媒充注量和换热器", + "priority": "high", + "detail": "COP异常降低可能由冷媒泄漏或换热器结垢导致", + }) + energy_loss = len(cop_anomalies) * 8.0 + cost_impact = energy_loss * 0.6 + + comm_anomalies = [a for a in anomalies if a.anomaly_type == "communication_loss"] + if comm_anomalies: + findings.append({ + "finding": f"通讯中断 {len(comm_anomalies)} 次", + "severity": "warning", + "detail": "频繁通讯丢失需检查网络和DTU设备", + }) + recommendations.append({ + "action": "检查通讯网络和DTU", + "priority": "medium", + "detail": "检查DTU设备状态、网线连接和信号强度", + }) + + if health and health.health_score < 70: + findings.append({ + "finding": f"设备健康评分偏低: {health.health_score}", + "severity": "warning", + "detail": f"当前趋势: {health.trend}", + }) + + if not findings: + findings.append({ + "finding": "设备运行状态良好", + "severity": "info", + "detail": "未发现明显异常", + }) + + return findings, recommendations, energy_loss, cost_impact + + +def _diagnose_general(device, anomalies, alarms, health): + """Generic diagnostic for other device types.""" + findings = [] + recommendations = [] + energy_loss = 0.0 + cost_impact = 0.0 + + if anomalies: + by_type = {} + for a in anomalies: + by_type.setdefault(a.anomaly_type, []).append(a) + for atype, items in by_type.items(): + findings.append({ + "finding": f"检测到 {len(items)} 次 {atype} 异常", + "severity": "warning" if len(items) > 2 else "info", + "detail": items[0].description if items else "", + }) + + if alarms: + findings.append({ + "finding": f"近7天触发 {len(alarms)} 条告警", + "severity": "warning" if len(alarms) > 3 else "info", + "detail": "频繁告警需要关注", + }) + + if health and health.health_score < 70: + recommendations.append({ + "action": "安排设备巡检", + "priority": "high", + "detail": f"健康评分 {health.health_score},趋势 {health.trend}", + }) + + if not findings: + findings.append({ + "finding": "设备运行正常", + "severity": "info", + "detail": "未发现异常", + }) + + return findings, recommendations, energy_loss, cost_impact + + +# ── Predictive Maintenance ────────────────────────────────────────── + +async def generate_maintenance_predictions(session: AsyncSession) -> list[MaintenancePrediction]: + """Generate maintenance predictions based on health trends and patterns.""" + now = datetime.now(timezone.utc) + predictions = [] + + result = await session.execute(select(Device).where(Device.is_active == True)) + devices = result.scalars().all() + + for device in devices: + # Get recent health scores + health_result = await session.execute( + select(DeviceHealthScore).where( + DeviceHealthScore.device_id == device.id + ).order_by(DeviceHealthScore.timestamp.desc()).limit(10) + ) + scores = health_result.scalars().all() + + if not scores: + continue + + latest = scores[0] + + # Rule: health score < 60 and degrading + if latest.health_score < 60 and latest.trend == "degrading": + days_to_failure = max(3, int((latest.health_score - 20) / 5)) + predictions.append(MaintenancePrediction( + device_id=device.id, + predicted_at=now, + component=_get_weak_component(latest.factors), + failure_mode="设备性能持续下降,可能导致故障停机", + probability=round(min(0.9, (100 - latest.health_score) / 100 + 0.2), 2), + predicted_failure_date=now + timedelta(days=days_to_failure), + recommended_action="安排全面检修,重点检查薄弱环节", + urgency="critical" if latest.health_score < 40 else "high", + estimated_downtime_hours=4.0 if device.device_type in ("heat_pump", "pv_inverter") else 2.0, + estimated_repair_cost=_estimate_repair_cost(device.device_type), + status="predicted", + )) + + # Rule: health between 60-75 and degrading + elif 60 <= latest.health_score < 75 and latest.trend == "degrading": + predictions.append(MaintenancePrediction( + device_id=device.id, + predicted_at=now, + component=_get_weak_component(latest.factors), + failure_mode="性能下降趋势,需预防性维护", + probability=round(min(0.6, (80 - latest.health_score) / 100 + 0.1), 2), + predicted_failure_date=now + timedelta(days=14), + recommended_action="安排预防性巡检和维护", + urgency="medium", + estimated_downtime_hours=2.0, + estimated_repair_cost=_estimate_repair_cost(device.device_type) * 0.5, + status="predicted", + )) + + # Rule: check alarm frequency + alarm_score = latest.factors.get("alarm_frequency", 100) if latest.factors else 100 + if alarm_score < 50: + predictions.append(MaintenancePrediction( + device_id=device.id, + predicted_at=now, + component="告警系统/传感器", + failure_mode="频繁告警可能预示设备故障", + probability=0.4, + predicted_failure_date=now + timedelta(days=7), + recommended_action="排查告警根因,检查传感器和控制系统", + urgency="medium", + estimated_downtime_hours=1.0, + estimated_repair_cost=500.0, + status="predicted", + )) + + for p in predictions: + session.add(p) + return predictions + + +def _get_weak_component(factors: dict | None) -> str: + """Identify the weakest factor as the component needing attention.""" + if not factors: + return "综合" + component_map = { + "power_stability": "电力输出系统", + "efficiency": "能效/换热系统", + "alarm_frequency": "监控传感器", + "uptime": "通讯/控制系统", + "temperature": "散热/温控系统", + } + weakest = min(factors, key=lambda k: factors.get(k, 100)) + return component_map.get(weakest, "综合") + + +def _estimate_repair_cost(device_type: str) -> float: + """Estimate repair cost by device type.""" + costs = { + "pv_inverter": 3000.0, + "heat_pump": 5000.0, + "meter": 800.0, + "sensor": 300.0, + "heat_meter": 1500.0, + } + return costs.get(device_type, 1000.0) + + +# ── Operational Insights ──────────────────────────────────────────── + +async def generate_insights(session: AsyncSession) -> list[OpsInsight]: + """Generate operational insights from data analysis.""" + now = datetime.now(timezone.utc) + insights = [] + + # Insight 1: Device efficiency comparison + result = await session.execute( + select( + DeviceHealthScore.device_id, + func.avg(DeviceHealthScore.health_score).label("avg_score"), + ).where( + DeviceHealthScore.timestamp >= now - timedelta(days=7) + ).group_by(DeviceHealthScore.device_id) + ) + scores_by_device = result.all() + + if scores_by_device: + scores = [float(s.avg_score) for s in scores_by_device] + avg_all = sum(scores) / len(scores) if scores else 0 + low_performers = [s for s in scores_by_device if float(s.avg_score) < avg_all - 10] + + if low_performers: + device_ids = [s.device_id for s in low_performers] + dev_result = await session.execute( + select(Device.id, Device.name).where(Device.id.in_(device_ids)) + ) + device_names = {r.id: r.name for r in dev_result.all()} + insights.append(OpsInsight( + insight_type="performance_comparison", + title="部分设备健康评分低于平均水平", + description=f"以下设备健康评分低于园区平均值({avg_all:.0f})超过10分: " + + ", ".join(device_names.get(d, f"#{d}") for d in device_ids), + data={"avg_score": round(avg_all, 1), "low_performers": [ + {"device_id": s.device_id, "score": round(float(s.avg_score), 1), + "name": device_names.get(s.device_id, f"#{s.device_id}")} + for s in low_performers + ]}, + impact_level="medium" if len(low_performers) <= 2 else "high", + actionable=True, + recommended_action="重点关注低评分设备,安排巡检和维护", + generated_at=now, + valid_until=now + timedelta(days=7), + )) + + # Insight 2: Anomaly trend + week_ago = now - timedelta(days=7) + two_weeks_ago = now - timedelta(days=14) + + this_week = await session.execute( + select(func.count(AnomalyDetection.id)).where( + AnomalyDetection.detected_at >= week_ago + ) + ) + last_week = await session.execute( + select(func.count(AnomalyDetection.id)).where(and_( + AnomalyDetection.detected_at >= two_weeks_ago, + AnomalyDetection.detected_at < week_ago, + )) + ) + this_count = this_week.scalar() or 0 + last_count = last_week.scalar() or 0 + + if this_count > last_count * 1.5 and this_count > 3: + insights.append(OpsInsight( + insight_type="efficiency_trend", + title="异常检测数量环比上升", + description=f"本周检测到 {this_count} 次异常,上周 {last_count} 次,增长 {((this_count/max(1,last_count))-1)*100:.0f}%", + data={"this_week": this_count, "last_week": last_count}, + impact_level="high", + actionable=True, + recommended_action="建议全面排查设备状态,加强巡检频次", + generated_at=now, + valid_until=now + timedelta(days=3), + )) + + # Insight 3: Energy cost optimization + insights.append(OpsInsight( + insight_type="cost_anomaly", + title="能源使用效率周报", + description="园区整体运行状况评估", + data={ + "total_devices": len(scores_by_device) if scores_by_device else 0, + "avg_health": round(avg_all, 1) if scores_by_device else 0, + "anomaly_count": this_count, + }, + impact_level="low", + actionable=False, + generated_at=now, + valid_until=now + timedelta(days=7), + )) + + for i in insights: + session.add(i) + return insights + + +# ── Dashboard Aggregation ─────────────────────────────────────────── + +async def get_dashboard_data(session: AsyncSession) -> dict: + """Get AI Ops dashboard overview data.""" + now = datetime.now(timezone.utc) + + # Latest health scores per device + subq = ( + select( + DeviceHealthScore.device_id, + func.max(DeviceHealthScore.timestamp).label("max_ts"), + ).group_by(DeviceHealthScore.device_id).subquery() + ) + health_result = await session.execute( + select(DeviceHealthScore).join( + subq, and_( + DeviceHealthScore.device_id == subq.c.device_id, + DeviceHealthScore.timestamp == subq.c.max_ts, + ) + ) + ) + health_scores = health_result.scalars().all() + + # Get device names + device_ids = [h.device_id for h in health_scores] + if device_ids: + dev_result = await session.execute( + select(Device.id, Device.name, Device.device_type).where(Device.id.in_(device_ids)) + ) + device_map = {r.id: {"name": r.name, "type": r.device_type} for r in dev_result.all()} + else: + device_map = {} + + health_summary = { + "healthy": sum(1 for h in health_scores if h.status == "healthy"), + "warning": sum(1 for h in health_scores if h.status == "warning"), + "critical": sum(1 for h in health_scores if h.status == "critical"), + "avg_score": round(sum(h.health_score for h in health_scores) / max(1, len(health_scores)), 1), + "devices": [{ + "device_id": h.device_id, + "device_name": device_map.get(h.device_id, {}).get("name", f"#{h.device_id}"), + "device_type": device_map.get(h.device_id, {}).get("type", "unknown"), + "health_score": h.health_score, + "status": h.status, + "trend": h.trend, + "factors": h.factors, + } for h in health_scores], + } + + # Recent anomalies + anomaly_result = await session.execute( + select(AnomalyDetection) + .where(AnomalyDetection.detected_at >= now - timedelta(days=7)) + .order_by(AnomalyDetection.detected_at.desc()) + .limit(20) + ) + recent_anomalies = anomaly_result.scalars().all() + anomaly_stats = { + "total": len(recent_anomalies), + "by_severity": {}, + "by_type": {}, + } + for a in recent_anomalies: + anomaly_stats["by_severity"][a.severity] = anomaly_stats["by_severity"].get(a.severity, 0) + 1 + anomaly_stats["by_type"][a.anomaly_type] = anomaly_stats["by_type"].get(a.anomaly_type, 0) + 1 + + # Maintenance predictions + pred_result = await session.execute( + select(MaintenancePrediction).where( + MaintenancePrediction.status == "predicted" + ).order_by(MaintenancePrediction.urgency.desc()).limit(10) + ) + predictions = pred_result.scalars().all() + + # Latest insights + insight_result = await session.execute( + select(OpsInsight).where( + OpsInsight.valid_until >= now + ).order_by(OpsInsight.generated_at.desc()).limit(5) + ) + latest_insights = insight_result.scalars().all() + + return { + "health": health_summary, + "anomalies": { + "stats": anomaly_stats, + "recent": [{ + "id": a.id, + "device_id": a.device_id, + "device_name": device_map.get(a.device_id, {}).get("name", f"#{a.device_id}"), + "anomaly_type": a.anomaly_type, + "severity": a.severity, + "description": a.description, + "detected_at": str(a.detected_at), + "status": a.status, + } for a in recent_anomalies[:10]], + }, + "predictions": [{ + "id": p.id, + "device_id": p.device_id, + "device_name": device_map.get(p.device_id, {}).get("name", f"#{p.device_id}"), + "component": p.component, + "failure_mode": p.failure_mode, + "probability": p.probability, + "predicted_failure_date": str(p.predicted_failure_date) if p.predicted_failure_date else None, + "urgency": p.urgency, + "recommended_action": p.recommended_action, + } for p in predictions], + "insights": [{ + "id": i.id, + "insight_type": i.insight_type, + "title": i.title, + "description": i.description, + "impact_level": i.impact_level, + "actionable": i.actionable, + "recommended_action": i.recommended_action, + "generated_at": str(i.generated_at), + } for i in latest_insights], + } diff --git a/backend/app/services/ai_prediction.py b/backend/app/services/ai_prediction.py new file mode 100644 index 0000000..a12e7cc --- /dev/null +++ b/backend/app/services/ai_prediction.py @@ -0,0 +1,606 @@ +"""AI预测引擎 - 光伏发电、负荷、热泵COP预测与自发自用优化 + +Uses physics-based models from weather_model.py combined with statistical +methods (moving averages, exponential smoothing, seasonal decomposition) +to generate realistic forecasts. Inspired by Envision's 天枢能源大模型. +""" + +import math +import logging +from datetime import datetime, timezone, timedelta +from typing import Optional + +import numpy as np + +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.device import Device +from app.models.energy import EnergyData +from app.models.prediction import PredictionTask, PredictionResult, OptimizationSchedule +from app.services.weather_model import ( + outdoor_temperature, solar_altitude, get_cloud_factor, + pv_power as _physics_pv_power, get_pv_orientation, + get_hvac_mode, MONTHLY_AVG_TEMP, MONTHLY_DIURNAL_SWING, +) + +logger = logging.getLogger("ai_prediction") + +# Beijing electricity TOU pricing (yuan/kWh) - simplified +TOU_PRICE = { + "peak": 1.2, # 10:00-15:00, 18:00-21:00 + "shoulder": 0.8, # 07:00-10:00, 15:00-18:00, 21:00-23:00 + "valley": 0.4, # 23:00-07:00 +} + + +def _get_tou_price(hour: int) -> float: + if 10 <= hour < 15 or 18 <= hour < 21: + return TOU_PRICE["peak"] + elif 7 <= hour < 10 or 15 <= hour < 18 or 21 <= hour < 23: + return TOU_PRICE["shoulder"] + else: + return TOU_PRICE["valley"] + + +# --------------------------------------------------------------------------- +# PV Power Forecasting +# --------------------------------------------------------------------------- + +async def forecast_pv( + db: AsyncSession, + device_id: int, + horizon_hours: int = 24, +) -> list[dict]: + """Forecast PV generation for the next horizon_hours. + + Combines physics-based solar model with historical pattern correction. + Returns list of {timestamp, predicted_power_kw, confidence_lower, confidence_upper}. + """ + device = await db.get(Device, device_id) + if not device: + raise ValueError(f"Device {device_id} not found") + + rated_power = device.rated_power or 110.0 + orientation = get_pv_orientation(device.code or "") + + # Fetch recent historical data for pattern correction + now = datetime.now(timezone.utc) + lookback = now - timedelta(days=7) + result = await db.execute( + select(EnergyData.timestamp, EnergyData.value) + .where(and_( + EnergyData.device_id == device_id, + EnergyData.data_type == "power", + EnergyData.timestamp >= lookback, + )) + .order_by(EnergyData.timestamp) + ) + historical = result.all() + + # Calculate hourly averages from history for bias correction + hourly_actual: dict[int, list[float]] = {h: [] for h in range(24)} + for ts, val in historical: + beijing_h = (ts.hour + 8) % 24 if ts.tzinfo else ts.hour + hourly_actual[beijing_h].append(val) + + hourly_avg = { + h: np.mean(vals) if vals else None + for h, vals in hourly_actual.items() + } + + # Generate physics-based forecast with bias correction + forecasts = [] + for h_offset in range(horizon_hours): + target_utc = now + timedelta(hours=h_offset) + target_utc = target_utc.replace(minute=0, second=0, microsecond=0) + + # Physics model baseline + base_power = _physics_pv_power(target_utc, rated_power=rated_power, + orientation=orientation, + device_code=device.code or "") + + # Bias correction from recent history + beijing_hour = (target_utc.hour + 8) % 24 + hist_avg = hourly_avg.get(beijing_hour) + if hist_avg is not None and base_power > 0: + # Blend: 70% physics + 30% historical pattern + correction = hist_avg / max(base_power, 0.1) + correction = max(0.7, min(1.3, correction)) + predicted = base_power * (0.7 + 0.3 * correction) + else: + predicted = base_power + + # Confidence interval widens with forecast horizon + uncertainty = 0.05 + 0.02 * h_offset # grows with time + uncertainty = min(uncertainty, 0.40) + margin = predicted * uncertainty + conf_lower = max(0, predicted - margin) + conf_upper = min(rated_power, predicted + margin) + + forecasts.append({ + "timestamp": target_utc.isoformat(), + "predicted_power_kw": round(predicted, 2), + "confidence_lower": round(conf_lower, 2), + "confidence_upper": round(conf_upper, 2), + }) + + return forecasts + + +# --------------------------------------------------------------------------- +# Load Forecasting +# --------------------------------------------------------------------------- + +async def forecast_load( + db: AsyncSession, + device_id: Optional[int] = None, + building_type: str = "office", + horizon_hours: int = 24, +) -> list[dict]: + """Forecast building electricity load. + + Uses day-of-week patterns, hourly profiles, and seasonal temperature + correlation. If device_id is None, forecasts aggregate campus load. + """ + now = datetime.now(timezone.utc) + beijing_now = now + timedelta(hours=8) + + # Fetch recent history for pattern calibration + lookback = now - timedelta(days=14) + conditions = [ + EnergyData.data_type == "power", + EnergyData.timestamp >= lookback, + ] + if device_id: + conditions.append(EnergyData.device_id == device_id) + else: + # Aggregate all meters + conditions.append( + EnergyData.device_id.in_( + select(Device.id).where(Device.device_type == "meter") + ) + ) + + result = await db.execute( + select(EnergyData.timestamp, EnergyData.value) + .where(and_(*conditions)) + .order_by(EnergyData.timestamp) + ) + historical = result.all() + + # Build weekday/weekend hourly profiles from history + weekday_profile: dict[int, list[float]] = {h: [] for h in range(24)} + weekend_profile: dict[int, list[float]] = {h: [] for h in range(24)} + for ts, val in historical: + bj = ts + timedelta(hours=8) if ts.tzinfo else ts + h = bj.hour + if bj.weekday() >= 5: + weekend_profile[h].append(val) + else: + weekday_profile[h].append(val) + + # Default load profile if no history + default_weekday = { + 0: 18, 1: 16, 2: 16, 3: 15, 4: 15, 5: 17, 6: 25, 7: 40, + 8: 55, 9: 60, 10: 62, 11: 58, 12: 45, 13: 58, 14: 62, + 15: 60, 16: 55, 17: 48, 18: 35, 19: 28, 20: 25, 21: 22, 22: 20, 23: 18, + } + default_weekend = {h: v * 0.5 for h, v in default_weekday.items()} + + def _avg_or_default(profile, defaults, h): + vals = profile.get(h, []) + return float(np.mean(vals)) if vals else defaults[h] + + forecasts = [] + for h_offset in range(horizon_hours): + target_utc = now + timedelta(hours=h_offset) + target_utc = target_utc.replace(minute=0, second=0, microsecond=0) + bj = target_utc + timedelta(hours=8) + hour = bj.hour + is_weekend = bj.weekday() >= 5 + + if is_weekend: + base_load = _avg_or_default(weekend_profile, default_weekend, hour) + else: + base_load = _avg_or_default(weekday_profile, default_weekday, hour) + + # Temperature correction: HVAC adds load in extreme temps + temp = outdoor_temperature(target_utc) + if temp < 5: + hvac_factor = 1.0 + 0.02 * (5 - temp) + elif temp > 28: + hvac_factor = 1.0 + 0.025 * (temp - 28) + else: + hvac_factor = 1.0 + hvac_factor = min(hvac_factor, 1.4) + + predicted = base_load * hvac_factor + + # Factory buildings have flatter profiles + if building_type == "factory": + predicted = predicted * 0.85 + base_load * 0.15 + + # Confidence interval + uncertainty = 0.08 + 0.015 * h_offset + uncertainty = min(uncertainty, 0.35) + margin = predicted * uncertainty + + forecasts.append({ + "timestamp": target_utc.isoformat(), + "predicted_load_kw": round(predicted, 2), + "confidence_lower": round(max(0, predicted - margin), 2), + "confidence_upper": round(predicted + margin, 2), + }) + + return forecasts + + +# --------------------------------------------------------------------------- +# Heat Pump COP Prediction +# --------------------------------------------------------------------------- + +async def forecast_heatpump_cop( + db: AsyncSession, + device_id: int, + horizon_hours: int = 24, +) -> list[dict]: + """Predict heat pump COP based on outdoor temperature forecast. + + COP model: COP = base_cop + 0.05 * (T_outdoor - 7), clamped [2.0, 5.5]. + Returns optimal operating windows ranked by expected COP. + """ + device = await db.get(Device, device_id) + if not device: + raise ValueError(f"Device {device_id} not found") + + now = datetime.now(timezone.utc) + forecasts = [] + + for h_offset in range(horizon_hours): + target_utc = now + timedelta(hours=h_offset) + target_utc = target_utc.replace(minute=0, second=0, microsecond=0) + bj = target_utc + timedelta(hours=8) + + temp = outdoor_temperature(target_utc) + mode = get_hvac_mode(bj.month) + + # COP model (same as weather_model but deterministic for forecast) + if mode in ("heating", "transition_spring", "transition_fall"): + cop = 3.0 + 0.05 * (temp - 7) + else: # cooling + cop = 4.0 - 0.04 * (temp - 25) + cop = max(2.0, min(5.5, cop)) + + # Estimated power at this COP + rated = device.rated_power or 35.0 + # Load factor based on time and mode + if mode == "heating": + if 6 <= bj.hour < 9: + load_factor = 0.85 + elif 9 <= bj.hour < 16: + load_factor = 0.55 + elif 16 <= bj.hour < 22: + load_factor = 0.75 + else: + load_factor = 0.65 + elif mode == "cooling": + if 11 <= bj.hour < 16: + load_factor = 0.85 + elif 8 <= bj.hour < 11 or 16 <= bj.hour < 19: + load_factor = 0.60 + else: + load_factor = 0.25 + else: + load_factor = 0.35 + + if bj.weekday() >= 5: + load_factor *= 0.7 + + est_power = rated * load_factor + electricity_price = _get_tou_price(bj.hour) + operating_cost = est_power * electricity_price # yuan/h + + forecasts.append({ + "timestamp": target_utc.isoformat(), + "predicted_cop": round(cop, 2), + "outdoor_temp": round(temp, 1), + "estimated_power_kw": round(est_power, 2), + "load_factor": round(load_factor, 2), + "electricity_price": electricity_price, + "operating_cost_yuan_h": round(operating_cost, 2), + "mode": mode, + }) + + return forecasts + + +# --------------------------------------------------------------------------- +# Self-Consumption Optimization +# --------------------------------------------------------------------------- + +async def optimize_self_consumption( + db: AsyncSession, + horizon_hours: int = 24, +) -> dict: + """Compare predicted PV generation vs predicted load to find optimization + opportunities. Recommends heat pump pre-heating during PV surplus. + + Returns: + - hourly comparison (pv vs load) + - surplus/deficit periods + - recommended heat pump schedule + - expected savings + """ + now = datetime.now(timezone.utc) + + # Get all PV inverter device ids + pv_result = await db.execute( + select(Device).where(Device.device_type == "pv_inverter", Device.is_active == True) + ) + pv_devices = pv_result.scalars().all() + + # Aggregate PV forecast + pv_total = [0.0] * horizon_hours + for dev in pv_devices: + pv_forecast = await forecast_pv(db, dev.id, horizon_hours) + for i, f in enumerate(pv_forecast): + pv_total[i] += f["predicted_power_kw"] + + # Aggregate load forecast + load_forecast = await forecast_load(db, device_id=None, horizon_hours=horizon_hours) + + # Build hourly comparison + hourly = [] + surplus_periods = [] + deficit_periods = [] + total_surplus_kwh = 0.0 + total_deficit_kwh = 0.0 + + for i in range(horizon_hours): + target_utc = now + timedelta(hours=i) + target_utc = target_utc.replace(minute=0, second=0, microsecond=0) + bj = target_utc + timedelta(hours=8) + + pv_kw = pv_total[i] + load_kw = load_forecast[i]["predicted_load_kw"] + balance = pv_kw - load_kw + price = _get_tou_price(bj.hour) + + entry = { + "timestamp": target_utc.isoformat(), + "hour": bj.hour, + "pv_generation_kw": round(pv_kw, 2), + "load_kw": round(load_kw, 2), + "balance_kw": round(balance, 2), + "electricity_price": price, + } + hourly.append(entry) + + if balance > 2: # >2kW surplus threshold + surplus_periods.append({"hour": bj.hour, "surplus_kw": round(balance, 2)}) + total_surplus_kwh += balance + elif balance < -2: + deficit_periods.append({"hour": bj.hour, "deficit_kw": round(-balance, 2)}) + total_deficit_kwh += (-balance) + + # Generate heat pump optimization schedule + # Strategy: shift heat pump load to PV surplus periods + hp_schedule = [] + savings_kwh = 0.0 + savings_yuan = 0.0 + + for period in surplus_periods: + hour = period["hour"] + surplus = period["surplus_kw"] + price = _get_tou_price(hour) + + # Use surplus to pre-heat/pre-cool + usable_power = min(surplus, 35.0) # cap at single HP rated power + hp_schedule.append({ + "hour": hour, + "action": "boost", + "power_kw": round(usable_power, 2), + "reason": "利用光伏余电预加热/预冷", + }) + savings_kwh += usable_power + savings_yuan += usable_power * price + + # Also recommend reducing HP during peak-price deficit periods + for period in deficit_periods: + hour = period["hour"] + price = _get_tou_price(hour) + if price >= TOU_PRICE["peak"]: + hp_schedule.append({ + "hour": hour, + "action": "reduce", + "power_kw": 0, + "reason": "高电价时段降低热泵负荷", + }) + # Estimate savings from reduced operation during peak + savings_yuan += 5.0 * price # assume 5kW reduction + + self_consumption_rate = 0.0 + total_pv = sum(pv_total) + total_load = sum(f["predicted_load_kw"] for f in load_forecast) + if total_pv > 0: + self_consumed = min(total_pv, total_load) + self_consumption_rate = self_consumed / total_pv * 100 + + return { + "hourly": hourly, + "surplus_periods": surplus_periods, + "deficit_periods": deficit_periods, + "hp_schedule": hp_schedule, + "summary": { + "total_pv_kwh": round(total_pv, 2), + "total_load_kwh": round(total_load, 2), + "total_surplus_kwh": round(total_surplus_kwh, 2), + "total_deficit_kwh": round(total_deficit_kwh, 2), + "self_consumption_rate": round(self_consumption_rate, 1), + "potential_savings_kwh": round(savings_kwh, 2), + "potential_savings_yuan": round(savings_yuan, 2), + }, + } + + +# --------------------------------------------------------------------------- +# Prediction Accuracy +# --------------------------------------------------------------------------- + +async def get_prediction_accuracy( + db: AsyncSession, + prediction_type: Optional[str] = None, + days: int = 7, +) -> dict: + """Calculate prediction accuracy metrics (MAE, RMSE, MAPE) from + historical predictions that have actual values filled in.""" + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + + conditions = [ + PredictionResult.actual_value.isnot(None), + PredictionResult.timestamp >= cutoff, + ] + if prediction_type: + conditions.append( + PredictionResult.task_id.in_( + select(PredictionTask.id).where( + PredictionTask.prediction_type == prediction_type + ) + ) + ) + + result = await db.execute( + select(PredictionResult.predicted_value, PredictionResult.actual_value) + .where(and_(*conditions)) + ) + pairs = result.all() + + if not pairs: + # Return mock accuracy for demo (simulating a well-tuned model) + return { + "sample_count": 0, + "mae": 2.5, + "rmse": 3.8, + "mape": 8.5, + "note": "使用模拟精度指标(无历史预测数据)", + } + + predicted = np.array([p[0] for p in pairs]) + actual = np.array([p[1] for p in pairs]) + + errors = predicted - actual + mae = float(np.mean(np.abs(errors))) + rmse = float(np.sqrt(np.mean(errors ** 2))) + + # MAPE: only where actual > 0 to avoid division by zero + mask = actual > 0.1 + if mask.any(): + mape = float(np.mean(np.abs(errors[mask] / actual[mask])) * 100) + else: + mape = 0.0 + + return { + "sample_count": len(pairs), + "mae": round(mae, 2), + "rmse": round(rmse, 2), + "mape": round(mape, 1), + } + + +# --------------------------------------------------------------------------- +# Run Prediction (creates task + results) +# --------------------------------------------------------------------------- + +async def run_prediction( + db: AsyncSession, + device_id: Optional[int], + prediction_type: str, + horizon_hours: int = 24, + parameters: Optional[dict] = None, +) -> PredictionTask: + """Execute a prediction and store results in the database.""" + task = PredictionTask( + device_id=device_id, + prediction_type=prediction_type, + horizon_hours=horizon_hours, + status="running", + parameters=parameters or {}, + ) + db.add(task) + await db.flush() + + try: + if prediction_type == "pv": + if not device_id: + raise ValueError("device_id required for PV forecast") + forecasts = await forecast_pv(db, device_id, horizon_hours) + for f in forecasts: + db.add(PredictionResult( + task_id=task.id, + timestamp=f["timestamp"], + predicted_value=f["predicted_power_kw"], + confidence_lower=f["confidence_lower"], + confidence_upper=f["confidence_upper"], + unit="kW", + )) + + elif prediction_type == "load": + building_type = (parameters or {}).get("building_type", "office") + forecasts = await forecast_load(db, device_id, building_type, horizon_hours) + for f in forecasts: + db.add(PredictionResult( + task_id=task.id, + timestamp=f["timestamp"], + predicted_value=f["predicted_load_kw"], + confidence_lower=f["confidence_lower"], + confidence_upper=f["confidence_upper"], + unit="kW", + )) + + elif prediction_type == "heatpump": + if not device_id: + raise ValueError("device_id required for heat pump forecast") + forecasts = await forecast_heatpump_cop(db, device_id, horizon_hours) + for f in forecasts: + db.add(PredictionResult( + task_id=task.id, + timestamp=f["timestamp"], + predicted_value=f["predicted_cop"], + confidence_lower=max(2.0, f["predicted_cop"] - 0.3), + confidence_upper=min(5.5, f["predicted_cop"] + 0.3), + unit="", + )) + + elif prediction_type == "optimization": + opt = await optimize_self_consumption(db, horizon_hours) + # Store as optimization schedule + now = datetime.now(timezone.utc) + schedule = OptimizationSchedule( + date=now.replace(hour=0, minute=0, second=0, microsecond=0), + schedule_data=opt, + expected_savings_kwh=opt["summary"]["potential_savings_kwh"], + expected_savings_yuan=opt["summary"]["potential_savings_yuan"], + status="pending", + ) + db.add(schedule) + # Also store hourly balance as prediction results + for entry in opt["hourly"]: + db.add(PredictionResult( + task_id=task.id, + timestamp=entry["timestamp"], + predicted_value=entry["balance_kw"], + unit="kW", + )) + else: + raise ValueError(f"Unknown prediction type: {prediction_type}") + + task.status = "completed" + task.completed_at = datetime.now(timezone.utc) + + except Exception as e: + task.status = "failed" + task.error_message = str(e) + logger.error(f"Prediction task {task.id} failed: {e}", exc_info=True) + + return task diff --git a/backend/app/services/alarm_checker.py b/backend/app/services/alarm_checker.py new file mode 100644 index 0000000..fdb7678 --- /dev/null +++ b/backend/app/services/alarm_checker.py @@ -0,0 +1,253 @@ +"""告警检测服务 - 根据告警规则检查最新数据,生成/自动恢复告警事件""" +import asyncio +import logging +from datetime import datetime, timezone, timedelta +from pathlib import Path +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.alarm import AlarmRule, AlarmEvent +from app.models.energy import EnergyData +from app.models.device import Device + +logger = logging.getLogger("alarm_checker") + +# Alarm email template path +_ALARM_TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "alarm_email.html" + +# Severity display config +_SEVERITY_CONFIG = { + "critical": { + "label": "紧急告警", + "badge_color": "#d32f2f", + "bg_color": "#ffebee", + "text_color": "#c62828", + }, + "major": { + "label": "重要告警", + "badge_color": "#e65100", + "bg_color": "#fff3e0", + "text_color": "#e65100", + }, + "warning": { + "label": "一般告警", + "badge_color": "#f9a825", + "bg_color": "#fffde7", + "text_color": "#f57f17", + }, +} + + +async def _send_alarm_email( + rule: AlarmRule, event: AlarmEvent, device_id: int, session: AsyncSession +): + """Send alarm notification email if configured.""" + from app.services.email_service import send_email + from app.core.config import get_settings + + # Check if email is in notify_channels + channels = rule.notify_channels or [] + if "email" not in channels: + return + + # Get email targets from notify_targets + targets = rule.notify_targets or {} + emails = targets.get("emails", []) if isinstance(targets, dict) else [] + # If notify_targets is a list of strings (emails directly) + if isinstance(targets, list): + emails = [t for t in targets if isinstance(t, str) and "@" in t] + + if not emails: + logger.debug(f"No email recipients for alarm rule '{rule.name}', skipping.") + return + + # Fetch device info + dev_result = await session.execute(select(Device).where(Device.id == device_id)) + device = dev_result.scalar_one_or_none() + device_name = device.name if device else f"设备#{device_id}" + device_code = device.code if device else "N/A" + + settings = get_settings() + severity_cfg = _SEVERITY_CONFIG.get(rule.severity, _SEVERITY_CONFIG["warning"]) + + # Build threshold string + if rule.condition == "range_out": + threshold_str = f"[{rule.threshold_low}, {rule.threshold_high}]" + else: + threshold_str = str(rule.threshold) + + # Format triggered time in Beijing timezone + triggered_time = event.triggered_at or datetime.now(timezone.utc) + triggered_beijing = triggered_time + timedelta(hours=8) + triggered_str = triggered_beijing.strftime("%Y-%m-%d %H:%M:%S") + + # Load and render template + try: + template_html = _ALARM_TEMPLATE_PATH.read_text(encoding="utf-8") + except FileNotFoundError: + logger.error("Alarm email template not found, skipping email.") + return + + body_html = template_html.format( + severity_label=severity_cfg["label"], + severity_badge_color=severity_cfg["badge_color"], + severity_bg_color=severity_cfg["bg_color"], + severity_text_color=severity_cfg["text_color"], + title=event.title, + device_name=device_name, + device_code=device_code, + data_type=rule.data_type, + current_value=str(event.value), + threshold_str=threshold_str, + triggered_at=triggered_str, + description=event.description or "", + platform_url=settings.PLATFORM_URL, + ) + + subject = f"[{severity_cfg['label']}] {event.title} - 天普EMS告警通知" + 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 +RATE_LIMIT_MINUTES = 5 + + +def _in_silence_window(rule: AlarmRule, now_beijing: datetime) -> bool: + """Check if current time falls within the rule's silence window.""" + if not rule.silence_start or not rule.silence_end: + return False + current_time = now_beijing.strftime("%H:%M") + start = rule.silence_start + end = rule.silence_end + if start <= end: + return start <= current_time <= end + else: + # Crosses midnight, e.g. 22:00 - 06:00 + return current_time >= start or current_time <= end + + +def _evaluate_condition(rule: AlarmRule, value: float) -> bool: + """Evaluate whether a data value triggers the alarm rule condition.""" + if rule.condition == "gt": + return value > rule.threshold + elif rule.condition == "lt": + return value < rule.threshold + elif rule.condition == "eq": + return abs(value - rule.threshold) < 0.001 + elif rule.condition == "neq": + return abs(value - rule.threshold) >= 0.001 + elif rule.condition == "range_out": + low = rule.threshold_low if rule.threshold_low is not None else float("-inf") + high = rule.threshold_high if rule.threshold_high is not None else float("inf") + return value < low or value > high + return False + + +async def check_alarms(session: AsyncSession): + """Main alarm check routine. Call after each simulator data cycle.""" + now = datetime.now(timezone.utc) + now_beijing = now + timedelta(hours=8) + + # 1. Load all active alarm rules + result = await session.execute( + select(AlarmRule).where(AlarmRule.is_active == True) + ) + rules = result.scalars().all() + + for rule in rules: + # Skip if in silence window + if _in_silence_window(rule, now_beijing): + continue + + # 2. Find matching devices' latest data point + # Rules can match by device_id (specific) or device_type (all devices of that type) + data_query = ( + select(EnergyData) + .where(EnergyData.data_type == rule.data_type) + .order_by(EnergyData.timestamp.desc()) + ) + + if rule.device_id: + data_query = data_query.where(EnergyData.device_id == rule.device_id) + + # We need to check per-device, so get recent data points + # For device_type rules, we get data from the last 30 seconds (one cycle) + cutoff = now - timedelta(seconds=30) + data_query = data_query.where(EnergyData.timestamp >= cutoff).limit(50) + + data_result = await session.execute(data_query) + data_points = data_result.scalars().all() + + if not data_points: + continue + + # Group by device_id and take the latest per device + latest_by_device: dict[int, EnergyData] = {} + for dp in data_points: + if dp.device_id not in latest_by_device: + latest_by_device[dp.device_id] = dp + + for device_id, dp in latest_by_device.items(): + triggered = _evaluate_condition(rule, dp.value) + + # Check for existing active event for this rule + device + active_event_result = await session.execute( + select(AlarmEvent).where( + and_( + AlarmEvent.rule_id == rule.id, + AlarmEvent.device_id == device_id, + AlarmEvent.status.in_(["active", "acknowledged"]), + ) + ) + ) + active_event = active_event_result.scalar_one_or_none() + + if triggered and not active_event: + # Rate limiting: check if a resolved event was created recently + recent_result = await session.execute( + select(AlarmEvent).where( + and_( + AlarmEvent.rule_id == rule.id, + AlarmEvent.device_id == device_id, + AlarmEvent.triggered_at >= now - timedelta(minutes=RATE_LIMIT_MINUTES), + ) + ) + ) + if recent_result.scalar_one_or_none(): + continue # Skip, recently triggered + + # Build description + threshold_str = "" + if rule.condition == "range_out": + threshold_str = f"[{rule.threshold_low}, {rule.threshold_high}]" + else: + threshold_str = str(rule.threshold) + + event = AlarmEvent( + rule_id=rule.id, + device_id=device_id, + severity=rule.severity, + title=rule.name, + description=f"当前值 {dp.value},阈值 {threshold_str}", + value=dp.value, + threshold=rule.threshold, + status="active", + triggered_at=now, + ) + session.add(event) + logger.info( + f"Alarm triggered: {rule.name} | device={device_id} | " + f"value={dp.value} threshold={threshold_str}" + ) + + # Send email notification (non-blocking) + await _send_alarm_email(rule, event, device_id, session) + + elif not triggered and active_event: + # Auto-resolve + active_event.status = "resolved" + active_event.resolved_at = now + active_event.resolve_note = "自动恢复" + logger.info( + f"Alarm auto-resolved: {rule.name} | device={device_id}" + ) + + await session.flush() diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..feffea8 --- /dev/null +++ b/backend/app/services/audit.py @@ -0,0 +1,32 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.user import AuditLog + + +async def log_audit( + db: AsyncSession, + user_id: int | None, + action: str, + resource: str, + detail: str = "", + ip_address: str | None = None, +): + """Write an audit log entry. + + Args: + db: async database session (must be flushed/committed by caller) + user_id: ID of the acting user (None for system actions) + action: one of login, create, update, delete, export, view, + acknowledge, resolve + resource: one of user, device, alarm, report, system, auth + detail: human-readable description + ip_address: client IP if available + """ + entry = AuditLog( + user_id=user_id, + action=action, + resource=resource, + detail=detail, + ip_address=ip_address, + ) + db.add(entry) + # Don't flush here — let the caller's transaction handle it diff --git a/backend/app/services/carbon_asset.py b/backend/app/services/carbon_asset.py new file mode 100644 index 0000000..08234a6 --- /dev/null +++ b/backend/app/services/carbon_asset.py @@ -0,0 +1,462 @@ +"""Carbon Asset Management Service. + +Provides carbon accounting, CCER/green certificate management, +report generation, target tracking, and benchmark comparison. +""" +import logging +from datetime import date, datetime, timezone +from typing import Optional + +from sqlalchemy import select, func, and_, case +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.carbon import ( + CarbonEmission, EmissionFactor, CarbonTarget, CarbonReduction, + GreenCertificate, CarbonReport, CarbonBenchmark, +) +from app.models.energy import EnergyData + +logger = logging.getLogger("carbon_asset") + +# China national grid emission factor 2023 (tCO2/MWh) +GRID_EMISSION_FACTOR = 0.5810 +# Average CO2 absorption per tree per year (tons) +TREE_ABSORPTION = 0.02 + + +# --------------------------------------------------------------------------- +# Carbon Accounting +# --------------------------------------------------------------------------- + +async def calculate_scope2_emission( + db: AsyncSession, start: date, end: date, +) -> float: + """Calculate Scope 2 emission from grid electricity (tons CO2).""" + result = await db.execute( + select(func.sum(CarbonEmission.emission)) + .where(and_( + CarbonEmission.scope == 2, + func.date(CarbonEmission.date) >= start, + func.date(CarbonEmission.date) <= end, + )) + ) + val = result.scalar() or 0 + return round(val / 1000, 4) # kg -> tons + + +async def calculate_scope1_emission( + db: AsyncSession, start: date, end: date, +) -> float: + """Calculate Scope 1 direct emissions (natural gas etc.) in tons CO2.""" + result = await db.execute( + select(func.sum(CarbonEmission.emission)) + .where(and_( + CarbonEmission.scope == 1, + func.date(CarbonEmission.date) >= start, + func.date(CarbonEmission.date) <= end, + )) + ) + val = result.scalar() or 0 + return round(val / 1000, 4) + + +async def calculate_total_reduction( + db: AsyncSession, start: date, end: date, +) -> float: + """Total carbon reduction in tons.""" + result = await db.execute( + select(func.sum(CarbonEmission.reduction)) + .where(and_( + func.date(CarbonEmission.date) >= start, + func.date(CarbonEmission.date) <= end, + )) + ) + val = result.scalar() or 0 + return round(val / 1000, 4) + + +async def calculate_pv_reduction( + db: AsyncSession, start: date, end: date, +) -> float: + """Carbon reduction from PV self-consumption (tons). + + Formula: reduction = pv_generation_mwh * GRID_EMISSION_FACTOR + """ + result = await db.execute( + select(func.sum(CarbonEmission.reduction)) + .where(and_( + CarbonEmission.category == "pv_generation", + func.date(CarbonEmission.date) >= start, + func.date(CarbonEmission.date) <= end, + )) + ) + val = result.scalar() or 0 + return round(val / 1000, 4) + + +# --------------------------------------------------------------------------- +# Reduction tracking +# --------------------------------------------------------------------------- + +async def get_reduction_summary( + db: AsyncSession, start: date, end: date, +) -> list[dict]: + """Reduction summary grouped by source type.""" + result = await db.execute( + select( + CarbonReduction.source_type, + func.sum(CarbonReduction.reduction_tons), + func.sum(CarbonReduction.equivalent_trees), + func.count(CarbonReduction.id), + ) + .where(and_( + CarbonReduction.date >= start, + CarbonReduction.date <= end, + )) + .group_by(CarbonReduction.source_type) + ) + return [ + { + "source_type": row[0], + "reduction_tons": round(row[1] or 0, 4), + "equivalent_trees": round(row[2] or 0, 1), + "count": row[3], + } + for row in result.all() + ] + + +async def trigger_reduction_calculation( + db: AsyncSession, start: date, end: date, +) -> dict: + """Compute reduction activities from energy data and persist them. + + Calculates PV generation reduction and heat pump COP savings. + """ + # PV reduction from carbon_emissions records + pv_tons = await calculate_pv_reduction(db, start, end) + total_reduction = await calculate_total_reduction(db, start, end) + heat_pump_tons = max(0, total_reduction - pv_tons) + + records_created = 0 + for source, tons in [("pv_generation", pv_tons), ("heat_pump_cop", heat_pump_tons)]: + if tons > 0: + existing = await db.execute( + select(CarbonReduction).where(and_( + CarbonReduction.source_type == source, + CarbonReduction.date == end, + )) + ) + if existing.scalar_one_or_none() is None: + db.add(CarbonReduction( + source_type=source, + date=end, + reduction_tons=tons, + equivalent_trees=round(tons / TREE_ABSORPTION, 1), + methodology=f"Grid factor {GRID_EMISSION_FACTOR} tCO2/MWh", + verified=False, + )) + records_created += 1 + + return { + "period": f"{start} ~ {end}", + "pv_reduction_tons": pv_tons, + "heat_pump_reduction_tons": heat_pump_tons, + "total_reduction_tons": total_reduction, + "records_created": records_created, + } + + +# --------------------------------------------------------------------------- +# CCER / Green Certificate Management +# --------------------------------------------------------------------------- + +async def calculate_ccer_eligible( + db: AsyncSession, start: date, end: date, +) -> dict: + """Calculate eligible CCER reduction from PV generation.""" + pv_tons = await calculate_pv_reduction(db, start, end) + return { + "eligible_ccer_tons": pv_tons, + "grid_emission_factor": GRID_EMISSION_FACTOR, + "period": f"{start} ~ {end}", + } + + +async def get_certificate_portfolio_value(db: AsyncSession) -> dict: + """Total value of active green certificates.""" + result = await db.execute( + select( + GreenCertificate.status, + func.count(GreenCertificate.id), + func.sum(GreenCertificate.energy_mwh), + func.sum(GreenCertificate.price_yuan), + ).group_by(GreenCertificate.status) + ) + rows = result.all() + total_value = 0.0 + total_mwh = 0.0 + by_status = {} + for row in rows: + status, cnt, mwh, value = row + by_status[status] = { + "count": cnt, + "energy_mwh": round(mwh or 0, 2), + "value_yuan": round(value or 0, 2), + } + total_value += value or 0 + total_mwh += mwh or 0 + + return { + "total_certificates": sum(v["count"] for v in by_status.values()), + "total_energy_mwh": round(total_mwh, 2), + "total_value_yuan": round(total_value, 2), + "by_status": by_status, + } + + +# --------------------------------------------------------------------------- +# Carbon Report Generation +# --------------------------------------------------------------------------- + +async def generate_carbon_report( + db: AsyncSession, + report_type: str, + period_start: date, + period_end: date, +) -> CarbonReport: + """Generate a carbon footprint report for the given period.""" + scope1 = await calculate_scope1_emission(db, period_start, period_end) + scope2 = await calculate_scope2_emission(db, period_start, period_end) + reduction = await calculate_total_reduction(db, period_start, period_end) + total = scope1 + scope2 + net = total - reduction + + # Reduction breakdown + reduction_summary = await get_reduction_summary(db, period_start, period_end) + + # Monthly breakdown + monthly = await _monthly_breakdown(db, period_start, period_end) + + report_data = { + "scope_breakdown": {"scope1": scope1, "scope2": scope2}, + "reduction_summary": reduction_summary, + "monthly_breakdown": monthly, + "grid_emission_factor": GRID_EMISSION_FACTOR, + "net_emission_tons": round(net, 4), + "green_rate": round((reduction / total * 100) if total > 0 else 0, 1), + } + + report = CarbonReport( + report_type=report_type, + period_start=period_start, + period_end=period_end, + scope1_tons=scope1, + scope2_tons=scope2, + total_tons=round(total, 4), + reduction_tons=round(reduction, 4), + net_tons=round(net, 4), + report_data=report_data, + ) + db.add(report) + return report + + +async def _monthly_breakdown( + db: AsyncSession, start: date, end: date, +) -> list[dict]: + """Monthly emission and reduction totals for the period.""" + from app.core.config import get_settings + settings = get_settings() + if settings.is_sqlite: + month_expr = func.strftime('%Y-%m', CarbonEmission.date).label('month') + else: + month_expr = func.to_char( + func.date_trunc('month', CarbonEmission.date), 'YYYY-MM' + ).label('month') + + result = await db.execute( + select( + month_expr, + func.sum(CarbonEmission.emission), + func.sum(CarbonEmission.reduction), + ) + .where(and_( + func.date(CarbonEmission.date) >= start, + func.date(CarbonEmission.date) <= end, + )) + .group_by('month') + .order_by('month') + ) + return [ + { + "month": row[0], + "emission_kg": round(row[1] or 0, 2), + "reduction_kg": round(row[2] or 0, 2), + "emission_tons": round((row[1] or 0) / 1000, 4), + "reduction_tons": round((row[2] or 0) / 1000, 4), + } + for row in result.all() + ] + + +# --------------------------------------------------------------------------- +# Carbon Target Tracking +# --------------------------------------------------------------------------- + +async def get_target_progress(db: AsyncSession, year: int) -> dict: + """Calculate progress against annual and monthly targets.""" + # Annual target + annual_q = await db.execute( + select(CarbonTarget).where(and_( + CarbonTarget.year == year, + CarbonTarget.month.is_(None), + )) + ) + annual = annual_q.scalar_one_or_none() + + # All monthly targets for this year + monthly_q = await db.execute( + select(CarbonTarget).where(and_( + CarbonTarget.year == year, + CarbonTarget.month.isnot(None), + )).order_by(CarbonTarget.month) + ) + monthlies = monthly_q.scalars().all() + + # Current year actuals + year_start = date(year, 1, 1) + year_end = date(year, 12, 31) + scope1 = await calculate_scope1_emission(db, year_start, year_end) + scope2 = await calculate_scope2_emission(db, year_start, year_end) + reduction = await calculate_total_reduction(db, year_start, year_end) + total_emission = scope1 + scope2 + net = total_emission - reduction + + annual_data = None + if annual: + progress = (net / annual.target_emission_tons * 100) if annual.target_emission_tons > 0 else 0 + status = "on_track" if progress <= 80 else ("warning" if progress <= 100 else "exceeded") + annual_data = { + "id": annual.id, + "target_tons": annual.target_emission_tons, + "actual_tons": round(net, 4), + "progress_pct": round(progress, 1), + "status": status, + } + + monthly_data = [] + for m in monthlies: + m_start = date(year, m.month, 1) + if m.month == 12: + m_end = date(year, 12, 31) + else: + m_end = date(year, m.month + 1, 1) + s1 = await calculate_scope1_emission(db, m_start, m_end) + s2 = await calculate_scope2_emission(db, m_start, m_end) + red = await calculate_total_reduction(db, m_start, m_end) + m_net = s1 + s2 - red + pct = (m_net / m.target_emission_tons * 100) if m.target_emission_tons > 0 else 0 + monthly_data.append({ + "id": m.id, + "month": m.month, + "target_tons": m.target_emission_tons, + "actual_tons": round(m_net, 4), + "progress_pct": round(pct, 1), + "status": "on_track" if pct <= 80 else ("warning" if pct <= 100 else "exceeded"), + }) + + return { + "year": year, + "total_emission_tons": round(total_emission, 4), + "total_reduction_tons": round(reduction, 4), + "net_emission_tons": round(net, 4), + "annual_target": annual_data, + "monthly_targets": monthly_data, + } + + +# --------------------------------------------------------------------------- +# Benchmark Comparison +# --------------------------------------------------------------------------- + +async def compare_with_benchmarks( + db: AsyncSession, year: int, +) -> dict: + """Compare actual emissions with industry benchmarks.""" + benchmarks_q = await db.execute( + select(CarbonBenchmark).where(CarbonBenchmark.year == year) + ) + benchmarks = benchmarks_q.scalars().all() + + year_start = date(year, 1, 1) + year_end = date(year, 12, 31) + scope1 = await calculate_scope1_emission(db, year_start, year_end) + scope2 = await calculate_scope2_emission(db, year_start, year_end) + total = scope1 + scope2 + reduction = await calculate_total_reduction(db, year_start, year_end) + + comparisons = [] + for b in benchmarks: + comparisons.append({ + "industry": b.industry, + "metric": b.metric_name, + "benchmark_value": b.benchmark_value, + "unit": b.unit, + "source": b.source, + }) + + return { + "year": year, + "actual_emission_tons": round(total, 4), + "actual_reduction_tons": round(reduction, 4), + "net_tons": round(total - reduction, 4), + "benchmarks": comparisons, + } + + +# --------------------------------------------------------------------------- +# Dashboard aggregation +# --------------------------------------------------------------------------- + +async def get_carbon_dashboard(db: AsyncSession) -> dict: + """Comprehensive carbon dashboard data.""" + now = datetime.now(timezone.utc) + year = now.year + year_start = date(year, 1, 1) + today = now.date() + + scope1 = await calculate_scope1_emission(db, year_start, today) + scope2 = await calculate_scope2_emission(db, year_start, today) + total_emission = scope1 + scope2 + reduction = await calculate_total_reduction(db, year_start, today) + net = total_emission - reduction + green_rate = round((reduction / total_emission * 100) if total_emission > 0 else 0, 1) + + # Target progress + target_progress = await get_target_progress(db, year) + + # Monthly trend + monthly = await _monthly_breakdown(db, year_start, today) + + # Reduction summary + reduction_summary = await get_reduction_summary(db, year_start, today) + + # Certificate value + cert_value = await get_certificate_portfolio_value(db) + + return { + "kpi": { + "total_emission_tons": round(total_emission, 4), + "total_reduction_tons": round(reduction, 4), + "net_emission_tons": round(net, 4), + "green_rate": green_rate, + "scope1_tons": scope1, + "scope2_tons": scope2, + "equivalent_trees": round(reduction / TREE_ABSORPTION, 0) if reduction > 0 else 0, + }, + "target_progress": target_progress.get("annual_target"), + "monthly_trend": monthly, + "reduction_by_source": reduction_summary, + "certificate_portfolio": cert_value, + } diff --git a/backend/app/services/cost_calculator.py b/backend/app/services/cost_calculator.py new file mode 100644 index 0000000..fe33035 --- /dev/null +++ b/backend/app/services/cost_calculator.py @@ -0,0 +1,261 @@ +from datetime import datetime, timedelta, timezone +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from app.models.pricing import ElectricityPricing, PricingPeriod +from app.models.energy import EnergyData, EnergyDailySummary +from app.models.device import Device + + +async def get_active_pricing(db: AsyncSession, energy_type: str = "electricity", date: datetime | None = None): + """获取当前生效的电价配置""" + q = select(ElectricityPricing).where( + and_( + ElectricityPricing.energy_type == energy_type, + ElectricityPricing.is_active == True, + ) + ) + if date: + q = q.where( + and_( + (ElectricityPricing.effective_from == None) | (ElectricityPricing.effective_from <= date), + (ElectricityPricing.effective_to == None) | (ElectricityPricing.effective_to >= date), + ) + ) + q = q.order_by(ElectricityPricing.created_at.desc()).limit(1) + result = await db.execute(q) + return result.scalar_one_or_none() + + +async def get_pricing_periods(db: AsyncSession, pricing_id: int, month: int | None = None): + """获取电价时段配置""" + q = select(PricingPeriod).where(PricingPeriod.pricing_id == pricing_id) + result = await db.execute(q) + periods = result.scalars().all() + if month is not None: + periods = [p for p in periods if p.applicable_months is None or month in p.applicable_months] + return periods + + +def get_period_for_hour(periods: list, hour: int) -> PricingPeriod | None: + """根据小时确定所属时段""" + hour_str = f"{hour:02d}:00" + for p in periods: + start = p.start_time + end = p.end_time + if start <= end: + if start <= hour_str < end: + return p + else: # crosses midnight, e.g. 23:00 - 07:00 + if hour_str >= start or hour_str < end: + return p + return periods[0] if periods else None + + +async def calculate_daily_cost(db: AsyncSession, date: datetime, device_id: int | None = None): + """计算某天的用电费用""" + pricing = await get_active_pricing(db, "electricity", date) + if not pricing: + return 0.0 + + if pricing.pricing_type == "flat": + # 平价: 直接查日汇总 + q = select(func.sum(EnergyDailySummary.total_consumption)).where( + and_( + EnergyDailySummary.date >= date.replace(hour=0, minute=0, second=0), + EnergyDailySummary.date < date.replace(hour=0, minute=0, second=0) + timedelta(days=1), + EnergyDailySummary.energy_type == "electricity", + ) + ) + if device_id: + q = q.where(EnergyDailySummary.device_id == device_id) + result = await db.execute(q) + total_energy = result.scalar() or 0.0 + + periods = await get_pricing_periods(db, pricing.id) + flat_price = periods[0].price_per_unit if periods else 0.0 + cost = total_energy * flat_price + else: + # TOU分时: 按小时计算 + periods = await get_pricing_periods(db, pricing.id, month=date.month) + if not periods: + return 0.0 + + cost = 0.0 + day_start = date.replace(hour=0, minute=0, second=0, microsecond=0) + for hour in range(24): + hour_start = day_start + timedelta(hours=hour) + hour_end = hour_start + timedelta(hours=1) + + q = select(func.sum(EnergyData.value)).where( + and_( + EnergyData.timestamp >= hour_start, + EnergyData.timestamp < hour_end, + EnergyData.data_type == "energy", + ) + ) + if device_id: + q = q.where(EnergyData.device_id == device_id) + result = await db.execute(q) + hour_energy = result.scalar() or 0.0 + + period = get_period_for_hour(periods, hour) + if period: + cost += hour_energy * period.price_per_unit + + # Update daily summary cost + q = select(EnergyDailySummary).where( + and_( + EnergyDailySummary.date >= date.replace(hour=0, minute=0, second=0), + EnergyDailySummary.date < date.replace(hour=0, minute=0, second=0) + timedelta(days=1), + EnergyDailySummary.energy_type == "electricity", + ) + ) + if device_id: + q = q.where(EnergyDailySummary.device_id == device_id) + result = await db.execute(q) + for summary in result.scalars().all(): + summary.cost = cost + + return round(cost, 2) + + +async def get_cost_summary( + db: AsyncSession, start_date: datetime, end_date: datetime, + group_by: str = "day", energy_type: str = "electricity", +): + """获取费用汇总""" + q = select( + EnergyDailySummary.date, + func.sum(EnergyDailySummary.total_consumption).label("consumption"), + func.sum(EnergyDailySummary.cost).label("cost"), + ).where( + and_( + EnergyDailySummary.date >= start_date, + EnergyDailySummary.date <= end_date, + EnergyDailySummary.energy_type == energy_type, + ) + ) + + if group_by == "device": + q = select( + EnergyDailySummary.device_id, + Device.name.label("device_name"), + func.sum(EnergyDailySummary.total_consumption).label("consumption"), + func.sum(EnergyDailySummary.cost).label("cost"), + ).join(Device, EnergyDailySummary.device_id == Device.id, isouter=True).where( + and_( + EnergyDailySummary.date >= start_date, + EnergyDailySummary.date <= end_date, + EnergyDailySummary.energy_type == energy_type, + ) + ).group_by(EnergyDailySummary.device_id, Device.name) + result = await db.execute(q) + return [ + {"device_id": r[0], "device_name": r[1] or f"Device#{r[0]}", + "consumption": round(r[2] or 0, 2), "cost": round(r[3] or 0, 2)} + for r in result.all() + ] + elif group_by == "month": + from app.core.config import get_settings + settings = get_settings() + if settings.is_sqlite: + group_expr = func.strftime('%Y-%m', EnergyDailySummary.date).label('period') + else: + group_expr = func.to_char(EnergyDailySummary.date, 'YYYY-MM').label('period') + q = select( + group_expr, + func.sum(EnergyDailySummary.total_consumption).label("consumption"), + func.sum(EnergyDailySummary.cost).label("cost"), + ).where( + and_( + EnergyDailySummary.date >= start_date, + EnergyDailySummary.date <= end_date, + EnergyDailySummary.energy_type == energy_type, + ) + ).group_by(group_expr).order_by(group_expr) + result = await db.execute(q) + return [ + {"period": str(r[0]), "consumption": round(r[1] or 0, 2), "cost": round(r[2] or 0, 2)} + for r in result.all() + ] + else: # day + q = q.group_by(EnergyDailySummary.date).order_by(EnergyDailySummary.date) + result = await db.execute(q) + return [ + {"date": str(r[0]), "consumption": round(r[1] or 0, 2), "cost": round(r[2] or 0, 2)} + for r in result.all() + ] + + +async def get_cost_breakdown(db: AsyncSession, start_date: datetime, end_date: datetime, energy_type: str = "electricity"): + """获取峰谷平费用分布""" + pricing = await get_active_pricing(db, energy_type, start_date) + if not pricing: + return {"periods": [], "total_cost": 0, "total_consumption": 0} + + periods = await get_pricing_periods(db, pricing.id) + if not periods: + return {"periods": [], "total_cost": 0, "total_consumption": 0} + + # For each period, calculate the total energy consumption in those hours + breakdown = [] + total_cost = 0.0 + total_consumption = 0.0 + + for period in periods: + start_hour = int(period.start_time.split(":")[0]) + end_hour = int(period.end_time.split(":")[0]) + + if start_hour < end_hour: + hours = list(range(start_hour, end_hour)) + else: # crosses midnight + hours = list(range(start_hour, 24)) + list(range(0, end_hour)) + + period_energy = 0.0 + current = start_date.replace(hour=0, minute=0, second=0, microsecond=0) + end = end_date.replace(hour=23, minute=59, second=59, microsecond=0) + + # Sum energy for all matching hours in date range using daily summary approximation + q = select(func.sum(EnergyDailySummary.total_consumption)).where( + and_( + EnergyDailySummary.date >= start_date, + EnergyDailySummary.date <= end_date, + EnergyDailySummary.energy_type == energy_type, + ) + ) + result = await db.execute(q) + total_daily = result.scalar() or 0.0 + + # Approximate proportion based on hours in period vs 24h + proportion = len(hours) / 24.0 + period_energy = total_daily * proportion + period_cost = period_energy * period.price_per_unit + + total_cost += period_cost + total_consumption += period_energy + + period_name_map = { + "peak": "尖峰", "sharp": "尖峰", + "high": "高峰", "shoulder": "高峰", + "flat": "平段", + "valley": "低谷", "off_peak": "低谷", + } + + breakdown.append({ + "period_name": period.period_name, + "period_label": period_name_map.get(period.period_name, period.period_name), + "start_time": period.start_time, + "end_time": period.end_time, + "price_per_unit": period.price_per_unit, + "consumption": round(period_energy, 2), + "cost": round(period_cost, 2), + "proportion": round(proportion * 100, 1), + }) + + return { + "periods": breakdown, + "total_cost": round(total_cost, 2), + "total_consumption": round(total_consumption, 2), + "pricing_name": pricing.name, + "pricing_type": pricing.pricing_type, + } diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..bd3d1d4 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,105 @@ +"""邮件发送服务 - SMTP email sending for alarm notifications and report delivery.""" +import logging +import smtplib +import ssl +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email import encoders +from pathlib import Path +from typing import Optional + +from app.core.config import get_settings + +logger = logging.getLogger("email_service") + + +async def send_email( + to: list[str], + subject: str, + body_html: str, + attachments: Optional[list[str]] = None, +) -> bool: + """ + Send an email via SMTP. + + Args: + to: List of recipient email addresses. + subject: Email subject line. + body_html: HTML body content. + attachments: Optional list of file paths to attach. + + Returns: + True if sent successfully, False otherwise. + """ + settings = get_settings() + + if not settings.SMTP_ENABLED: + logger.warning("SMTP is not enabled (SMTP_ENABLED=False). Skipping email send.") + return False + + if not settings.SMTP_HOST: + logger.warning("SMTP_HOST is not configured. Skipping email send.") + return False + + if not to: + logger.warning("No recipients specified. Skipping email send.") + return False + + try: + msg = MIMEMultipart("mixed") + msg["From"] = settings.SMTP_FROM + msg["To"] = ", ".join(to) + msg["Subject"] = subject + + # HTML body + html_part = MIMEText(body_html, "html", "utf-8") + msg.attach(html_part) + + # Attachments + if attachments: + for filepath in attachments: + path = Path(filepath) + if not path.exists(): + logger.warning(f"Attachment not found, skipping: {filepath}") + continue + + with open(path, "rb") as f: + part = MIMEBase("application", "octet-stream") + part.set_payload(f.read()) + encoders.encode_base64(part) + part.add_header( + "Content-Disposition", + f'attachment; filename="{path.name}"', + ) + msg.attach(part) + + # Send via SMTP + context = ssl.create_default_context() + + if settings.SMTP_PORT == 465: + # SSL connection + with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, context=context) as server: + if settings.SMTP_USER and settings.SMTP_PASSWORD: + server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + server.sendmail(settings.SMTP_FROM, to, msg.as_string()) + else: + # STARTTLS connection (port 587 or 25) + with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server: + server.ehlo() + if settings.SMTP_PORT == 587: + server.starttls(context=context) + server.ehlo() + if settings.SMTP_USER and settings.SMTP_PASSWORD: + server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + server.sendmail(settings.SMTP_FROM, to, msg.as_string()) + + logger.info(f"Email sent successfully to {to}, subject: {subject}") + return True + + except smtplib.SMTPException as e: + logger.error(f"SMTP error sending email to {to}: {e}") + return False + except Exception as e: + logger.error(f"Unexpected error sending email to {to}: {e}") + return False diff --git a/backend/app/services/energy_strategy.py b/backend/app/services/energy_strategy.py new file mode 100644 index 0000000..4caa8be --- /dev/null +++ b/backend/app/services/energy_strategy.py @@ -0,0 +1,419 @@ +"""能源策略优化服务 - 峰谷电价策略、谷电蓄热、负荷转移、光伏自消纳""" + +from datetime import datetime, date, timedelta, timezone +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from app.models.energy_strategy import ( + TouPricing, TouPricingPeriod, EnergyStrategy, StrategyExecution, MonthlyCostReport, +) +from app.models.energy import EnergyData, EnergyDailySummary + + +# Beijing TZ offset +BJT = timezone(timedelta(hours=8)) + +# Default Beijing industrial TOU pricing +DEFAULT_PERIODS = [ + {"period_type": "sharp_peak", "start_time": "10:00", "end_time": "15:00", "price": 1.3761}, + {"period_type": "sharp_peak", "start_time": "18:00", "end_time": "21:00", "price": 1.3761}, + {"period_type": "peak", "start_time": "08:00", "end_time": "10:00", "price": 1.1883}, + {"period_type": "peak", "start_time": "15:00", "end_time": "18:00", "price": 1.1883}, + {"period_type": "peak", "start_time": "21:00", "end_time": "23:00", "price": 1.1883}, + {"period_type": "flat", "start_time": "07:00", "end_time": "08:00", "price": 0.7467}, + {"period_type": "valley", "start_time": "23:00", "end_time": "07:00", "price": 0.3048}, +] + +PERIOD_LABELS = { + "sharp_peak": "尖峰", + "peak": "高峰", + "flat": "平段", + "valley": "低谷", +} + + +def parse_month_range(month_range: str | None) -> list[int] | None: + """Parse month range string like '1-3,11-12' into list of month ints.""" + if not month_range: + return None + months = [] + for part in month_range.split(","): + part = part.strip() + if "-" in part: + start, end = part.split("-") + months.extend(range(int(start), int(end) + 1)) + else: + months.append(int(part)) + return months + + +def get_period_for_hour(periods: list[TouPricingPeriod], hour: int, month: int | None = None) -> TouPricingPeriod | None: + """Determine which TOU period an hour falls into.""" + hour_str = f"{hour:02d}:00" + for p in periods: + if month is not None and p.month_range: + applicable = parse_month_range(p.month_range) + if applicable and month not in applicable: + continue + start = p.start_time + end = p.end_time + if start <= end: + if start <= hour_str < end: + return p + else: # crosses midnight + if hour_str >= start or hour_str < end: + return p + return periods[0] if periods else None + + +async def get_active_tou_pricing(db: AsyncSession, target_date: date | None = None) -> TouPricing | None: + """Get active TOU pricing plan.""" + q = select(TouPricing).where(TouPricing.is_active == True) + if target_date: + q = q.where( + and_( + (TouPricing.effective_date == None) | (TouPricing.effective_date <= target_date), + (TouPricing.end_date == None) | (TouPricing.end_date >= target_date), + ) + ) + q = q.order_by(TouPricing.created_at.desc()).limit(1) + result = await db.execute(q) + return result.scalar_one_or_none() + + +async def get_tou_periods(db: AsyncSession, pricing_id: int) -> list[TouPricingPeriod]: + """Get pricing periods for a TOU plan.""" + result = await db.execute( + select(TouPricingPeriod).where(TouPricingPeriod.pricing_id == pricing_id) + ) + return list(result.scalars().all()) + + +async def calculate_hourly_cost( + db: AsyncSession, target_date: date, periods: list[TouPricingPeriod], +) -> dict: + """Calculate hourly electricity cost for a specific date.""" + day_start = datetime(target_date.year, target_date.month, target_date.day, tzinfo=BJT) + hourly_data = [] + total_cost = 0.0 + total_kwh = 0.0 + + for hour in range(24): + hour_start = day_start + timedelta(hours=hour) + hour_end = hour_start + timedelta(hours=1) + + q = select(func.sum(EnergyData.value)).where( + and_( + EnergyData.timestamp >= hour_start, + EnergyData.timestamp < hour_end, + EnergyData.data_type == "energy", + ) + ) + result = await db.execute(q) + hour_kwh = result.scalar() or 0.0 + + period = get_period_for_hour(periods, hour, target_date.month) + price = period.price_yuan_per_kwh if period else 0.7467 + period_type = period.period_type if period else "flat" + cost = hour_kwh * price + + total_cost += cost + total_kwh += hour_kwh + + hourly_data.append({ + "hour": hour, + "consumption_kwh": round(hour_kwh, 2), + "price": price, + "cost": round(cost, 2), + "period_type": period_type, + "period_label": PERIOD_LABELS.get(period_type, period_type), + }) + + return { + "date": str(target_date), + "hourly": hourly_data, + "total_cost": round(total_cost, 2), + "total_kwh": round(total_kwh, 2), + } + + +async def calculate_monthly_cost_breakdown( + db: AsyncSession, year: int, month: int, +) -> dict: + """Calculate monthly cost breakdown by TOU period type.""" + pricing = await get_active_tou_pricing(db, date(year, month, 1)) + if not pricing: + return _empty_cost_breakdown(year, month) + + periods = await get_tou_periods(db, pricing.id) + if not periods: + return _empty_cost_breakdown(year, month) + + # Build hour -> period mapping + period_stats = {pt: {"kwh": 0.0, "cost": 0.0, "hours": 0} + for pt in ["sharp_peak", "peak", "flat", "valley"]} + + for hour in range(24): + period = get_period_for_hour(periods, hour, month) + if not period: + continue + pt = period.period_type + if pt not in period_stats: + period_stats[pt] = {"kwh": 0.0, "cost": 0.0, "hours": 0} + period_stats[pt]["hours"] += 1 + + # Get daily summaries for the month + month_start = date(year, month, 1) + if month == 12: + month_end = date(year + 1, 1, 1) + else: + month_end = date(year, month + 1, 1) + + q = select( + func.sum(EnergyDailySummary.total_consumption), + ).where( + and_( + EnergyDailySummary.date >= datetime(month_start.year, month_start.month, month_start.day), + EnergyDailySummary.date < datetime(month_end.year, month_end.month, month_end.day), + EnergyDailySummary.energy_type == "electricity", + ) + ) + result = await db.execute(q) + total_monthly_kwh = result.scalar() or 0.0 + + # Distribute by hour proportion + total_hours = sum(ps["hours"] for ps in period_stats.values()) + for pt, ps in period_stats.items(): + proportion = ps["hours"] / total_hours if total_hours > 0 else 0 + ps["kwh"] = total_monthly_kwh * proportion + period_obj = next((p for p in periods if p.period_type == pt), None) + price = period_obj.price_yuan_per_kwh if period_obj else 0 + ps["cost"] = ps["kwh"] * price + + total_cost = sum(ps["cost"] for ps in period_stats.values()) + + breakdown = [] + for pt, ps in period_stats.items(): + if ps["hours"] == 0: + continue + breakdown.append({ + "period_type": pt, + "period_label": PERIOD_LABELS.get(pt, pt), + "consumption_kwh": round(ps["kwh"], 2), + "cost_yuan": round(ps["cost"], 2), + "hours_per_day": ps["hours"], + "proportion": round(ps["kwh"] / total_monthly_kwh * 100, 1) if total_monthly_kwh > 0 else 0, + }) + + return { + "year_month": f"{year}-{month:02d}", + "total_consumption_kwh": round(total_monthly_kwh, 2), + "total_cost_yuan": round(total_cost, 2), + "breakdown": breakdown, + "pricing_name": pricing.name, + } + + +def _empty_cost_breakdown(year: int, month: int) -> dict: + return { + "year_month": f"{year}-{month:02d}", + "total_consumption_kwh": 0, + "total_cost_yuan": 0, + "breakdown": [], + "pricing_name": "未配置", + } + + +def calculate_heat_storage_savings( + daily_kwh: float, periods: list[TouPricingPeriod], shift_ratio: float = 0.3, +) -> dict: + """Calculate savings from valley-electricity heat storage strategy (谷电蓄热). + + Assumes shift_ratio of heat pump load can be moved from peak/sharp_peak to valley hours. + """ + peak_prices = [] + valley_price = 0.3048 + + for p in periods: + if p.period_type in ("sharp_peak", "peak"): + peak_prices.append(p.price_yuan_per_kwh) + elif p.period_type == "valley": + valley_price = p.price_yuan_per_kwh + + avg_peak_price = sum(peak_prices) / len(peak_prices) if peak_prices else 1.2 + shifted_kwh = daily_kwh * shift_ratio + savings_per_day = shifted_kwh * (avg_peak_price - valley_price) + + return { + "shifted_kwh": round(shifted_kwh, 2), + "avg_peak_price": round(avg_peak_price, 4), + "valley_price": round(valley_price, 4), + "savings_per_day": round(savings_per_day, 2), + "savings_per_month": round(savings_per_day * 30, 2), + "savings_per_year": round(savings_per_day * 365, 2), + "strategy": "谷电蓄热", + "description": f"将{shift_ratio*100:.0f}%的热泵负荷从尖峰/高峰时段转移至低谷时段(23:00-7:00)预热水箱", + } + + +def calculate_pv_priority_savings( + pv_daily_kwh: float, grid_price: float = 0.7467, feed_in_price: float = 0.3548, +) -> dict: + """Calculate savings from PV self-consumption priority strategy.""" + self_consume_value = pv_daily_kwh * grid_price + feed_in_value = pv_daily_kwh * feed_in_price + savings_per_day = self_consume_value - feed_in_value + + return { + "pv_daily_kwh": round(pv_daily_kwh, 2), + "self_consume_value": round(self_consume_value, 2), + "feed_in_value": round(feed_in_value, 2), + "savings_per_day": round(savings_per_day, 2), + "savings_per_month": round(savings_per_day * 30, 2), + "strategy": "光伏自消纳优先", + "description": "优先使用光伏发电供给园区负荷,减少向电网购电", + } + + +def simulate_strategy_impact( + daily_consumption_kwh: float, + pv_daily_kwh: float, + periods: list[TouPricingPeriod], + strategies: list[str], +) -> dict: + """Simulate impact of enabling various strategies.""" + baseline_cost = 0.0 + optimized_cost = 0.0 + + # Calculate baseline cost (proportional by hours) + period_hours = {} + for p in periods: + start_h = int(p.start_time.split(":")[0]) + end_h = int(p.end_time.split(":")[0]) + if start_h < end_h: + hours = end_h - start_h + else: + hours = (24 - start_h) + end_h + period_hours[p.period_type] = period_hours.get(p.period_type, 0) + hours + + total_hours = sum(period_hours.values()) or 24 + for p in periods: + start_h = int(p.start_time.split(":")[0]) + end_h = int(p.end_time.split(":")[0]) + hours = end_h - start_h if start_h < end_h else (24 - start_h) + end_h + proportion = hours / total_hours + kwh = daily_consumption_kwh * proportion + baseline_cost += kwh * p.price_yuan_per_kwh + + optimized_cost = baseline_cost + savings_details = [] + + if "heat_storage" in strategies: + hs = calculate_heat_storage_savings(daily_consumption_kwh * 0.4, periods, 0.3) + optimized_cost -= hs["savings_per_day"] + savings_details.append(hs) + + if "pv_priority" in strategies: + pv = calculate_pv_priority_savings(pv_daily_kwh) + optimized_cost -= pv["savings_per_day"] + savings_details.append(pv) + + if "load_shift" in strategies: + # Shift 15% of peak load to flat/valley + valley_p = next((p for p in periods if p.period_type == "valley"), None) + peak_p = next((p for p in periods if p.period_type == "sharp_peak"), None) + if valley_p and peak_p: + shift_kwh = daily_consumption_kwh * 0.15 + saved = shift_kwh * (peak_p.price_yuan_per_kwh - valley_p.price_yuan_per_kwh) + optimized_cost -= saved + savings_details.append({ + "strategy": "负荷转移", + "savings_per_day": round(saved, 2), + "savings_per_month": round(saved * 30, 2), + "description": "将15%的尖峰时段负荷转移至低谷时段", + }) + + return { + "baseline_cost_per_day": round(baseline_cost, 2), + "optimized_cost_per_day": round(max(0, optimized_cost), 2), + "total_savings_per_day": round(baseline_cost - max(0, optimized_cost), 2), + "total_savings_per_month": round((baseline_cost - max(0, optimized_cost)) * 30, 2), + "total_savings_per_year": round((baseline_cost - max(0, optimized_cost)) * 365, 2), + "savings_percentage": round((1 - max(0, optimized_cost) / baseline_cost) * 100, 1) if baseline_cost > 0 else 0, + "details": savings_details, + } + + +async def get_recommendations(db: AsyncSession) -> list[dict]: + """Generate current strategy recommendations based on data.""" + recommendations = [] + + # Always recommend valley heat storage for heating season + now = datetime.now(BJT) + month = now.month + if month in (11, 12, 1, 2, 3): + recommendations.append({ + "type": "heat_storage", + "title": "谷电蓄热策略", + "description": "当前为采暖季,建议在低谷时段(23:00-7:00)预热水箱,减少尖峰时段热泵运行", + "priority": "high", + "estimated_savings": "每月可节约约3000-5000元", + }) + + # PV priority during daytime + recommendations.append({ + "type": "pv_priority", + "title": "光伏自消纳优先", + "description": "优先使用屋顶光伏发电满足园区负荷,减少购电成本", + "priority": "medium", + "estimated_savings": "每月可节约约1500-2500元", + }) + + # Load shifting + hour = now.hour + if 10 <= hour <= 15 or 18 <= hour <= 21: + recommendations.append({ + "type": "load_shift", + "title": "当前处于尖峰时段", + "description": "建议减少非必要大功率设备运行,可延迟至低谷时段执行", + "priority": "high", + "estimated_savings": "尖峰电价1.3761元/kWh,低谷电价0.3048元/kWh", + }) + + return recommendations + + +async def get_savings_report(db: AsyncSession, year: int) -> dict: + """Generate yearly savings report.""" + reports = [] + total_savings = 0.0 + total_baseline = 0.0 + total_optimized = 0.0 + + result = await db.execute( + select(MonthlyCostReport).where( + MonthlyCostReport.year_month.like(f"{year}-%") + ).order_by(MonthlyCostReport.year_month) + ) + monthly_reports = result.scalars().all() + + for r in monthly_reports: + reports.append({ + "year_month": r.year_month, + "total_consumption_kwh": r.total_consumption_kwh, + "total_cost_yuan": r.total_cost_yuan, + "baseline_cost": r.baseline_cost, + "optimized_cost": r.optimized_cost, + "savings_yuan": r.savings_yuan, + }) + total_savings += r.savings_yuan + total_baseline += r.baseline_cost + total_optimized += r.optimized_cost + + return { + "year": year, + "monthly_reports": reports, + "total_savings_yuan": round(total_savings, 2), + "total_baseline_cost": round(total_baseline, 2), + "total_optimized_cost": round(total_optimized, 2), + "savings_percentage": round(total_savings / total_baseline * 100, 1) if total_baseline > 0 else 0, + } diff --git a/backend/app/services/quota_checker.py b/backend/app/services/quota_checker.py new file mode 100644 index 0000000..2ff287e --- /dev/null +++ b/backend/app/services/quota_checker.py @@ -0,0 +1,124 @@ +"""配额检测服务 - 计算配额使用率,超限时生成告警事件""" +import logging +from datetime import datetime, timezone, timedelta +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.quota import EnergyQuota, QuotaUsage +from app.models.alarm import AlarmEvent +from app.models.energy import EnergyDailySummary + +logger = logging.getLogger("quota_checker") + + +def _get_period_range(period: str, now: datetime) -> tuple[datetime, datetime]: + """根据配额周期计算当前统计区间""" + if period == "monthly": + start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + # 下月1号 + if now.month == 12: + end = start.replace(year=now.year + 1, month=1) + else: + end = start.replace(month=now.month + 1) + else: # yearly + start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) + end = start.replace(year=now.year + 1) + return start, end + + +async def check_quotas(session: AsyncSession): + """主配额检测循环,计算每个活跃配额的使用率并更新记录""" + now = datetime.now(timezone.utc) + + result = await session.execute( + select(EnergyQuota).where(EnergyQuota.is_active == True) + ) + quotas = result.scalars().all() + + for quota in quotas: + period_start, period_end = _get_period_range(quota.period, now) + + # 从 EnergyDailySummary 汇总实际用量 + # target_id 对应 device_groups,这里按 device_id 关联 + # 简化处理:按 energy_type 汇总所有匹配设备的消耗 + usage_query = select(func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0)).where( + and_( + EnergyDailySummary.energy_type == quota.energy_type, + EnergyDailySummary.date >= period_start, + EnergyDailySummary.date < period_end, + ) + ) + actual_value = (await session.execute(usage_query)).scalar() or 0 + + # 计算使用率 + usage_rate_pct = (actual_value / quota.quota_value * 100) if quota.quota_value > 0 else 0 + + # 确定状态 + if usage_rate_pct >= quota.alert_threshold_pct: + status = "exceeded" + elif usage_rate_pct >= quota.warning_threshold_pct: + status = "warning" + else: + status = "normal" + + # 更新或创建 QuotaUsage 记录 + existing_result = await session.execute( + select(QuotaUsage).where( + and_( + QuotaUsage.quota_id == quota.id, + QuotaUsage.period_start == period_start, + QuotaUsage.period_end == period_end, + ) + ) + ) + usage_record = existing_result.scalar_one_or_none() + + if usage_record: + usage_record.actual_value = actual_value + usage_record.usage_rate_pct = usage_rate_pct + usage_record.status = status + usage_record.calculated_at = now + else: + usage_record = QuotaUsage( + quota_id=quota.id, + period_start=period_start, + period_end=period_end, + actual_value=actual_value, + quota_value=quota.quota_value, + usage_rate_pct=usage_rate_pct, + status=status, + ) + session.add(usage_record) + + # 超过预警阈值时生成告警事件 + if status in ("warning", "exceeded"): + # 检查是否已存在未解决的同配额告警 + active_alarm = await session.execute( + select(AlarmEvent).where( + and_( + AlarmEvent.title == f"配额预警: {quota.name}", + AlarmEvent.status.in_(["active", "acknowledged"]), + ) + ) + ) + if not active_alarm.scalar_one_or_none(): + severity = "critical" if status == "exceeded" else "warning" + event = AlarmEvent( + rule_id=None, + device_id=quota.target_id, + severity=severity, + title=f"配额预警: {quota.name}", + description=f"当前使用 {actual_value:.1f}{quota.unit}," + f"配额 {quota.quota_value:.1f}{quota.unit}," + f"使用率 {usage_rate_pct:.1f}%", + value=actual_value, + threshold=quota.quota_value, + status="active", + triggered_at=now, + ) + session.add(event) + logger.info( + f"Quota alert: {quota.name} | usage={actual_value:.1f} " + f"quota={quota.quota_value:.1f} rate={usage_rate_pct:.1f}%" + ) + + await session.flush() diff --git a/backend/app/services/report_generator.py b/backend/app/services/report_generator.py new file mode 100644 index 0000000..55179fd --- /dev/null +++ b/backend/app/services/report_generator.py @@ -0,0 +1,523 @@ +""" +报表生成服务 - PDF/Excel report generation for Tianpu EMS. +""" +import os +import io +from datetime import datetime, date, timedelta +from pathlib import Path +from typing import Any + +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.device import Device +from app.models.energy import EnergyDailySummary +from app.models.alarm import AlarmEvent +from app.models.carbon import CarbonEmission + +REPORTS_DIR = Path(__file__).resolve().parent.parent.parent / "reports" +REPORTS_DIR.mkdir(exist_ok=True) + +PLATFORM_TITLE = "天普零碳园区智慧能源管理平台" + +ENERGY_TYPE_LABELS = { + "electricity": "电力", + "heat": "热能", + "water": "水", + "gas": "天然气", +} + +DEVICE_STATUS_LABELS = { + "online": "在线", + "offline": "离线", + "alarm": "告警", + "maintenance": "维护中", +} + +SEVERITY_LABELS = { + "critical": "紧急", + "major": "重要", + "warning": "一般", +} + + +def _register_chinese_font(): + """Register a Chinese font for ReportLab PDF generation.""" + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + + font_paths = [ + "C:/Windows/Fonts/simsun.ttc", + "C:/Windows/Fonts/simhei.ttf", + "C:/Windows/Fonts/msyh.ttc", + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/System/Library/Fonts/PingFang.ttc", + ] + for fp in font_paths: + if os.path.exists(fp): + try: + pdfmetrics.registerFont(TTFont("ChineseFont", fp)) + return "ChineseFont" + except Exception: + continue + return "Helvetica" + + +class ReportGenerator: + """Generates PDF and Excel reports from EMS data.""" + + def __init__(self, db: AsyncSession): + self.db = db + + # ------------------------------------------------------------------ # + # Data fetching helpers + # ------------------------------------------------------------------ # + + async def _fetch_energy_daily( + self, start_date: date, end_date: date, device_ids: list[int] | None = None + ) -> list[dict]: + q = select(EnergyDailySummary).where( + and_( + func.date(EnergyDailySummary.date) >= start_date, + func.date(EnergyDailySummary.date) <= end_date, + ) + ) + if device_ids: + q = q.where(EnergyDailySummary.device_id.in_(device_ids)) + q = q.order_by(EnergyDailySummary.date) + result = await self.db.execute(q) + rows = result.scalars().all() + return [ + { + "date": str(r.date.date()) if r.date else "", + "device_id": r.device_id, + "energy_type": ENERGY_TYPE_LABELS.get(r.energy_type, r.energy_type), + "total_consumption": round(r.total_consumption or 0, 2), + "total_generation": round(r.total_generation or 0, 2), + "peak_power": round(r.peak_power or 0, 2), + "avg_power": round(r.avg_power or 0, 2), + "operating_hours": round(r.operating_hours or 0, 1), + "cost": round(r.cost or 0, 2), + "carbon_emission": round(r.carbon_emission or 0, 2), + } + for r in rows + ] + + async def _fetch_devices(self) -> list[dict]: + result = await self.db.execute( + select(Device).where(Device.is_active == True).order_by(Device.id) + ) + return [ + { + "id": d.id, + "name": d.name, + "code": d.code, + "device_type": d.device_type, + "status": DEVICE_STATUS_LABELS.get(d.status, d.status), + "rated_power": d.rated_power or 0, + "location": d.location or "", + "last_data_time": str(d.last_data_time) if d.last_data_time else "N/A", + } + for d in result.scalars().all() + ] + + async def _fetch_alarms(self, start_date: date, end_date: date) -> list[dict]: + q = select(AlarmEvent).where( + and_( + func.date(AlarmEvent.triggered_at) >= start_date, + func.date(AlarmEvent.triggered_at) <= end_date, + ) + ).order_by(AlarmEvent.triggered_at.desc()) + result = await self.db.execute(q) + return [ + { + "id": a.id, + "device_id": a.device_id, + "severity": SEVERITY_LABELS.get(a.severity, a.severity), + "title": a.title, + "description": a.description or "", + "value": a.value, + "threshold": a.threshold, + "status": a.status, + "triggered_at": str(a.triggered_at) if a.triggered_at else "", + "resolved_at": str(a.resolved_at) if a.resolved_at else "", + } + for a in result.scalars().all() + ] + + async def _fetch_carbon(self, start_date: date, end_date: date) -> list[dict]: + q = select(CarbonEmission).where( + and_( + func.date(CarbonEmission.date) >= start_date, + func.date(CarbonEmission.date) <= end_date, + ) + ).order_by(CarbonEmission.date) + result = await self.db.execute(q) + return [ + { + "date": str(c.date.date()) if c.date else "", + "scope": c.scope, + "category": c.category, + "emission": round(c.emission or 0, 2), + "reduction": round(c.reduction or 0, 2), + "energy_consumption": round(c.energy_consumption or 0, 2), + "energy_unit": c.energy_unit or "", + } + for c in result.scalars().all() + ] + + # ------------------------------------------------------------------ # + # Public generation methods + # ------------------------------------------------------------------ # + + async def generate_energy_daily_report( + self, + start_date: date, + end_date: date, + device_ids: list[int] | None = None, + export_format: str = "xlsx", + ) -> str: + data = await self._fetch_energy_daily(start_date, end_date, device_ids) + title = "每日能耗报表" + date_range_str = f"{start_date} ~ {end_date}" + summary = self._compute_energy_summary(data) + headers = ["日期", "设备ID", "能源类型", "消耗量", "产出量", "峰值功率(kW)", "平均功率(kW)", "运行时长(h)", "费用(元)", "碳排放(kgCO₂)"] + table_keys = ["date", "device_id", "energy_type", "total_consumption", "total_generation", "peak_power", "avg_power", "operating_hours", "cost", "carbon_emission"] + + filename = f"energy_daily_{start_date}_{end_date}_{datetime.now().strftime('%H%M%S')}" + if export_format == "pdf": + return self._generate_pdf(title, date_range_str, summary, headers, table_keys, data, filename) + else: + return self._generate_excel(title, date_range_str, summary, headers, table_keys, data, filename) + + async def generate_monthly_summary( + self, month: int, year: int, export_format: str = "xlsx" + ) -> str: + start = date(year, month, 1) + if month == 12: + end = date(year + 1, 1, 1) - timedelta(days=1) + else: + end = date(year, month + 1, 1) - timedelta(days=1) + + data = await self._fetch_energy_daily(start, end) + title = f"{year}年{month}月能耗月报" + date_range_str = f"{start} ~ {end}" + summary = self._compute_energy_summary(data) + headers = ["日期", "设备ID", "能源类型", "消耗量", "产出量", "峰值功率(kW)", "平均功率(kW)", "运行时长(h)", "费用(元)", "碳排放(kgCO₂)"] + table_keys = ["date", "device_id", "energy_type", "total_consumption", "total_generation", "peak_power", "avg_power", "operating_hours", "cost", "carbon_emission"] + + filename = f"monthly_summary_{year}_{month:02d}" + if export_format == "pdf": + return self._generate_pdf(title, date_range_str, summary, headers, table_keys, data, filename) + else: + return self._generate_excel(title, date_range_str, summary, headers, table_keys, data, filename) + + async def generate_device_status_report(self, export_format: str = "xlsx") -> str: + data = await self._fetch_devices() + title = "设备状态报表" + date_range_str = f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}" + summary = self._compute_device_summary(data) + headers = ["设备ID", "设备名称", "设备编号", "设备类型", "状态", "额定功率(kW)", "位置", "最近数据时间"] + table_keys = ["id", "name", "code", "device_type", "status", "rated_power", "location", "last_data_time"] + + filename = f"device_status_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + if export_format == "pdf": + return self._generate_pdf(title, date_range_str, summary, headers, table_keys, data, filename) + else: + return self._generate_excel(title, date_range_str, summary, headers, table_keys, data, filename) + + async def generate_alarm_report( + self, start_date: date, end_date: date, export_format: str = "xlsx" + ) -> str: + data = await self._fetch_alarms(start_date, end_date) + title = "告警分析报表" + date_range_str = f"{start_date} ~ {end_date}" + summary = self._compute_alarm_summary(data) + headers = ["告警ID", "设备ID", "严重程度", "标题", "描述", "触发值", "阈值", "状态", "触发时间", "解决时间"] + table_keys = ["id", "device_id", "severity", "title", "description", "value", "threshold", "status", "triggered_at", "resolved_at"] + + filename = f"alarm_report_{start_date}_{end_date}" + if export_format == "pdf": + return self._generate_pdf(title, date_range_str, summary, headers, table_keys, data, filename) + else: + return self._generate_excel(title, date_range_str, summary, headers, table_keys, data, filename) + + async def generate_carbon_report( + self, start_date: date, end_date: date, export_format: str = "xlsx" + ) -> str: + data = await self._fetch_carbon(start_date, end_date) + title = "碳排放分析报表" + date_range_str = f"{start_date} ~ {end_date}" + summary = self._compute_carbon_summary(data) + headers = ["日期", "范围", "类别", "排放量(kgCO₂e)", "减排量(kgCO₂e)", "能耗", "单位"] + table_keys = ["date", "scope", "category", "emission", "reduction", "energy_consumption", "energy_unit"] + + filename = f"carbon_report_{start_date}_{end_date}" + if export_format == "pdf": + return self._generate_pdf(title, date_range_str, summary, headers, table_keys, data, filename) + else: + return self._generate_excel(title, date_range_str, summary, headers, table_keys, data, filename) + + # ------------------------------------------------------------------ # + # Summary computation helpers + # ------------------------------------------------------------------ # + + @staticmethod + def _compute_energy_summary(data: list[dict]) -> list[tuple[str, str]]: + total_consumption = sum(r["total_consumption"] for r in data) + total_generation = sum(r["total_generation"] for r in data) + total_cost = sum(r["cost"] for r in data) + total_carbon = sum(r["carbon_emission"] for r in data) + return [ + ("数据条数", str(len(data))), + ("总消耗量", f"{total_consumption:,.2f}"), + ("总产出量", f"{total_generation:,.2f}"), + ("总费用(元)", f"{total_cost:,.2f}"), + ("总碳排放(kgCO₂)", f"{total_carbon:,.2f}"), + ] + + @staticmethod + def _compute_device_summary(data: list[dict]) -> list[tuple[str, str]]: + total = len(data) + online = sum(1 for d in data if d["status"] == "在线") + offline = sum(1 for d in data if d["status"] == "离线") + alarm = sum(1 for d in data if d["status"] == "告警") + return [ + ("设备总数", str(total)), + ("在线", str(online)), + ("离线", str(offline)), + ("告警", str(alarm)), + ] + + @staticmethod + def _compute_alarm_summary(data: list[dict]) -> list[tuple[str, str]]: + total = len(data) + critical = sum(1 for a in data if a["severity"] == "紧急") + major = sum(1 for a in data if a["severity"] == "重要") + resolved = sum(1 for a in data if a["status"] == "resolved") + return [ + ("告警总数", str(total)), + ("紧急", str(critical)), + ("重要", str(major)), + ("已解决", str(resolved)), + ] + + @staticmethod + def _compute_carbon_summary(data: list[dict]) -> list[tuple[str, str]]: + total_emission = sum(r["emission"] for r in data) + total_reduction = sum(r["reduction"] for r in data) + net = total_emission - total_reduction + return [ + ("数据条数", str(len(data))), + ("总排放(kgCO₂e)", f"{total_emission:,.2f}"), + ("总减排(kgCO₂e)", f"{total_reduction:,.2f}"), + ("净排放(kgCO₂e)", f"{net:,.2f}"), + ] + + # ------------------------------------------------------------------ # + # PDF generation (ReportLab) + # ------------------------------------------------------------------ # + + def _generate_pdf( + self, + title: str, + date_range_str: str, + summary: list[tuple[str, str]], + headers: list[str], + table_keys: list[str], + data: list[dict], + filename: str, + ) -> str: + from reportlab.lib.pagesizes import A4 + from reportlab.lib import colors + from reportlab.lib.styles import ParagraphStyle + from reportlab.lib.units import mm + from reportlab.platypus import ( + SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer + ) + + font_name = _register_chinese_font() + filepath = str(REPORTS_DIR / f"{filename}.pdf") + + doc = SimpleDocTemplate( + filepath, pagesize=A4, + topMargin=20 * mm, bottomMargin=20 * mm, + leftMargin=15 * mm, rightMargin=15 * mm, + ) + + title_style = ParagraphStyle( + "Title", fontName=font_name, fontSize=16, alignment=1, spaceAfter=6, + ) + subtitle_style = ParagraphStyle( + "Subtitle", fontName=font_name, fontSize=10, alignment=1, + textColor=colors.grey, spaceAfter=4, + ) + section_style = ParagraphStyle( + "Section", fontName=font_name, fontSize=12, spaceBefore=12, spaceAfter=6, + ) + normal_style = ParagraphStyle( + "Normal", fontName=font_name, fontSize=9, + ) + + elements: list[Any] = [] + + # Header + elements.append(Paragraph(PLATFORM_TITLE, subtitle_style)) + elements.append(Paragraph(title, title_style)) + elements.append(Paragraph(date_range_str, subtitle_style)) + elements.append(Spacer(1, 8 * mm)) + + # Summary section + elements.append(Paragraph("概要", section_style)) + summary_data = [[Paragraph(k, normal_style), Paragraph(v, normal_style)] for k, v in summary] + summary_table = Table(summary_data, colWidths=[120, 200]) + summary_table.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (0, -1), colors.Color(0.94, 0.94, 0.94)), + ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ])) + elements.append(summary_table) + elements.append(Spacer(1, 8 * mm)) + + # Detail table + elements.append(Paragraph("明细数据", section_style)) + if data: + page_width = A4[0] - 30 * mm + col_width = page_width / len(headers) + header_row = [Paragraph(h, ParagraphStyle("H", fontName=font_name, fontSize=7, alignment=1)) for h in headers] + table_data = [header_row] + cell_style = ParagraphStyle("Cell", fontName=font_name, fontSize=7) + for row in data[:500]: # limit rows for PDF + table_data.append([Paragraph(str(row.get(k, "")), cell_style) for k in table_keys]) + detail_table = Table(table_data, colWidths=[col_width] * len(headers), repeatRows=1) + detail_table.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.2, 0.4, 0.7)), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.Color(0.96, 0.96, 0.96)]), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 3), + ("RIGHTPADDING", (0, 0), (-1, -1), 3), + ("TOPPADDING", (0, 0), (-1, -1), 3), + ("BOTTOMPADDING", (0, 0), (-1, -1), 3), + ])) + elements.append(detail_table) + if len(data) > 500: + elements.append(Spacer(1, 4 * mm)) + elements.append(Paragraph(f"(共 {len(data)} 条记录,PDF 仅显示前500条)", normal_style)) + else: + elements.append(Paragraph("暂无数据", normal_style)) + + # Footer + elements.append(Spacer(1, 10 * mm)) + footer_style = ParagraphStyle("Footer", fontName=font_name, fontSize=8, textColor=colors.grey, alignment=2) + elements.append(Paragraph(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", footer_style)) + + doc.build(elements) + return filepath + + # ------------------------------------------------------------------ # + # Excel generation (OpenPyXL) + # ------------------------------------------------------------------ # + + def _generate_excel( + self, + title: str, + date_range_str: str, + summary: list[tuple[str, str]], + headers: list[str], + table_keys: list[str], + data: list[dict], + filename: str, + ) -> str: + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment, PatternFill, Border, Side + + filepath = str(REPORTS_DIR / f"{filename}.xlsx") + wb = Workbook() + + header_font = Font(bold=True, color="FFFFFF", size=11) + header_fill = PatternFill(start_color="336699", end_color="336699", fill_type="solid") + header_align = Alignment(horizontal="center", vertical="center", wrap_text=True) + thin_border = Border( + left=Side(style="thin", color="CCCCCC"), + right=Side(style="thin", color="CCCCCC"), + top=Side(style="thin", color="CCCCCC"), + bottom=Side(style="thin", color="CCCCCC"), + ) + title_font = Font(bold=True, size=14) + subtitle_font = Font(size=10, color="666666") + summary_key_fill = PatternFill(start_color="F0F0F0", end_color="F0F0F0", fill_type="solid") + + # --- Summary sheet --- + ws_summary = wb.active + ws_summary.title = "概要" + ws_summary.append([PLATFORM_TITLE]) + ws_summary.merge_cells("A1:D1") + ws_summary["A1"].font = title_font + + ws_summary.append([title]) + ws_summary.merge_cells("A2:D2") + ws_summary["A2"].font = Font(bold=True, size=12) + + ws_summary.append([date_range_str]) + ws_summary.merge_cells("A3:D3") + ws_summary["A3"].font = subtitle_font + + ws_summary.append([]) + ws_summary.append(["指标", "值"]) + ws_summary["A5"].font = Font(bold=True) + ws_summary["B5"].font = Font(bold=True) + + for label, value in summary: + row = ws_summary.max_row + 1 + ws_summary.append([label, value]) + ws_summary.cell(row=row, column=1).fill = summary_key_fill + + ws_summary.column_dimensions["A"].width = 25 + ws_summary.column_dimensions["B"].width = 30 + + # --- Detail sheet --- + ws_detail = wb.create_sheet("明细数据") + + # Header row + for col_idx, h in enumerate(headers, 1): + cell = ws_detail.cell(row=1, column=col_idx, value=h) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_align + cell.border = thin_border + + # Data rows + for row_idx, row_data in enumerate(data, 2): + for col_idx, key in enumerate(table_keys, 1): + val = row_data.get(key, "") + cell = ws_detail.cell(row=row_idx, column=col_idx, value=val) + cell.border = thin_border + if isinstance(val, float): + cell.number_format = "#,##0.00" + cell.alignment = Alignment(vertical="center") + + # Auto-width columns + for col_idx in range(1, len(headers) + 1): + max_len = len(str(headers[col_idx - 1])) + for row_idx in range(2, min(len(data) + 2, 102)): + val = ws_detail.cell(row=row_idx, column=col_idx).value + if val: + max_len = max(max_len, len(str(val))) + ws_detail.column_dimensions[ws_detail.cell(row=1, column=col_idx).column_letter].width = min(max_len + 4, 40) + + # Auto-filter + if data: + ws_detail.auto_filter.ref = f"A1:{ws_detail.cell(row=1, column=len(headers)).column_letter}{len(data) + 1}" + + # Freeze header + ws_detail.freeze_panes = "A2" + + wb.save(filepath) + return filepath diff --git a/backend/app/services/report_scheduler.py b/backend/app/services/report_scheduler.py new file mode 100644 index 0000000..f286ace --- /dev/null +++ b/backend/app/services/report_scheduler.py @@ -0,0 +1,192 @@ +"""报表定时调度服务 - Schedule report tasks via APScheduler and send results by email.""" +import logging +from datetime import date, timedelta, datetime, timezone + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from sqlalchemy import select + +from app.core.database import async_session +from app.models.report import ReportTask, ReportTemplate +from app.services.report_generator import ReportGenerator +from app.services.email_service import send_email + +logger = logging.getLogger("report_scheduler") + +_scheduler: AsyncIOScheduler | None = None + + +def _parse_cron(cron_expr: str) -> dict: + """Parse a 5-field cron expression into APScheduler CronTrigger kwargs.""" + parts = cron_expr.strip().split() + if len(parts) != 5: + raise ValueError(f"Invalid cron expression (need 5 fields): {cron_expr}") + return { + "minute": parts[0], + "hour": parts[1], + "day": parts[2], + "month": parts[3], + "day_of_week": parts[4], + } + + +async def _run_report_task(task_id: int): + """Execute a single report task: generate the report and email it to recipients.""" + logger.info(f"Running scheduled report task id={task_id}") + + async with async_session() as session: + # Load task + task_result = await session.execute( + select(ReportTask).where(ReportTask.id == task_id) + ) + task = task_result.scalar_one_or_none() + if not task: + logger.warning(f"Report task id={task_id} not found, skipping.") + return + if not task.is_active: + logger.info(f"Report task id={task_id} is inactive, skipping.") + return + + # Update status + task.status = "running" + task.last_run = datetime.now(timezone.utc) + await session.flush() + + # Load template to determine report type + tmpl_result = await session.execute( + select(ReportTemplate).where(ReportTemplate.id == task.template_id) + ) + template = tmpl_result.scalar_one_or_none() + if not template: + logger.error(f"Template id={task.template_id} not found for task id={task_id}") + task.status = "failed" + await session.commit() + return + + try: + generator = ReportGenerator(session) + today = date.today() + export_format = task.export_format or "xlsx" + + # Choose generation method based on template report_type + if template.report_type == "daily": + yesterday = today - timedelta(days=1) + filepath = await generator.generate_energy_daily_report( + start_date=yesterday, end_date=yesterday, export_format=export_format + ) + elif template.report_type == "monthly": + # Generate for previous month + first_of_month = today.replace(day=1) + last_month_end = first_of_month - timedelta(days=1) + last_month_start = last_month_end.replace(day=1) + filepath = await generator.generate_monthly_summary( + month=last_month_start.month, + year=last_month_start.year, + export_format=export_format, + ) + elif template.report_type == "custom" and "device" in template.name.lower(): + filepath = await generator.generate_device_status_report( + export_format=export_format + ) + else: + # Default: daily report for yesterday + yesterday = today - timedelta(days=1) + filepath = await generator.generate_energy_daily_report( + start_date=yesterday, end_date=yesterday, export_format=export_format + ) + + task.file_path = filepath + task.status = "completed" + logger.info(f"Report task id={task_id} completed: {filepath}") + + # Send email with attachment if recipients configured + recipients = task.recipients or [] + if isinstance(recipients, list) and recipients: + report_name = task.name or template.name + subject = f"{report_name} - 天普EMS自动报表" + body_html = f""" +
+

天普零碳园区智慧能源管理平台

+

您好,

+

系统已自动生成 {report_name},请查收附件。

+

+ 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+ 报表类型: {template.report_type}
+ 格式: {export_format.upper()} +

+
+

此为系统自动发送,请勿回复。

+
+ """ + await send_email( + to=recipients, + subject=subject, + body_html=body_html, + attachments=[filepath], + ) + + except Exception as e: + logger.error(f"Report task id={task_id} failed: {e}", exc_info=True) + task.status = "failed" + + await session.commit() + + +async def _load_and_schedule_tasks(): + """Load all active report tasks with schedules and register them with APScheduler.""" + global _scheduler + if not _scheduler: + return + + async with async_session() as session: + result = await session.execute( + select(ReportTask).where( + ReportTask.is_active == True, + ReportTask.schedule != None, + ReportTask.schedule != "", + ) + ) + tasks = result.scalars().all() + + for task in tasks: + try: + cron_kwargs = _parse_cron(task.schedule) + _scheduler.add_job( + _run_report_task, + CronTrigger(**cron_kwargs), + args=[task.id], + id=f"report_task_{task.id}", + replace_existing=True, + misfire_grace_time=3600, + ) + logger.info( + f"Scheduled report task id={task.id} name='{task.name}' " + f"cron='{task.schedule}'" + ) + except Exception as e: + logger.error(f"Failed to schedule report task id={task.id}: {e}") + + logger.info(f"Report scheduler loaded {len(tasks)} task(s).") + + +async def start_scheduler(): + """Start the APScheduler-based report scheduler.""" + global _scheduler + if _scheduler and _scheduler.running: + logger.warning("Report scheduler is already running.") + return + + _scheduler = AsyncIOScheduler(timezone="Asia/Shanghai") + _scheduler.start() + logger.info("Report scheduler started.") + + await _load_and_schedule_tasks() + + +async def stop_scheduler(): + """Stop the report scheduler gracefully.""" + global _scheduler + if _scheduler and _scheduler.running: + _scheduler.shutdown(wait=False) + logger.info("Report scheduler stopped.") + _scheduler = None diff --git a/backend/app/services/simulator.py b/backend/app/services/simulator.py new file mode 100644 index 0000000..472b6ea --- /dev/null +++ b/backend/app/services/simulator.py @@ -0,0 +1,295 @@ +"""模拟数据生成器 - 为天普园区设备生成真实感的模拟数据 + +Uses physics-based solar position, Beijing weather models, cloud transients, +temperature derating, and realistic building load patterns to produce data +that is convincing to industrial park asset owners. +""" +import asyncio +import random +import math +import logging +from datetime import datetime, timezone, timedelta +from sqlalchemy import select +from app.core.database import async_session +from app.models.device import Device +from app.models.energy import EnergyData +from app.models.alarm import AlarmEvent +from app.services.alarm_checker import check_alarms +from app.services.weather_model import ( + pv_power, pv_electrical, get_pv_orientation, + heat_pump_data, building_load, indoor_sensor, + heat_meter_data, get_hvac_mode, outdoor_temperature, + should_skip_reading, should_go_offline, +) + +logger = logging.getLogger("simulator") + + +class DataSimulator: + def __init__(self): + self._task = None + self._running = False + self._cycle_count = 0 + # Track daily energy accumulators per device + self._daily_energy: dict[int, float] = {} + self._total_energy: dict[int, float] = {} + self._last_day: int = -1 + # Track offline status per device + self._offline_until: dict[int, datetime] = {} + # Cache heat pump totals for heat meter correlation + self._last_hp_power: float = 0.0 + self._last_hp_cop: float = 3.0 + + async def start(self): + self._running = True + self._task = asyncio.create_task(self._run_loop()) + + async def stop(self): + self._running = False + if self._task: + self._task.cancel() + + async def _run_loop(self): + while self._running: + try: + await self._generate_data() + except Exception as e: + logger.error(f"Simulator error: {e}", exc_info=True) + await asyncio.sleep(15) # 每15秒生成一次 + + async def _generate_data(self): + now = datetime.now(timezone.utc) + beijing_dt = now + timedelta(hours=8) + self._cycle_count += 1 + + # Reset daily energy accumulators at midnight Beijing time + current_day = beijing_dt.timetuple().tm_yday + if current_day != self._last_day: + self._daily_energy.clear() + self._last_day = current_day + + async with async_session() as session: + result = await session.execute(select(Device).where(Device.is_active == True)) + devices = result.scalars().all() + + data_points = [] + hp_total_power = 0.0 + hp_cop_sum = 0.0 + hp_count = 0 + + # First pass: generate heat pump data (needed for heat meter correlation) + hp_results: dict[int, dict] = {} + for device in devices: + if device.device_type == "heat_pump": + hp_data = self._gen_heatpump_data(device, now) + hp_results[device.id] = hp_data + if hp_data: + hp_total_power += hp_data.get("_power", 0) + cop = hp_data.get("_cop", 0) + if cop > 0: + hp_cop_sum += cop + hp_count += 1 + + self._last_hp_power = hp_total_power + self._last_hp_cop = hp_cop_sum / hp_count if hp_count > 0 else 3.0 + + for device in devices: + # Simulate communication glitch: skip a reading ~1% of cycles + if should_skip_reading(self._cycle_count): + continue + + # Simulate brief device offline events + if device.id in self._offline_until: + if now < self._offline_until[device.id]: + device.status = "offline" + continue + else: + del self._offline_until[device.id] + + if should_go_offline(): + self._offline_until[device.id] = now + timedelta(seconds=random.randint(15, 30)) + device.status = "offline" + continue + + points = self._generate_device_data(device, now, hp_results) + data_points.extend(points) + device.status = "online" + device.last_data_time = now + + if data_points: + session.add_all(data_points) + + await session.flush() + + # Run alarm checker after data generation + try: + await check_alarms(session) + except Exception as e: + logger.error(f"Alarm checker error: {e}", exc_info=True) + + await session.commit() + + def _should_trigger_anomaly(self, anomaly_type: str) -> bool: + """Determine if we should inject an anomalous value for demo purposes. + + Preserves the existing alarm demo trigger pattern: + - PV low power: every ~10 min (40 cycles), lasts ~2 min (8 cycles) + - Heat pump low COP: every ~20 min (80 cycles), lasts ~2 min + - Sensor out of range: every ~30 min (120 cycles), lasts ~2 min + """ + c = self._cycle_count + if anomaly_type == "pv_low_power": + return (c % 40) < 8 + elif anomaly_type == "hp_low_cop": + return (c % 80) < 8 + elif anomaly_type == "sensor_out_of_range": + return (c % 120) < 8 + return False + + def _generate_device_data(self, device: Device, now: datetime, + hp_results: dict) -> list[EnergyData]: + points = [] + if device.device_type == "pv_inverter": + points = self._gen_pv_data(device, now) + elif device.device_type == "heat_pump": + hp_data = hp_results.get(device.id) + if hp_data: + points = hp_data.get("_points", []) + elif device.device_type == "meter": + points = self._gen_meter_data(device, now) + elif device.device_type == "sensor": + points = self._gen_sensor_data(device, now) + elif device.device_type == "heat_meter": + points = self._gen_heat_meter_data(device, now) + return points + + def _gen_pv_data(self, device: Device, now: datetime) -> list[EnergyData]: + """光伏逆变器数据 - 基于太阳位置、云层、温度降额模型""" + rated = device.rated_power or 110.0 + orientation = get_pv_orientation(device.code) + + power = pv_power(now, rated_power=rated, orientation=orientation, + device_code=device.code) + + # Demo anomaly: cloud cover drops INV-01 power very low for alarm testing + if self._should_trigger_anomaly("pv_low_power") and device.code == "INV-01": + power = random.uniform(1.0, 3.0) + + elec = pv_electrical(power, rated_power=rated) + + # Demo anomaly: over-temperature for alarm testing + if self._should_trigger_anomaly("pv_low_power") and device.code == "INV-01": + elec["temperature"] = round(random.uniform(67, 72), 1) + + # Accumulate daily energy (power * 15s interval) + interval_hours = 15.0 / 3600.0 + energy_increment = power * interval_hours + self._daily_energy[device.id] = self._daily_energy.get(device.id, 0) + energy_increment + + # Total energy: start from a reasonable base + if device.id not in self._total_energy: + self._total_energy[device.id] = 170000 + random.uniform(0, 5000) + self._total_energy[device.id] += energy_increment + + return [ + EnergyData(device_id=device.id, timestamp=now, data_type="power", + value=round(power, 2), unit="kW"), + EnergyData(device_id=device.id, timestamp=now, data_type="daily_energy", + value=round(self._daily_energy[device.id], 2), unit="kWh"), + EnergyData(device_id=device.id, timestamp=now, data_type="total_energy", + value=round(self._total_energy[device.id], 1), unit="kWh"), + EnergyData(device_id=device.id, timestamp=now, data_type="dc_voltage", + value=elec["dc_voltage"], unit="V"), + EnergyData(device_id=device.id, timestamp=now, data_type="ac_voltage", + value=elec["ac_voltage"], unit="V"), + EnergyData(device_id=device.id, timestamp=now, data_type="temperature", + value=elec["temperature"], unit="℃"), + ] + + def _gen_heatpump_data(self, device: Device, now: datetime) -> dict: + """热泵机组数据 - 基于室外温度和COP模型""" + rated = device.rated_power or 35.0 + data = heat_pump_data(now, rated_power=rated, device_code=device.code) + + cop = data["cop"] + power = data["power"] + + # Demo anomaly: low COP for HP-01 + if self._should_trigger_anomaly("hp_low_cop") and device.code == "HP-01": + cop = random.uniform(1.2, 1.8) + + # Demo anomaly: overload for HP-02 + if self._should_trigger_anomaly("hp_low_cop") and device.code == "HP-02": + power = random.uniform(39, 42) + + points = [ + EnergyData(device_id=device.id, timestamp=now, data_type="power", + value=round(power, 2), unit="kW"), + EnergyData(device_id=device.id, timestamp=now, data_type="cop", + value=round(cop, 2), unit=""), + EnergyData(device_id=device.id, timestamp=now, data_type="inlet_temp", + value=data["inlet_temp"], unit="℃"), + EnergyData(device_id=device.id, timestamp=now, data_type="outlet_temp", + value=data["outlet_temp"], unit="℃"), + EnergyData(device_id=device.id, timestamp=now, data_type="flow_rate", + value=data["flow_rate"], unit="m³/h"), + EnergyData(device_id=device.id, timestamp=now, data_type="outdoor_temp", + value=data["outdoor_temp"], unit="℃"), + ] + + return { + "_points": points, + "_power": power, + "_cop": cop, + } + + def _gen_meter_data(self, device: Device, now: datetime) -> list[EnergyData]: + """电表数据 - 基于建筑负荷模型(工作日/周末、午餐低谷、HVAC季节贡献)""" + data = building_load(now, base_power=50.0, meter_code=device.code) + + return [ + EnergyData(device_id=device.id, timestamp=now, data_type="power", + value=data["power"], unit="kW"), + EnergyData(device_id=device.id, timestamp=now, data_type="voltage", + value=data["voltage"], unit="V"), + EnergyData(device_id=device.id, timestamp=now, data_type="current", + value=data["current"], unit="A"), + EnergyData(device_id=device.id, timestamp=now, data_type="power_factor", + value=data["power_factor"], unit=""), + ] + + def _gen_sensor_data(self, device: Device, now: datetime) -> list[EnergyData]: + """温湿度传感器数据 - 室内HVAC控制 / 室外天气模型""" + is_outdoor = False + if device.metadata_: + is_outdoor = device.metadata_.get("type") == "outdoor" + + data = indoor_sensor(now, is_outdoor=is_outdoor, device_code=device.code) + + temp = data["temperature"] + # Demo anomaly: sensor out of range for alarm testing + if self._should_trigger_anomaly("sensor_out_of_range") and device.code == "TH-01": + temp = random.uniform(31, 34) + + return [ + EnergyData(device_id=device.id, timestamp=now, data_type="temperature", + value=round(temp, 1), unit="℃"), + EnergyData(device_id=device.id, timestamp=now, data_type="humidity", + value=data["humidity"], unit="%"), + ] + + def _gen_heat_meter_data(self, device: Device, now: datetime) -> list[EnergyData]: + """热量表数据 - 与热泵运行功率和COP相关联""" + data = heat_meter_data(now, hp_power=self._last_hp_power, + hp_cop=self._last_hp_cop) + + return [ + EnergyData(device_id=device.id, timestamp=now, data_type="heat_power", + value=data["heat_power"], unit="kW"), + EnergyData(device_id=device.id, timestamp=now, data_type="flow_rate", + value=data["flow_rate"], unit="m³/h"), + EnergyData(device_id=device.id, timestamp=now, data_type="supply_temp", + value=data["supply_temp"], unit="℃"), + EnergyData(device_id=device.id, timestamp=now, data_type="return_temp", + value=data["return_temp"], unit="℃"), + ] diff --git a/backend/app/services/weather_model.py b/backend/app/services/weather_model.py new file mode 100644 index 0000000..c7f6090 --- /dev/null +++ b/backend/app/services/weather_model.py @@ -0,0 +1,739 @@ +"""Beijing weather and solar position models for realistic data simulation. + +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) +""" + +import math +import random +from datetime import datetime, timezone, timedelta + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +BEIJING_LAT = 39.9 # degrees N +BEIJING_LON = 116.4 # degrees E +BEIJING_TZ_OFFSET = 8 # UTC+8 + +# Monthly average temperatures for Beijing (index 0 = Jan) +# Source: typical climatological data +MONTHLY_AVG_TEMP = [-3.0, 0.0, 7.0, 14.0, 21.0, 26.0, 27.5, 26.0, 21.0, 13.0, 4.0, -1.5] + +# Diurnal temperature swing amplitude by month (half-range) +MONTHLY_DIURNAL_SWING = [6.0, 7.0, 7.5, 8.0, 7.5, 7.0, 6.0, 6.0, 7.0, 7.5, 7.0, 6.0] + +# Monthly average relative humidity (%) +MONTHLY_AVG_HUMIDITY = [44, 42, 38, 38, 45, 58, 72, 75, 62, 55, 50, 46] + +# Sunrise/sunset hours (approximate, Beijing local time) by month +MONTHLY_SUNRISE = [7.5, 7.1, 6.4, 5.7, 5.2, 5.0, 5.1, 5.5, 6.0, 6.3, 6.8, 7.3] +MONTHLY_SUNSET = [17.1, 17.6, 18.2, 18.7, 19.2, 19.5, 19.4, 19.0, 18.3, 17.6, 17.0, 16.9] + +# Solar declination approximation (degrees) for day-of-year +# and equation of time are computed analytically below + + +_rng = random.Random() + + +def set_seed(seed: int): + """Set the random seed for reproducible data generation.""" + global _rng + _rng = random.Random(seed) + + +def _gauss(mu: float, sigma: float) -> float: + return _rng.gauss(mu, sigma) + + +def _uniform(a: float, b: float) -> float: + return _rng.uniform(a, b) + + +def _random() -> float: + return _rng.random() + + +# --------------------------------------------------------------------------- +# Solar position (simplified but accurate enough for simulation) +# --------------------------------------------------------------------------- + +def _day_of_year(dt: datetime) -> int: + """Day of year 1-366.""" + beijing_dt = dt + timedelta(hours=BEIJING_TZ_OFFSET) if dt.tzinfo else dt + return beijing_dt.timetuple().tm_yday + + +def solar_declination(day_of_year: int) -> float: + """Solar declination in degrees.""" + return 23.45 * math.sin(math.radians((360 / 365) * (day_of_year - 81))) + + +def _equation_of_time(day_of_year: int) -> float: + """Equation of time in minutes.""" + b = math.radians((360 / 365) * (day_of_year - 81)) + return 9.87 * math.sin(2 * b) - 7.53 * math.cos(b) - 1.5 * math.sin(b) + + +def solar_altitude(dt: datetime) -> float: + """Solar altitude angle in degrees for Beijing at given UTC datetime. + Returns negative values when sun is below horizon. + """ + doy = _day_of_year(dt) + decl = math.radians(solar_declination(doy)) + lat = math.radians(BEIJING_LAT) + + # 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 + 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 + + hour_angle = math.radians(15 * (solar_hour - 12)) + + sin_alt = (math.sin(lat) * math.sin(decl) + + math.cos(lat) * math.cos(decl) * math.cos(hour_angle)) + return math.degrees(math.asin(max(-1, min(1, sin_alt)))) + + +def solar_azimuth(dt: datetime) -> float: + """Solar azimuth in degrees (0=N, 90=E, 180=S, 270=W).""" + doy = _day_of_year(dt) + decl = math.radians(solar_declination(doy)) + lat = math.radians(BEIJING_LAT) + alt = math.radians(solar_altitude(dt)) + + if math.cos(alt) < 1e-6: + return 180.0 + + cos_az = (math.sin(decl) - math.sin(lat) * math.sin(alt)) / (math.cos(lat) * math.cos(alt)) + cos_az = max(-1, min(1, cos_az)) + az = math.degrees(math.acos(cos_az)) + + beijing_dt = dt + timedelta(hours=BEIJING_TZ_OFFSET) if dt.tzinfo else dt + if beijing_dt.hour + beijing_dt.minute / 60.0 > 12: + az = 360 - az + return az + + +# --------------------------------------------------------------------------- +# Cloud transient model +# --------------------------------------------------------------------------- + +class CloudModel: + """Simulates random cloud events that reduce PV output.""" + + def __init__(self): + self._events: list[dict] = [] # list of {start_minute, duration_minutes, opacity} + self._last_day: int = -1 + + def _generate_day_events(self, doy: int, month: int): + """Generate cloud events for a day. More clouds in summer monsoon.""" + self._events.clear() + self._last_day = doy + + # Number of cloud events varies by season + if month in (7, 8): # monsoon + n_events = int(_uniform(3, 8)) + elif month in (6, 9): + n_events = int(_uniform(2, 5)) + elif month in (3, 4, 5, 10): + n_events = int(_uniform(1, 4)) + else: # winter: clearer skies in Beijing + n_events = int(_uniform(0, 3)) + + for _ in range(n_events): + start = _uniform(6 * 60, 18 * 60) # minutes from midnight + duration = _uniform(2, 15) + opacity = _uniform(0.3, 0.7) # how much output drops + self._events.append({ + "start": start, + "duration": duration, + "opacity": opacity, + }) + + def get_cloud_factor(self, dt: datetime) -> float: + """Returns multiplier 0.3-1.0 (1.0 = clear sky).""" + beijing_dt = dt + timedelta(hours=BEIJING_TZ_OFFSET) if dt.tzinfo else dt + doy = beijing_dt.timetuple().tm_yday + month = beijing_dt.month + + if doy != self._last_day: + self._generate_day_events(doy, month) + + minute_of_day = beijing_dt.hour * 60 + beijing_dt.minute + factor = 1.0 + for ev in self._events: + if ev["start"] <= minute_of_day <= ev["start"] + ev["duration"]: + factor = min(factor, 1.0 - ev["opacity"]) + return factor + + +# Global cloud model instance (shared across inverters for correlated weather) +_cloud_model = CloudModel() + + +def get_cloud_factor(dt: datetime) -> float: + return _cloud_model.get_cloud_factor(dt) + + +def reset_cloud_model(): + """Reset cloud model (useful for backfill where each day is independent).""" + global _cloud_model + _cloud_model = CloudModel() + + +# --------------------------------------------------------------------------- +# Outdoor temperature model +# --------------------------------------------------------------------------- + +def outdoor_temperature(dt: datetime) -> float: + """Realistic outdoor temperature for Beijing based on month, hour, and noise. + + Uses sinusoidal diurnal pattern with peak at ~15:00 and minimum at ~06:00. + """ + beijing_dt = dt + timedelta(hours=BEIJING_TZ_OFFSET) if dt.tzinfo else dt + month_idx = beijing_dt.month - 1 + hour = beijing_dt.hour + beijing_dt.minute / 60.0 + + avg = MONTHLY_AVG_TEMP[month_idx] + swing = MONTHLY_DIURNAL_SWING[month_idx] + + # Sinusoidal with peak at 15:00, minimum at 06:00 (shifted cosine) + diurnal = -swing * math.cos(2 * math.pi * (hour - 15) / 24) + + # Day-to-day variation (slow drift) + doy = beijing_dt.timetuple().tm_yday + day_drift = 3.0 * math.sin(doy * 0.7) + 2.0 * math.cos(doy * 1.3) + + noise = _gauss(0, 0.5) + return avg + diurnal + day_drift + noise + + +def outdoor_humidity(dt: datetime) -> float: + """Outdoor humidity correlated with temperature and season.""" + beijing_dt = dt + timedelta(hours=BEIJING_TZ_OFFSET) if dt.tzinfo else dt + month_idx = beijing_dt.month - 1 + hour = beijing_dt.hour + + base = MONTHLY_AVG_HUMIDITY[month_idx] + # Higher humidity at night, lower during day + diurnal = 8.0 * math.cos(2 * math.pi * (hour - 4) / 24) + noise = _gauss(0, 3) + return max(15, min(95, base + diurnal + noise)) + + +# --------------------------------------------------------------------------- +# PV power model +# --------------------------------------------------------------------------- + +def pv_power(dt: datetime, rated_power: float = 110.0, + orientation: str = "south", device_code: str = "") -> float: + """Calculate realistic PV inverter output. + + Args: + dt: UTC datetime + rated_power: Inverter rated power in kW + orientation: 'east', 'west', or 'south' - affects morning/afternoon bias + device_code: Device code for per-inverter variation + + Returns: + Power in kW (0 at night, clipped at rated_power) + """ + alt = solar_altitude(dt) + + # Night: exactly 0 + if alt <= 0: + return 0.0 + + # Dawn/dusk ramp: gradual startup below 5 degrees altitude + if alt < 5: + ramp_factor = alt / 5.0 + else: + ramp_factor = 1.0 + + # Base clear-sky irradiance (simplified: proportional to sin(altitude)) + # With atmosphere correction (air mass) + air_mass = 1.0 / max(math.sin(math.radians(alt)), 0.01) + air_mass = min(air_mass, 40) # cap for very low sun + atmospheric_transmission = 0.7 ** (air_mass ** 0.678) # Meinel model simplified + clear_sky_factor = math.sin(math.radians(alt)) * atmospheric_transmission + + # Seasonal factor: panels at fixed tilt (~30 degrees in Beijing) + # Summer sun is higher -> slightly less optimal for tilted panels at noon + # but longer days compensate + doy = _day_of_year(dt) + decl = solar_declination(doy) + # Approximate panel tilt correction + panel_tilt = 30 # degrees + tilt_factor = max(0.5, math.cos(math.radians(abs(alt - (90 - BEIJING_LAT + decl)) * 0.3))) + + # Orientation bias + azimuth = solar_azimuth(dt) + if orientation == "east": + # East-facing gets more morning sun + orient_factor = 1.0 + 0.1 * math.cos(math.radians(azimuth - 120)) + elif orientation == "west": + # West-facing gets more afternoon sun + orient_factor = 1.0 + 0.1 * math.cos(math.radians(azimuth - 240)) + else: + orient_factor = 1.0 + + # Cloud effect (correlated across all inverters) + cloud = get_cloud_factor(dt) + + # Temperature derating + temp = outdoor_temperature(dt) + # Panel temperature is ~20-30C above ambient when producing + panel_temp = temp + 20 + 10 * clear_sky_factor + temp_derate = 1.0 + (-0.004) * max(0, panel_temp - 25) # -0.4%/C above 25C + temp_derate = max(0.75, temp_derate) + + # Per-inverter variation (use device code hash for deterministic offset) + if device_code: + inv_hash = hash(device_code) % 1000 / 1000.0 + inv_variation = 0.97 + 0.06 * inv_hash # 0.97 to 1.03 + else: + inv_variation = 1.0 + + # Gaussian noise (1-3%) + noise = 1.0 + _gauss(0, 0.015) + + # Final power + power = (rated_power * clear_sky_factor * tilt_factor * orient_factor * + cloud * temp_derate * ramp_factor * inv_variation * noise) + + # Inverter clipping + power = min(power, rated_power) + power = max(0.0, power) + + return round(power, 2) + + +def get_pv_orientation(device_code: str) -> str: + """Determine inverter orientation based on device code. + INV-01, INV-02 are east building, INV-03 is west building. + """ + if device_code in ("INV-01", "INV-02"): + return "east" + elif device_code == "INV-03": + return "west" + return "south" + + +# --------------------------------------------------------------------------- +# Heat pump model +# --------------------------------------------------------------------------- + +def get_hvac_mode(month: int) -> str: + """Determine HVAC operating mode by month.""" + if month in (11, 12, 1, 2, 3): + return "heating" + elif month in (6, 7, 8, 9): + return "cooling" + elif month in (4, 5): + return "transition_spring" + else: # Oct + return "transition_fall" + + +def heat_pump_data(dt: datetime, rated_power: float = 35.0, + device_code: str = "") -> dict: + """Generate realistic heat pump operating data. + + Returns dict with: power, cop, inlet_temp, outlet_temp, flow_rate, outdoor_temp, mode + """ + beijing_dt = dt + timedelta(hours=BEIJING_TZ_OFFSET) if dt.tzinfo else dt + hour = beijing_dt.hour + beijing_dt.minute / 60.0 + month = beijing_dt.month + is_weekend = beijing_dt.weekday() >= 5 + + mode = get_hvac_mode(month) + out_temp = outdoor_temperature(dt) + + # COP model: varies with outdoor temperature + cop = 3.0 + 0.05 * (out_temp - 7) + cop = max(2.0, min(5.5, cop)) + cop += _gauss(0, 0.1) + cop = max(2.0, min(5.5, cop)) + + # Operating pattern depends on mode + if mode == "heating": + # Higher demand at night/morning (cold), lower during warmest part of day + if 6 <= hour <= 9: + load_factor = _uniform(0.75, 0.95) + elif 9 <= hour <= 16: + load_factor = _uniform(0.45, 0.65) + elif 16 <= hour <= 22: + load_factor = _uniform(0.65, 0.85) + else: # night + load_factor = _uniform(0.55, 0.75) + + if is_weekend: + load_factor *= 0.7 + + inlet_temp = 35 + _gauss(0, 1.5) # return water + delta_t = _uniform(5, 8) + outlet_temp = inlet_temp + delta_t + + elif mode == "cooling": + # Higher demand in afternoon (hot) + if 8 <= hour <= 11: + load_factor = _uniform(0.45, 0.65) + elif 11 <= hour <= 16: + load_factor = _uniform(0.75, 0.95) + elif 16 <= hour <= 19: + load_factor = _uniform(0.60, 0.80) + elif 19 <= hour <= 22: + load_factor = _uniform(0.35, 0.55) + else: + load_factor = _uniform(0.15, 0.30) + + if is_weekend: + load_factor *= 0.7 + + inlet_temp = 12 + _gauss(0, 1.0) # return water (chilled) + delta_t = _uniform(3, 5) + outlet_temp = inlet_temp - delta_t + + else: # transition + # Intermittent operation + if _random() < 0.4: + # Off period + return { + "power": 0.0, "cop": 0.0, + "inlet_temp": round(out_temp + _gauss(5, 1), 1), + "outlet_temp": round(out_temp + _gauss(5, 1), 1), + "flow_rate": 0.0, "outdoor_temp": round(out_temp, 1), + "mode": "standby", + } + load_factor = _uniform(0.25, 0.55) + # Could be either heating or cooling depending on temp + if out_temp < 15: + inlet_temp = 32 + _gauss(0, 1.5) + delta_t = _uniform(4, 6) + outlet_temp = inlet_temp + delta_t + else: + inlet_temp = 14 + _gauss(0, 1.0) + delta_t = _uniform(3, 4) + outlet_temp = inlet_temp - delta_t + + power = rated_power * load_factor + power += _gauss(0, power * 0.02) # noise + power = max(0, min(rated_power, power)) + + # Flow rate correlates with power (not random!) + # Higher power -> higher flow for heat transfer + flow_rate = 8 + (power / rated_power) * 7 # 8-15 m3/h range + flow_rate += _gauss(0, 0.3) + flow_rate = max(5, min(18, flow_rate)) + + # Per-unit variation + if device_code: + unit_offset = (hash(device_code) % 100 - 50) / 500.0 # +/- 10% + power *= (1 + unit_offset) + + return { + "power": round(max(0, power), 2), + "cop": round(cop, 2), + "inlet_temp": round(inlet_temp, 1), + "outlet_temp": round(outlet_temp, 1), + "flow_rate": round(flow_rate, 1), + "outdoor_temp": round(out_temp, 1), + "mode": mode, + } + + +# --------------------------------------------------------------------------- +# Building load (meter) model +# --------------------------------------------------------------------------- + +def building_load(dt: datetime, base_power: float = 50.0, + meter_code: str = "") -> dict: + """Generate realistic building electrical load. + + Returns dict with: power, voltage, current, power_factor + """ + beijing_dt = dt + timedelta(hours=BEIJING_TZ_OFFSET) if dt.tzinfo else dt + hour = beijing_dt.hour + beijing_dt.minute / 60.0 + month = beijing_dt.month + is_weekend = beijing_dt.weekday() >= 5 + + # Base load profile + if is_weekend: + # Weekend: much lower, no office activity + if 8 <= hour <= 18: + load_factor = _uniform(0.35, 0.50) + else: + load_factor = _uniform(0.25, 0.35) + else: + # Weekday office pattern + if hour < 6: + load_factor = _uniform(0.25, 0.35) # night minimum (security, servers) + elif 6 <= hour < 8: + # Morning ramp-up + ramp = (hour - 6) / 2.0 + load_factor = _uniform(0.35, 0.50) + ramp * 0.3 + elif 8 <= hour < 12: + load_factor = _uniform(0.75, 0.95) # morning work + elif 12 <= hour < 13: + load_factor = _uniform(0.55, 0.70) # lunch dip + elif 13 <= hour < 18: + load_factor = _uniform(0.80, 1.0) # afternoon peak + elif 18 <= hour < 19: + # Evening ramp-down + ramp = (19 - hour) + load_factor = _uniform(0.50, 0.65) + ramp * 0.2 + elif 19 <= hour < 22: + load_factor = _uniform(0.35, 0.50) # evening + else: + load_factor = _uniform(0.25, 0.35) # night + + # HVAC seasonal contribution + hvac_mode = get_hvac_mode(month) + if hvac_mode == "heating": + hvac_add = _uniform(0.10, 0.20) + elif hvac_mode == "cooling": + hvac_add = _uniform(0.15, 0.25) + else: + hvac_add = _uniform(0.03, 0.08) + + # Random load events (elevator, kitchen, EV charging) + spike = 0.0 + if _random() < 0.08: # ~8% chance per reading + spike = _uniform(5, 25) # kW spike + + power = base_power * (load_factor + hvac_add) + spike + + # Minimum night base load (security, servers, emergency lighting) + min_load = 15 + _gauss(0, 1) + power = max(min_load, power) + + # Noise + power += _gauss(0, power * 0.015) + power = max(0, power) + + # Voltage (realistic grid: 220V +/- 5%) + voltage = 220 + _gauss(0, 2.0) + voltage = max(209, min(231, voltage)) + + # Power factor + pf = _uniform(0.88, 0.96) + if 8 <= hour <= 18 and not is_weekend: + pf = _uniform(0.90, 0.97) # better during office hours (capacitor bank) + + # Current derived from power + current = power / (voltage * math.sqrt(3) * pf / 1000) # 3-phase + + # Per-meter variation + if meter_code == "METER-GRID": + pass # main meter, use as-is + elif meter_code == "METER-PV": + # PV meter shows generation, not load — handled separately + pass + elif meter_code == "METER-HP": + power *= _uniform(0.2, 0.35) # heat pump subset of total + elif meter_code == "METER-PUMP": + power *= _uniform(0.05, 0.12) # circulation pumps + + return { + "power": round(power, 2), + "voltage": round(voltage, 1), + "current": round(current, 1), + "power_factor": round(pf, 3), + } + + +# --------------------------------------------------------------------------- +# Sensor model +# --------------------------------------------------------------------------- + +def indoor_sensor(dt: datetime, is_outdoor: bool = False, + device_code: str = "") -> dict: + """Generate realistic temperature and humidity sensor data. + + Returns dict with: temperature, humidity + """ + beijing_dt = dt + timedelta(hours=BEIJING_TZ_OFFSET) if dt.tzinfo else dt + hour = beijing_dt.hour + beijing_dt.minute / 60.0 + month = beijing_dt.month + is_weekend = beijing_dt.weekday() >= 5 + + if is_outdoor: + temp = outdoor_temperature(dt) + hum = outdoor_humidity(dt) + return {"temperature": round(temp, 1), "humidity": round(hum, 1)} + + # Indoor: HVAC controlled during office hours + hvac_mode = get_hvac_mode(month) + + if not is_weekend and 7 <= hour <= 19: + # HVAC on: well-controlled + if hvac_mode == "heating": + temp = _uniform(20.5, 23.5) + elif hvac_mode == "cooling": + temp = _uniform(23.0, 25.5) + else: + temp = _uniform(21.0, 25.0) + hum = _uniform(40, 55) + else: + # HVAC off or weekend: drifts toward outdoor + out_temp = outdoor_temperature(dt) + if hvac_mode == "heating": + # Indoor cools slowly without heating + temp = max(16, min(22, 22 - (22 - out_temp) * 0.15)) + elif hvac_mode == "cooling": + # Indoor warms slowly without cooling + temp = min(30, max(24, 24 + (out_temp - 24) * 0.15)) + else: + temp = 20 + (out_temp - 15) * 0.2 + + hum = _uniform(35, 65) + # Summer monsoon: higher indoor humidity without dehumidification + if month in (7, 8) and is_weekend: + hum = _uniform(55, 75) + + # Per-sensor variation (different rooms have slightly different temps) + if device_code: + room_offset = (hash(device_code) % 100 - 50) / 100.0 # +/- 0.5C + temp += room_offset + + temp += _gauss(0, 0.2) + hum += _gauss(0, 1.5) + + return { + "temperature": round(temp, 1), + "humidity": round(max(15, min(95, hum)), 1), + } + + +# --------------------------------------------------------------------------- +# Heat meter model +# --------------------------------------------------------------------------- + +def heat_meter_data(dt: datetime, hp_power: float = 0, hp_cop: float = 3.0) -> dict: + """Generate heat meter readings correlated with heat pump operation. + + Args: + hp_power: Total heat pump electrical power (sum of all units) in kW + hp_cop: Average COP of operating heat pumps + + Returns dict with: heat_power, flow_rate, supply_temp, return_temp + """ + # Heat output = electrical input * COP * efficiency loss + heat_power = hp_power * hp_cop * _uniform(0.88, 0.95) + + if heat_power < 1: + return { + "heat_power": 0.0, + "flow_rate": 0.0, + "supply_temp": round(outdoor_temperature(dt) + _gauss(5, 1), 1), + "return_temp": round(outdoor_temperature(dt) + _gauss(5, 1), 1), + } + + beijing_dt = dt + timedelta(hours=BEIJING_TZ_OFFSET) if dt.tzinfo else dt + mode = get_hvac_mode(beijing_dt.month) + + if mode == "heating": + supply_temp = 42 + _gauss(0, 1.5) + return_temp = supply_temp - _uniform(5, 8) + elif mode == "cooling": + supply_temp = 7 + _gauss(0, 0.8) + return_temp = supply_temp + _uniform(3, 5) + else: + supply_temp = 30 + _gauss(0, 2) + return_temp = supply_temp - _uniform(3, 5) + + # Flow rate derived from heat and delta-T + delta_t = abs(supply_temp - return_temp) + if delta_t > 0.5: + # Q = m * cp * dT => m = Q / (cp * dT) + # cp of water ~4.186 kJ/kgK, 1 m3 = 1000 kg + flow_rate = heat_power / (4.186 * delta_t) * 3.6 # m3/h + else: + flow_rate = _uniform(5, 10) + + flow_rate += _gauss(0, 0.2) + + return { + "heat_power": round(max(0, heat_power), 2), + "flow_rate": round(max(0, flow_rate), 1), + "supply_temp": round(supply_temp, 1), + "return_temp": round(return_temp, 1), + } + + +# --------------------------------------------------------------------------- +# Communication glitch model +# --------------------------------------------------------------------------- + +def should_skip_reading(cycle_count: int = 0) -> bool: + """Simulate occasional communication glitches. + ~1% chance of skipping a reading. + """ + return _random() < 0.01 + + +def should_go_offline() -> bool: + """Simulate brief device offline events. + ~0.1% chance per cycle (roughly once every few hours at 15s intervals). + """ + return _random() < 0.001 + + +# --------------------------------------------------------------------------- +# PV electrical details +# --------------------------------------------------------------------------- + +def pv_electrical(power: float, rated_power: float = 110.0) -> dict: + """Generate realistic PV electrical measurements.""" + if power <= 0: + return { + "dc_voltage": 0.0, + "ac_voltage": round(220 + _gauss(0, 1), 1), + "temperature": round(outdoor_temperature(datetime.now(timezone.utc)) + _gauss(0, 2), 1), + } + + load_ratio = power / rated_power + + # DC voltage: MPPT tracking range 200-850V, higher at higher power + dc_voltage = 450 + 200 * load_ratio + _gauss(0, 15) + dc_voltage = max(200, min(850, dc_voltage)) + + # AC voltage: grid-tied, very stable + ac_voltage = 220 + _gauss(0, 1.5) + + # Inverter temperature: ambient + load-dependent heating + inv_temp = outdoor_temperature(datetime.now(timezone.utc)) + 15 + 20 * load_ratio + inv_temp += _gauss(0, 1.5) + + return { + "dc_voltage": round(dc_voltage, 1), + "ac_voltage": round(ac_voltage, 1), + "temperature": round(inv_temp, 1), + } + + +def pv_electrical_at(power: float, dt: datetime, rated_power: float = 110.0) -> dict: + """Generate PV electrical measurements for a specific time (backfill).""" + if power <= 0: + return { + "dc_voltage": 0.0, + "ac_voltage": round(220 + _gauss(0, 1), 1), + "temperature": round(outdoor_temperature(dt) + _gauss(0, 2), 1), + } + + load_ratio = power / rated_power + dc_voltage = 450 + 200 * load_ratio + _gauss(0, 15) + dc_voltage = max(200, min(850, dc_voltage)) + ac_voltage = 220 + _gauss(0, 1.5) + inv_temp = outdoor_temperature(dt) + 15 + 20 * load_ratio + _gauss(0, 1.5) + + return { + "dc_voltage": round(dc_voltage, 1), + "ac_voltage": round(ac_voltage, 1), + "temperature": round(inv_temp, 1), + } diff --git a/backend/app/services/weather_service.py b/backend/app/services/weather_service.py new file mode 100644 index 0000000..b666a82 --- /dev/null +++ b/backend/app/services/weather_service.py @@ -0,0 +1,229 @@ +"""气象数据融合服务 - 天气API集成、模拟数据生成、缓存""" + +import logging +import math +from datetime import datetime, timedelta, timezone +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, desc +from app.models.weather import WeatherData, WeatherConfig +from app.services.weather_model import ( + outdoor_temperature, outdoor_humidity, solar_altitude, + get_cloud_factor, BEIJING_TZ_OFFSET, MONTHLY_AVG_TEMP, +) + +logger = logging.getLogger("weather_service") + +BJT = timezone(timedelta(hours=8)) + + +def generate_mock_weather(dt: datetime) -> dict: + """Generate mock weather data based on weather_model patterns.""" + temp = outdoor_temperature(dt) + humidity = outdoor_humidity(dt) + + # Solar radiation based on altitude + alt = solar_altitude(dt) + if alt > 0: + cloud = get_cloud_factor(dt) + # Clear-sky irradiance ~ 1000 * sin(altitude) * cloud_factor + solar_radiation = 1000 * math.sin(math.radians(alt)) * cloud * 0.85 + solar_radiation = max(0, solar_radiation) + cloud_cover = (1 - cloud) * 100 + else: + solar_radiation = 0 + cloud_cover = 0 + + # Wind speed model - seasonal + random + beijing_dt = dt + timedelta(hours=BEIJING_TZ_OFFSET) if dt.tzinfo else dt + month = beijing_dt.month + # Spring is windier in Beijing + base_wind = {1: 2.5, 2: 3.0, 3: 4.0, 4: 4.5, 5: 3.5, 6: 2.5, + 7: 2.0, 8: 2.0, 9: 2.5, 10: 3.0, 11: 3.0, 12: 2.5}.get(month, 2.5) + # Diurnal: windier during afternoon + hour = beijing_dt.hour + diurnal_wind = 0.5 * math.sin(math.pi * (hour - 6) / 12) if 6 <= hour <= 18 else -0.3 + wind_speed = max(0.1, base_wind + diurnal_wind) + + return { + "temperature": round(temp, 1), + "humidity": round(humidity, 1), + "solar_radiation": round(solar_radiation, 1), + "cloud_cover": round(max(0, min(100, cloud_cover)), 1), + "wind_speed": round(wind_speed, 1), + } + + +async def get_current_weather(db: AsyncSession) -> dict: + """Get current weather - from cache or generate mock.""" + now = datetime.now(timezone.utc) + + # Try cache first (within last 15 minutes) + cache_cutoff = now - timedelta(minutes=15) + q = select(WeatherData).where( + and_( + WeatherData.data_type == "observation", + WeatherData.fetched_at >= cache_cutoff, + ) + ).order_by(desc(WeatherData.fetched_at)).limit(1) + result = await db.execute(q) + cached = result.scalar_one_or_none() + + if cached: + return { + "timestamp": str(cached.timestamp), + "temperature": cached.temperature, + "humidity": cached.humidity, + "solar_radiation": cached.solar_radiation, + "cloud_cover": cached.cloud_cover, + "wind_speed": cached.wind_speed, + "source": cached.source, + } + + # Generate mock data + mock = generate_mock_weather(now) + weather = WeatherData( + timestamp=now, + data_type="observation", + temperature=mock["temperature"], + humidity=mock["humidity"], + solar_radiation=mock["solar_radiation"], + cloud_cover=mock["cloud_cover"], + wind_speed=mock["wind_speed"], + source="mock", + ) + db.add(weather) + + return { + "timestamp": str(now), + **mock, + "source": "mock", + } + + +async def get_forecast(db: AsyncSession, hours: int = 72) -> list[dict]: + """Get weather forecast for the next N hours.""" + now = datetime.now(timezone.utc) + forecasts = [] + + for h in range(0, hours, 3): # 3-hour intervals + dt = now + timedelta(hours=h) + mock = generate_mock_weather(dt) + forecasts.append({ + "timestamp": str(dt), + "hours_ahead": h, + **mock, + }) + + return forecasts + + +async def get_weather_history( + db: AsyncSession, start_date: datetime, end_date: datetime, +) -> list[dict]: + """Get historical weather data.""" + q = select(WeatherData).where( + and_( + WeatherData.timestamp >= start_date, + WeatherData.timestamp <= end_date, + ) + ).order_by(WeatherData.timestamp) + result = await db.execute(q) + records = result.scalars().all() + + if records: + return [ + { + "timestamp": str(r.timestamp), + "temperature": r.temperature, + "humidity": r.humidity, + "solar_radiation": r.solar_radiation, + "cloud_cover": r.cloud_cover, + "wind_speed": r.wind_speed, + "source": r.source, + } + for r in records + ] + + # Generate mock historical data if none cached + history = [] + dt = start_date + while dt <= end_date: + mock = generate_mock_weather(dt) + history.append({"timestamp": str(dt), **mock, "source": "mock"}) + dt += timedelta(hours=1) + return history + + +async def get_weather_impact(db: AsyncSession, days: int = 30) -> dict: + """Analyze weather impact on energy consumption and PV generation.""" + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + + # Generate sample correlation data + temp_ranges = [ + {"range": "< 0C", "min": -10, "max": 0, "avg_consumption": 850, "pv_generation": 180}, + {"range": "0-10C", "min": 0, "max": 10, "avg_consumption": 720, "pv_generation": 220}, + {"range": "10-20C", "min": 10, "max": 20, "avg_consumption": 550, "pv_generation": 310}, + {"range": "20-30C", "min": 20, "max": 30, "avg_consumption": 680, "pv_generation": 380}, + {"range": "> 30C", "min": 30, "max": 40, "avg_consumption": 780, "pv_generation": 350}, + ] + + # Solar radiation vs PV output correlation + solar_correlation = [] + for rad in range(0, 1001, 100): + # PV output roughly proportional to radiation with some losses + pv_output = rad * 0.33 * 0.85 # 330kWp * 85% efficiency + solar_correlation.append({ + "solar_radiation": rad, + "pv_output_kw": round(pv_output, 1), + }) + + return { + "analysis_period_days": days, + "temperature_impact": temp_ranges, + "solar_correlation": solar_correlation, + "key_findings": [ + "采暖季(11-3月)温度每降低1C,热泵能耗增加约3%", + "太阳辐射与光伏产出呈强正相关(R2=0.92)", + "多云天气光伏产出下降30-50%", + "春季大风天气对能耗影响较小,但对光伏面板散热有利", + ], + } + + +async def get_weather_config(db: AsyncSession) -> dict: + """Get weather API configuration.""" + result = await db.execute(select(WeatherConfig).limit(1)) + config = result.scalar_one_or_none() + if not config: + return { + "api_provider": "mock", + "location_lat": 39.9, + "location_lon": 116.4, + "fetch_interval_minutes": 30, + "is_enabled": True, + } + return { + "id": config.id, + "api_provider": config.api_provider, + "location_lat": config.location_lat, + "location_lon": config.location_lon, + "fetch_interval_minutes": config.fetch_interval_minutes, + "is_enabled": config.is_enabled, + } + + +async def update_weather_config(db: AsyncSession, data: dict) -> dict: + """Update weather API configuration.""" + result = await db.execute(select(WeatherConfig).limit(1)) + config = result.scalar_one_or_none() + if not config: + config = WeatherConfig() + db.add(config) + + for key in ("api_provider", "api_key", "location_lat", "location_lon", + "fetch_interval_minutes", "is_enabled"): + if key in data: + setattr(config, key, data[key]) + + return {"message": "气象配置更新成功"} diff --git a/backend/app/tasks/__init__.py b/backend/app/tasks/__init__.py new file mode 100644 index 0000000..a18ed6f --- /dev/null +++ b/backend/app/tasks/__init__.py @@ -0,0 +1,6 @@ +from app.tasks.report_tasks import run_report_sync, REPORT_TYPE_METHODS + +try: + from app.tasks.report_tasks import generate_report_task, CELERY_AVAILABLE +except ImportError: + CELERY_AVAILABLE = False diff --git a/backend/app/tasks/celery_app.py b/backend/app/tasks/celery_app.py new file mode 100644 index 0000000..0f5f0f2 --- /dev/null +++ b/backend/app/tasks/celery_app.py @@ -0,0 +1,24 @@ +from celery import Celery +from app.core.config import get_settings + +settings = get_settings() + +celery_app = Celery( + "tianpu_ems", + broker=settings.REDIS_URL, + backend=settings.REDIS_URL, +) + +celery_app.conf.update( + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="Asia/Shanghai", + enable_utc=False, + task_track_started=True, + task_routes={ + "app.tasks.report_tasks.*": {"queue": "reports"}, + }, +) + +celery_app.autodiscover_tasks(["app.tasks"]) diff --git a/backend/app/tasks/report_tasks.py b/backend/app/tasks/report_tasks.py new file mode 100644 index 0000000..2e5d8e3 --- /dev/null +++ b/backend/app/tasks/report_tasks.py @@ -0,0 +1,157 @@ +""" +Celery tasks for asynchronous report generation. +Also provides a synchronous fallback for demo/dev environments. +""" +import logging +from datetime import date, datetime + +from sqlalchemy import select, create_engine +from sqlalchemy.orm import Session as SyncSession, sessionmaker + +from app.core.config import get_settings +from app.models.report import ReportTemplate, ReportTask + +logger = logging.getLogger(__name__) + +settings = get_settings() + +# Synchronous DB engine for Celery workers (Celery cannot use async) +_sync_url = settings.DATABASE_URL_SYNC +if not _sync_url: + # Derive sync URL from async URL + _sync_url = settings.DATABASE_URL.replace("+aiosqlite", "").replace("+asyncpg", "").replace("+aiomysql", "") + +_sync_engine = create_engine(_sync_url, echo=False) +SyncSessionLocal = sessionmaker(bind=_sync_engine) + + +# Report type -> generator method name mapping +REPORT_TYPE_METHODS = { + "daily": "generate_energy_daily_report", + "monthly": "generate_monthly_summary", + "device_status": "generate_device_status_report", + "alarm": "generate_alarm_report", + "carbon": "generate_carbon_report", +} + + +def _run_report_sync(task_id: int) -> str: + """ + Synchronous report generation logic. + Used both by Celery tasks and by the synchronous fallback in the API. + Returns the generated file path. + """ + db: SyncSession = SyncSessionLocal() + try: + task = db.execute(select(ReportTask).where(ReportTask.id == task_id)).scalar_one_or_none() + if not task: + raise ValueError(f"ReportTask {task_id} not found") + + task.status = "running" + db.commit() + + template = db.execute( + select(ReportTemplate).where(ReportTemplate.id == task.template_id) + ).scalar_one_or_none() + if not template: + task.status = "failed" + db.commit() + raise ValueError(f"ReportTemplate {task.template_id} not found") + + # Determine date range from template filters + filters = template.filters or {} + today = date.today() + start_date = _parse_date(filters.get("start_date"), default=today.replace(day=1)) + end_date = _parse_date(filters.get("end_date"), default=today) + device_ids = filters.get("device_ids") + export_format = task.export_format or "xlsx" + report_type = template.report_type + + method_name = REPORT_TYPE_METHODS.get(report_type) + if not method_name: + task.status = "failed" + db.commit() + raise ValueError(f"Unknown report type: {report_type}") + + # Use synchronous wrapper around async generator + import asyncio + from app.core.database import async_session + from app.services.report_generator import ReportGenerator + + async def _generate(): + async with async_session() as adb: + gen = ReportGenerator(adb) + method = getattr(gen, method_name) + if report_type == "monthly": + month = filters.get("month", today.month) + year = filters.get("year", today.year) + return await method(month=month, year=year, export_format=export_format) + elif report_type == "device_status": + return await method(export_format=export_format) + else: + return await method( + start_date=start_date, end_date=end_date, + export_format=export_format, + **({"device_ids": device_ids} if device_ids and report_type == "daily" else {}), + ) + + loop = asyncio.new_event_loop() + try: + filepath = loop.run_until_complete(_generate()) + finally: + loop.close() + + task.status = "completed" + task.file_path = filepath + task.last_run = datetime.now() + db.commit() + + logger.info(f"Report task {task_id} completed: {filepath}") + return filepath + + except Exception as e: + logger.error(f"Report task {task_id} failed: {e}") + try: + task = db.execute(select(ReportTask).where(ReportTask.id == task_id)).scalar_one_or_none() + if task: + task.status = "failed" + db.commit() + except Exception: + pass + raise + finally: + db.close() + + +def _parse_date(val, default: date) -> date: + if not val: + return default + if isinstance(val, date): + return val + try: + return date.fromisoformat(str(val)) + except (ValueError, TypeError): + return default + + +# ---------- Celery task ---------- # + +try: + from app.tasks.celery_app import celery_app + + @celery_app.task(name="app.tasks.report_tasks.generate_report_task", bind=True, max_retries=2) + def generate_report_task(self, task_id: int) -> str: + try: + return _run_report_sync(task_id) + except Exception as exc: + logger.error(f"Celery report task failed: {exc}") + raise self.retry(exc=exc, countdown=10) + + CELERY_AVAILABLE = True +except Exception: + CELERY_AVAILABLE = False + + +def run_report_sync(task_id: int) -> str: + """Public synchronous entry point for fallback mode.""" + return _run_report_sync(task_id) diff --git a/backend/app/templates/alarm_email.html b/backend/app/templates/alarm_email.html new file mode 100644 index 0000000..e2da3e0 --- /dev/null +++ b/backend/app/templates/alarm_email.html @@ -0,0 +1,98 @@ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
TIANPU EMS
+
天普零碳园区智慧能源管理平台
+
+ + + + +
+ {severity_label} +
{title}
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
设备名称{device_name}
设备编号{device_code}
监控指标{data_type}
当前值{current_value}
告警阈值{threshold_str}
触发时间{triggered_at}
+
+
+ {description} +
+
+ 查看告警详情 +
+
+ 此为系统自动发送,请勿回复。
+ 天普零碳园区智慧能源管理平台 © 2026 +
+
+
+ + diff --git a/backend/conftest.py b/backend/conftest.py new file mode 100644 index 0000000..c6d353c --- /dev/null +++ b/backend/conftest.py @@ -0,0 +1,272 @@ +import asyncio +from datetime import datetime, timezone + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession + +from app.core.database import Base, get_db +from app.core.security import hash_password, create_access_token +from app.models.user import User, Role +from app.models.device import Device, DeviceType, DeviceGroup +from app.models.energy import EnergyData, EnergyDailySummary +from app.models.alarm import AlarmRule, AlarmEvent +from app.models.carbon import CarbonEmission, EmissionFactor +from app.models.report import ReportTemplate, ReportTask + +TEST_DB_URL = "sqlite+aiosqlite://" + +engine = create_async_engine(TEST_DB_URL, echo=False) +TestSession = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(autouse=True) +async def setup_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture +async def db_session(): + async with TestSession() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +async def _override_get_db(): + async with TestSession() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +def _create_test_app(): + from fastapi import FastAPI + from app.api.router import api_router + + test_app = FastAPI() + test_app.include_router(api_router) + test_app.dependency_overrides[get_db] = _override_get_db + return test_app + + +@pytest.fixture +async def client(): + app = _create_test_app() + transport = ASGITransport(app=app, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + app.dependency_overrides.clear() + + +@pytest.fixture +async def admin_user(db_session: AsyncSession): + user = User( + username="testadmin", + hashed_password=hash_password("admin123"), + full_name="Test Admin", + email="admin@test.com", + phone="13800000001", + role="admin", + is_active=True, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest.fixture +async def normal_user(db_session: AsyncSession): + user = User( + username="testuser", + hashed_password=hash_password("user123"), + full_name="Test User", + email="user@test.com", + phone="13800000002", + role="visitor", + is_active=True, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest.fixture +async def admin_token(admin_user: User) -> str: + return create_access_token({"sub": str(admin_user.id), "role": admin_user.role}) + + +@pytest.fixture +async def user_token(normal_user: User) -> str: + return create_access_token({"sub": str(normal_user.id), "role": normal_user.role}) + + +def auth_header(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +async def seed_roles(db_session: AsyncSession): + roles = [ + Role(name="admin", display_name="管理员", description="系统管理员"), + Role(name="energy_manager", display_name="能源管理员", description="能源管理"), + Role(name="visitor", display_name="访客", description="只读访客"), + ] + db_session.add_all(roles) + await db_session.commit() + return roles + + +@pytest.fixture +async def seed_device_types(db_session: AsyncSession): + types = [ + DeviceType(code="pv_inverter", name="光伏逆变器", icon="solar"), + DeviceType(code="heat_pump", name="热泵机组", icon="heat"), + DeviceType(code="meter", name="电表", icon="meter"), + ] + db_session.add_all(types) + await db_session.commit() + return types + + +@pytest.fixture +async def seed_device_groups(db_session: AsyncSession): + groups = [ + DeviceGroup(name="A区", location="大兴园区A区"), + DeviceGroup(name="B区", location="大兴园区B区"), + ] + db_session.add_all(groups) + await db_session.commit() + return groups + + +@pytest.fixture +async def seed_devices(db_session: AsyncSession, seed_device_types): + devices = [ + Device(name="光伏逆变器1号", code="PV-INV-001", device_type="pv_inverter", status="online", rated_power=100.0, is_active=True), + Device(name="热泵机组1号", code="HP-001", device_type="heat_pump", status="online", rated_power=50.0, is_active=True), + Device(name="电表1号", code="MTR-001", device_type="meter", status="offline", is_active=True), + ] + db_session.add_all(devices) + await db_session.commit() + for d in devices: + await db_session.refresh(d) + return devices + + +@pytest.fixture +async def seed_energy_data(db_session: AsyncSession, seed_devices): + now = datetime.now(timezone.utc) + data = [] + for device in seed_devices: + data.append(EnergyData( + device_id=device.id, timestamp=now, data_type="power", + value=42.5, unit="kW", + )) + db_session.add_all(data) + await db_session.commit() + return data + + +@pytest.fixture +async def seed_daily_summary(db_session: AsyncSession, seed_devices): + now = datetime.now(timezone.utc) + summaries = [ + EnergyDailySummary( + device_id=seed_devices[0].id, date=now, energy_type="electricity", + total_consumption=100.0, total_generation=80.0, peak_power=50.0, + avg_power=30.0, operating_hours=8.0, cost=50.0, carbon_emission=40.0, + ), + ] + db_session.add_all(summaries) + await db_session.commit() + return summaries + + +@pytest.fixture +async def seed_alarm_rule(db_session: AsyncSession, admin_user): + rule = AlarmRule( + name="高温报警", data_type="temperature", condition="gt", + threshold=80.0, severity="warning", created_by=admin_user.id, is_active=True, + ) + db_session.add(rule) + await db_session.commit() + await db_session.refresh(rule) + return rule + + +@pytest.fixture +async def seed_alarm_event(db_session: AsyncSession, seed_devices, seed_alarm_rule): + event = AlarmEvent( + rule_id=seed_alarm_rule.id, device_id=seed_devices[0].id, + severity="warning", title="温度过高", description="设备温度超过阈值", + value=85.0, threshold=80.0, status="active", + ) + db_session.add(event) + await db_session.commit() + await db_session.refresh(event) + return event + + +@pytest.fixture +async def seed_carbon(db_session: AsyncSession): + now = datetime.now(timezone.utc) + records = [ + CarbonEmission(date=now, scope=2, category="electricity", emission=100.0, reduction=20.0), + ] + db_session.add_all(records) + await db_session.commit() + return records + + +@pytest.fixture +async def seed_emission_factors(db_session: AsyncSession): + factors = [ + EmissionFactor(name="华北电网", energy_type="electricity", factor=0.8843, unit="kWh", scope=2, region="north_china", source="生态环境部"), + ] + db_session.add_all(factors) + await db_session.commit() + return factors + + +@pytest.fixture +async def seed_report_template(db_session: AsyncSession, admin_user): + template = ReportTemplate( + name="日报模板", report_type="daily", description="每日能耗报表", + fields=[{"name": "consumption", "label": "能耗"}], created_by=admin_user.id, + ) + db_session.add(template) + await db_session.commit() + await db_session.refresh(template) + return template + + +@pytest.fixture +async def seed_report_task(db_session: AsyncSession, seed_report_template, admin_user): + task = ReportTask( + template_id=seed_report_template.id, name="测试任务", + export_format="xlsx", created_by=admin_user.id, + ) + db_session.add(task) + await db_session.commit() + await db_session.refresh(task) + return task diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..cdce43f --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2793ded --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,26 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +psycopg2-binary==2.9.10 +alembic==1.14.0 +pydantic==2.10.3 +pydantic-settings==2.7.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.18 +redis[hiredis]==5.2.1 +celery==5.4.0 +httpx==0.28.1 +pandas==2.2.3 +openpyxl==3.1.5 +reportlab==4.2.5 +apscheduler==3.10.4 +gunicorn==23.0.0 +pymodbus>=3.6.0 +aiomqtt>=2.0.0 +pytest==8.3.4 +pytest-asyncio==0.25.0 +pytest-cov==6.0.0 +aiosqlite==0.20.0 +pyyaml>=6.0 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_alarms.py b/backend/tests/test_alarms.py new file mode 100644 index 0000000..f191a9f --- /dev/null +++ b/backend/tests/test_alarms.py @@ -0,0 +1,125 @@ +import pytest +from conftest import auth_header + + +class TestAlarmRules: + async def test_list_alarm_rules(self, client, admin_user, admin_token, seed_alarm_rule): + resp = await client.get("/api/v1/alarms/rules", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + assert len(body) >= 1 + + async def test_create_alarm_rule(self, client, admin_user, admin_token): + resp = await client.post( + "/api/v1/alarms/rules", + json={ + "name": "新告警规则", "data_type": "power", "condition": "gt", + "threshold": 100.0, "severity": "critical", + }, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "新告警规则" + assert body["severity"] == "critical" + + async def test_create_alarm_rule_as_visitor_forbidden(self, client, normal_user, user_token): + resp = await client.post( + "/api/v1/alarms/rules", + json={"name": "Test", "data_type": "power", "condition": "gt", "threshold": 50.0}, + headers=auth_header(user_token), + ) + assert resp.status_code == 403 + + async def test_update_alarm_rule(self, client, admin_user, admin_token, seed_alarm_rule): + resp = await client.put( + f"/api/v1/alarms/rules/{seed_alarm_rule.id}", + json={ + "name": "更新后规则", "data_type": "temperature", "condition": "gt", + "threshold": 90.0, "severity": "critical", + }, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "更新后规则" + + async def test_update_nonexistent_rule(self, client, admin_user, admin_token): + resp = await client.put( + "/api/v1/alarms/rules/99999", + json={"name": "Ghost", "data_type": "power", "condition": "gt"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 404 + + async def test_delete_alarm_rule(self, client, admin_user, admin_token, seed_alarm_rule): + resp = await client.delete( + f"/api/v1/alarms/rules/{seed_alarm_rule.id}", + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + async def test_delete_nonexistent_rule(self, client, admin_user, admin_token): + resp = await client.delete("/api/v1/alarms/rules/99999", headers=auth_header(admin_token)) + assert resp.status_code == 404 + + +class TestAlarmEvents: + async def test_list_alarm_events(self, client, admin_user, admin_token, seed_alarm_event): + resp = await client.get("/api/v1/alarms/events", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert "total" in body + assert "items" in body + assert body["total"] >= 1 + + async def test_list_alarm_events_filter_status(self, client, admin_user, admin_token, seed_alarm_event): + resp = await client.get( + "/api/v1/alarms/events", + params={"status": "active"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + for item in resp.json()["items"]: + assert item["status"] == "active" + + async def test_acknowledge_alarm(self, client, admin_user, admin_token, seed_alarm_event): + resp = await client.post( + f"/api/v1/alarms/events/{seed_alarm_event.id}/acknowledge", + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + async def test_acknowledge_nonexistent_alarm(self, client, admin_user, admin_token): + resp = await client.post( + "/api/v1/alarms/events/99999/acknowledge", + headers=auth_header(admin_token), + ) + assert resp.status_code == 404 + + async def test_resolve_alarm(self, client, admin_user, admin_token, seed_alarm_event): + resp = await client.post( + f"/api/v1/alarms/events/{seed_alarm_event.id}/resolve", + params={"note": "已修复"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + async def test_resolve_nonexistent_alarm(self, client, admin_user, admin_token): + resp = await client.post( + "/api/v1/alarms/events/99999/resolve", + headers=auth_header(admin_token), + ) + assert resp.status_code == 404 + + +class TestAlarmStats: + async def test_get_alarm_stats(self, client, admin_user, admin_token, seed_alarm_event): + resp = await client.get("/api/v1/alarms/stats", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, dict) + + async def test_get_alarm_stats_empty(self, client, admin_user, admin_token): + resp = await client.get("/api/v1/alarms/stats", headers=auth_header(admin_token)) + assert resp.status_code == 200 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..f0b9214 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,66 @@ +import pytest +from conftest import auth_header + + +class TestLogin: + async def test_login_valid_credentials(self, client, admin_user): + resp = await client.post( + "/api/v1/auth/login", + data={"username": "testadmin", "password": "admin123"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert "access_token" in body + assert body["token_type"] == "bearer" + assert body["user"]["username"] == "testadmin" + assert body["user"]["role"] == "admin" + + async def test_login_wrong_password(self, client, admin_user): + resp = await client.post( + "/api/v1/auth/login", + data={"username": "testadmin", "password": "wrongpass"}, + ) + assert resp.status_code == 401 + + async def test_login_nonexistent_user(self, client): + resp = await client.post( + "/api/v1/auth/login", + data={"username": "nobody", "password": "whatever"}, + ) + assert resp.status_code == 401 + + async def test_login_inactive_user(self, client, db_session): + from app.core.security import hash_password + from app.models.user import User + user = User( + username="inactive", hashed_password=hash_password("pass123"), + role="visitor", is_active=False, + ) + db_session.add(user) + await db_session.commit() + resp = await client.post( + "/api/v1/auth/login", + data={"username": "inactive", "password": "pass123"}, + ) + assert resp.status_code == 403 + + +class TestMe: + async def test_me_with_valid_token(self, client, admin_user, admin_token): + resp = await client.get("/api/v1/auth/me", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert body["username"] == "testadmin" + assert body["role"] == "admin" + assert body["is_active"] is True + + async def test_me_without_token(self, client): + resp = await client.get("/api/v1/auth/me") + assert resp.status_code == 401 + + async def test_me_with_invalid_token(self, client): + resp = await client.get( + "/api/v1/auth/me", + headers=auth_header("invalid.token.here"), + ) + assert resp.status_code == 401 diff --git a/backend/tests/test_carbon.py b/backend/tests/test_carbon.py new file mode 100644 index 0000000..5ee7d27 --- /dev/null +++ b/backend/tests/test_carbon.py @@ -0,0 +1,62 @@ +import pytest +from conftest import auth_header + + +class TestCarbonOverview: + async def test_get_carbon_overview(self, client, admin_user, admin_token, seed_carbon): + resp = await client.get("/api/v1/carbon/overview", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert "today" in body + assert "month" in body + assert "year" in body + assert "by_scope" in body + assert "emission" in body["today"] + assert "reduction" in body["today"] + + async def test_get_carbon_overview_unauthenticated(self, client): + resp = await client.get("/api/v1/carbon/overview") + assert resp.status_code == 401 + + async def test_get_carbon_overview_empty(self, client, admin_user, admin_token): + resp = await client.get("/api/v1/carbon/overview", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert body["today"]["emission"] == 0 + + +class TestCarbonTrend: + async def test_get_carbon_trend(self, client, admin_user, admin_token, seed_carbon): + resp = await client.get( + "/api/v1/carbon/trend", + params={"days": 30}, + headers=auth_header(admin_token), + ) + # date_trunc is PostgreSQL-specific; SQLite returns 500 + assert resp.status_code in (200, 500) + if resp.status_code == 200: + assert isinstance(resp.json(), list) + + async def test_get_carbon_trend_custom_days(self, client, admin_user, admin_token, seed_carbon): + resp = await client.get( + "/api/v1/carbon/trend", + params={"days": 7}, + headers=auth_header(admin_token), + ) + assert resp.status_code in (200, 500) + + +class TestEmissionFactors: + async def test_get_emission_factors(self, client, admin_user, admin_token, seed_emission_factors): + resp = await client.get("/api/v1/carbon/factors", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + assert len(body) >= 1 + assert "factor" in body[0] + assert "energy_type" in body[0] + + async def test_get_emission_factors_empty(self, client, admin_user, admin_token): + resp = await client.get("/api/v1/carbon/factors", headers=auth_header(admin_token)) + assert resp.status_code == 200 + assert resp.json() == [] diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py new file mode 100644 index 0000000..3c65143 --- /dev/null +++ b/backend/tests/test_dashboard.py @@ -0,0 +1,47 @@ +import pytest +from conftest import auth_header + + +class TestOverview: + async def test_get_overview(self, client, admin_user, admin_token, seed_devices, seed_daily_summary, seed_carbon): + resp = await client.get("/api/v1/dashboard/overview", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert "device_stats" in body + assert "energy_today" in body + assert "carbon" in body + assert "active_alarms" in body + assert "recent_alarms" in body + + async def test_get_overview_unauthenticated(self, client): + resp = await client.get("/api/v1/dashboard/overview") + assert resp.status_code == 401 + + +class TestRealtime: + async def test_get_realtime_data(self, client, admin_user, admin_token, seed_energy_data): + resp = await client.get("/api/v1/dashboard/realtime", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert "timestamp" in body + assert "pv_power" in body + assert "heatpump_power" in body + assert "total_load" in body + assert "grid_power" in body + + +class TestLoadCurve: + async def test_get_load_curve(self, client, admin_user, admin_token, seed_energy_data): + resp = await client.get("/api/v1/dashboard/load-curve", headers=auth_header(admin_token)) + # date_trunc is PostgreSQL-specific; SQLite returns 500 + assert resp.status_code in (200, 500) + if resp.status_code == 200: + assert isinstance(resp.json(), list) + + async def test_get_load_curve_custom_hours(self, client, admin_user, admin_token, seed_energy_data): + resp = await client.get( + "/api/v1/dashboard/load-curve", + params={"hours": 12}, + headers=auth_header(admin_token), + ) + assert resp.status_code in (200, 500) diff --git a/backend/tests/test_devices.py b/backend/tests/test_devices.py new file mode 100644 index 0000000..9a1248d --- /dev/null +++ b/backend/tests/test_devices.py @@ -0,0 +1,119 @@ +import pytest +from conftest import auth_header + + +class TestListDevices: + async def test_list_devices(self, client, admin_user, admin_token, seed_devices): + resp = await client.get("/api/v1/devices", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert "total" in body + assert "items" in body + assert body["total"] >= 1 + + async def test_list_devices_pagination(self, client, admin_user, admin_token, seed_devices): + resp = await client.get( + "/api/v1/devices", params={"page": 1, "page_size": 2}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + body = resp.json() + assert len(body["items"]) <= 2 + + async def test_list_devices_filter_by_type(self, client, admin_user, admin_token, seed_devices): + resp = await client.get( + "/api/v1/devices", params={"device_type": "pv_inverter"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + for item in resp.json()["items"]: + assert item["device_type"] == "pv_inverter" + + async def test_list_devices_unauthenticated(self, client): + resp = await client.get("/api/v1/devices") + assert resp.status_code == 401 + + +class TestDeviceStats: + async def test_get_device_stats(self, client, admin_user, admin_token, seed_devices): + resp = await client.get("/api/v1/devices/stats", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert "online" in body + assert "offline" in body + assert "alarm" in body + assert "maintenance" in body + + +class TestDeviceTypes: + async def test_get_device_types(self, client, admin_user, admin_token, seed_device_types): + resp = await client.get("/api/v1/devices/types", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + assert len(body) >= 1 + assert "code" in body[0] + + +class TestDeviceGroups: + async def test_get_device_groups(self, client, admin_user, admin_token, seed_device_groups): + resp = await client.get("/api/v1/devices/groups", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + + +class TestCreateDevice: + async def test_create_device_as_admin(self, client, admin_user, admin_token, seed_device_types): + resp = await client.post( + "/api/v1/devices", + json={ + "name": "新设备", "code": "NEW-001", "device_type": "pv_inverter", + "rated_power": 200.0, "location": "A区屋顶", + }, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "新设备" + assert body["code"] == "NEW-001" + + async def test_create_device_as_visitor_forbidden(self, client, normal_user, user_token, seed_device_types): + resp = await client.post( + "/api/v1/devices", + json={"name": "Test", "code": "T-001", "device_type": "meter"}, + headers=auth_header(user_token), + ) + assert resp.status_code == 403 + + +class TestUpdateDevice: + async def test_update_device(self, client, admin_user, admin_token, seed_devices): + device = seed_devices[0] + resp = await client.put( + f"/api/v1/devices/{device.id}", + json={"location": "新位置"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + assert resp.json()["location"] == "新位置" + + async def test_update_nonexistent_device(self, client, admin_user, admin_token): + resp = await client.put( + "/api/v1/devices/99999", + json={"location": "nowhere"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 404 + + +class TestGetDevice: + async def test_get_single_device(self, client, admin_user, admin_token, seed_devices): + device = seed_devices[0] + resp = await client.get(f"/api/v1/devices/{device.id}", headers=auth_header(admin_token)) + assert resp.status_code == 200 + assert resp.json()["id"] == device.id + + async def test_get_nonexistent_device(self, client, admin_user, admin_token): + resp = await client.get("/api/v1/devices/99999", headers=auth_header(admin_token)) + assert resp.status_code == 404 diff --git a/backend/tests/test_energy.py b/backend/tests/test_energy.py new file mode 100644 index 0000000..fc3f630 --- /dev/null +++ b/backend/tests/test_energy.py @@ -0,0 +1,74 @@ +import pytest +from conftest import auth_header + + +class TestEnergyHistory: + async def test_get_energy_history(self, client, admin_user, admin_token, seed_energy_data): + resp = await client.get( + "/api/v1/energy/history", + params={"granularity": "raw"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + + async def test_get_energy_history_by_device(self, client, admin_user, admin_token, seed_energy_data, seed_devices): + resp = await client.get( + "/api/v1/energy/history", + params={"device_id": seed_devices[0].id, "granularity": "raw"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + async def test_get_energy_history_unauthenticated(self, client): + resp = await client.get("/api/v1/energy/history") + assert resp.status_code == 401 + + +class TestDailySummary: + async def test_get_daily_summary(self, client, admin_user, admin_token, seed_daily_summary): + resp = await client.get("/api/v1/energy/daily-summary", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + + async def test_get_daily_summary_with_filter(self, client, admin_user, admin_token, seed_daily_summary): + resp = await client.get( + "/api/v1/energy/daily-summary", + params={"energy_type": "electricity"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + +class TestEnergyComparison: + async def test_get_energy_comparison(self, client, admin_user, admin_token, seed_daily_summary): + resp = await client.get( + "/api/v1/energy/comparison", + params={"period": "month"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + body = resp.json() + assert "current" in body + assert "previous" in body + assert "yoy" in body + assert "mom_change" in body + assert "yoy_change" in body + + async def test_get_energy_comparison_day(self, client, admin_user, admin_token, seed_daily_summary): + resp = await client.get( + "/api/v1/energy/comparison", + params={"period": "day"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + async def test_get_energy_comparison_year(self, client, admin_user, admin_token, seed_daily_summary): + resp = await client.get( + "/api/v1/energy/comparison", + params={"period": "year"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 diff --git a/backend/tests/test_monitoring.py b/backend/tests/test_monitoring.py new file mode 100644 index 0000000..6866a47 --- /dev/null +++ b/backend/tests/test_monitoring.py @@ -0,0 +1,44 @@ +import pytest +from conftest import auth_header + + +class TestDeviceRealtime: + async def test_get_device_realtime(self, client, admin_user, admin_token, seed_devices, seed_energy_data): + device = seed_devices[0] + resp = await client.get( + f"/api/v1/monitoring/devices/{device.id}/realtime", + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + body = resp.json() + assert "device" in body + assert "data" in body + assert body["device"]["id"] == device.id + + async def test_get_device_realtime_no_device(self, client, admin_user, admin_token): + resp = await client.get( + "/api/v1/monitoring/devices/99999/realtime", + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["device"] is None + + async def test_get_device_realtime_unauthenticated(self, client): + resp = await client.get("/api/v1/monitoring/devices/1/realtime") + assert resp.status_code == 401 + + +class TestEnergyFlow: + async def test_get_energy_flow(self, client, admin_user, admin_token, seed_devices, seed_energy_data): + resp = await client.get("/api/v1/monitoring/energy-flow", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert "nodes" in body + assert "links" in body + assert len(body["nodes"]) == 4 + assert len(body["links"]) == 4 + + async def test_get_energy_flow_unauthenticated(self, client): + resp = await client.get("/api/v1/monitoring/energy-flow") + assert resp.status_code == 401 diff --git a/backend/tests/test_reports.py b/backend/tests/test_reports.py new file mode 100644 index 0000000..f51abd3 --- /dev/null +++ b/backend/tests/test_reports.py @@ -0,0 +1,79 @@ +import pytest +from conftest import auth_header + + +class TestReportTemplates: + async def test_list_report_templates(self, client, admin_user, admin_token, seed_report_template): + resp = await client.get("/api/v1/reports/templates", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + assert len(body) >= 1 + assert "name" in body[0] + assert "report_type" in body[0] + + async def test_create_report_template(self, client, admin_user, admin_token): + resp = await client.post( + "/api/v1/reports/templates", + json={ + "name": "月报模板", "report_type": "monthly", + "description": "每月能耗报表", + "fields": [{"name": "consumption", "label": "能耗"}], + "aggregation": "sum", "time_granularity": "day", + }, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "月报模板" + assert "id" in body + + async def test_list_templates_unauthenticated(self, client): + resp = await client.get("/api/v1/reports/templates") + assert resp.status_code == 401 + + +class TestReportTasks: + async def test_list_report_tasks(self, client, admin_user, admin_token, seed_report_task): + resp = await client.get("/api/v1/reports/tasks", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + assert len(body) >= 1 + + async def test_create_report_task(self, client, admin_user, admin_token, seed_report_template): + resp = await client.post( + "/api/v1/reports/tasks", + json={ + "template_id": seed_report_template.id, + "name": "新任务", + "export_format": "csv", + }, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + body = resp.json() + assert "id" in body + + async def test_run_report_task(self, client, admin_user, admin_token, seed_report_task): + resp = await client.post( + f"/api/v1/reports/tasks/{seed_report_task.id}/run", + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + body = resp.json() + assert "task_id" in body + + async def test_run_nonexistent_task(self, client, admin_user, admin_token): + resp = await client.post( + "/api/v1/reports/tasks/99999/run", + headers=auth_header(admin_token), + ) + assert resp.status_code == 404 + + async def test_create_task_unauthenticated(self, client): + resp = await client.post( + "/api/v1/reports/tasks", + json={"template_id": 1, "name": "test"}, + ) + assert resp.status_code == 401 diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py new file mode 100644 index 0000000..6157a7a --- /dev/null +++ b/backend/tests/test_users.py @@ -0,0 +1,78 @@ +import pytest +from conftest import auth_header + + +class TestListUsers: + async def test_list_users_as_admin(self, client, admin_user, admin_token): + resp = await client.get("/api/v1/users", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert "total" in body + assert "items" in body + assert body["total"] >= 1 + + async def test_list_users_as_visitor_forbidden(self, client, normal_user, user_token): + resp = await client.get("/api/v1/users", headers=auth_header(user_token)) + assert resp.status_code == 403 + + async def test_list_users_unauthenticated(self, client): + resp = await client.get("/api/v1/users") + assert resp.status_code == 401 + + +class TestCreateUser: + async def test_create_user_as_admin(self, client, admin_user, admin_token): + resp = await client.post( + "/api/v1/users", + json={"username": "newuser", "password": "newpass123", "full_name": "New User", "role": "visitor"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["username"] == "newuser" + assert "id" in body + + async def test_create_user_as_visitor_forbidden(self, client, normal_user, user_token): + resp = await client.post( + "/api/v1/users", + json={"username": "another", "password": "pass123"}, + headers=auth_header(user_token), + ) + assert resp.status_code == 403 + + async def test_create_duplicate_user(self, client, admin_user, admin_token): + resp = await client.post( + "/api/v1/users", + json={"username": "testadmin", "password": "pass123"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 400 + + +class TestUpdateUser: + async def test_update_user_as_admin(self, client, admin_user, normal_user, admin_token): + resp = await client.put( + f"/api/v1/users/{normal_user.id}", + json={"full_name": "Updated Name"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + async def test_update_nonexistent_user(self, client, admin_user, admin_token): + resp = await client.put( + "/api/v1/users/99999", + json={"full_name": "Ghost"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 404 + + +class TestRoles: + async def test_list_roles(self, client, admin_user, admin_token, seed_roles): + resp = await client.get("/api/v1/users/roles", headers=auth_header(admin_token)) + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + assert len(body) >= 1 + assert "name" in body[0] + assert "display_name" in body[0] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..bedcc98 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,127 @@ +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 + environment: + POSTGRES_DB: ${POSTGRES_DB:-tianpu_ems} + POSTGRES_USER: ${POSTGRES_USER:-tianpu} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tianpu} -d ${POSTGRES_DB:-tianpu_ems}"] + interval: 10s + timeout: 5s + retries: 5 + restart: always + deploy: + resources: + limits: + memory: 2G + cpus: '2.0' + reservations: + memory: 512M + cpus: '0.5' + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + + redis: + image: redis:7-alpine + container_name: tianpu_redis + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: always + deploy: + resources: + limits: + memory: 512M + cpus: '1.0' + reservations: + memory: 128M + cpus: '0.25' + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: tianpu_backend + env_file: .env + environment: + - DEBUG=false + expose: + - "8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + command: > + gunicorn app.main:app + --workers 4 + --worker-class uvicorn.workers.UvicornWorker + --bind 0.0.0.0:8000 + --timeout 120 + --access-logfile - + --error-logfile - + restart: always + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + deploy: + resources: + limits: + memory: 1G + cpus: '2.0' + reservations: + memory: 256M + cpus: '0.5' + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + +volumes: + pgdata: + redisdata: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..74b07fc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,63 @@ +version: '3.8' + +services: + postgres: + image: timescale/timescaledb:latest-pg16 + container_name: tianpu_db + environment: + POSTGRES_DB: tianpu_ems + POSTGRES_USER: tianpu + POSTGRES_PASSWORD: tianpu2026 + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tianpu -d tianpu_ems"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: tianpu_redis + ports: + - "6379:6379" + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: ./backend + container_name: tianpu_backend + env_file: .env + ports: + - "8000:8000" + volumes: + - ./backend:/app + depends_on: + postgres: + condition: service_healthy + redis: + 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: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# 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? diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..381b906 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 3000 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..983f6c9 --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,20 @@ +# 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;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# 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... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +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, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..14bcc3c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + 天普智慧能源管理平台 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e26b189 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6012 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "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": "^19.2.4", + "react-dom": "^19.2.4", + "react-i18next": "^15.4.1", + "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" + } + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.1.1.tgz", + "integrity": "sha512-AMT4N2y++TZETNHiM77fs4a0uPVCJGuL5MTonk13Pvv7UN7sID1cNEZOc1qNqx6zLKAOilTEFAdAoAFKa0U//Q==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/pro-card": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@ant-design/pro-card/-/pro-card-2.10.0.tgz", + "integrity": "sha512-sLONn1odmE0Wkbse8pol4WiaEzBV8JU5s3FAMflPpycfUcbSaa1ktXzQ7LCo2SAvOS7gkfmpFjBPtrfbigKh4g==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.1", + "@ant-design/icons": "^5.0.0", + "@ant-design/pro-provider": "2.16.2", + "@ant-design/pro-utils": "2.18.0", + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.4.0" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0" + } + }, + "node_modules/@ant-design/pro-card/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/pro-card/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/pro-card/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/pro-components": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/@ant-design/pro-components/-/pro-components-2.8.10.tgz", + "integrity": "sha512-QHnnIXdmC5GTAtm6i8eeJy5yT9npPlFyxpDm+duiDrTRKRFaAQBduArxlH3DA/hoRCCypzPONxfK9BQNIhIyZA==", + "license": "MIT", + "dependencies": { + "@ant-design/pro-card": "2.10.0", + "@ant-design/pro-descriptions": "2.6.10", + "@ant-design/pro-field": "3.1.0", + "@ant-design/pro-form": "2.32.0", + "@ant-design/pro-layout": "7.22.7", + "@ant-design/pro-list": "2.6.10", + "@ant-design/pro-provider": "2.16.2", + "@ant-design/pro-skeleton": "2.2.1", + "@ant-design/pro-table": "3.21.0", + "@ant-design/pro-utils": "2.18.0", + "@babel/runtime": "^7.16.3" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@ant-design/pro-descriptions": { + "version": "2.6.10", + "resolved": "https://registry.npmjs.org/@ant-design/pro-descriptions/-/pro-descriptions-2.6.10.tgz", + "integrity": "sha512-+4MbiOfumnWlW0Awm4m8JML5o3lR649FD24AaivCmr8BQvIAAXdTITnDMXEg8BqvdP4KOvNsStZrvYfqoev33A==", + "license": "MIT", + "dependencies": { + "@ant-design/pro-field": "3.1.0", + "@ant-design/pro-form": "2.32.0", + "@ant-design/pro-provider": "2.16.2", + "@ant-design/pro-skeleton": "2.2.1", + "@ant-design/pro-utils": "2.18.0", + "@babel/runtime": "^7.18.0", + "rc-resize-observer": "^0.2.3", + "rc-util": "^5.0.6" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0" + } + }, + "node_modules/@ant-design/pro-descriptions/node_modules/rc-resize-observer": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-0.2.6.tgz", + "integrity": "sha512-YX6nYnd6fk7zbuvT6oSDMKiZjyngjHoy+fz+vL3Tez38d/G5iGdaDJa2yE7345G6sc4Mm1IGRUIwclvltddhmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-util": "^5.0.0", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/pro-field": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@ant-design/pro-field/-/pro-field-3.1.0.tgz", + "integrity": "sha512-+Dgp31WjD+iwg9KIRAMgNkfQivkJKMcYBrIBmho1e8ep/O0HgWSp48g70tBIWi/Lfem/Ky2schF7O8XCFouczw==", + "license": "MIT", + "dependencies": { + "@ant-design/icons": "^5.0.0", + "@ant-design/pro-provider": "2.16.2", + "@ant-design/pro-utils": "2.18.0", + "@babel/runtime": "^7.18.0", + "@chenshuai2144/sketch-color": "^1.0.8", + "classnames": "^2.3.2", + "dayjs": "^1.11.10", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "rc-util": "^5.4.0", + "swr": "^2.0.0" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0" + } + }, + "node_modules/@ant-design/pro-field/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/pro-field/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/pro-field/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/pro-form": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/@ant-design/pro-form/-/pro-form-2.32.0.tgz", + "integrity": "sha512-GZnVAMeYv+YHJb17lJ7rX5PYuQPvEA6EotQnPbHi9tGLN3PfexcAd21rqzuO+OrulU2x7TEMDIxtY9MzvvOGbg==", + "license": "MIT", + "dependencies": { + "@ant-design/icons": "^5.0.0", + "@ant-design/pro-field": "3.1.0", + "@ant-design/pro-provider": "2.16.2", + "@ant-design/pro-utils": "2.18.0", + "@babel/runtime": "^7.18.0", + "@chenshuai2144/sketch-color": "^1.0.7", + "@umijs/use-params": "^1.0.9", + "classnames": "^2.3.2", + "dayjs": "^1.11.10", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.0.6" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "rc-field-form": ">=1.22.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@ant-design/pro-form/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/pro-form/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/pro-form/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/pro-layout": { + "version": "7.22.7", + "resolved": "https://registry.npmjs.org/@ant-design/pro-layout/-/pro-layout-7.22.7.tgz", + "integrity": "sha512-fvmtNA1r9SaasVIQIQt611VSlNxtVxDbQ3e+1GhYQza3tVJi/3gCZuDyfMfTnbLmf3PaW/YvLkn7MqDbzAzoLA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.1", + "@ant-design/icons": "^5.0.0", + "@ant-design/pro-provider": "2.16.2", + "@ant-design/pro-utils": "2.18.0", + "@babel/runtime": "^7.18.0", + "@umijs/route-utils": "^4.0.0", + "@umijs/use-params": "^1.0.9", + "classnames": "^2.3.2", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "path-to-regexp": "8.2.0", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.0.6", + "swr": "^2.0.0", + "warning": "^4.0.3" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@ant-design/pro-layout/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/pro-layout/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/pro-layout/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/pro-list": { + "version": "2.6.10", + "resolved": "https://registry.npmjs.org/@ant-design/pro-list/-/pro-list-2.6.10.tgz", + "integrity": "sha512-xSWwnqCr+hPEYR4qY7nFUaxO5RQBxNlFaPNmobP2i+Im31slk9JuAusgWeIYO0mNhLJuLbxd8CCma2AZij3fBQ==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.1", + "@ant-design/icons": "^5.0.0", + "@ant-design/pro-card": "2.10.0", + "@ant-design/pro-field": "3.1.0", + "@ant-design/pro-table": "3.21.0", + "@ant-design/pro-utils": "2.18.0", + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "dayjs": "^1.11.10", + "rc-resize-observer": "^1.0.0", + "rc-util": "^4.19.0" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@ant-design/pro-list/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/pro-list/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/pro-list/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/pro-list/node_modules/@ant-design/icons/node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/pro-list/node_modules/rc-util": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.21.1.tgz", + "integrity": "sha512-Z+vlkSQVc1l8O2UjR3WQ+XdWlhj5q9BMQNLk2iOBch75CqPfrJyGtcWMcnhRlNuDu0Ndtt4kLVO8JI8BrABobg==", + "license": "MIT", + "dependencies": { + "add-dom-event-listener": "^1.1.0", + "prop-types": "^15.5.10", + "react-is": "^16.12.0", + "react-lifecycles-compat": "^3.0.4", + "shallowequal": "^1.1.0" + } + }, + "node_modules/@ant-design/pro-list/node_modules/rc-util/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/@ant-design/pro-provider": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@ant-design/pro-provider/-/pro-provider-2.16.2.tgz", + "integrity": "sha512-0KmCH1EaOND787Jz6VRMYtLNZmqfT0JPjdUfxhyOxFfnBRfrjyfZgIa6CQoAJLEUMWv57PccWS8wRHVUUk2Yiw==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.1", + "@babel/runtime": "^7.18.0", + "@ctrl/tinycolor": "^3.4.0", + "dayjs": "^1.11.10", + "rc-util": "^5.0.1", + "swr": "^2.0.0" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@ant-design/pro-skeleton": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/pro-skeleton/-/pro-skeleton-2.2.1.tgz", + "integrity": "sha512-3M2jNOZQZWEDR8pheY00OkHREfb0rquvFZLCa6DypGmiksiuuYuR9Y4iA82ZF+mva2FmpHekdwbje/GpbxqBeg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@ant-design/pro-table": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@ant-design/pro-table/-/pro-table-3.21.0.tgz", + "integrity": "sha512-sI81d3FYRv5sXamUc+M5CsHZ9CchuUQgOAPzo5H4oPAVL5h+mkYGRsBzPsxQX7khTNpWjrAtPoRm5ipx3vvWog==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.1", + "@ant-design/icons": "^5.0.0", + "@ant-design/pro-card": "2.10.0", + "@ant-design/pro-field": "3.1.0", + "@ant-design/pro-form": "2.32.0", + "@ant-design/pro-provider": "2.16.2", + "@ant-design/pro-utils": "2.18.0", + "@babel/runtime": "^7.18.0", + "@dnd-kit/core": "^6.0.8", + "@dnd-kit/modifiers": "^6.0.1", + "@dnd-kit/sortable": "^7.0.2", + "@dnd-kit/utilities": "^3.2.1", + "classnames": "^2.3.2", + "dayjs": "^1.11.10", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.0.1" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "rc-field-form": ">=1.22.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@ant-design/pro-table/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/pro-table/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/pro-table/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/pro-utils": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/@ant-design/pro-utils/-/pro-utils-2.18.0.tgz", + "integrity": "sha512-8+ikyrN8L8a8Ph4oeHTOJEiranTj18+9+WHCHjKNdEfukI7Rjn8xpYdLJWb2AUJkb9d4eoAqjd5+k+7w81Df0w==", + "license": "MIT", + "dependencies": { + "@ant-design/icons": "^5.0.0", + "@ant-design/pro-provider": "2.16.2", + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "dayjs": "^1.11.10", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "rc-util": "^5.0.6", + "safe-stable-stringify": "^2.4.3", + "swr": "^2.0.0" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@ant-design/pro-utils/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/pro-utils/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/pro-utils/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@chenshuai2144/sketch-color": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@chenshuai2144/sketch-color/-/sketch-color-1.0.9.tgz", + "integrity": "sha512-obzSy26cb7Pm7OprWyVpgMpIlrZpZ0B7vbrU0RMbvRg0YAI890S5Xy02Aj1Nhl4+KTbi1lVYHt6HQP8Hm9s+1w==", + "license": "MIT", + "dependencies": { + "reactcss": "^1.2.3", + "tinycolor2": "^1.4.2" + }, + "peerDependencies": { + "react": ">=16.12.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz", + "integrity": "sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.1", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.0.6", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", + "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.0", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.0.7", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz", + "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/color-picker/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", + "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz", + "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.10.0.tgz", + "integrity": "sha512-aY9GLBuiUdpyfIUpAWSYer4Tu3mVaZCo5A0q9NtXcazT3MRiI3/WNHCR+DUn5VAtR6iRRf0ynCqQUcHli5UdYw==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@react-three/drei": { + "version": "10.7.7", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", + "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", + "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/postprocessing": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-3.0.4.tgz", + "integrity": "sha512-e4+F5xtudDYvhxx3y0NtWXpZbwvQ0x1zdOXWTbXMK6fFLVDd4qucN90YaaStanZGS4Bd5siQm0lGL/5ogf8iDQ==", + "license": "MIT", + "dependencies": { + "maath": "^0.6.0", + "n8ao": "^1.9.4", + "postprocessing": "^6.36.6" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19.0", + "three": ">= 0.156.0" + } + }, + "node_modules/@react-three/postprocessing/node_modules/maath": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.6.0.tgz", + "integrity": "sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.144.0", + "three": ">=0.144.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@umijs/route-utils": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@umijs/route-utils/-/route-utils-4.0.3.tgz", + "integrity": "sha512-zPEcYhl1cSfkSRDzzGgoD1mDvGjxoOTJFvkn55srfgdQ3NZe2ZMCScCU6DEnOxuKP1XDVf8pqyqCDVd2+RCQIw==", + "license": "MIT" + }, + "node_modules/@umijs/use-params": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@umijs/use-params/-/use-params-1.0.9.tgz", + "integrity": "sha512-QlN0RJSBVQBwLRNxbxjQ5qzqYIGn+K7USppMoIOVlf7fxXHsnQZ2bEsa6Pm74bt6DVQxpUE8HqvdStn6Y9FV1w==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/add-dom-event-listener": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/add-dom-event-listener/-/add-dom-event-listener-1.1.0.tgz", + "integrity": "sha512-WCxx1ixHT0GQU9hb0KI/mhgRQhnU+U3GvwY6ZvVjYq8rsihIGoaIOUbY0yMPBxLH5MDtr0kz3fisWGNcbWW7Jw==", + "license": "MIT", + "dependencies": { + "object-assign": "4.x" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/antd": { + "version": "5.29.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", + "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/antd/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/antd/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/antd/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts-for-react": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz", + "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "size-sensor": "^1.0.1" + }, + "peerDependencies": { + "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "react": "^15.0.0 || >=16.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.329", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", + "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "24.2.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz", + "integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.10" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/n8ao": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/n8ao/-/n8ao-1.10.1.tgz", + "integrity": "sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w==", + "license": "ISC", + "peerDependencies": { + "postprocessing": ">=6.30.0", + "three": ">=0.137" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postprocessing": { + "version": "6.39.0", + "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.0.tgz", + "integrity": "sha512-/G6JY8hs426lcto/pBZlnFSkyEo1fHsh4gy7FPJtq1SaSUOzJgDW6f6f1K/+aMOYzK/eQEefyOb3++jPPIUeDA==", + "license": "Zlib", + "peer": true, + "peerDependencies": { + "three": ">= 0.168.0 < 0.184.0" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-i18next": { + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", + "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.4.0", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/reactcss": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", + "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.0.1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/size-sensor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz", + "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/swr": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "license": "MIT", + "peer": true + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", + "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b23556e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,45 @@ +{ + "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" + } +} diff --git a/frontend/public/devices/default.svg b/frontend/public/devices/default.svg new file mode 100644 index 0000000..73a9d56 --- /dev/null +++ b/frontend/public/devices/default.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + IoT Device + 通用设备 + \ No newline at end of file diff --git a/frontend/public/devices/heat_meter.svg b/frontend/public/devices/heat_meter.svg new file mode 100644 index 0000000..b086c1f --- /dev/null +++ b/frontend/public/devices/heat_meter.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + 256.8 + GJ + 累计热量 + + 流量: 2.4 m³/h + 温差: 8.2°C + + + + 供水 + 回水 + + + + + 热量表 + Ultrasonic Heat Meter + \ No newline at end of file diff --git a/frontend/public/devices/heat_pump.svg b/frontend/public/devices/heat_pump.svg new file mode 100644 index 0000000..05dc57a --- /dev/null +++ b/frontend/public/devices/heat_pump.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 空气源热泵 + Air Source Heat Pump + \ No newline at end of file diff --git a/frontend/public/devices/meter.svg b/frontend/public/devices/meter.svg new file mode 100644 index 0000000..1253a37 --- /dev/null +++ b/frontend/public/devices/meter.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + 1847.5 + kWh + 正向有功总 + + + + + 运行 + + + + + + OK + + + + + + + + + + + + 智能电表 + Smart Power Meter + \ No newline at end of file diff --git a/frontend/public/devices/pv_inverter.svg b/frontend/public/devices/pv_inverter.svg new file mode 100644 index 0000000..48bb479 --- /dev/null +++ b/frontend/public/devices/pv_inverter.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 光伏逆变器 + Huawei SUN2000-110KTL + \ No newline at end of file diff --git a/frontend/public/devices/sensor.svg b/frontend/public/devices/sensor.svg new file mode 100644 index 0000000..df10953 --- /dev/null +++ b/frontend/public/devices/sensor.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + 23.5 + °C + + + + 湿度 64% + + + + + + + + + + + + + 温湿度传感器 + Temperature & Humidity Sensor + \ No newline at end of file diff --git a/frontend/public/devices/water_meter.svg b/frontend/public/devices/water_meter.svg new file mode 100644 index 0000000..1253a37 --- /dev/null +++ b/frontend/public/devices/water_meter.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + 1847.5 + kWh + 正向有功总 + + + + + 运行 + + + + + + OK + + + + + + + + + + + + 智能电表 + Smart Power Meter + \ No newline at end of file diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..3026007 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,84 @@ +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 ; + return <>{children}; +} + +function AppContent() { + const { darkMode } = useTheme(); + const { i18n } = useTranslation(); + + return ( + + + + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); +} + +export default function App() { + return ( + + + + ); +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..cc51a3d20ad4bc961b596a6adfd686685cd84bb0 GIT binary patch literal 44919 zcma%i^5TDbT`tlgo2c`(n!ND-Q6MGAYIbZ-QCh5-QC^YozK_ne*b_MKK#O- zIWy zd$aJVZ?rl%;eiC7d#Sl-cWLv9rA0(UOX(@I3k&yyL+3GaQ4xpb1EGC|i|{byaTI># zBO=0pyZu5XO!hzGNPch4cx%6XJAJpDa<+98BOcYNo1=XER1sv!UW z^>ZDMp%FSmVnt)n^EIR+Nth`vRO^_=UF3EWv75ym{S;#2F8MPot@-y$>ioj!)a1bE zijXPQY;U`qNwl9|wl{W>{FhMSb<>m4{;8Udp4psl)NwFRo(W-T)Y6-qDf=L#U?g<@ zV+T|3+RuE~!E&nodKrkfPcOpJ)&1|p`Tbtd12@MSE8DjWkD|9M>GZsHLf>TTbLx)B z#5K5l%gS7s(yWk?Lj{Nvm`Z-s8xb-Xr`5-xRr%w8v>!oSz{dN*MmxbscQl#Z40qSd z!PQXs-utLEF&$@S#__Lo*pOhG{l(%jyCh-0ME8owiT>U~r&q@MaDRePL(aZAAff9= zBd@*7RZxmiqK^nZH7`bTjIEQw#Y=V6(h{$>7ZIf=7S0;$8~4NXLd4T;Ai~C8&3k-; zYEtJWq6x$#5rrCJ%zspgO z((R)&>BIkkr^qQSEZljO*B+ZDvTeBKJ9N%8Ej=U+62GI)dc|ZMEM66~W12v&QFAIS zoDs`J`wjsl?WdE(NTnjCO!^yB>{yU-2UPT`&FOyVQVmxy#un2Po>GiPPfzd0M^d_i z+Kr}dPhIfsDLd~jOiJ(sHTN;2u)@MaX&0AdXR;BAwr_;1sR;)MM+&{XTzNnKWH@0a zoy9ApaUt=>jjHICu3W42)5;nzHS!M3?aOvZfv-sIc%wc9#l0uHFc}aS4JSrIDOQ?4ri_bS?pjH{U{6qr+6m z--%u=5oc&PxE==-I$~$5gw}yiu_y_o?|ag2+rAgSg%G)}EU}r%*A|v|pjbE`lxJpU zy0{?;(US(i-TiKq6s_(KTYy|YVi&!plMT)EJ4wMU{C7Y;!Xow1nJ+X@ks@r0v25R; z*o$8AP*G*f3$UlYR~18PxKyPj9vU#v)4#GgEx4*?KOhlh>0%3M$-LN7&b*0fXgm$k zH78>bObkx^3_K+RY;G+Usy6L}p9iT!hlnJCmR=;=JL1TdtB#vL!RTJ1TABQx8Ux0w zl^{Jkf(hU>-jr59iK_v-PkV!WwG!LvW<@{3{IbbSiWBrX@S8^`8JFRrc+(AqsUIvm zCTstACtCZ~qy-5^Gr@_z#X!N1*1vH=7@8oL4AEOxWl^YW&LW|1$1J?gG061vk1epe zRI_*s(lrX?-2#tCt_`)p?{zZC+)onl60CU~%4!vPA}h0+fB9ucNkTQ3u29((9Wq=> z^JUm|{_2-=?dMKu&9)#x{lgPOCM`U1^tXDbmZ%I$0fw7|Y-@3Tyj1LGfk$lvzYC85 z=R()QEER%Dz=mTMZ=7E?K74&?)4b~-uj34rKwb~7vU(48%+1xYc^VYn| zncI4NL8xEnmi>eM9EK&~si%*s|BX@zKIUU?cAWA5pdc`xEZIF1Ce=Wcg3#AP?N~p# zD7mfb{oR=ZPE^jgwD3G< z#8h1K&u&zKD4q*Pxt0ta#d}bm;QqZ!hFift22a~7c529SkmFQyN-*H zzQck2cL5iH2@d@Lhq4$~_!wMWL6(&mNq=7HhT}YYI$pVVZeQr>)4>qObE$PPNZ2!0 z&7?y_upwfiefj8-`B$ju)}QKTz*Zs<$Lb?XHBo(jyU(405&`EL({mgxA$Ov49U|rN z2@(l@n`1vzG(v=!u4AZ*0s}~H4{VgcNOJ1rB?Kg!=)mGHKWeC|MHb>aiQ4Qd+gq7|??WH7;?J+kYL8z# z@juTBhW#n3rN))N7T1~)qr~Es;2rln6_U>_Ejxj(E5%Cpoc^vfw64mua!ADSZ8i|+ zB}g?u(dtvesTegnG!9K33T)4eq>)>ZFp?L>R8Qp#(J=bxz2mscD;ZNoJB@ZUqPpI>o7VgScniW4c()#;@;-9PfR`b(r+#4c; z;1-)`!?b}4A3v^zVtGa(a;O%bzu(ZG;(l4+W^vU|a&n*xV0kU$uFQ!5!aWy)^q4^r zn!-6hfj79_B#>GGNvQiKMD?xyW>F&GS>3y?Ric*xp4cz3FH3Gd1z|e+Vuug7*Ya48 zL~K*l5zo1XRuWm%S~GzE4LQyuRsH1&L`Gz-%>!ZTYn9K_Ttz+Pa@9hKob^)gmLVN` zKJz}C50X$$>G1Q_p;%C}B?<9h`60%vwalt2*Ymd44dGF(oOa2mJQuPQmE~Yurn0UC z6(+5$posAd@e$nvJQFL^C~E0E4IH`B68)j#L_u|Ex5mNE8a8{>gAGcIFVS|K?g77# zE@R|9nR>Rw3(5}{d~HnPpooZ*XZC$5FYt20 z3Ydvy9t)XHw8qFCd;mt8r$e?RQ%MiUF@}!oDGG#E6xxV z=z>11f!msSqbAZYnSvt}&J+QXZCU5b`0!gi_R}Z@Qq2d2Mwc z%9aWfp&x2UGbLDvtjGb*p>4O(#}UE+QhYmf0&Vc_Ay<~3V0zym%`Lk}-3MOz<%)%#Pl z<=OjGrvuBq318+CJ-{30QA1-O@<-O!-zFNM^&wp}iWGG$B&eIYtF)Rs4;5FK=>Aa9 zyTJdUgpK$di~MI|ZC=Vkd^V6T5h^z))sl~Dq7~stg?&l_LW6N1>0nX=aS46Ks+vj7 zr#P2~h=M-LLX2!W_k&dv^Tm2}o9vK&uKMDMmPkEcj7~C78vw2XJx^s8uo(Lw>9ET2 zzXG^MDxZzwh4y=Hs@h^Y2$ntYP+GSm>#cM9ZiUR^>tiFtIol3wi8=y~L2f@Bun;{B zr@yZMir9Ur@yw@7ni+Jd*Oc9hFx zK$M%P9+XKj>`spPB?k6^h1pok(_k*E$fr(SnXlXEnE{ODRWuWqB2u+8*2z?-wl+WC zntSCtFwpr0nF!avN+7`^Pt@XDvec7%ipuHYXg%5TXDAXv;U-33A(vzDB8V%0%j-R@ zk!2mox%%pJ<_M$o0lf*YButy@IP%9Zz=UDDlr|NuSNW*bYB{&18Xj|$eVP~(lx>y3 zgjJh3l1)5_uw6CTgk`ABQVoCHT$nbFS*edKLAbhRxLyzMI-{#6H!q_O@+mM7#~@Kw zWFDq#m<+NGVr`grM*Mh=Dq@8Tzl-$WKFWsWruYa^v`B30wDORai8q&__SDBzc?K#o z^UN`hN&IN;bep+mS1Z}i#zurS+Vl`B&+6`B#XK@l^8+&2+e@&zII(kdzid}Lm^AE5 zqjZ+3N*0O?1%{glymHcUP?g3vB#mH9MA)__>pUakjX+4jPuRS$9mmbImM8^= zOGMzKSY0_htZs;&-)|di4DJjSjVQ}hf2vq`u?G4@2@M(y#8xp{#1&$)ZW$rlUwG%{ z-S3I$D5~^(7stnQ#qh(0D6TnSA5R2*0u@x*22u1y%V5wYfW$b@)H*9X9{5!1Gw0`$ z4^fR@T%cw74(zCoPNP98@iS+WaFoE>g!a7#s-iwfRHKJSou%<97*I%619(655MjTr z6;k$p>T1-|cb9V=`;0i>gjBf%t=3jn_oC874-1o3(J|G-g$c?a=wn!m?U?CAd4WKW zm>=k4ApUHFtra|}Wl_G|#Y@n(Qv*q-frfU@rg{K1dLr%5(jA(Als7lSt8bue+zbab zVF0VKb`8x4k`2s^D1=P<^mk&LXhA!1jsr46^sGC@bsZfT)hZq4gnT+I+aHp`_XRE{ zDgx9ExOOSGF^DuVB_iQ8s$S{7agA7rKLtYG0nVl0q1kdJPQ3g#tw9qL?gP!_e~V$R z7B*H7J0{kp*t0|SM#+|$l6`>>9*GXki2@B!1?#&`s}t$D9D05bdTLaq__DzJ3hhhx z4>Z*xjuhGkL>lPDr8KhXi~8N*3~eqgebLTG`3g)&9`ESMo4O`ywJ{RymGvLXG}!Y?yAZ!5^Y19ukC`n~3GM7)2v! zx|C7WvVV`|+~>K~FRJPdp3VTPY##;_7#_^stFuo>5ewhPn5=@ApsXs_<27I&gPv>g~?s5SHzci&*$xeFVsI6?MsNJwojSpg9-+xbDwNanO9CUPbs06^E~@ zW3}{)@boKx;MgISD4?gb;X2~Nzv6Vu z_d;=oiM*wq!ou(NN8Zrg1ZYYlE==ylKlarfHe9u21xL{BI8t!pRC1^0=DGRrV0_Q@ zC#L85xcROt(T$6-@Y|KI-@7cgFD>WF?-)WG5jRleK;pn&=Rb9nZ+_@Mx-Fk~VSb{E zq@Ay=ub)@s&Mz*$+FSlG0WrrMKZI+3YuZ5k`RZGGO+r;}6mJy$DM;>AadvNZ=5yf|1r(je z0NIXNIS||Cv*MHEs{?>y+_cZmakNb+;cq-QqDcP%tMf{NmoE%a zN}Y33Vukiwxzm0dhmNsZQ>TsfYfZ-XZJv?ZTQ(=j1nt6FMd#;_K1oqQ{yq$GC6%)U zZU3B>;dh0p{DE?0kaj|iKj8?vvgC|-pv7<_WZBV7+B?`x+~3_las0^52<3d}UOOFD z7O7yf($skvy4y{NCq)B!Z=x|~NnJN+V(IV6LPL~?ORfvDDj*}q67_9}bTd~ci zlKmqOV)pG2tgWwY4Xr65@I8rddMwBV71bVAeGxT?v8-f6l9tsu9MFYr4r+BQr%mT; zO=G1)NW}SP4_kI0273Ew)qtwOwo=X-`1?bJ^>I^-9FXhSX17W>;{G^F+<9U(<%-*JPc!x>jH zSpfzK?Tx3%`#8Qlql2)Lf)TAiKHBQ5IOieg6~2NY7g@9IFI!7$DETtUG^srTsi2YS zc$`cq59-bK0{Yv})|#O4%XrxCkS29A6q~iTWNRlF;SlDMr$~v5hgerQQg_UB>M>2% zI6J+NtM*`(N7ghI_emz^lYyF_O8LW&&6oX-gU1h39L7r@8tpHA@>FGx*W=fR6E@q@ zg{!zJeVuJaQCuA=1@IE7|3##J$1oumJ5vky^UJEjKU#$)KuHS7B;vs(wJ%$?>4zlr z<=b*ca@HsJ!Osy3xBOqrn__D7pqhw2^7;n0$R~Z;twx??hrssk#C1cMtRHfFzhTG1 zE{;!Tmiq;ZD9#2W4(M?+!*~v>l$%5;__SINKTNAEIBf46X8185dhp4TD9_K#gp?em zl9d>E%I2x(q#pB8rt!89i!Mi7sMMmaZ?N?eM2!JHoQ{QdAoSm@`@TtaEkw{)WuZe^ zzrVO3sL=ewi4YYv1t!gfQ_Xo()Is9PQtqh!#?v&Mscaiz6wb$F>GjZE1xw7d5)*24 zu~!(MAawsNH*G-kU-c=3l(?|JJl0^q#LV(WKmSHC=#5YKstmI(V=6c4>73kKDwk3F zD!sjK#(*WYb8j>uP??1gq4SEU63;>Pk_#yOYu7(GAy4!ABPQY-WoeY1I=l2&k9RM( z;&F-Ki}KoHAb;HXNP-^_3u`-L$+~dmP7LmypyE23q+IsyIAyGbu{1T^)Y7+m(;oN@;N26N#9X<& zwqI@>wi=7v)<%`#h|WWx1pPuT%3Hx zTmHj4u@(m6TMc`y;_9#P8As?uJeu-!|Lgzd>}uWMUo5{kA<)1ndxs@UZR32fT6pJHGaO!4QH(eAa5+t zS1N59EQ1r6i z<(E$QmAL~w+VkGpLI9*Hnm0tLT@_hjW9JWQXev%DVG3YZJ@}x78{*jc{asC?1L_)h zF^DC#%H`1`O_VrpaQ}@~&1zbs5~&ja^i#ZVXwP!}j8mnEV@;<{Ahw)4%S3LKNFJ3i zaiK4p7j50(Gg`7o7JU5p$cw9Ok3@$*lZ@g;nFZi|2gmE)4`U4Rnm2m{vKk-zbX%kA zCoK32`kIhZtyUTzRW&2mT0PG|s|zU{4QPllcC91scP>F97ZXap<9Bv#F$2P|qk;b&2$rxv~0fH76P8hs?SUZLs6n%pW)x z{94NZ^zuBrMOvmx1jBKr7I^C(e7yj;&kgD*7xRHBhV0n=;gNznW(J%ArEdQ3v2RnW zr(kstOqa&TJ`*F&kJM}we0``YRAQ>!`T?;}wzZgRk(fa^)#2*9%Z+psyrobKU%nac znGGN&)Npn`s=}e$R4yL6IsRDDSF=Ps)Z;1?NH}K#C*jVV4dx0@(DMhJqOL*I6)&L4 z9cLFcW!bbaiw~-ib4#2tjht6tOE}{zD6zU{xlC2$ zI>jGRD=rdrA25&Qq4jqQAhS4A^TEeuR}+ZLmIn&KRN3!3YkB-ej*-b9-c-AE)S%N> zf?x6evrm$2MOQ(b0-<^gvSC_6oBe@p+i`Ajxy1G91_dbm9z>* z`v6e3>~L1a-C*c2`$0^HXjr4(?IN{jFy+;}uvyb!LNh16HAJ)d@63e8GRMmWrMZ&F zv_aLU&4#ktx$@=QM^zZSdGAFn^&JpWIEc06k(WFQd*!&PpmY;wf3>)TvXQM+vqd#z zyU8VT;5@(~T!27u_1N3Z<{-f&SNd-M>^C*BK>cKP5&U7*KXmq@FP2FiN4aT+-1iF~ zfRiPbO{*ky%`uehvD+s~XnH7V{jvXcN8((ts-<3M-#N&I$MX3xlZ!UGg+fiN+}`r5 zkj3AjM%Sj6BRHE5?Q@(GmaEXx+0)r!TPtcgyrsy<^`_Wc*hwyr-;OCdQ4#vF=h5Xj!r_#p6O*Q* z)GM*S@GP^XHnavtL<^TD>&W%F)LS4nt}T73^w2{aE8S?2vByR~WOdM+N!yff<@?z8 zI#ww-Zu3B+Dw2VJIAV7nOX9!ujfO>l`;d|vXtw#0QXN#ak`$I0n8kN5(2;87J-CD? zHmL*sL>eCfe*GTXwvDI2D~K%nI37JKu}-!Po8ExO7L8{#pw*RuB`6KEDkQxqNdG4R zbz*yTL(6Iv2z+#WI#BgSE1!LJckdfI7H#~xxtSQ;JHtJbofI^}g8L7|Kn}2;V?6dd zK9bChE}t-w#v@|YYe!RB4PsH{@hW+RWHlR3f&YL23-N7 zB={^p7mTZ^ud}HaFV%4UvxHK!)luf%KBVaoi+}5rSQwa@bCw;vYHCGARWld==<7kL z=59v02kEeG3Rm_z)Zc3=MXmaA)I9-9T+O+St{6L3)`@2_41VCAA&8E3bj5sZx5x4s zmtI{uQpw=7HHzdjnUy|za5p(fC=*%NXWhuB(Dh_u6(6Y_e%!8tO&OI$^_@sEYZMc) z<_`+vf$U0(c!m5aMnvIZvM^uI5SEj)Z(;;xrCT_CmpZM4!RQ9UsISG;<-MiaiPA(v1+;q7waq z#DaO&yeXX-esRlYcP9QBezojM(;1VYYslzFHa5kqnhTql9tB)(1PR83ymJM)zr}u2 zA!bL-PF~HWs6_&|a2T`59w8gMCgzI0ZUSUfQfl;Ojkd&KMV<)NhcnfxuOH2mUXuwQ zAM*!OvW!{`MXjm7TIXfL-k+n%0dP~x1% zi$3~@96_CUQxT;Gzf^B~3kR0u=7eg2I4Fgw5M>k5m~x;XrP_^xUNLYFvz1}cRTX7r z0lHVaPz&tCq!B@(_+nwtq0RK$#IV+@P;sE{>RX8Bn-rrhrkj}46K*PBvhLdC@?i7h zJjx#Hk>f+3F<_Y0nGofcP^IE@)+(L~Q4*1fl-B_6231_D^dqI(^dhIc= z=LA*Dx+nYb(z7F472oY=W@o*6`ujtJZ|o#z!EAVr%)^Fux|HNxTtvhvDsp6UwTFwJ zM*F1zvWTTAmTD7v5DPy;dkkH$be+d!3z!mh9?~B zP;G9Vwc=}F40A(Sds~L)9PeFHO$%36su`>ADF4lttX|1!{}kJEkmfex*_yNVfSVdD*&UI|G|lX40rxwlAPgKpuk`23wH2sCfRuKK%fnp1R#=<@<9%+; zML4y^o|%u9_V0m5cLefgy9n<{uobfvYeu+aZKo0Ktc|gWw&pasMBNnfI2UHbKn{9O z)8)imqR}+@&r{T;xui0wrvTi{YW)CT-RWebe0G8{202Acf|Llgnqf=$=%XtXfK4Qv z=zT1j1nI9*CySKsm0?}}<#3SfXM2MsnAkgZs>SG?0o-+s-LK%L80d)#K;3u!6;8=5 zX@g4Fm=G<8m!gGW=R{0399feKC9Xe6!If(%Vf-@0mQ7tBX0NzqmY|9qPu^277yohID3?W6U;XA5NfW2T%outqW~PhQ+n&nro#DcM$Z$THW`N zvNBz|DwU7qm-tFK?Q`5dA&PTB@?7}m0eDq==POEw^{A`Fa?qK z&48UqJjKg|to+>?O{Xf0(K=JOzIa?8#vDp}6Rf^uG9;_RQ>Sv54OQdMjViE9g742S zMhS8Ye+*}NihDGfGuOzbNvx`CgC7KR%vHu{O-ehz$6LT4Mk3SiWVM?^5C{rNs<(ci zqw`nSS8I-1*=qA%mSmm%)UgQ`dsW)FynP!Cpz`|ATE_}k?|*Q37_<7=60FiHwB(_h zw5+MMx={v+RgSy*%jLa^{Rki@+7`oxIZt}@^zY`)n@lMhgAPv!!2u;Sa^;2L@?^x z%A-Mrjx%teimuzTAPSO;F~lr&gy>_G4IY{^P*NEOF|%r&ntw4|Ix}Z6Za4>|Vq}%A z6pcxIPQ@tDsnqjX?bEekhr8)RQoOi)#Gg%k8s-M;;psx6&rT16qf|d(x zQm|i=dq2&*4+`a7Tfs#LSH|);MEHt+!b{0d7;B0PK<1QGH_ynoq!E*2hGkz#6O9hV z?$@wob1i#9kmr+^>ORB=Br!O}1{@=Or zo%h~IPq;QRxJrZG=B=N=LCa3_ths#xboN?(E~BHD0#-A0HRWBd% zQcIeW%y@>zZ8l81ks#C7e+hpvP3-w#+7K8!Z#+falSF*kz#{e>Br}RGNxX7AU1lVi zBM!bs|1pEQkrg!e8V!3s{|$r6OO-b5{0em=IHTj>B%>xTM{2fQAz|zH#Py4>+?xni_0O!81gn!QL~C|A^iO>kV^4a_%tZvJM}($5)k4nG z1`n!DqAq7NrQbVbxd2VW=*}I~?A_RaioH~%?eBYLjJ5@FW1Pu+UAm(%H!%U>%pk7} zejlDzFG%i?NWK}?hzUWsKEW}sW!hRv85emvYXb>bj9PjkEJUSs#y-}~vu{`L=EN&3c~hF@`6?yd zt*{wD)SEe5tJzqXKE$Yy+1IchWywJgfw_Q4!wv!!5v&6E{)Mf7)=|Ty$5R8b@U^UT zH*#GGHSYPR@bGZ$75&;Bj!Dh8Z%`1MNltRwF(-lxD(>)-*7(HhmG5nQ+i+Z`;k`|g z%h9)2??XolklwMj)H3$J>HaS9heUSwj9nb|SnvxxR~23MWzjJ&wWNu0GHR|_`D@uU zJcWrzlRcU6ndDlgFI8Lbxu<+@@QxstO@yNH$yd+_nh{q=e4eP<==cK*H3z8Y(t_9COqt4~v_Qlm%pPjo%wZFKfn|@@9(-C_ zTK~A)tQ3f~*E*=hg0)-;lGt;ScvIjOMibwZ4x zJ_UAlwx$oR%6XV>upP2|637WYo24&Q}Y_fL*yf-Q)J=sU0Ln?t+}=J zO{6MCeh7$_?fo>?^zii23s=e9C&jWN+3Wk&N8il?$Rn1TVg8b_3$+-c4t1EpM3jNP1tx-~ZtZSw|kM3YHhY<3yn%Vn1xhDJu% z4Dv4H$I&nplNH^mY?|6wy=hopGrWsK{z&zWzg~2L(?_BXd*1qJV>321H#9~{E*{+K z!e9TFLZas6aujoB{o2~V*B17dvd{&Iqsk3=Epw1yoDK19=8B`6=j}^sM*D%B$mSlQ zX#nr4DX~ji#!=Nj_)ias_^{Y(lA?qcE`a>{=4^TOc?#56oiVbq2ANi8i&=TNn?&pk zt`VtbWh*T;WGoa9?%8a=={cj52ay?-Yi9r)62hP4b&xzbC(HecT>GQPlc<;0Z%*7x zZodr#pCg`OB3`dw!hrntXAoJmo=QMs$@kx$r(LhAPd=epl?(E@ zTyv?TwckxHOeIZy3=>WJv}?OuzDp~badvrF4_ zZAYU~d}%i=v{4M&=+*K|6X*V2+1Qvjc2Ko9YD}ENS~}lpu>xTCv^#n6e-9qt zhV_&E$RMR>%`RQ@$54%E!G$j!61RAW5b~GSPP)}#v)oupgLY4;dEuZK@1+Gg;XV}I$rIL*jyWr z%#b+Fa2-|41c5tm(GN?a8dVl1zFisqiPky)WPO?`%oSsK(Hf&IDaL(r`%S z-2Wn#BoRnHfqGV*!s*;zG-l;5+rkmw$u*-sA!lNdlNI=^8=bE^h^& zEODXG-PWduHouXLwjF4F!(35IXa!Q$a@o0)hwQe^4f(f-JAX*4-Cow;VDb*TZdS@H zqUd9T*+%su%e6L7M5t%M=UJ7V9HyWKQT0MWs3COo66`!uFnY3gmQjYiy2x8XhO@)> z$~WPw(}UW1aF~-s=CIaPH+8kG4exyi}ai$+h{shB*3W0rRF7=mD$#s zvR#Q@SDXD3D^=`Ph`BRQ^{vl_$cFGe&)d~zCy%|q@PdImLSty)@pAQ1>&enPc=}Hc zxK|095i`i|VQrKL0815&JK&dK9DdZJTv=}cxe}!(rRTVQA zz>Br`kSb^ePLUvOWki3xxKlM4deNqbyEV}je3vb|B;s5&FGql9?_#CDoYdH0y-F&x zmmEfNh6h@>F{QJ{ho4NR2lD=9hGNH2oIC_rb$IML zpQS^1(_7Yop5+Vhy%+YHF|E`%=bc9rjv2?=;WM~G<|FyL6?u#%TieI6z;E_?35N=+ z0Ixo25mhW*iKUS!M5jj`B4Aoh4{hmH(BZwuOSArZaffRMr0bkL=(zyx)q{3nGIFCt zP?|CQYOzYk5rJl?01bIJjV$ahRJVSWd3!3Z>FXU+^up2{FBnzM>P|-;XGsVkL5`RF z^7=C zeC2+{=kIBc)0DD5`G_YoUabnci0OMA>;XphacRZ#+lS*D8?ARGW7fDCOLMwkx#)by zx#YDL*_I7FjrWyjTBGud;0GL)qpsT(*rB1J-_=`Uw&ydA;1-mYlcj^y@4#eC#Oae{ zJMzbmnKyLiYBU&+6!x)+AHU8|r(4I|5gXO|yvLXkB8XQ!H zX2baRkI_{jpLFvC2dRbFcD)-@6RwWk6)$7O2aHGPQ4w5Ljz{X^ANl66!{l)US^OWr z7AZob!By7dm7H-cRkSe7adHaySI*vu#vJk0AzD%0Oj~;1NL0@B4>hMui3vafOxJH( z4|j*!N321k^8ELv`Q|voWIy=68f3oF19ight;SN>tLXSx=j7MN<#sD^G zXN=O6OXa?}ym}R~{&5qmA3br7O-gH%p>*6pf0>seX8#r;TT_si#b~RwReA-by-m5@KaM)U^CF;34yDGKb(cEIZa6%3o05E4cb7* z+;9{Ba~%6OZ?QP*qY4Lw{;`lW{Fw2)eDG(3ZA~DV=!e=H;w!?-D#OdFS1(gG zyzFg7o63quNB{kdv#R(Yms~Bi4g9(oQwOYZYF`fcDwZ;-e&+u6T3W7QyfyOLH~hV{ zcv{U@RWmFQUhZo-NV~bPb^B)Ma;IYLenRx_^`LpLomh?w_P?t)9#vU4oFt$%US2J7 zG3u77_b6!)XWOBm!OJr?p02gOc^iVO`vx^92i{QobuWO~{!bcylk#?ZolipoAuKZr5iYfc{YDSBTuZQWm0!K#TmjNYXzrs)cQG&h zs{O^UW3-$Pb6!s4t@cgj;iXW3B7S7t=z3bJhFpwR45Ez8fI41>sx74>ekw!_IkXfy zaL5ml)#=(w-DYW8AfCLQ1e{;|xE}b|M;gTf5I`}KA*Be@mJHPc`IVnmN zKzM}j2YhkQ(rua?wS`rnM9N_)A*)+I#aruc65|6j1X`K72zoM*5Z~k)`YpJg5u#T# z1UnK~t?@aOUqv`d{*9m0_V4EBFisI{SFXLr&WLI~tQ zdF3Fs&^^1nyLsQF`roY8z^SLRWCE{Et)_#r$;h|s@RR6~(s*+?KO^%8-RISZ$H2>s zU{yd|BIT`kpIB5PjcsOqU)MkLBt+l-ru8wdyMpf~uKXlS!ZkG8fCc|ZBT$+q#M{LXUTT@!$(pFyi+Z!=WrIl!ht(fbk6;GJYVD*)Qw*}LClLT+2yS_;POgF zq9xDxnSU7MfAAHf5i3~pi3m+?P6Eyb=Wi3&phKKk`PYcAC-FI3!sn7~p9jc`Cj$Q8 zuHDipWtBYU8|yeb(Ipdt&#=;h?}Loqf`0}UBZ!p$r;RqQfsXP)&wO+4Vflp$K6?&Q z;twAQ9bh;;J&DQ?%~cJxeA4^Usg3;(?o`E|Mm8(tG|Ayr6JOM1hW!Z zqxD=krm74NT!{cb)MHL-r<17RXDy8XM(g;r)EeD?j?WYa&0OkUiQjcxzi13nL8K!H zeDiiC=kH~xEt7u3fCSK42D#NOh42IayWdgWtoKjlQnwdQM6un!^>Q};JNS3NxvanR zz__R3*d{xY)ysy%#g0*R>YHm?_pI#R?Qj044R??sFMD2~Kf4zvu{NBA_$usENKfTS z4Gaw@rs*oK9f_aLy@FV(2ZI);S8rim-Z8N3*Dz@+q80$8+CUpR`}czcAl9#Nm*w` z3|4wuio*VcAN5^%L%@{ESF$qq8bp%5q0YxJqK_}=U17JDLBB@&VnLzg8n{M7<51&(7bIU0jO&t zore{7s{$>&?z~!j{}cowSNOHUwt9R85(Umm&g{Vt?c}9`e7nV{JA^-{`()zWc}mP< z`6vz@TnCDyM`=+5RT8M76SsxK1reI)_I0bypU)^%KHehFfB%DUBrq5-5*yhuSmA{K zg;^?iEVP{?k%jiZ^P{_rUv90*a`V}0T|DlP7nH#NEk?)g@D!tQ88(Hzh=ZT!Ipr*U z`$%5ehv&a@uTgn1q`VV-gj@&HX?$b+@rmi(FbA5?fQfs@S1S0_0zft0jJDHE{%Koh zJ}Yt3x&j;YrLThxA1C?y%Im9L>9sWfg@~pxH)IpP6d7j^Rp84-`?w#;l8_>mLOU$b zsHSafe6DIKD~U7^dD|Fa5hAcEABzc6^Ktz%I<)h8d7rUL$;n|Or^b9< zreSTSTbv4S4e zb+4F~=Rivm>wW8;?bgzr-caIP$LEvo{?<~D?wb*f zZzmBM!r>(u$Kar};P##{zdSDu1fuBpt zTQBv*X8N3?HakuultkMtd4Q8C_V4LnBc ze2rw!s6?G6Uf98Phn-$ud5-UQXr(!yslCjt!C&F2N z42*250>QOtI?~TE?4s8%=3ts;Mezd=8L2BMI?lDT` zd+-%YaKTWgiUykY6;X$SH8WzJweL&qkIL~-{r2?12=un^tCjyE$j^eWlG=R)b31$4 zkO%>Vx<_(5UEW5hTP8D@Bgr(i{ZlwprU{UL2MxN=FqS}t>rLg&(9wFi5&|a?mrz&# zoRbHGs<#$=Op@a|-xV_Vm;kCqZ$2nWvjFWH`@0g7A6!LRVAWKP@LcmdKUJmGD^juJxC{MLX2GZvG;>X!!?68TZ^|$=XepiPnI_ zw7cM~+XO<*d*G+10HH=PNat07nZYlXwM@rPmO7qLXF!Qson(VS$82|Sra<}4PZMZ7c8b7fmPo~Zh5UZ z8?C7AAgO@JmB^Lw$JuK7FPee+iUh%!WLW-D7|TxUKs2)mc23L(zxnOpF{>7~e|-~t zbXysjma)vW3S8&i124Twu-3@uWC36HbFS0tID++G@BkdO@4}9WIp8^;aod!0VE$I4 z5;fO>p#q#OGeyM@^ah^>oA=vc>$sD!WAYKOo00&|IytaQ`xdy*D`N*(3eq_ZuzOw$ zIBQjakA4H}(SHCUoigxU#Jzd`lQpGIf8|7aJx@rPiiDYsd|b{%#vtYR4|TP4qD1Ui#tqq>Y+bmSmg z+z30qxeji#D!^@KHArVQG7@eAhbcu6u%r+A~fUC79DP7T;iz6qqP>aA;GauX-0lUmB1ZVAH z_OsO>oKgUmQ;vh}^my3zVKK~m?Sv9DSJi{!$pfW;*{indelQza2iBidfaQ!sAexo| zPK*$(r)0pcX@wB7vWcC5TJYAZW`DlNGS@ng&Z~hyBLySeI*x!{=iCE7!y4GTv>AMt zmVuXk1^f9L2wK_(A#2#*o0AMKbJJ1-)?5j{o7qg$W{F&hT>Bxi_OzG<&uGuwKfjIf z$8B($p21eRx!}LF0QN3t8K+Sl1g>acoYKfv&v!w}2zD;Lm^6TFX*IadD*~B*3&<8Iz)iOh_N{4x&{fS4xV()0>{SrXIL-de)42zC zT=V_D`JV&mh9hz%a_#%5IRC#BbG?4r5j;ncCegYJHs2kk*xSgs93s}2gYC39u$_8}eepBkHv2-_F}GWG%{AYX9!um( z774GGer*__v8MIZZRi0t{)o=TgM;mtgF{f1@A>Sz*Fx&rV%=tyvBa#2@k$NsUcfkLVHNCNR0SThtHEXFUGQ5}559VhEa7VgnO+;XOl8R) z%Wx(0a#?bB4$McCF=BOQNu+&*GB>nFO;-tl$tt@+bD%d&8R!Sg)$+h*Oc|`77zD05 z=fG#tCGgZOV8n^t5G*xc(g?vTo4GIKKD&%d**)j7>{Y)Q0*q_GcafZ(glY&jsRQqM z)!@Cj7`$|=A!5S=kQ&?p|CQIkb#@k5Pf7rLmK{rG+yvJdSHROK^H{-|CMw+`awT%@ zBWQ2>Wx)0DUyZXwKRL#4{2rn<7lEzz2@uW50;g%|u<6SquzBoJ5PTL4Zu7EX_mb-@ zfvaYuSP3C3Tfl2!IUHQq%CcF;D@!W5l`_f#vPDg>Tfd4+@?2)!WB*nO$4%~YO1av6 z|HX`-3`$wndx0f!=eQ=RDFbDU<8}*PQf5q6@yebw(48^63up|Kz{1zkz~Y^H*g5$u ztp3awJmzJAXjTqe?pLw{ui~l#b}z)Ge=+P?S`TjX3&C;5ZT98Z7uKs|%l{TQAW*QA zQ3{?5%D|nyrS`97ZxzETkSr(!kA;`ObzTN+85<27zl>zr@nNvlJPndr*BOalJbldW zu6yaFmM`e$BoKNp?wt8yTI}ZU_T=vV6@1xJ-`n6Sm`~adn_P~fyN+s9%uO*1JRQwsS zy2CV;K){ZzwL=TRdSV_|>*_e|G@89Q9&<}rdS3$v);7U@(+ZF+$p?GQR9N%L0dSh0 z4i*|mVaMbcu$dAM`_~jgqII+MPTY@kTN}S4J(fV|O~%z{ny00>v^pL$ZwolGwgY^% z8$dj*7|f>zGtxW@J2ayi+2+IMua3g{&%;@gbp!&J-GZ>yb&OL=S!PosuYp}vM#mDC8kv z={xzL#a84DIWH+YwACWibOs&j&=}|mlLzjGDJs6O;`J-A>x(9^(`HL|ta0Y3WG?Dr4Y$zkNVR1QH)TfuKp4eVoC>%nyj zmd!RpuyGR{SXU3nEf_IRJqs2SPO_651J;w0!C`tTh-RmOn?Wkei0?p>umO%+)p+L} zRT#9^|D-}UE`h*b)D(8Sm*HPyeqc>Wc+`d_aQ?g*Hmg^{mJjd3?!|Xt-w>+`8rkakE=YB&z+1l(r1Pu5XUQGz-?bWl8CI%Y<5uLF1N{Uq z^+f2X9JJI?J;Y_Ls7=fnbQG-LYhugy3t&GbnH^+2OSN-BGQWhqL9isEhGn1C?29rY zHDsi^t_^}$H$a4W3xus}VSjFffK_tvSyT?eYpPkwUkSbjmF%Qd!#?(Nht`*a``k>h zo0I`A)3aF?n+|3Z!eFP?aR^va0It(2!SS~famu?$wP99*>Tv!5>mAH8~(xn2clZT5LzmBLKbNSHi8lK4_j##EKS?8yVYQS@cx z8UtI@8(BJk58QM!VB7c@Muu6O*MO&P8OuPM*&BjouZD8i%ib`7#?`Qwy-oHQGcsMt zvRn3630P6XveibAu~hwlNjvx%RKf10g>Z093&d_G9T$tvD*Eta`X zRSAG)ujj(Hj|xFF?+kd(y9{o#&w+Se9(XLg12QAbLTe#JAO|n@wg@s|>HNkPh}iHQ z_%APmgY3kFnKi=E9c>V{z6rb+-G{I>55U{75JJ|<*$FIV+3g*$7=Ik>7`g5oe+F#7 zP2)5YYwZ}=FDQi_U)%+UcOHOX=zS2pQ4YIjH^I?O3fQ+)9(ygaV=3L-1VYc?{^iCm z4sE+B+h=k+9B1z>`!F1|RS$si>-lUMUceHwIWJ|MP(pmNnGffMmQ*Fhmh6v5VEQX{Fbt; zl##Fh@(M<}b=>MXbWH;U88t$vaT`cMaayu1HPo zl;i_Y(DA`h$D1ypD{me?wBar+dp{B;4R8k?)o{=q6wi{NYA{i|3zowhz;0v{h{v{q zNcSQLXU4tDCu%@Zl}3 zj3XLguW==W7`HI;t>@}peU=t;yc1^H0=v|NatLE2(x0wA(h~} z^ghQIK`ZMZa2fk`c|H4mEd;V|-RlcWEtq zTQozcNi9Tfd;k#}+Zftm?{Yb(vmW3269lfR1liJ32wqbLksBT`(yd`{mPR47L&PmDOIx~kY4K6{@vN{ld!#?}nA7SgTa`sj%0+ZM8 zv5R;X=BUPij>Ic;2MIby!)824qAEbuy95) zXulzaZ(g;5X#)dU*6POX(M(qjWzT0NtWqmvxB*+$tHI{I1_(541vlL+u+%&TYrYJE z9TVfhW7ZXLoR$vTzfS!B*?SM5s+P4~ch_HMF9RwFm=o$+>e6KnC?YvXFs-%se{Q|^8|^-)>fZYAxqsSwuQ0o+Yfi=-a{^;_ zzx}*lf87HKx_3})+mEaxy~wugWzd#r^on$%pY&u5`8Gqypkuj5N0DaSPa;Y#S^Fi+ z3W(HviA*zY)h9un-fI%^cPKeNgb=yTo&?n%xj+5di@w0EAg7f*2vfNMpS>60E7^iX zy+@2*Q}l;%+GZT5k4+-O^gSZ!c!AXz@~jB$P5an|NHuwl)7BqQ;xNrHpL;F!P%m-EKEeG>UE;$`*4-3ZLLnd!@JcCukz}DunxbU;%kiV zJrSwhQWdXz1N(o7VFJ42I}Z|69|kj9zjMMadd@9AlAVdHW7I5Bq5#jQ;5vzFvr_8vpA`z&0FY+u$3CaeLZSfvC zM+n^P`;nmEjU;aI(UCzC(>|PW7-7yh!;G8c8ep;3Q)Z(`IsA4qT(8UgPrua?q|{&@ zEPJzui@nAkxJm!;019nB(8w`BLfOZH&m5t0G1e^l=Sxpa;jH5*&e}|o;0_V3zDJek zr*9XIaKF@PjD+_Uk~JU0N8$=R_B7-8)+z)@cfeb=0rC59BSEVVfg2{^vT%&Z^&u?h z_rQq%J~ZcCgx1_3QKS1hD116WILSaY)RFX8mpVcL8iCy&Xia+-`atxth&? zLFD=dCxl1fw7eUM>YS~A1#bc+FR6NjD7C?PcO6`I)xr9w5+v)~NB+?lNIpp7YSNEF z>v0qxpC)Y>L8{?<6rC7D43RIFZIo@^hg>4md`nJDhnX8rHtgYC^JI+v)1VqB2>j`{ zUV^sW7YJ5t4T{majRGznLiV2{(cEK$EEJG__#LuLhfwS|fl?CM94q?S;w{dc7-6sH zSq{?$A0#2}qvLN-e1Z!T+(v{-7yPBJ!%wOe-qM%p%V{JPMZ|U%_c%FB}&1 z!&2}S)ovOkTUl~2w+}6sHYPqZl15c8HghRS0=wfoPaIxf27kF5aFQtPED3q+@nP@_ zZz(OW^6I})uUGY``0cAb=PFy;>Lq^;G6Eq)roOCC{q$!$Y@gwdT{C=1SVO39xwE?K zJ3mITTtC$3?}P#WHI{;9E8Gje??;F#2a#ra2Y!1m!$GtHZW8BN*e^)tCQfXtK@sUf z?vXdhGJlJ_W1NQcp}=+sXNgYpkB%YFx}P*=l3)_jb_wjZZ$N84(g zeir%D@2#{(KqSv{pdjf`H;p<2$h90~IA7^Lg?y_K78c;dw8V7`7kqv}h5HzaY)4S- zJwc<-2x`5)&?xl*70#nLZP88k|1KQ2*O9n(z-`ZE1S+&3P^lRyMo*EhF$K?6LvUKq zha-Y7a9H3W^yjs+g$~lQQdoFEj6{~Zn*z58f*Vc6W^f~}2lg$>#esDxY&~)QVFMU9k!Jcgg~lo1wBajQWi$392o&(IXdQEtOh%osZ$TfdLBHDu@>j@S|AHz%Z3cU8Tv8Avl74E}BvL2_bA0tU?5Z-GCVK4lS z<-D5AzXP3l%~0hlCrXW`8p|qYSGf4kZW?j9y&JioxkkXnizMdx!E*CyBp-N)Gp?^A zZeD!D+uD#<|FCte|I@6qUQdD(_TMK_y#oF9ao9P-8(U{Mv)!Y(y7kXa*!mqOpeOPD z|2XjN_)I?*ca@qE#~dSDDnGjfM*I(PRIrBtXb2}3_9I?-nDpQ|eB~~|RxA%T+ltww zwVP-o{KRg+Pr4aJR^2GJ??WNcYNmM)k?R1m&H9mVJ&e4gBLrikD03yva2`YcF><&D z1Cv$WlTLs7qm|ra{pQ8TCwel>-Xg)^InqqHT(nW-+r1-vA0)A*3*|C_QujfWoR~l% z;eIiVN;MwSM6W~0F@6oZ&6V&LZ%3$n7d#|rgcGko-2NMgP<;*mpN8PIWD2%I-;$IK z`ENsgPA$u?6PpqCO+aUId3P~PV7XD2YXssmBA5Vk!FW*;+e2&f5vbZgcI0hVvHSDz z{s+IT;&nD&{iD>0v5)`KakftHnAnaI=uJ7&6J*Gz(snIYIY(~DJZ z5^L*s&P20b*h1%Uiv{*@uXE{FGXhztfCHPovvZ(5w~=7yCai^@!DZnPyw?vPQLmrv zC%|nd%B{e3qkiosO3$TlAyBp*sRwVP*zpxIEnlL{X#zE#pOJ4lOcXneT#F$R*Vm}< zqUScqv-e` z%ALkh>NJ2_mm#Fm4pGVv;3{4RFWEY>1aA>0{T^=1`*2v`4hic`m~LP;)3<2AAMZoPkykwxZa>TM)b#(Oq?z=XSGs)cDY6?wDOrDRLaV}M6a{uYD03ab zS*Ly?*g;ggllZ!gBGcd%0wiw1aVJ>^>1*(oYC?c)8&XZlQYiMqf898o7xt3{c>puA zA$oJ$**(9wbUB@qa8E2+*V)qoFmqqM66ueBR8kPIYW)P=W&4l8cYdx zP6+qIZOIT~l*W*5!rddQ8IGbAu-$nUo}$fg+1?E2?M;Z&xQDaWZ;@m14#f_`k~>HM<>tuO$W6mK!B&9|Blk=|5v9<=Z`&Q_LHdg;)2rysBoSjitRy-$0W`= zzQ;xXG31%NMyUK91WP=mFQW|}VvUGUe1I&=yGYW1i@?nja9lXRtcMX1tl|9YP@H`l zDtx6xsu}Dq3R1IU*`vaoEV3+F)Hpm@I6#gsm1-slZ5*5YQsB#F;R10Qouy`S?@5ID zrXr*oJ;p_sPZ4#2<35A0KMM0YDX;z(Yg68P18=3~Mw{)mIIuPg67zhqWrjT@=7g|# z>aLkS*iCgid+r5^*^zAWN_=J*#AXN5InL~L>A&5fWGBlZk0kdO%*d4s#c^3WYI7=K zA=pd8Is~VMJqTVuf<*2nfd{(~CVvY-vbR{ydVtJzSZ+LvK5*wvIt@fM zrS)12zn|peby!~gP23IO-lx??)*q4s74Ka3lx~6f>iTc_sk3~ja*zIyntKx4W;hYS zx>I{6H%EZ+(|0x`s6?@R0W2)QCbmdyxv&5ibL9k<>sR9B_&CAkZkr;{m(9eL+v%TM z@@gym9zGlTk;>f$>hKe|iPs}V;|)&iu7KOFD>$*`0wU#}A>ZN!F8B_k+IIkD!X z#@jN?pYuWh|J8CoA0kyA!)@ixBe)##5p8k5px*Bbs@#Xr;5+&^aeV-n-3{;*Yi3_e zIJa}o(RWBv8-nO2%L-zkIN?dw->U@4S=c(d< zbE)(CY+mI)-cxAbgEF^%BH1xC_>Un`^AY?cI^npj9$pen@Yr(&?oxHgws?%x{iE>v zVU$M5XE2$6m&IOn=3Rp3ybJ7$-a9Ls=rsT;^9sr4L@+DEG6-h)KxTFlqg!r87nl30 z$d~&qR4_Y*H5i#WTnbk*l=!o$;dwE-zjznR9Pr%J20t48(v0pRVgGBy z?3#k@qDMF;^csf*?!rKzlj?P-&M9Fc%84SEHo~nO;cN>RfBlvN8_DuqcQT=k$6lgS zZgPtwRT(~_T)r6Wq>)^7*0-ELMzgcSuwS?l#}+)Hzvm@RYP2I%qn6SpOp09e`%qBrIz;yW8DdnPBShv7+;%syow6boA0k=r2?~z&Ax35b zp=-Y2m|!eT)pMu zrPS9JqwhcR;<3E?53LWc_iXf0ZK^M_8cqw5y9w=udC(JRf%?2MYQu3jxS$15+SlMM zc^g{%wbbULAwJKKg#~ua@?=80W2P&1&T@z3oKULYh<59YZ^yTP=fWm>C8=+4E3&x0 z!Q36WzyIX`xk+Sh+fP0ICRhkQh2z3r_-=WJ48s9rnLLA=< z*Xeon?_J-%8WavQt2w2#+-t~gdjlNB>qsb%LvBtIOqSe)@?2{BWZ@k)JV2hs3wV*Z z%FRuNq<|k}_(R!b6_-*aKQ9HlXZuj~BC&PHZa#PHne9u|>I><45%k=Tfrb>{$-hBI z9Lv7pM3n;;4o=kOl|xsc9)|_)v$RNuMQ;!+(T7~iK6aOAZWpXj`CIUn?3nZxZFSR-cP2$@68=YsvI;D0{w>EiMRz{M;1C z^QU0zOnVa9lThSO!y(~j78)=Tyic~ukKUKWNLg!nDgu=*AzZ7mChJ&NTIac!3Oo_u z)xSs03vKn#Tov|SdATR-cAbIdl2m9c%76sF7c_*5p(AvWxh-{pBE%?UAp)8Qa(z6t( zFK}5lGP4ueq%W6KzL)xo`n*c$^IwB5|0UQ6_rQPkDAF`PpxkK)soLG}mZIa^N`mAB zoOp57Ut0;<)*}!l_d3W=>MDHpbi!5a0>ZT~Am<&-YN3?2! zc_hH!LI-klH{Fzp3Xg7_wS9}jYb%&w%JE0B39JK)>ZqMZ!brFi z@tUuYsPPth!sj4HA}S*gitT)MM5r!M6;6k&z)2{~r}jNJjE=ct*KBueo@vEGV%%hw zvcM_q;q#`?i(zvR9F(wyIOO!W%7q5B1kS-s_#Tc4y`cIEUh9UCa$pFjtRBEes;MpC zaEKRI{nam}m3uDYw)=8{pF}&Nw6CJfVG2<)18`qDf+Ki_%EeK8r*& zi>Ni7&2Dn3S5kbD*e6)Ph*f%SB#Wc&nc+{PaR|{Yjrt4oNnAr%I6#3vmCcMw&k2Vp zpFdRQXG29W8`|^F!FJJeSS+~@t@$-jqETI${}hpNGE{^zpeRUUyCfd=d&-b*dKcdE zHO(a_Z#a+iP4PsQSN~J>_SI+Goz?R%>a2==Z?mHm5o)(letZD+zT-&L?1RdJ6zt@4 zf&#TYZNVC-2^2zZUK}iz-XVAQ0`WSJVX(NK03Zf(LLnrm^|w|$_O$Ax?tj!%Y(Ic(-7oN1(+|f5BQ$EhgrQI?bOr07 zKED_W0?G9FZGTs8a!Yn@JPQ$Uiv?unMl-SHVpOX9IYg_WbSxH1H1caMEQF@eSrXP* zSgg7Ub-{cVCQzE6O3w>mBzOxJ3m+5J=F`ZYgS~T;sbL1N_bQSos|cq;RKN)`!hWz9 ztw6NyRm7XL3LyHa7E{OLx%q(k*zPb&vJys+#nL*a3bLdBHC~Lg0*qJQ0Cyci7qj2?qYTdl;;&< zztCkI7V3iif;Vtl@_sU8S3fVV`kP(jX@oid}rpkl^=$ z;krz?%9bNu_hv=vk_D(i($6Bi@7MZ`FV&`>O+>%bGZKWnzczOfk14TX^Wk6 z9NC`6asts%m>&z#dG6F+!yrD_2jYBwP!ddr)Vx5JJs>{k+oRs%3O4V+Wz=wcbnKkz z0mV5vP@Q)chlFpynuOI<@NQy|2ye;i@1~TPLnL6^+XD9`lVsOlkv+MEgY!F}KChgJ zw1_Nw9*JirON!=bRDFICTO1%sqqExl( zL1#qaB zpwd_Qy-l|o@r7!-x0u}?T3=BwJ-X7Gl~ zE+Nl!5M_2F(57>?@!1lM20?1RHzfJJAuZ@f?K23{0>KcQ=SkG+OFsu=>nt0hRewgV zoUn3X16lqU)*sXab69RTN3GmEg#v$8kB-0vUR?E$Qgj3^n;S2^+H+t*6AmqHf#}R& z$nvF-rHRD81vyZfpH8E1I;8nxAU->otW*inY(5EO0yU~2Xf7;(I-SSmx603tV|jku z`y}TDu+d#fD3MJLSS@}5GvSBO5I#ennMR~rMvc1wYQmW$tiI4(mJZd0Tzo4W@(aRP z)m)kdr9~&9x;Pe!ivw{&{4CsLOIyPYE*9Ua$mQeoRbv&2@yNfDd-ec4Q#~ z(YfxdjVlVpvQUBS+!!|D^=*#gB%4=I7tEQIm>m%$ClJI70sIk*fpBZk!9|yQSRj6O zDE0{!u~ZTz!8Ee+1vK&okSG#i&Iy2uP&zx#k*BIqCX3U`%!{P+a-g%Y90n`OS-J{m zmn7!;lkGYOvn4lRvGg9ah+GdYJI_*Jl!Y>&ESyXYof_c6R3g?;77mahN-$V`8ZyE@ zP+1ZM)umC;SWHyBA{oY;GGVki2FJznZ+fT~T^#5c<89FW2dRb8S5BC0Pq}wwQz5K( z6(RM&3)Fi~pe1Aq^+7|p6gGu(Uejz7=}M=sM6uIIQ0_*Z=M?IEh7qv0mBsWW1l?Kt zG+EKc#E^r5AhEYd)p?0P@t4%5v!NgqNzN&l2KxvoFNlZE@>48pU>6^^aKMd`ujm|4 z0)TXu_sT6IP^EsMFh3sqmy|(8Fat^g1Pp@N`EmjYJW>6lmu)k>L=@&F6sS?-(pqo^ za&r>N;uo=5PZ|C&i1P)q6)IdKQ(KS)**P)va}o;?=q;>d@l)+ZMNE9PmgKMr0JVi_ zEM@D+lKZe;{usK#)ht%ag%0!=*FtaU8K^Euh78#)xdnl27WdHFLZ}g~sxKyzT|ktv zG!Y65=x-46!GX0T=8Hn0yxg1JmDWl8Y-d5xRj&^NUuN+H=y$qgwWDvVyYjh4gCCN+ zjn`$tWm^*>Rqmn6VF;IfKjKRC2Q)>Dp&{TS>ioZ=<$+j37ZJ7+A!?Kp3P20wFFyVl5a0-Q@*rgBO+gS=cheu5H&$KVArcSN`83 z>m;&QApZWog`7afu!R8{3ksmWw2}q(rRS13F3g4e{8*w{YIt-GH<`szuh!yxYIq!x zCPIZoQ(|r)S+N`(THFH1HE*H2s1jNvw%ob%;j63u^vasu`!sft!D$d z%92PDSYH~@1DJp+2~%5NK$N?b+USyW?4IKcjYTA~i&LPoFqYmE!QeuAZusPGJ|An(yUL=us0oMYf+B4_PU0;%V1x53)o)ECowrNd`+>QC*l0MS&C|f=U>z zswF|qhV1-sXp`6)uc?9QifcHr>Mf3~d<0E8CdVJcLJ6FWGFV+mjg!bgAOLd0L<}NX zFyB}Pjpg(jk%r;gd?JVt9NkzAll4W=6-mXxwYgATMg+Yq5(j@shyMCdm~Tye5U6#& zrn%yQ8c&>l+qF4s+$37_RZW=kLnNpUB2lRqQL@hwEB6L@h65qrc#y z-zd&|d_twm2b{5*Mve0ql-m!Z;LrftB0l1j(QBBktA(_%7bN&SVY{IV#!FkEyQByw z)^_8R;d`X(z9Ru{hW7F_Cahxf+;QmpGdQrS0DA?)Aw}e>ydVxTf&l~#evn@n3Q7I| zBGz0ky=zipo?noTNIowFz$^d$VzusS5VzD%V{s-_g;QC|2^TsrTvC7iONm_5ptrmTh9YHbWy}5*r=h+e8*V?mhw~4;Fj#t?&W(YxU#2G!xsSYp%n1aXak3e+VOy^DtOeNewv*`)}@g+hrxJL5=?$dhT+Ee=SglC!iRb$c_RBOuYHd`t*CSwi7K$@&dNFR z90`i=5ib6SNVNx%k}r`c-_JxgOLqXp#|BaBI)LWzF*Jnrk+^FJ`I=GKzDHwIPuk5l1Fyy42fzcWckC%_MgSkbuBo$;xSy;_u}yC z258ec2bPz^YQt5?3x~7DtG_ZIN{hp&hT`a^D#$PPV|1#%A_6MQsBwRv4ZE#%B(gbB zrJt3T2E%mYX&l>93H8;1&{!FbeJdhi@?$QHf6T<8^~um#8w&fqIn8Y)uX(qc`8B3i z4Sbq)HD&B*(b0Dq*$3a?ockDZ4BsI^;T__n-y>S`4I)WYW2Ac!A@vNo2ZvDOGJw{Q zk7y)XZ9VxB&5_e+4E%~3x6i0N{uyOfUs31#85LF^Q13B~O1lX-h}L6|fCEdT;s$)X zjklq*q=?#JB?^wx?78kn$u+ab096`1t}qKBG+_sVX2cU z!g0JMtGx2}De^+m=0vVNN`i?nSXB!Bg9W~@+)~EuKNljq~=w5AAJD-#mUd2v-<`A1|Gs4q?m(pZ{?L#xVhaAg@(7bd`RT@#D9 zaJ^g zn+tGkTQO{QmB4s?9(Ak`=zkvz&D8<#GQ69D``?TU@&xXmQ*Tv$P)RlHKNF_>urW&W z2?C^^!hJ(O&X|8jOV}r5X!Q}LK1YJ=0Fo8@5hM4SYBy5U-l5iMoQQP-*Au>=BkmKf zM1IEQ@Xx6A{DiZ1lPIy7Mxpr>YFtN=r8SH?pHVu08cusIlid%3>e5J9ZM*{KZI5VR zFM#9r>nODyp*l{KS`2wQhYJU2uSg~^h=Kf~U=r3099W&(X1F1P7gyz#e{7Lk93f(` zvbf;z_vO%8LDaam0@{mDLt|+Q4A-7vL4QLU^);4c!+Fy)cbEvfK}{iydIFF1|Z6u-<3j?FU{w z_8(O5cf8%2*$3UWKF}kpf8?jrFyC|rMjK9n+x5sv^dedR zQzWdpFj$|0!y8XQ=lhf3wwXI2R>?%v?5BK$sdv!p39#N?2162N(@nW>5xopI(KhNl z!PvJl5cYd>o3B>A;N5EG?^uW4P0mesX^ODjQ`F@kb{;l6t6;vN0@mbayhUHZW7{jF zDSSb-%QQ}NHwWB1jKsbD2ormXB*g*5%l0Equ^UzPV`%W6MxFlN|-Sx;`}$6GM};UbCbC8TMM zvsGNal8+!eKMZ2?U7))rj%w1R#>%)LUa#hrUsZ7z>oPa_p{hrFX)c_1U4tG`sp^tw z99&%t`;E5{B-#t}bq&329QF{IuFr<;o-@#29|I@xY9^w=N>^Fz)pAQdG}i=?pyt4ET^6ji zR4{Qh`za4cx0K<;&N?FDWE|WON1q@1-by<2>h1PtTX|ym-#A${I`uCXv+o&Oi>2MP z-%|t+$xCn)y?|poO6fZ;fz9Si@DRHX@7*M#Y9nY4`2}Y!2av8jiZ}%>OQ0Ju(yx&y z*N1GaQMS_Ra?l5~M}K4?f%b&YXbR`{6PQBviND~i#YYsGOyHu|M-*E0quiknO+gdz zmT953Qb2=l1~gVA!gljj8t{{8;6IP-gCoc}{04SgFXPz8dX|Nvu`)K%Nv?($SLKyo zXE7AX7tvpxS75mIG#s~e;_wfpFkD+i4Z9saJKy5yh8D76#V}f13EgE}icA%Ze>j8v zt21D=qlC@)ANV02$9Ggwr)-AR_97hGkcI;r5@GTaS^OUpm{3}7D}d?dEVxQufF+5s zt>_t;Z_b0owp(gPexdg#`AHifnd@1ICGe&H1Gq?m<}UFX%I=WLZC!rlflyo-=jmFUA{|Rjo6S$fD8SU|( z(Gu|)&0)Xbf;W-t@vkU3LXSs(#s&AUIDPN~&O3fWD+zXx%1s)m^I`ZyHV%JZi4&V| zLw7|stVvL7oIau0b`b7jH|h1Pwg^SuT~>MJH&Rp=Cy4k?Z(M`3~z)2K$)UrHRN6AX)t&M}xk7;n&T?^w4r=Ynygv2!q zUecFgur3kiTe7f!eH8o^T41&{okTYd2i7N$Ko`POrU3!+?Qj++TH3~mb2n<1&eJ6MLWfDnID2O?X?8blYllXmSQmDF1`|t6uNjm~gZq!)Dj1 zI~MePSZ*#LN^!V@ zoMA+2u_X^4(nOgXGf5b0;iuS4RGI^4i5eKJkH-lyqSPHZ@Y&k{lT8`07cIewJykfV zc7su^?apEx-jqcIb()c}&CYVTN;JV$tOfQv>TrDLdANwS&}TP5XDt`MO@WjA+2)Sw zZY7>*{`+caSeL8G#<=Ilcb>-a-6brx>L$?wf7vb~$2{2Ys)ZwcudZU3ad;gKv^$y* zq1=lIsUcL^lEn|6LZ1EzQkBM#sxXWMxjw{6_aaa411>mC5upy@R_a%DBut|%mfNu9 zD=zwcMfC|1R`bs&F#JRU`vrA=M8GDasQ3PWQ-*J8u)YAJP093~o`S)O3fOMBf+IiH z;H2!k$qfBBLHRn9ybu7d{Pv6f%G{una{ZHjqVM3a?K;fY*TQaV3yy8R058c~FxhYh z2iK*+jI8~!?S&+u`Sd&!hCjwrhpnK;M7T+vN3c>m9nZ#bu_8KthU|ScTqLXEuUwC# zJ9FV7bAdW^Cj8_ZVX`@$Xtj*aD`V+e9JzAD>MM5@{&LsgE!z&;9W_K*<#3UzLzwD4 zmLF^UV+I$R=(dzh>*#qk$O{$x8+Bsr^S@LicN~q>ZmzQ1k$2BxOAZXzXTx2h6;9%f z@Q`eQuk1BAN>tJJl@I$p6*RaJ#cr!W@ZKlz6@QK}i9wXwki`%Dj7*}|Or=RA$n>$A zrZ9#a-4S+k!H%fUxSq_#TR-DU6p?GdN1XHeMB+-sYWf*@2S4Jh`4`kUf5171Pq-EL zugEfd!4{oZkhmMJ%Z0DZ6BeQ}`=KgdN2ErC*CTo5cU7FW4T+qTdtcxw`Vcl-8sRS1 z1(!XYj4+PxK8FMAl8GwoVYR)O1Tq&EM5vAuWw0d?^;Nh8N3m+SOPz!9rbH&9CnV0m zVmk?`LL;1{N@2IB2v$4u>3yf*y_e`$>=aIjmcxlUxWB>`mLuyS(+FqD^K|Syf|Rep zQ??l{;!W_A>x8p-13hnqx6Cyd(BERPE&&I=Pk5W=aXECTcanFjnZMN+w+1)(X_r@- z{gi|gyGm(ryNnQ(M|6#EP;G~oTr)ydZX;6jK927pXR$pW`s?H9JGp{rjb}u)*AS&N zh!nL^T=e{idjAhZt;2{E?M4QPY|7pdB*_mU-(Vb9LZ)#e@eA6MCU7nOE1FM!!X^K| zpvr-)ztt4-4}PNh1;s}`q4?-9%8yN=$>(R}m=2QbDIf=Q7H;D0u-ks6&286hUR;$| ze&?YAA_uKiNj)|{U4fhEb)wg59Q+{*MjLWS46ETof@dR^LjqUd0B}Az=+uX@i4AF|2pzljs)0iRjjg z&h?PKM4wv=f29_Ls9q<5y$%-=bPu^Y7LRolyNCe!E_(lCgztL@XNfxcyHa4aC$H;5 z)-#how5ZtZ?j0A&a&i)lNIBS#VC4sN%{$2z+(CqP7Y$N%aFed5L8^_# z!~+ytV7-&RAE^uQl)i#6h1Up?=|PU(6zY9GW$ zXbzepVx7jVl)sR;{){V;KeO!x&stBT(s~L-#*@f7Fo8-U)-DU<%HUFN)A$18uRa$-lTx$Tbn9(VB$SZ%Gw@ttJRcjhtLwAh&e7ikhr(E^xn z&W7>UIJipHAW-QtJY;L&qi}%;H49d|v*9CON4CBKmOIjkL@%@m;m>+}nsCrRzk-mtnW-9Erv|Bxt`!f^IMT zWFNBZ1e+bD_k1-jo$IbgqX5~PY$DBJPhD5B&zpdezA3)nyQp3)xS{W(T2}8Ue!A0Lt^y~uy6Bp| zAYpxp812`H*!L3Any(O|b{C#<%|x*`i1=?IT>S>z_SO)s()U1O9HMp&o-&u|x?Uz{ z(uEYQ5tjJRS^bKm)5uW%fJB*oB+3pTokTW$-w-bQeMEiW09*3f8a0g$I=3l=6Vkt+ z!fqOQhF_3pFom4`pV1oj7Ze(g;(E-#(rd$Q8RpM8caCgi z6A5btcfTw|s*~`^H<10mKpnM=I&dw#h+N%>YLAQO(uG5AyoM~0#xe}ta1&R=8uSU8%PLlQHO71L>r*eMr2lxP{k)m zJw)`X^B(b9eTY#VMxy2b;&flaTka}}NEb4U`U^V?#`TBaPyg;j_Vw+tb*abN)10Nw zcDT@W3{~lXi{vHt|A(qRK$O-~q#F&;HGhjlonE@0w-KaD!m4(gxr0c}E_f@}(?Hlj z-x=pD&e4EbN!PfUg%aXaxXoCm&>sH@S^GwjC`Z><<{P!9DU2iEU<{p!A8|YFXS794 z;a2+3XpR1gOM$=OywhJ$ZTAJGmYlGTB2#A!7d$6Xe0chPliw#^T$NXN<=-lPa!qnR z@(n#fO3g&8NhGkRVY54rMDRQUl^ftBUWz3BTVy%QsFqOYt-;Y-?nrjT`T0vU#VNINuu6vG}8m?wzUdxY~rBVKK#Z}$BjM3viU zJj0p${*12luehG{Gdk$J%RxV*C4i{a{xfP%d_?Ynzal|-5NFLlOkQ;R z%-af(S9s;$6_1rDGG9l4w8IIbY$XY4H4$hVLNy!Mv1pA>oRBz89k`x^wiw}B z&FmaknG)EEXORfrN4owK1S+(^Pw^t+^@&=Qn~9_@z(ejl32+zL+zxokUm)vRPn67A z+XiM~{S`aO`aVXHEp>MNaikC-rBTf@oj{h!AYyf&QhiRs{0uRA50Gm7xFA^PLREA5 z-QVo3X0Da=YWb>G*83?};iP&yBDFecKx=}xLIWbTJBik>Bh$Eti2fBa=^7**c#Zh| z-N-Q;M4a9W_{d*@A6@H{tE^d6FTCET7y30vhTm5(*7$7jK5_H zLhJtQ7@N(A?q zKKCAy44=SeNA|t5L7iUxJ)^&wUAJx&4{8dBkfyL+ZhINIB4lLc>pJ3iyJn(Vvm2@&Q>?(-p>%sxXEOm2tF%eMU#jXBH0V zNce*53IB?gkpGEhzptpWpGJ}C&u!($K5ygo5?tazv$qCEb|%7nM*^Ir3K2?{G;Cip3FUQ0xBg0Xh}5}CcAlt8 zyOmzMf|P@gNeEsbl%B`x+@WLFkYWB92}Grdy04LAI*hpeFOhv{0I_O)$TAv7n(;g2 zS`3j8KSP?~TN2erM6OQ|O=25O!t5k=mc+cGwKVv?*YjKb8-A^#TAzFWP=e9b!Wga2 znsk#}h^0X$PWuMjaQW;WN5Mk5F`c5NRgeH1NEk|Mv+p z4)+k1J}1F_LD#nf*~YJsV)y|5>gN%uOV{|oJ%p&X(sjH|M0*=~hewcaJc_2UDO_}) z!YS2BCaxJuACR~26G~0Kp!MVw?xg*UdpTTa;1_fz{(^I!Q)u@6OHYZ-&%C%Qukgx$ zXYp66F?WkDq{5BE&{(`mN%@zjcjl$S?SjBgeMtJh!jQ>!JxqyfeF0TF!*VszWtwaGSl zie%$kNH*$X0}^+Q@-2H2yZ;^vtOt;5)r&&AVH#B4Aj_u!3=o)e%fz(6yiC|mc ztyoI~&UM7jEIPx_<;ncnv4abYzh9qg7SGG0AAshzhCi?uW$-iz0%_(TL4EQR8GVqHLoH> zy`HG_D(oe55w3QH#Fd0X>l)GL6Qmt@h#=(#66F>mu)B!gPn2eG4e6$L$O1n=010&N zv8P0(kC0+?AE!xBGmLsrU^Rp?r%@Cf`G8`ZPbjgS###Gexec$q6)@c#54&A?u-lWB1G@KUHCLglh5E+9s;6G=psN&D|2LH`C4xa(qkpM>*1(hfdE zmI+-ygXajR!7Ib;ISKAF`v2c^*%FA-d`QImgs$~{oHBcfaE&(Pm_McW--DC%S-Q?Q zk!*0A1|crwatEmfeROSyQ1AW)o$H7}0vkR}wi@BUtqk z(n%n=i7{WLYD8*Zq0Zh#V)=rJNwUFRqOvNlhktyks%fOw(7$H76RgeuJ~e-;v1NM20C@U$Ym8)@&!yK93;P z^YB%yftOq*0u<_zr1cD0hn^QkX|>g)**C@4r#~^fd9hpO+0DKUAI2vCOeQG`5hUQv6&Is4Mj5r-G4ecDlROlM$-$A4X4LJ58b1a|&g4 zUvSQeNbC47$g>zm_K~;9HYZDL{t}soU*nAJ01`>4i>>;QbnrT|4nJVR606mTOrkh0 zmKmbj1YeaZL};}jN%s-`t}6)LcL{!q=iseS2`{BmBFgg1QTk0~;Rff63q89+tAk#6 zRmVI$(U|tqq9*pS-Gzi_HWw3LST&{gSQPu-52*Be<(FX6mK&|zQI%?V|4bo?VW!y~ zoH_msr!0vkEgm39tq$QTtwi>XNYd{jF{SHZ&`HF3i>}diqW%tqX&zq6+j@LSsFKKj2C9-!YFs5jZN^CwjL>}zM5s5AZS;hQ zwTrASQR|_bD71cwY|DEnuzXEoL&wb?lQ`ZbI(vtV!!J?dIEs=JA5i7+7ZTPlR6ioe zWR$3Fg2ZYNnoy^fP^N=u!E@YD&qAz5v_FfNNzYlFWU(J1|&c_j8ZhHnt4QU@PdI;M67@jAB=soTol@2_%>Y&`ufI_)H)O)Qly zT>T3D-#1yDG>qsrL7$!_)B9|H!IjXTaXfC!DEVuDtZSq*d~&3Kaa}aL1-kTj{f5W~F-f%m9kLmWbfSh*+ng`BMWL&TWxm96-M3 z1Sz;DcyNhA*}z3qhb#)|)P}61o)lJ*|2&cF7V1LxN!{+FPW=(h!9UP@htNfQ#{H{b zP!sf?l-nCLN57_HY$4BQ3Z;RwL@JYL4S9nyuN5Ng4I%L&j~P<0Q>3h)A=P0JNw&{$ z&yEzeWhbs$wjtGd5Q(-u^qmGMRG*NW13%xS(E7G@50T_F?QcX5h3NMjheV-EJDJ@O zV*jN3N}>*9$aEc(Vqd27IO0yWka}JxLVZDD`iP_^QXHNO$uj{nnO-~DPRE^;bV0t$ z0@CPx&bgNQ&7(EqHGQ6euE{D&{7K25e~C8DKHYHMj@l!oZ=}yA z61}jEn)9UE&(5JNa9R{_)mbL!byBl?s8S!IHS8k{X+IOeenExf5sFV9q1yI)eeNIk zPALDu3KaZ;QR+P}ty>u`!!or+WQ!`lRU|t+LayrsDoK$gIrJiv-Y@o^qfq`0DaEfT zf({K4B`L3(&~>z3+(%8wTQr{EqmcM5>I42N>4Ca)2e=>i1@|w1Phsv$v}$%~`)$+( zzmgm-tGzP6S!AmW^gNGpBI+z6xJ*)@?2V9aKTe;wfa}(zQtf&X`{xD;$&-mFZ=LC( zM>mSxSBNB^6Nx?{GA6+oVAY2_)jZvVjA)M7L{0b{ zo%13JJ!eoIxQ3eGHRvMW(Yd`LmHG<0n73%YctB)(2z~qq6bCGzJ?bs)+CC+s9ieOb zO3pjqbDVB2Q>gOi-1Pw|*pKLp{24C_e#AiHk0>~~H(Y6BR`RL}6#SZ?*O*V_IL(+! z{TD^OwuHQ+aGGiYcx~M}m$G)cLJv2q_pelG1#eqDCutZ92naJfON{F!YJPp#pQ0z4) z?M*4RBgpX>CuKPyQ)8TSWd)mTI}ELDAGG$pq;l!|l2T2uc}T=MMEeYhZ$b)fljk{2 z1U`p+w|S&GJx8%8h2Zo#1@wEas}XnY`{?&sB-;!jkq9%_;|1=KYUN^8rs@Tev=M3c zBhcE=b}q|A)MKP(pP|xslL&cC+SeMx*3lTbiX!hBQTMgyRwd-`y0VM5m_2mF(Ye!g zYKt+GQvHOs*gaCPTj;*Lht}{nbi|eE?=e;U zlX);v8Cg}J;8%?ln?ZHD-MEQKj#X=!&jPp|sfNh3J^Ced;U-BJ6nYye?B~`hBay=< z>WCog&%Z-c#1UGekI)%?EWV+gM6#`ndLU0VgA7u!Tv<<7jiSVFiHLAmh_cdeQwm=RXC6t& zU+lU{g!mX*B0Kh2V8YFJofSgN;DVIhfE3HJRgXXKa#u8YVdm8(7T1lf+$NV0h@ zeXQxK5jw_W$={ZGt;@04lYzG@^fb~aaFqHB|$*U?*@LPfU z8|@#8{f*iRzZL0w&2$+;ZP2=ezPhLlDZJ<|yp#f0Y2X}Mqu)S(?ErO=Cdnx_h8>|P zY#;UKj?jDk3z5hNv_%uiM7%_G$R_Q(i@I~KNa1nQ{WIhenPxhTN&zj42#`AllI)+z z2rv616niXFC{CgIsryK_A0%~aK&s;q%Kg?!Wlqq(FC-^gva|lLEFgnHlX3+tKr&klag0epy0QNmhin3jUnrG zP2p>#4Es@eb^-Zb6VMS!Hk{i=y?Td8caunS9gnqUw8tFDAVG5kg})b%(G>E%cnx%1 zqR=?{E$Sn`qtJLCO&4BE(|tXW5G%imvok30m?okk0uNZC*Onwtnqc(=_v{T)mFJM0 z+oL#7SsA!NA^JFy9iAb@W=KA}+;dHeX6cS&@}0C+Po>kM zk*-5a)F#RTh@gFVpn``YUZRA~fzP`&`jBo&`)H4QPsF-UukF!|hR=Tjts(Ew5xs*F zQvXGs({xVDXb9diHHMg!ys82PzXz218!f5=R!mHUMZS|1)|+tu(k_L;q*|liqMFoJ z=f%%xzp@K`ycr!ae?dpoPiT!erqK2idT)Fo;yp$cZCB*Ggs#{lv|f0Raw4GKtNWq= zn}T1VKKMInmn!y{MODB$DNdabCAU{`=*~T^Om3w*>Iqn{1ZOUjBh&%-DroMbbAeAju|Cc|}@2=j?_B&3ll=5#}W+X7NZ zS*O!}_v}YWl`hJDxsJ1>u(`PP0!`uU6JSJ{zY&cT=9l@-)Ad+GXY9T#u~HZI22B@t z>3V&U9BSv4w}*dyk?{O*ad_1#?5#qLNotpy2n2T;D-;ZSaz*%zqB$ z>RA-}Orb)(Bn2AIqu#%IB$G&-chz6|5&D?FqAlt(+B9Z#UOPlR&)A3WNP6JG6)y1X zpf%D&q_jaH{vyhFd^B)@NNrYz9B!O^AYpr!>zJ6zTtBH7<;teuT(rvbn39PoE;ywT z`Q>{}BhPhCUQaqRK*wB_^}*5{264x>k5np8J{hE^H`{576srLl6z*rL#*ldGvGmMl z5n&elEQ+^66{%w;b{#3qMC(3DLGVhcm%nY6ylo~OubR%kniPEfxw&YX0t{kH|f?J3_qa~ckG~#bWq=z!4)f%;rhV!qXi++bf3bD&c zxiy~OAVtd_uOp-|hltRIQRFcvrYLMMQ{*>`yAF?0;l(C41KPi=yQA zDd|a7&7e@4`{`It&yhl;cuVrIqteQi?au90Q!-l1#jYeLQlkz={K>V3@Aw}*-<$3>H*D0jhjY!V)mQ9z8#&Rlvy9e08tH5=MRPMMGpbAI{ zr`irtm~Rvnnqb?DZ0BiGuk%Q8d4dv8Qj%`-k{;mpDs}@a@S3LI4dB6wo3xMgysD;U z{Pwnu9?1?*kx0t6A#@#OzD(u=bc_k;FTFwg#T^v-&p>~TZYUSc=#Dp|>+&bGXx@{u zKQQa#54E)#lac~Zpg_TY50$|inpVv_Q>*3!p4|EweOLd22b!PIL+Y(2=m1R@KBDL9 zPo(bNqATtYr2(r%I`2vKy^*{nw=k7@Eh5u(Sb9qHJV+tBE+9`e2lhZwV$+D2b3G@C zEC*yHHplfJz63<(N!CQ*J}*$_wSilwdJy~PCZyA6CtCI+mB_V#4Y7%!a~zFC-UgHh z&Y>Y>19|S_XpZD@;C0lU+d+M}33U-BI@iylTnQY_kX$8qB2)*g(EHz^#*h77 znZzE+iU@2V%>^o672)O?y(~wQ>oO|~D(1N?kcu@Bnev$I91-9!GTcUpC|^hm)s0h~ za;y@M6>+ZO@mMZ~@%U?!^#Bs>dL&)IT?$OX9QxMKq+?7<5lhx0vwbQA&)x!e zNilP~SatA%OqgZ67*Oav30=e%YJykL5VcL@x`X!Ek7x`(94_@&TB{T&Q1DMcZMgYF zZP17Ldi4=1{Xd{9>Sxr29H2VHgx1K9XrV`S@GDdWZAoFLI%o+c{?kOp8$wP+9F{v7 zP@tml-gQ!PpX_rQZ>g77D4rf;MVo3jOkw$|7`5=~3d!_4o2+mOAxAYO4*#WIt3;xM zQUqf+tyqf&$)ED%R+=M|=71EmxW6^UaY*`Ib6t$c^&Lln#~doWwk3Cao3=?OMa_c* zoNvu>8xz%9;6JovXbovznZ@|&&jYrmd6tjK*4 zU78(Khs~l{y^Fin{kR|ZnjNyt`R< zdlO_k%%Iqloxq;px>c795^$^6bt}De4ctEU5Y52{NK^HrR=rL)f=Lv5O`-V$6ZNpZ zRK0#e`HL%1py2-uecGQ-=%Nqm+AhC`F8Tu+LibR4b{n-suEoC7Vh&U7zb-jUcHLs@ zJ~nRQu7C^*w|Taoi%#MZ;QXAz^)1}A?3Hjo{&WZOT;^nufX%eIbD+eVkFzM&g;yOr%5vLPp8FKi>_(Azx=-A;_;ntCWu;plNXpk|O~!8XJ!X-3rk_-;frz5*2iR#sV6pg_Sd6xG4&>h@@piI+S{aeOT4fozW5)2 z#GS%!&lNFUNhT%AD*)uUOd`j5nh3C8icdEzdt@Y)yj>wou+hI)706cPg&9aTuY8Nu>nS5DAFCd;*dG(w# zr`e5YYgNh+fC2>yekEuOTT`_}Zg%Imj#Ajaj0(SHBF28{HRWOx6WnzQ?^A7grGiBn zL5=uhIpQt!qFmYBrNDFMt39F0fE4>-Sr(i<2zVHPC%rf=Q0coRBwHS^Ecshb4aiCd zr+H1Tr*!;bWVso{RqHNo&t~1V>g{2j`cR{>s8vW+fdU1;PSmQ`PxM@QqfU1k94_}> zm$s+dR=r4fG$74xOnO^W9S3D~fZL}Y%TnLmubSpGfP8OKwXPE~rpjw#C0aj}@SY7< zcx07Hl}BH%pX?U@ST?@SRvGEI2C*&Fp6)||`+^J{q}V(k&UH6x`v6HY%ga|Zzzs+eRs|9MaKTx`lZlikqEY5R%}gn7?6;ktN*;b3zPA!(+?J|S$5`SJ5H+=g{nY-g5Mn~Jhr|m z@tjwcc&%s>tRLj%yUz`$+6@igv3<0Y=`dxEx44hEZ(GE$MQh!MT<2L_`nJ)W?rhje zw0^vkV*ji=%WbqST{WU*)0rz4?cZoE<`ptkpg@5F1qyzP_zyN4`RKUL%sc=9002ov JPDHLkV1myZcL)Fg literal 0 HcmV?d00001 diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..f20eb9e --- /dev/null +++ b/frontend/src/contexts/ThemeContext.tsx @@ -0,0 +1,33 @@ +import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; + +interface ThemeContextType { + darkMode: boolean; + toggleDarkMode: () => void; +} + +const ThemeContext = createContext({ + 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 ( + + {children} + + ); +} + +export const useTheme = () => useContext(ThemeContext); diff --git a/frontend/src/hooks/useRealtimeWebSocket.ts b/frontend/src/hooks/useRealtimeWebSocket.ts new file mode 100644 index 0000000..51a14c9 --- /dev/null +++ b/frontend/src/hooks/useRealtimeWebSocket.ts @@ -0,0 +1,196 @@ +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(null); + const [connected, setConnected] = useState(false); + const [usingFallback, setUsingFallback] = useState(false); + + const wsRef = useRef(null); + const reconnectDelayRef = useRef(INITIAL_RECONNECT_DELAY); + const reconnectTimerRef = useRef | null>(null); + const pingTimerRef = useRef | null>(null); + const fallbackTimerRef = useRef | 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 }; +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..99508f2 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,18 @@ +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; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json new file mode 100644 index 0000000..ef7d2ef --- /dev/null +++ b/frontend/src/i18n/locales/en.json @@ -0,0 +1,64 @@ +{ + "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" + } +} diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json new file mode 100644 index 0000000..d6839d3 --- /dev/null +++ b/frontend/src/i18n/locales/zh.json @@ -0,0 +1,64 @@ +{ + "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": "选择日期范围" + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..ccd2059 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,84 @@ +* { + 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; + } +} diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx new file mode 100644 index 0000000..ac1d705 --- /dev/null +++ b/frontend/src/layouts/MainLayout.tsx @@ -0,0 +1,224 @@ +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 = { + critical: { icon: , color: 'red' }, + warning: { icon: , color: 'orange' }, + info: { icon: , color: 'blue' }, +}; + +export default function MainLayout() { + const [collapsed, setCollapsed] = useState(false); + const [alarmCount, setAlarmCount] = useState(0); + const [recentAlarms, setRecentAlarms] = useState([]); + const navigate = useNavigate(); + const location = useLocation(); + const user = getUser(); + const { darkMode, toggleDarkMode } = useTheme(); + const { t, i18n } = useTranslation(); + + const menuItems = [ + { key: '/', icon: , label: t('menu.dashboard') }, + { key: '/monitoring', icon: , label: t('menu.monitoring') }, + { key: '/devices', icon: , label: t('menu.devices') }, + { key: '/analysis', icon: , label: t('menu.analysis') }, + { key: '/alarms', icon: , label: t('menu.alarms') }, + { key: '/carbon', icon: , label: t('menu.carbon') }, + { key: '/reports', icon: , label: t('menu.reports') }, + { key: '/quota', icon: , label: t('menu.quota', '定额管理') }, + { key: '/charging', icon: , label: t('menu.charging', '充电管理') }, + { key: '/maintenance', icon: , label: t('menu.maintenance', '运维管理') }, + { key: '/data-query', icon: , label: t('menu.dataQuery', '数据查询') }, + { key: '/prediction', icon: , label: t('menu.prediction', 'AI预测') }, + { key: '/management', icon: , label: t('menu.management', '管理体系') }, + { key: '/energy-strategy', icon: , label: t('menu.energyStrategy', '策略优化') }, + { key: '/ai-operations', icon: , label: t('menu.aiOperations', 'AI运维') }, + { key: 'bigscreen-group', icon: , label: t('menu.bigscreen'), + children: [ + { key: '/bigscreen', icon: , label: t('menu.bigscreen2d') }, + { key: '/bigscreen-3d', icon: , label: t('menu.bigscreen3d') }, + ], + }, + { key: '/system', icon: , 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: , label: t('header.profile') }, + { type: 'divider' as const }, + { key: 'logout', icon: , label: t('header.logout'), onClick: handleLogout }, + ], + }; + + return ( + + +
+ + {!collapsed && {t('header.brandName')}} +
+ { + if (key === '/bigscreen' || key === '/bigscreen-3d') { + window.open(key, '_blank'); + } else { + navigate(key); + } + }} + /> + + +
+
setCollapsed(!collapsed)}> + {collapsed ? : + } +
+
+ setFilters((f) => ({ ...f, severity: v }))} + options={[ + { label: '严重', value: 'critical' }, + { label: '警告', value: 'warning' }, + { label: '信息', value: 'info' }, + ]} + /> + handleRun(v)} + options={devices.map((d: any) => ({ label: `${d.device_name} (${d.device_type})`, value: d.device_id }))} + /> +
+ `共 ${t} 条` }} + size="small" + /> + + setDetailReport(null)} + width={640} + > + {detailReport && ( + <> + + {detailReport.device_name} + {detailReport.report_type} + {dayjs(detailReport.generated_at).format('YYYY-MM-DD HH:mm')} + + + + ({ + color: f.severity === 'warning' ? 'orange' : f.severity === 'critical' ? 'red' : 'blue', + children: ( +
+
{f.finding}
+
{f.detail}
+
+ ), + }))} + /> +
+ + {detailReport.recommendations?.length > 0 && ( + + ( + + {r.priority === 'high' ? '高' : r.priority === 'medium' ? '中' : '低'}} + title={r.action} + description={r.detail} + /> + + )} + /> + + )} + + {detailReport.estimated_impact && ( + + +
+ + + + + + + + )} + + )} + + + ); +} + +// ── Tab: Maintenance Predictor ───────────────────────────────────── + +function MaintenancePredictor() { + const [predictions, setPredictions] = useState({ total: 0, items: [] }); + const [schedule, setSchedule] = useState([]); + 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) => , + }, + { + 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) => {v === 'critical' ? '紧急' : v === 'high' ? '高' : v === 'medium' ? '中' : '低'}, + }, + { + 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) => {v === 'predicted' ? '预测' : v === 'scheduled' ? '已排期' : v === 'completed' ? '完成' : '误报'}, + }, + ]; + + 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); + + const dateCellRender = (value: dayjs.Dayjs) => { + const key = value.format('YYYY-MM-DD'); + const items = calendarSchedule[key]; + if (!items) return null; + return ( +
    + {items.slice(0, 2).map((item: any, i: number) => ( +
  • + {item.device_name}} /> +
  • + ))} + {items.length > 2 &&
  • +{items.length - 2} more
  • } +
+ ); + }; + + return ( +
+
+ + + 预测性维护 + + +
+ + `共 ${t} 条` }} + size="small" + /> + ), + }, + { + key: 'calendar', + label: '维护日历', + children: ( + + { + if (info.type === 'date') return dateCellRender(value); + return null; + }} /> + + ), + }, + ]} + /> +
+ ); +} + +// ── Tab: Insights Board ──────────────────────────────────────────── + +function InsightsBoard() { + const [insights, setInsights] = useState({ 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 = { + efficiency_trend: , + cost_anomaly: , + performance_comparison: , + seasonal_pattern: , + }; + + const BarChartOutlined = () => {"#"}; + + return ( +
+
+ + + 运营洞察 + + +
+ {loading ? : insights.items?.length === 0 ? ( + + ) : ( + + {insights.items?.map((insight: any) => ( +
+ +
+ + {insight.impact_level === 'high' ? '高影响' : insight.impact_level === 'medium' ? '中影响' : '低影响'} + + {insightTypeLabels[insight.insight_type] || insight.insight_type} +
+
{insight.title}
+
{insight.description}
+ {insight.actionable && insight.recommended_action && ( +
+ + {insight.recommended_action} +
+ )} +
+ {dayjs(insight.generated_at).format('YYYY-MM-DD HH:mm')} + {insight.valid_until && ` | 有效至 ${dayjs(insight.valid_until).format('MM-DD')}`} +
+
+ + ))} + + )} + + ); +} + +// ── Main Page ────────────────────────────────────────────────────── + +export default function AIOperations() { + const [dashboard, setDashboard] = useState(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 ( +
+ {/* Overview cards */} + +
+ + } + loading={loading} + /> + + + + + } + loading={loading} + /> + + + + + } + loading={loading} + /> + + + + + } + loading={loading} + /> + + + + + {/* Tabs */} + + 设备健康, + children: , + }, + { + key: 'anomalies', + label: 异常检测, + children: , + }, + { + key: 'diagnostics', + label: 智能诊断, + children: , + }, + { + key: 'maintenance', + label: 预测维护, + children: , + }, + { + key: 'insights', + label: 运营洞察, + children: , + }, + ]} + /> + + + ); +} diff --git a/frontend/src/pages/Alarms/index.tsx b/frontend/src/pages/Alarms/index.tsx new file mode 100644 index 0000000..b34d30a --- /dev/null +++ b/frontend/src/pages/Alarms/index.tsx @@ -0,0 +1,314 @@ +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 = { + critical: { color: 'red', text: '紧急' }, + major: { color: 'orange', text: '重要' }, + warning: { color: 'gold', text: '一般' }, +}; + +const statusMap: Record = { + active: { color: 'red', text: '活跃' }, + acknowledged: { color: 'orange', text: '已确认' }, + resolved: { color: 'green', text: '已解决' }, +}; + +function AlarmAnalyticsTab() { + const [analytics, setAnalytics] = useState(null); + const [topDevices, setTopDevices] = useState([]); + const [mttr, setMttr] = useState({}); + 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 ( +
+ + {(['critical', 'major', 'warning'] as const).map(sev => ( +
+ + +
+ 已解决 {mttr[sev]?.count || 0} 条 +
+
+ + ))} + + + + + + {analytics && } + + + + + + + + + + + + + + ); +} + +export default function Alarms() { + const [events, setEvents] = useState({ total: 0, items: [] }); + const [rules, setRules] = useState([]); + 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({ 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 {sv.text}; + }}, + { 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 {st.text}; + }}, + { title: '触发时间', dataIndex: 'triggered_at', width: 180 }, + { title: '操作', key: 'action', width: 180, render: (_: any, r: any) => ( + + {r.status === 'active' && } + {r.status !== 'resolved' && } + + )}, + ]; + + const ruleColumns = [ + { title: '规则名称', dataIndex: 'name' }, + { title: '数据类型', dataIndex: 'data_type' }, + { title: '条件', dataIndex: 'condition' }, + { title: '阈值', dataIndex: 'threshold' }, + { title: '级别', dataIndex: 'severity', render: (s: string) => {severityMap[s]?.text} }, + { + title: '启用', + dataIndex: 'is_active', + width: 80, + render: (v: boolean, r: any) => ( + handleToggleRule(r.id)} size="small" /> + ), + }, + { + title: '操作', + key: 'action', + width: 100, + render: (_: any, r: any) => ( + + ), + }, + ]; + + const historyColumns = [ + { title: '级别', dataIndex: 'severity', width: 80, render: (s: string) => {severityMap[s]?.text} }, + { title: '告警标题', dataIndex: 'title' }, + { title: '触发值', dataIndex: 'value', render: (v: number) => v?.toFixed(2) }, + { title: '状态', dataIndex: 'status', render: (s: string) => {statusMap[s]?.text} }, + { title: '触发时间', dataIndex: 'triggered_at', width: 180 }, + { title: '解决时间', dataIndex: 'resolved_at', width: 180 }, + ]; + + return ( +
+ +
+ + )}, + { key: 'rules', label: '告警规则', children: ( + } + onClick={() => setShowRuleModal(true)}>新建规则}> +
+ + )}, + { key: 'analytics', label: '分析', children: }, + ]} /> + + setShowRuleModal(false)} + onOk={() => form.submit()} okText="创建" cancelText="取消"> +
+ + + + + + + + + + +
+ + + ); +} diff --git a/frontend/src/pages/Analysis/CostAnalysis.tsx b/frontend/src/pages/Analysis/CostAnalysis.tsx new file mode 100644 index 0000000..eb1135a --- /dev/null +++ b/frontend/src/pages/Analysis/CostAnalysis.tsx @@ -0,0 +1,245 @@ +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(null); + const [summary, setSummary] = useState([]); + const [breakdown, setBreakdown] = useState(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([]); + 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 ( +
+ {/* Controls */} + + + dates && setDateRange(dates as [Dayjs, Dayjs])} + /> + + + + + + + + + +
+ + + ); +} diff --git a/frontend/src/pages/Analysis/MomAnalysis.tsx b/frontend/src/pages/Analysis/MomAnalysis.tsx new file mode 100644 index 0000000..236ec6c --- /dev/null +++ b/frontend/src/pages/Analysis/MomAnalysis.tsx @@ -0,0 +1,130 @@ +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(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 = { + 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 ( +
+ + + 对比周期: + + + + + +
+ + + + + + + + + + + + = 0 ? : } + valueStyle={{ color: changePct >= 0 ? '#f5222d' : '#52c41a' }} + precision={1} + /> + + + + + + {data && } + + + ); +} diff --git a/frontend/src/pages/Analysis/SubitemAnalysis.tsx b/frontend/src/pages/Analysis/SubitemAnalysis.tsx new file mode 100644 index 0000000..a01c86f --- /dev/null +++ b/frontend/src/pages/Analysis/SubitemAnalysis.tsx @@ -0,0 +1,222 @@ +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([]); + const [flatCategories, setFlatCategories] = useState([]); + const [selectedCodes, setSelectedCodes] = useState([]); + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([ + dayjs().subtract(30, 'day'), dayjs(), + ]); + const [byCategory, setByCategory] = useState([]); + const [ranking, setRanking] = useState([]); + const [trend, setTrend] = useState([]); + 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 = {}; + 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 ( +
+ + +
+ 日期范围: + dates && setDateRange(dates as [Dayjs, Dayjs])} + /> + + + 分项类别: + setSelectedCodes(vals as string[])} + options={flatCategories.map(c => ({ label: c.name, value: c.code }))} + /> + + + + + + + + + + + + + + + + + + + + + + +
+ + + ); +} diff --git a/frontend/src/pages/Analysis/YoyAnalysis.tsx b/frontend/src/pages/Analysis/YoyAnalysis.tsx new file mode 100644 index 0000000..8d16a37 --- /dev/null +++ b/frontend/src/pages/Analysis/YoyAnalysis.tsx @@ -0,0 +1,108 @@ +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([]); + 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) => ( + 0 ? '#f5222d' : v < 0 ? '#52c41a' : '#666' }}> + {v > 0 ? : v < 0 ? : null} + {' '}{Math.abs(v).toFixed(1)}% + + ), + }, + ]; + + const yearOptions = []; + for (let y = dayjs().year(); y >= dayjs().year() - 5; y--) { + yearOptions.push({ label: `${y}年`, value: y }); + } + + return ( +
+ + + 年份: + + + + + + + + + +
+ + + ); +} diff --git a/frontend/src/pages/Analysis/index.tsx b/frontend/src/pages/Analysis/index.tsx new file mode 100644 index 0000000..b24a1ca --- /dev/null +++ b/frontend/src/pages/Analysis/index.tsx @@ -0,0 +1,336 @@ +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([]); + const [data2, setData2] = useState([]); + const [summary1, setSummary1] = useState([]); + const [summary2, setSummary2] = useState([]); + const [comparison, setComparison] = useState(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 ( + + +
{label}
+ +
+
时段一
+
{v1.toFixed(precision)}
+
{unit}
+ + +
时段二
+
{v2.toFixed(precision)}
+
{unit}
+ + +
0 ? '#f5222d' : '#666', + }}> + {change > 0 ? : change < 0 ? : null} + {' '}{Math.abs(change).toFixed(1)}% {isImproved ? '减少' : change > 0 ? '增加' : '持平'} +
+ + + ); + }; + + return ( +
+ + + 时段一: + dates && setRange1(dates as [Dayjs, Dayjs])} + /> + 时段二: + dates && setRange2(dates as [Dayjs, Dayjs])} + /> + + + + + + {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)} + + + + + +
+ ); +} + +export default function Analysis() { + const [historyData, setHistoryData] = useState([]); + const [comparison, setComparison] = useState(null); + const [dailySummary, setDailySummary] = useState([]); + 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 = ( +
+ +
+ + + + + + + + + + + + + + + = 0 ? : } + valueStyle={{ color: comparison?.mom_change >= 0 ? '#f5222d' : '#52c41a' }} precision={1} /> + + + + + = 0 ? : } + valueStyle={{ color: comparison?.yoy_change >= 0 ? '#f5222d' : '#52c41a' }} precision={1} /> + + + + + + }> + + + + +
+ + + ); + + return ( +
+ }, + { key: 'loss', label: '损耗分析', children: }, + { key: 'yoy', label: '同比分析', children: }, + { key: 'mom', label: '环比分析', children: }, + { key: 'cost', label: '费用分析', children: }, + { key: 'subitem', label: '分项分析', children: }, + ]} + /> +
+ ); +} diff --git a/frontend/src/pages/BigScreen/components/AlarmCard.tsx b/frontend/src/pages/BigScreen/components/AlarmCard.tsx new file mode 100644 index 0000000..53faf4a --- /dev/null +++ b/frontend/src/pages/BigScreen/components/AlarmCard.tsx @@ -0,0 +1,91 @@ +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 ( +
+
告警信息
+
+
+
+ 活跃告警 +
+ + +
+
+
+
+ {(alarmEvents ?? []).slice(0, 5).map((alarm: any, idx: number) => ( +
+ + {alarm.message ?? alarm.description ?? '未知告警'} + {formatTime(alarm.triggered_at ?? alarm.created_at)} +
+ ))} + {(!alarmEvents || alarmEvents.length === 0) && ( +
暂无告警
+ )} +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen/components/AnimatedNumber.tsx b/frontend/src/pages/BigScreen/components/AnimatedNumber.tsx new file mode 100644 index 0000000..82605a0 --- /dev/null +++ b/frontend/src/pages/BigScreen/components/AnimatedNumber.tsx @@ -0,0 +1,38 @@ +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(0); + const startRef = useRef(0); + const fromRef = useRef(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 {display.toFixed(decimals)}; +} diff --git a/frontend/src/pages/BigScreen/components/CarbonCard.tsx b/frontend/src/pages/BigScreen/components/CarbonCard.tsx new file mode 100644 index 0000000..e0ef6e9 --- /dev/null +++ b/frontend/src/pages/BigScreen/components/CarbonCard.tsx @@ -0,0 +1,139 @@ +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 ( +
+
碳排放
+
+
+
+ +
+
+
+ 年碳排放 + + + kg + +
+
+ 年碳减排 + + + kg + +
+
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen/components/EnergyFlowDiagram.tsx b/frontend/src/pages/BigScreen/components/EnergyFlowDiagram.tsx new file mode 100644 index 0000000..f7416b5 --- /dev/null +++ b/frontend/src/pages/BigScreen/components/EnergyFlowDiagram.tsx @@ -0,0 +1,190 @@ +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(null); + const containerRef = useRef(null); + const particlesRef = useRef([]); + const rafRef = useRef(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 ( +
+ +
+ ); +} diff --git a/frontend/src/pages/BigScreen/components/EnergyOverviewCard.tsx b/frontend/src/pages/BigScreen/components/EnergyOverviewCard.tsx new file mode 100644 index 0000000..c347514 --- /dev/null +++ b/frontend/src/pages/BigScreen/components/EnergyOverviewCard.tsx @@ -0,0 +1,101 @@ +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 ( +
+
综合能源概览
+
+
+
+ 今日用电 + + + kWh + +
+
+ 光伏发电 + + + kWh + +
+
+
+
+ 电网购电 + + + kWh + +
+
+ 实时功率 + + + kW + +
+
+
+
+ +
+
+
+ 碳排放 + + + kgCO2 + +
+
+ 碳减排 + + + kgCO2 + +
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen/components/HeatPumpCard.tsx b/frontend/src/pages/BigScreen/components/HeatPumpCard.tsx new file mode 100644 index 0000000..80567a6 --- /dev/null +++ b/frontend/src/pages/BigScreen/components/HeatPumpCard.tsx @@ -0,0 +1,96 @@ +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 ( +
+
热泵系统
+
+
+
+
实时功率
+ + + kW + +
+
+ +
+
+ 平均COP +
+
+
+
+ 今日用电 + + + kWh + +
+
+ 本月用电 + + + kWh + +
+
+
+
+ 今日运行 + + + h + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen/components/LoadCurveCard.tsx b/frontend/src/pages/BigScreen/components/LoadCurveCard.tsx new file mode 100644 index 0000000..d282d78 --- /dev/null +++ b/frontend/src/pages/BigScreen/components/LoadCurveCard.tsx @@ -0,0 +1,83 @@ +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 ( +
+
用电分析
+
+
+
+ 峰值 {peak.toFixed(1)} kW +
+
+ 谷值 {valley.toFixed(1)} kW +
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen/components/PVCard.tsx b/frontend/src/pages/BigScreen/components/PVCard.tsx new file mode 100644 index 0000000..05bc661 --- /dev/null +++ b/frontend/src/pages/BigScreen/components/PVCard.tsx @@ -0,0 +1,110 @@ +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 ( +
+
光伏发电
+
+
+
+
实时功率
+ + + kW + +
+
+ +
+
+ 自用率
+ {selfUseRate.toFixed(1)}% +
+
+
+
+ 今日发电 + + + kWh + +
+
+ 本月发电 + + + kWh + +
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen/index.tsx b/frontend/src/pages/BigScreen/index.tsx new file mode 100644 index 0000000..af92b77 --- /dev/null +++ b/frontend/src/pages/BigScreen/index.tsx @@ -0,0 +1,195 @@ +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(null); + const [realtime, setRealtime] = useState(null); + const [loadData, setLoadData] = useState(null); + const [alarmEvents, setAlarmEvents] = useState([]); + const [alarmStats, setAlarmStats] = useState(null); + const [carbonOverview, setCarbonOverview] = useState(null); + const [carbonTrend, setCarbonTrend] = useState(null); + const [deviceStats, setDeviceStats] = useState(null); + const timerRef = useRef(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 ( +
+ {/* Header */} +
+ {formatDate(clock)} +

天普零碳园区智慧能源管理平台

+ {formatTime(clock)} +
+ + {/* WebSocket connection indicator */} +
+ + {wsConnected ? '实时' : '轮询'} +
+ + {/* Main 3-column grid */} +
+ {/* Left Column */} +
+ + + +
+ + {/* Center Column */} +
+
+
能源流向
+ +
+
+
设备状态
+
+
+ + 总设备 + +
+
+ + 在线 + +
+
+ + 离线 + +
+
+ + 告警 + +
+
+
+
+ + {/* Right Column */} +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen/styles.module.css b/frontend/src/pages/BigScreen/styles.module.css new file mode 100644 index 0000000..04697bf --- /dev/null +++ b/frontend/src/pages/BigScreen/styles.module.css @@ -0,0 +1,658 @@ +/* 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; + } +} diff --git a/frontend/src/pages/BigScreen3D/components/Buildings.tsx b/frontend/src/pages/BigScreen3D/components/Buildings.tsx new file mode 100644 index 0000000..1d8b3b0 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/Buildings.tsx @@ -0,0 +1,130 @@ +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 ( + + {windows.map((w, i) => ( + + + + + ))} + + ); +} + +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 ( + { e.stopPropagation(); onClick(); } : undefined}> + {/* Main body */} + + + + + + {/* Edge highlight */} + + + + + {/* Windows on front face */} + + + {/* Label */} + +
{label}
+ +
+ ); +} + +export default function Buildings({ detailMode, onBuildingClick }: BuildingsProps) { + const opacity = detailMode ? 0.15 : 0.85; + + return ( + + onBuildingClick('east') : undefined} + /> + onBuildingClick('west') : undefined} + /> + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/CampusScene.tsx b/frontend/src/pages/BigScreen3D/components/CampusScene.tsx new file mode 100644 index 0000000..bd87228 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/CampusScene.tsx @@ -0,0 +1,182 @@ +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; + energyFlow: { nodes: any[]; links: any[] }; + realtimeData: any | null; + selectedDevice: any | null; + selectedDevicePosition: [number, number, number] | null; + detailRealtimeData: Record | 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 ( + <> + + + + + + + + + + + + + + + + {viewMode === 'device-detail' && selectedDevice && selectedDevicePosition && ( + + )} + + + + + + + + ); +} + +export default function CampusScene(props: CampusSceneProps) { + return ( + + + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/DeviceDetailView.tsx b/frontend/src/pages/BigScreen3D/components/DeviceDetailView.tsx new file mode 100644 index 0000000..353d748 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/DeviceDetailView.tsx @@ -0,0 +1,490 @@ +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 | 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(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 ( + + {/* Scale the whole panel array 1.5x */} + + {panels.items.map((p, i) => ( + + + + + ))} + {/* Mounting rails */} + + + + + + + + + + + {/* HTML overlay – block diagram + live data */} + +
+
+{`┌─────────┐ ┌──────┐ ┌─────────┐ +│ DC Input │ → │ MPPT │ → │ AC Output│ +└─────────┘ └──────┘ └─────────┘`} +
+
+
DC电压: {getValue('dc_voltage').toFixed(1)} V
+
AC电压: {getValue('ac_voltage').toFixed(1)} V
+
功率: {getValue('power').toFixed(1)} kW
+
温度: {getValue('temperature').toFixed(1)} ℃
+
+
+ +
+ ); +} + +// ─── Heat Pump Detail ─────────────────────────────────────────────── +function HeatPumpDetail({ + getValue, +}: { + getValue: (key: string) => number; +}) { + const particlesRef = useRef(null); + const fanRef = useRef(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 ( + + {/* Main housing – transparent cutaway */} + + + + + + {/* Compressor */} + + + + + + {/* Evaporator (left) */} + + + + + + {/* Condenser (right) */} + + + + + + {/* Expansion valve */} + + + + + + {/* Refrigerant particles */} + + {Array.from({ length: particleCount }).map((_, i) => ( + + + + + ))} + + + {/* Fan on top */} + + {[0, 1, 2].map((i) => ( + + + + + ))} + {/* Fan hub */} + + + + + + + {/* HTML overlay */} + +
+
热泵详情
+
功率: {getValue('power').toFixed(1)} kW
+
COP: {getValue('cop').toFixed(2)}
+
进水温度: {getValue('inlet_temp').toFixed(1)} ℃
+
出水温度: {getValue('outlet_temp').toFixed(1)} ℃
+
流量: {getValue('flow_rate').toFixed(2)} m³/h
+
室外温度: {getValue('outdoor_temp').toFixed(1)} ℃
+
+ +
+ ); +} + +// ─── Meter Detail ─────────────────────────────────────────────────── +function MeterDetail({ + getValue, +}: { + getValue: (key: string) => number; +}) { + const needleRef = useRef(null); + + useFrame(() => { + if (needleRef.current) { + const power = getValue('power'); + const angle = (power / 150) * Math.PI - Math.PI / 2; + needleRef.current.rotation.z = angle; + } + }); + + return ( + + {/* Body */} + + + + + + {/* Screen */} + + + + + + {/* Dial face */} + + + + + + {/* Needle */} + + + + + + {/* HTML overlay */} + +
+
电表详情
+
功率: {getValue('power').toFixed(1)} kW
+
电压: {getValue('voltage').toFixed(1)} V
+
电流: {getValue('current').toFixed(2)} A
+
功率因数: {getValue('power_factor').toFixed(3)}
+
+ +
+ ); +} + +// ─── Heat Meter Detail ────────────────────────────────────────────── +function HeatMeterDetail({ + getValue, +}: { + getValue: (key: string) => number; +}) { + const needleRef = useRef(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 ( + + {/* Body */} + + + + + + {/* Display screen */} + + + + + + {/* Dial */} + + + + + + {/* Needle */} + + + + + + {/* HTML overlay */} + +
+
热量表详情
+
热功率: {getValue('heat_power').toFixed(1)} kW
+
流量: {getValue('flow_rate').toFixed(2)} m³/h
+
供水温度: {getValue('supply_temp').toFixed(1)} ℃
+
回水温度: {getValue('return_temp').toFixed(1)} ℃
+
累计热量: {getValue('cumulative_heat').toFixed(3)} GJ
+
+ +
+ ); +} + +// ─── Sensor Detail ────────────────────────────────────────────────── +function SensorDetail({ + getValue, +}: { + getValue: (key: string) => number; +}) { + const sphereRef = useRef(null); + const ringRef = useRef(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 ( + + {/* Sensor sphere */} + + + + + + {/* Antenna */} + + + + + + {/* Ring */} + + + + + + {/* HTML overlay */} + +
+
传感器详情
+
温度: {getValue('temperature').toFixed(1)} ℃
+
湿度: {getValue('humidity').toFixed(1)} %
+
+ +
+ ); +} + +// ─── Fallback ─────────────────────────────────────────────────────── +function DefaultDetail({ name }: { name: string }) { + const sphereRef = useRef(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 ( + + + + + + +
+
{name}
+
+ +
+ ); +} + +// ─── Main Component ───────────────────────────────────────────────── +export default function DeviceDetailView({ + device, + position, + realtimeData, +}: DeviceDetailViewProps) { + const groupRef = useRef(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 ; + case 'heat_pump': + return ; + case 'meter': + return ; + case 'heat_meter': + return ; + case 'sensor': + return ; + default: + return ; + } + }; + + return ( + + {renderDetail()} + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/DeviceInfoPanel.tsx b/frontend/src/pages/BigScreen3D/components/DeviceInfoPanel.tsx new file mode 100644 index 0000000..8a83728 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/DeviceInfoPanel.tsx @@ -0,0 +1,178 @@ +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 = { + 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 = { + online: '#00ff88', + offline: '#666666', + alarm: '#ff4757', + maintenance: '#ff8c00', +}; + +const STATUS_LABELS: Record = { + online: '在线', + offline: '离线', + alarm: '告警', + maintenance: '维护', +}; + +export default function DeviceInfoPanel({ device, onClose, onViewDetail }: DeviceInfoPanelProps) { + const [realtimeData, setRealtimeData] = useState>({}); + const timerRef = useRef | 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); + } 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 ( +
+
+ {device.name} + +
+ +
+ {device.name} +
+ +
+
+ 状态 + + {STATUS_LABELS[device.status] || device.status} + +
+ {device.model && ( +
+ 型号 + {device.model} +
+ )} + {device.manufacturer && ( +
+ 厂家 + {device.manufacturer} +
+ )} + {device.rated_power != null && ( +
+ 额定功率 + {device.rated_power} kW +
+ )} +
+ +
+ {params.map(param => { + const data = realtimeData[param.key]; + const valueStr = data != null ? `${data.value}${param.unit ? ' ' + param.unit : ''}` : '--'; + return ( +
+ {param.label} + {valueStr} +
+ ); + })} +
+ + +
+ ); +} diff --git a/frontend/src/pages/BigScreen3D/components/DeviceListPanel.tsx b/frontend/src/pages/BigScreen3D/components/DeviceListPanel.tsx new file mode 100644 index 0000000..a38c457 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/DeviceListPanel.tsx @@ -0,0 +1,85 @@ +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 = { + pv_inverter: '光伏逆变器', + heat_pump: '空气源热泵', + meter: '电表', + sensor: '温湿度传感器', + heat_meter: '热量表', +}; + +const STATUS_COLORS: Record = { + online: '#00ff88', + offline: '#666666', + alarm: '#ff4757', + maintenance: '#ff8c00', +}; + +export default function DeviceListPanel({ devices, selectedDeviceId, onDeviceSelect }: DeviceListPanelProps) { + const [collapsed, setCollapsed] = useState>({}); + + const groups: Record = {}; + 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 ( +
+ {Object.entries(TYPE_LABELS).map(([type, label]) => { + const group = groups[type]; + if (!group || group.length === 0) return null; + const isCollapsed = collapsed[type] ?? false; + + return ( +
+
toggleGroup(type)}> + {isCollapsed ? '▸' : '▾'} {label} +
+ {!isCollapsed && + group.map(device => ( +
onDeviceSelect(device)} + > + + + {device.name} + {device.primaryValue && ( + {device.primaryValue} + )} +
+ ))} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/pages/BigScreen3D/components/DeviceMarkers.tsx b/frontend/src/pages/BigScreen3D/components/DeviceMarkers.tsx new file mode 100644 index 0000000..2fb0bbb --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/DeviceMarkers.tsx @@ -0,0 +1,200 @@ +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 ( + { e.stopPropagation(); onHover(device.id); }} + onPointerOut={(e) => { e.stopPropagation(); onHover(null); }} + onClick={(e) => { e.stopPropagation(); onClick(device); }} + > + {/* Body */} + + + + + {/* Front dial */} + + + + + {/* Label */} + +
+
{device.name}
+ {device.primaryValue &&
{device.primaryValue}
} +
+ +
+ ); +} + +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 ( + { e.stopPropagation(); onHover(device.id); }} + onPointerOut={(e) => { e.stopPropagation(); onHover(null); }} + onClick={(e) => { e.stopPropagation(); onClick(device); }} + > + {/* Sphere */} + + + + + {/* Antenna */} + + + + + {/* Label */} + +
+
{device.name}
+ {device.primaryValue &&
{device.primaryValue}
} +
+ +
+ ); +} + +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 ( + + {/* Regular meters */} + {categorized.meters.map((d) => { + const posInfo = DEVICE_POSITIONS[d.code]; + if (!posInfo) return null; + return ( + + ); + })} + + {/* Heat meters */} + {categorized.heatMeters.map((d) => { + const posInfo = DEVICE_POSITIONS[d.code]; + if (!posInfo) return null; + return ( + + ); + })} + + {/* Sensors */} + {categorized.sensors.map((d) => { + const posInfo = DEVICE_POSITIONS[d.code]; + if (!posInfo) return null; + return ( + + ); + })} + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/EnergyParticles.tsx b/frontend/src/pages/BigScreen3D/components/EnergyParticles.tsx new file mode 100644 index 0000000..ccab272 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/EnergyParticles.tsx @@ -0,0 +1,164 @@ +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(() => { + 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 ( + + {paths.map((path) => ( + + ))} + + ); +} + +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(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(new Float32Array(count * 3)); + if (positionsRef.current.length !== count * 3) { + positionsRef.current = new Float32Array(count * 3); + } + + const geomRef = useRef(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 ( + + + + + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/Ground.tsx b/frontend/src/pages/BigScreen3D/components/Ground.tsx new file mode 100644 index 0000000..89728d8 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/Ground.tsx @@ -0,0 +1,22 @@ +import { Grid } from '@react-three/drei'; + +export default function Ground() { + return ( + + + + + + + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/HUDOverlay.tsx b/frontend/src/pages/BigScreen3D/components/HUDOverlay.tsx new file mode 100644 index 0000000..862a575 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/HUDOverlay.tsx @@ -0,0 +1,93 @@ +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 ( +
+
+ {formatDate(now)} + 天普零碳园区 3D智慧能源管理平台 + {formatTime(now)} +
+ +
+
+ 光伏发电 + + + kW + +
+
+ 电网功率 + + + kW + +
+
+ 总负荷 + + + kW + +
+
+ 今日发电 + + + kWh + +
+
+ 碳减排 + + + kg + +
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen3D/components/HeatPumps.tsx b/frontend/src/pages/BigScreen3D/components/HeatPumps.tsx new file mode 100644 index 0000000..8a1b786 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/HeatPumps.tsx @@ -0,0 +1,147 @@ +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(null); + + useFrame((_, delta) => { + if (!bladesRef.current) return; + if (status === 'offline') return; + bladesRef.current.rotation.y += speed * delta; + }); + + return ( + + {/* Fan housing */} + + + + + {/* Blades */} + + {[0, 1, 2].map((i) => ( + + + + + ))} + + + ); +} + +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(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 ( + { e.stopPropagation(); if (device) onHover(device.id); }} + onPointerOut={(e) => { e.stopPropagation(); onHover(null); }} + onClick={(e) => { e.stopPropagation(); if (device) onClick(device); }} + > + {/* Main body */} + + + + + + {/* Fan on top */} + + + {/* Side pipes - left */} + + + + + + + + + + {/* Side pipes - right */} + + + + + + + + + + ); +} + +export default function HeatPumps({ devices, hoveredId, onHover, onClick }: HeatPumpsProps) { + const deviceMap = useMemo(() => { + const map = new Map(); + devices.forEach((d) => map.set(d.code, d)); + return map; + }, [devices]); + + return ( + + {HP_CODES.map((code) => { + const pos = DEVICE_POSITIONS[code].position; + const device = deviceMap.get(code); + return ( + + ); + })} + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/PVPanels.tsx b/frontend/src/pages/BigScreen3D/components/PVPanels.tsx new file mode 100644 index 0000000..00dffe0 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/PVPanels.tsx @@ -0,0 +1,107 @@ +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(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 ( + { 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) => ( + + + + + ))} + + ); +} + +export default function PVPanels({ devices, hoveredId, onHover, onClick }: PVPanelsProps) { + const deviceMap = useMemo(() => { + const map = new Map(); + devices.forEach((d) => map.set(d.code, d)); + return map; + }, [devices]); + + return ( + + {PV_ZONES.map((zone) => { + const device = deviceMap.get(zone.code); + return ( + + ); + })} + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/SceneEnvironment.tsx b/frontend/src/pages/BigScreen3D/components/SceneEnvironment.tsx new file mode 100644 index 0000000..eebd480 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/SceneEnvironment.tsx @@ -0,0 +1,24 @@ +import { Stars } from '@react-three/drei'; + +export default function SceneEnvironment() { + return ( + <> + + + + + + ); +} diff --git a/frontend/src/pages/BigScreen3D/constants.ts b/frontend/src/pages/BigScreen3D/constants.ts new file mode 100644 index 0000000..b9fd874 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/constants.ts @@ -0,0 +1,120 @@ +// 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 = { + // 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 = { + online: '#00ff88', + offline: '#666666', + alarm: '#ff4757', + maintenance: '#ff8c00', +}; diff --git a/frontend/src/pages/BigScreen3D/hooks/useCameraAnimation.ts b/frontend/src/pages/BigScreen3D/hooks/useCameraAnimation.ts new file mode 100644 index 0000000..6f246be --- /dev/null +++ b/frontend/src/pages/BigScreen3D/hooks/useCameraAnimation.ts @@ -0,0 +1,69 @@ +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; + }, + }; +} diff --git a/frontend/src/pages/BigScreen3D/hooks/useDeviceData.ts b/frontend/src/pages/BigScreen3D/hooks/useDeviceData.ts new file mode 100644 index 0000000..5cfc06b --- /dev/null +++ b/frontend/src/pages/BigScreen3D/hooks/useDeviceData.ts @@ -0,0 +1,129 @@ +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 = { + 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(); + const result: DeviceWithPosition[] = []; + + // Group devices by type + const byType: Record = {}; + 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([]); + const [deviceStats, setDeviceStats] = useState(null); + const [overview, setOverview] = useState(null); + const [realtimeData, setRealtimeData] = useState(null); + const [devicesWithPositions, setDevicesWithPositions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const intervalRef = useRef | null>(null); + + const fetchAll = useCallback(async () => { + try { + const results = await Promise.allSettled([ + getDevices({ page_size: 100 }) as Promise, + getDeviceStats() as Promise, + getDashboardOverview() as Promise, + getRealtimeData() as Promise, + ]); + + // 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 }; +} diff --git a/frontend/src/pages/BigScreen3D/hooks/useEnergyFlow.ts b/frontend/src/pages/BigScreen3D/hooks/useEnergyFlow.ts new file mode 100644 index 0000000..4ad8df9 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/hooks/useEnergyFlow.ts @@ -0,0 +1,33 @@ +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([]); + const [links, setLinks] = useState([]); + const [loading, setLoading] = useState(true); + const intervalRef = useRef | 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 }; +} diff --git a/frontend/src/pages/BigScreen3D/index.tsx b/frontend/src/pages/BigScreen3D/index.tsx new file mode 100644 index 0000000..b9aa1fe --- /dev/null +++ b/frontend/src/pages/BigScreen3D/index.tsx @@ -0,0 +1,132 @@ +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(null); + const [hoveredDeviceId, setHoveredDeviceId] = useState(null); + const [viewMode, setViewMode] = useState('campus'); + const [detailRealtimeData, setDetailRealtimeData] = useState | null>(null); + const detailTimerRef = useRef | 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); + } 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 ( +
+
+

天普零碳园区 3D智慧能源管理平台

+

正在加载设备数据...

+
+
+ ); + } + + return ( +
+ {/* 3D Canvas — fills entire screen */} +
+ +
+ + {/* HUD: header bar + bottom metrics — pointer-events: none */} + + + {/* Left device list panel (only in campus view) */} + {viewMode === 'campus' && ( + + )} + + {/* Right device info panel (when device selected in campus view) */} + {selectedDevice && viewMode === 'campus' && ( + + )} + + {/* Return button in detail view */} + {viewMode === 'device-detail' && ( + + )} +
+ ); +} diff --git a/frontend/src/pages/BigScreen3D/styles.module.css b/frontend/src/pages/BigScreen3D/styles.module.css new file mode 100644 index 0000000..b41fb15 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/styles.module.css @@ -0,0 +1,329 @@ +/* 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; +} diff --git a/frontend/src/pages/BigScreen3D/types.ts b/frontend/src/pages/BigScreen3D/types.ts new file mode 100644 index 0000000..19b19bb --- /dev/null +++ b/frontend/src/pages/BigScreen3D/types.ts @@ -0,0 +1,73 @@ +// 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; + +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; +} diff --git a/frontend/src/pages/Carbon/index.tsx b/frontend/src/pages/Carbon/index.tsx new file mode 100644 index 0000000..6326794 --- /dev/null +++ b/frontend/src/pages/Carbon/index.tsx @@ -0,0 +1,626 @@ +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 = { + pv_generation: '光伏发电', + heat_pump_cop: '热泵节能', + energy_saving: '节能措施', +}; + +const STATUS_COLOR: Record = { + on_track: 'green', warning: 'orange', exceeded: 'red', + active: 'green', used: 'blue', expired: 'default', traded: 'purple', +}; + +// ============================================================ +// Overview Tab +// ============================================================ +function OverviewTab() { + const [dashboard, setDashboard] = useState(null); + const [trend, setTrend] = useState([]); + const [days, setDays] = useState(30); + const [overview, setOverview] = useState(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 ( +
+ +
+ + } valueStyle={{ color: '#f5222d' }} /> + + + + + } valueStyle={{ color: '#52c41a' }} /> + + + + + } /> + + + + + } valueStyle={{ color: '#52c41a' }} /> + + + + + {target && ( + + `${target.actual_tons} / ${target.target_tons} tCO\u2082`} + /> + + {target.status === 'on_track' ? '达标' : target.status === 'warning' ? '预警' : '超标'} + + + )} + + + + + }> + + + + + + + + + + + + + + {(dashboard?.reduction_by_source || []).length > 0 + ? + : } + + + + + +

基于年度减排量折算 (1棵树 ≈ 0.02 tCO₂/年)

+
+ + + + ); +} + +// ============================================================ +// Targets Tab +// ============================================================ +function TargetsTab() { + const [targets, setTargets] = useState([]); + const [progress, setProgress] = useState(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) => ( + {v === 'on_track' ? '达标' : v === 'warning' ? '预警' : '超标'} + )}, + ]; + + 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 ( +
+ +
+ } onClick={() => setModalOpen(true)}>新建目标 + }> + {progressGaugeOption + ? + : } + + + + + {(progress?.monthly_targets || []).length > 0 ? ( + `${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 }} /> + ) : } + + + + + +
+ + + setModalOpen(false)} okText="创建"> + + + + + + + + ); +} + +// ============================================================ +// Reductions Tab +// ============================================================ +function ReductionsTab() { + const [reductions, setReductions] = useState([]); + const [summary, setSummary] = useState([]); + 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 ? : }, + ]; + + 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 ( +
+ +
+ } loading={calculating} onClick={handleCalc}>重新计算 + }> + {summary.length > 0 ? : } + + + + + + {summary.map((s: any) => ( + + +

等效植树 {s.equivalent_trees} 棵

+ + ))} + + + + + + +
+ + + ); +} + +// ============================================================ +// Certificates Tab +// ============================================================ +function CertificatesTab() { + const [certs, setCerts] = useState([]); + const [portfolio, setPortfolio] = useState(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) => {v} }, + { 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) => {v} }, + ]; + + return ( +
+ +
+ + + + + + + + + + + + + + + + + + {Object.entries(portfolio?.by_status || {}).map(([k, v]: any) => ( + + {k} + + ))} + + + + + + } onClick={() => setModalOpen(true)}>登记绿证 + }> +
+ + + setModalOpen(false)} okText="登记" width={520}> +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +// ============================================================ +// Reports Tab +// ============================================================ +function ReportsTab() { + const [reports, setReports] = useState([]); + const [detail, setDetail] = useState(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) => {v} }, + { 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) => ( + + )}, + ]; + + return ( +
+ } onClick={() => setModalOpen(true)}>生成报告 + }> +
+ + + {detail && ( + setDetail(null)}>关闭}> + + + + + + + {detail.report_data?.monthly_breakdown && ( + 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' } }, + ], + }} /> + )} + + )} + + setModalOpen(false)} + okText="生成" confirmLoading={generating}> +
+ +
+ + + + + ); +} + +// ============================================================ +// Main Page +// ============================================================ +export default function Carbon() { + const items = [ + { key: 'overview', label: '总览', icon: , children: }, + { key: 'targets', label: '目标管理', icon: , children: }, + { key: 'reductions', label: '减排追踪', icon: , children: }, + { key: 'certificates', label: '绿证管理', icon: , children: }, + { key: 'reports', label: '碳报告', icon: , children: }, + { key: 'benchmarks', label: '行业对标', icon: , children: }, + ]; + + return ( +
+ ({ ...t, label: {t.icon} {t.label} }))} /> +
+ ); +} diff --git a/frontend/src/pages/Charging/Dashboard.tsx b/frontend/src/pages/Charging/Dashboard.tsx new file mode 100644 index 0000000..5bd2b49 --- /dev/null +++ b/frontend/src/pages/Charging/Dashboard.tsx @@ -0,0 +1,169 @@ +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(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 = { + '空闲': '#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 ( +
+ +
+ + } + precision={2} + valueStyle={{ color: '#cf1322' }} + /> + + + + + } + precision={1} + valueStyle={{ color: '#1890ff' }} + /> + + + + + } + valueStyle={{ color: '#52c41a' }} + /> + + + + + } + suffix="%" + valueStyle={{ color: '#faad14' }} + /> + + + + + + + + {data?.revenue_trend?.length > 0 ? ( + + ) : ( +
暂无数据
+ )} +
+ + + + {pileStatusData.length > 0 ? ( + + ) : ( +
暂无数据
+ )} +
+ + + + + + + {data?.station_ranking?.length > 0 ? ( + + ) : ( +
暂无数据
+ )} +
+ + + + ); +} diff --git a/frontend/src/pages/Charging/Orders.tsx b/frontend/src/pages/Charging/Orders.tsx new file mode 100644 index 0000000..b3492db --- /dev/null +++ b/frontend/src/pages/Charging/Orders.tsx @@ -0,0 +1,201 @@ +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 = { + 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 ( + }, + { key: 'history', label: '历史订单', children: }, + { key: 'abnormal', label: '异常订单', children: }, + ]} + /> + ); +} + +function RealtimeOrders() { + const [data, setData] = useState([]); + 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: () => ( + } color="processing">充电中 + )}, + ]; + + return ( + 刷新}> +
+ + ); +} + +function HistoryOrders() { + const [data, setData] = useState({ total: 0, items: [] }); + const [loading, setLoading] = useState(true); + const [filters, setFilters] = useState>({ page: 1, page_size: 20 }); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const cleanQuery: Record = {}; + 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 {st.text}; + }}, + { title: '创建时间', dataIndex: 'created_at', width: 170 }, + ]; + + return ( + + +
`共 ${total} 条订单`, + onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })), + }} + /> + + ); +} + +function AbnormalOrders() { + const [data, setData] = useState({ total: 0, items: [] }); + const [loading, setLoading] = useState(true); + const [filters, setFilters] = useState>({ 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 } color={st.color}>{st.text}; + }}, + { 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' && ( + + ) + )}, + ]; + + return ( + +
`共 ${total} 条异常订单`, + onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })), + }} + /> + + ); +} diff --git a/frontend/src/pages/Charging/Piles.tsx b/frontend/src/pages/Charging/Piles.tsx new file mode 100644 index 0000000..d558782 --- /dev/null +++ b/frontend/src/pages/Charging/Piles.tsx @@ -0,0 +1,203 @@ +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 = { + 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({ total: 0, items: [] }); + const [stations, setStations] = useState([]); + const [brands, setBrands] = useState([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({ page: 1, page_size: 20 }); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const cleanQuery: Record = {}; + 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 {st.text}; + }}, + { title: '状态', dataIndex: 'status', width: 80, render: (s: string) => ( + {s === 'active' ? '启用' : '停用'} + )}, + { title: '操作', key: 'action', width: 150, fixed: 'right' as const, render: (_: any, record: any) => ( + + + + + )}, + ]; + + return ( + } onClick={openAddModal}>添加充电桩 + }> + + handleFilterChange('type', v)} /> +
`共 ${total} 个充电桩`, + onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })), + }} + /> + + { setShowModal(false); form.resetFields(); }} + onOk={() => form.submit()} + okText={editing ? '保存' : '创建'} + cancelText="取消" + width={640} + destroyOnClose + > + + + + + + + + + ({ label: b.brand_name, value: b.brand_name }))} /> + + + + + + + + +
; + }; + + return ( + } onClick={openAddModal}>新建策略 + }> +
+ + { setShowModal(false); form.resetFields(); }} + onOk={() => form.submit()} + okText={editing ? '保存' : '创建'} + cancelText="取消" + width={800} + destroyOnClose + > + + + + + + + + + + + + + + + + + + handleFilterChange('type', v)} /> +
`共 ${total} 个充电站`, + onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })), + }} + /> + + { setShowModal(false); form.resetFields(); }} + onOk={() => form.submit()} + okText={editing ? '保存' : '创建'} + cancelText="取消" + width={640} + destroyOnClose + > + + + + + + ({ label: m.name, value: m.id }))} /> + + + + + + + + + + + + + + + + + + + + + + + + + {Object.keys(chartData).length > 0 && ( + <> + + + + + +
`共 ${total} 条` }} + /> + + + )} + + + ); +} diff --git a/frontend/src/pages/DeviceDetail/index.tsx b/frontend/src/pages/DeviceDetail/index.tsx new file mode 100644 index 0000000..83cc59e --- /dev/null +++ b/frontend/src/pages/DeviceDetail/index.tsx @@ -0,0 +1,490 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + Card, Tabs, Tag, Button, Statistic, Row, Col, Table, Descriptions, Select, + DatePicker, Space, Badge, Spin, message, Empty, +} from 'antd'; +import { + ArrowLeftOutlined, ReloadOutlined, ThunderboltOutlined, + DashboardOutlined, FireOutlined, ExperimentOutlined, +} from '@ant-design/icons'; +import ReactECharts from 'echarts-for-react'; +import dayjs from 'dayjs'; +import { getDevice, getDeviceRealtime, getEnergyHistory, getAlarmEvents } from '../../services/api'; +import { getDevicePhoto } from '../../utils/devicePhoto'; + +const { RangePicker } = DatePicker; + +const statusMap: Record = { + online: { color: 'green', text: '在线' }, + offline: { color: 'default', text: '离线' }, + alarm: { color: 'red', text: '告警' }, + maintenance: { color: 'orange', text: '维护' }, +}; + +const severityMap: Record = { + critical: { color: 'red', text: '严重' }, + warning: { color: 'orange', text: '警告' }, + info: { color: 'blue', text: '信息' }, +}; + +const alarmStatusMap: Record = { + active: { color: 'red', text: '活跃' }, + acknowledged: { color: 'orange', text: '已确认' }, + resolved: { color: 'green', text: '已解决' }, +}; + +const protocolLabels: Record = { + modbus_tcp: 'Modbus TCP', + modbus_rtu: 'Modbus RTU', + opc_ua: 'OPC UA', + mqtt: 'MQTT', + http_api: 'HTTP API', + dlt645: 'DL/T 645', + image: '图像采集', +}; + +const dataTypeOptions = [ + { label: '功率 (kW)', value: 'power' }, + { label: '电量 (kWh)', value: 'energy' }, + { label: '温度 (°C)', value: 'temperature' }, + { label: 'COP', value: 'cop' }, + { label: '电流 (A)', value: 'current' }, + { label: '电压 (V)', value: 'voltage' }, + { label: '频率 (Hz)', value: 'frequency' }, + { label: '功率因数', value: 'power_factor' }, + { label: '流量 (m³/h)', value: 'flow_rate' }, + { label: '湿度 (%)', value: 'humidity' }, +]; + +const granularityOptions = [ + { label: '原始数据', value: 'raw' }, + { label: '5分钟', value: '5min' }, + { label: '小时', value: 'hour' }, + { label: '天', value: 'day' }, +]; + +const timeRangePresets = [ + { label: '24小时', value: '24h' }, + { label: '7天', value: '7d' }, + { label: '30天', value: '30d' }, +]; + +function getTimeRange(preset: string): [dayjs.Dayjs, dayjs.Dayjs] { + const now = dayjs(); + switch (preset) { + case '24h': return [now.subtract(24, 'hour'), now]; + case '7d': return [now.subtract(7, 'day'), now]; + case '30d': return [now.subtract(30, 'day'), now]; + default: return [now.subtract(24, 'hour'), now]; + } +} + +export default function DeviceDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const deviceId = Number(id); + + const [device, setDevice] = useState(null); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState('realtime'); + + // Realtime state + const [realtimeData, setRealtimeData] = useState(null); + const [realtimeLoading, setRealtimeLoading] = useState(false); + + // History state + const [historyData, setHistoryData] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [dataType, setDataType] = useState('power'); + const [granularity, setGranularity] = useState('hour'); + const [timePreset, setTimePreset] = useState('24h'); + const [timeRange, setTimeRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>(getTimeRange('24h')); + + // Alarm state + const [alarmData, setAlarmData] = useState({ total: 0, items: [] }); + const [alarmLoading, setAlarmLoading] = useState(false); + const [alarmPage, setAlarmPage] = useState(1); + + // Load device info + useEffect(() => { + if (!deviceId) return; + setLoading(true); + getDevice(deviceId) + .then((res: any) => setDevice(res)) + .catch(() => message.error('加载设备信息失败')) + .finally(() => setLoading(false)); + }, [deviceId]); + + // Load realtime data + const loadRealtime = useCallback(async () => { + if (!deviceId) return; + setRealtimeLoading(true); + try { + const res = await getDeviceRealtime(deviceId); + setRealtimeData(res); + } catch { setRealtimeData(null); } + finally { setRealtimeLoading(false); } + }, [deviceId]); + + // Auto-refresh realtime every 15s + useEffect(() => { + if (activeTab !== 'realtime') return; + loadRealtime(); + const timer = setInterval(loadRealtime, 15000); + return () => clearInterval(timer); + }, [activeTab, loadRealtime]); + + // Load history data + const loadHistory = useCallback(async () => { + if (!deviceId) return; + setHistoryLoading(true); + try { + const res = await getEnergyHistory({ + device_id: deviceId, + data_type: dataType, + granularity, + start_time: timeRange[0].format('YYYY-MM-DD HH:mm:ss'), + end_time: timeRange[1].format('YYYY-MM-DD HH:mm:ss'), + page_size: 1000, + }); + setHistoryData(res as any[]); + } catch { setHistoryData([]); } + finally { setHistoryLoading(false); } + }, [deviceId, dataType, granularity, timeRange]); + + useEffect(() => { + if (activeTab === 'history') loadHistory(); + }, [activeTab, loadHistory]); + + // Load alarm events + const loadAlarms = useCallback(async () => { + if (!deviceId) return; + setAlarmLoading(true); + try { + const res = await getAlarmEvents({ device_id: deviceId, page: alarmPage, page_size: 20 }); + setAlarmData(res as any); + } catch { setAlarmData({ total: 0, items: [] }); } + finally { setAlarmLoading(false); } + }, [deviceId, alarmPage]); + + useEffect(() => { + if (activeTab === 'alarms') loadAlarms(); + }, [activeTab, loadAlarms]); + + const handleTimePreset = (preset: string) => { + setTimePreset(preset); + setTimeRange(getTimeRange(preset)); + }; + + const handleRangeChange = (dates: any) => { + if (dates && dates[0] && dates[1]) { + setTimePreset(''); + setTimeRange([dates[0], dates[1]]); + } + }; + + // ---- Chart options ---- + const getChartOption = () => { + const isRaw = granularity === 'raw'; + const times = historyData.map(d => isRaw ? d.timestamp : d.time); + const typeLabel = dataTypeOptions.find(o => o.value === dataType)?.label || dataType; + + if (isRaw) { + return { + tooltip: { trigger: 'axis' }, + legend: { data: [typeLabel] }, + grid: { left: 60, right: 30, top: 40, bottom: 40 }, + xAxis: { type: 'category', data: times, axisLabel: { rotate: 30 } }, + yAxis: { type: 'value', name: typeLabel }, + series: [{ + name: typeLabel, + type: 'line', + data: historyData.map(d => d.value), + smooth: true, + lineStyle: { width: 2 }, + areaStyle: { opacity: 0.1 }, + }], + }; + } + + // Aggregated data with avg/max/min + const avgData = historyData.map(d => d.avg); + const maxData = historyData.map(d => d.max); + const minData = historyData.map(d => d.min); + const avgVal = avgData.length ? (avgData.reduce((a, b) => a + b, 0) / avgData.length).toFixed(2) : '-'; + const maxVal = maxData.length ? Math.max(...maxData).toFixed(2) : '-'; + const minVal = minData.length ? Math.min(...minData).toFixed(2) : '-'; + + return { + tooltip: { trigger: 'axis' }, + legend: { + data: [ + `平均 (${avgVal})`, + `最大 (${maxVal})`, + `最小 (${minVal})`, + ], + }, + grid: { left: 60, right: 30, top: 50, bottom: 40 }, + xAxis: { type: 'category', data: times, axisLabel: { rotate: 30 } }, + yAxis: { type: 'value', name: typeLabel }, + series: [ + { + name: `平均 (${avgVal})`, + type: 'line', + data: avgData, + smooth: true, + lineStyle: { width: 2, color: '#1890ff' }, + itemStyle: { color: '#1890ff' }, + areaStyle: { opacity: 0.08, color: '#1890ff' }, + }, + { + name: `最大 (${maxVal})`, + type: 'line', + data: maxData, + smooth: true, + lineStyle: { width: 1, type: 'dashed', color: '#ff4d4f' }, + itemStyle: { color: '#ff4d4f' }, + }, + { + name: `最小 (${minVal})`, + type: 'line', + data: minData, + smooth: true, + lineStyle: { width: 1, type: 'dashed', color: '#52c41a' }, + itemStyle: { color: '#52c41a' }, + }, + ], + }; + }; + + // ---- Alarm columns ---- + const alarmColumns = [ + { + title: '时间', dataIndex: 'triggered_at', width: 170, + render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-', + }, + { title: '标题', dataIndex: 'title', width: 200, ellipsis: true }, + { + title: '严重程度', dataIndex: 'severity', width: 90, + render: (v: string) => { + const s = severityMap[v] || { color: 'default', text: v }; + return {s.text}; + }, + }, + { + title: '状态', dataIndex: 'status', width: 90, + render: (v: string) => { + const s = alarmStatusMap[v] || { color: 'default', text: v }; + return {s.text}; + }, + }, + { + title: '实际值', dataIndex: 'value', width: 100, + render: (v: number) => v != null ? v.toFixed(2) : '-', + }, + { + title: '阈值', dataIndex: 'threshold', width: 100, + render: (v: number) => v != null ? v.toFixed(2) : '-', + }, + { + title: '解决时间', dataIndex: 'resolved_at', width: 170, + render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-', + }, + ]; + + // ---- Render metric cards for realtime ---- + const renderRealtimeMetrics = () => { + if (!realtimeData?.data) return ; + const entries = Object.entries(realtimeData.data) as [string, any][]; + return ( + + {entries.map(([key, val]) => { + let icon = ; + let color: string | undefined; + if (key.includes('power') || key.includes('功率')) { icon = ; color = '#1890ff'; } + else if (key.includes('temp') || key.includes('温度')) { icon = ; color = '#fa541c'; } + else if (key.includes('cop') || key.includes('COP')) { icon = ; color = '#52c41a'; } + return ( + + + + + + ); + })} + + ); + }; + + if (loading) { + return
; + } + + if (!device) { + return + + ; + } + + const st = statusMap[device.status] || { color: 'default', text: device.status || '-' }; + + return ( +
+ {/* Header */} + +
+ {device.name} +
+
+

{device.name}

+ {st.text} + {device.code} +
+ +
型号:{device.model || '-'} + 厂商:{device.manufacturer || '-'} + 额定功率:{device.rated_power != null ? `${device.rated_power} kW` : '-'} + 位置:{device.location || '-'} + 协议:{protocolLabels[device.protocol] || device.protocol || '-'} + 采集间隔:{device.collect_interval ? `${device.collect_interval}s` : '-'} + 最近数据:{device.last_data_time || '-'} + + + + + + + {/* Tabs */} + + +
+ + 每15秒自动刷新 +
+ {renderRealtimeMetrics()} + + ), + }, + { + key: 'history', + label: '历史趋势', + children: ( +
+ + + {timeRangePresets.map(p => ( + + ))} + + + + + {historyData.length > 0 ? ( + + ) : ( + + )} + +
+ ), + }, + { + key: 'alarms', + label: '告警记录', + children: ( +
`共 ${total} 条告警`, + onChange: (page: number) => setAlarmPage(page), + }} + /> + ), + }, + { + key: 'info', + label: '设备信息', + children: ( + + {device.name} + {device.code} + {device.device_type || '-'} + {device.group_id || '-'} + {device.model || '-'} + {device.manufacturer || '-'} + {device.serial_number || '-'} + {device.rated_power != null ? `${device.rated_power} kW` : '-'} + {device.location || '-'} + {protocolLabels[device.protocol] || device.protocol || '-'} + {device.collect_interval ? `${device.collect_interval} 秒` : '-'} + + + + + {device.is_active ? '启用' : '停用'} + + {device.last_data_time || '-'} + + {device.connection_params ? ( +
+                      {JSON.stringify(device.connection_params, null, 2)}
+                    
+ ) : '-'} +
+
+ ), + }, + ]} /> + + + ); +} diff --git a/frontend/src/pages/Devices/Topology.tsx b/frontend/src/pages/Devices/Topology.tsx new file mode 100644 index 0000000..4d2fa55 --- /dev/null +++ b/frontend/src/pages/Devices/Topology.tsx @@ -0,0 +1,186 @@ +import { useEffect, useState } from 'react'; +import { Card, Tree, Table, Tag, Badge, Button, Space, Row, Col, Empty } from 'antd'; +import { ApartmentOutlined, ExpandOutlined, CompressOutlined } from '@ant-design/icons'; +import { getDeviceTopology, getDevices } from '../../services/api'; +import type { DataNode } from 'antd/es/tree'; + +const statusMap: Record = { + online: { color: 'green', text: '在线' }, + offline: { color: 'default', text: '离线' }, + alarm: { color: 'red', text: '告警' }, + maintenance: { color: 'orange', text: '维护' }, +}; + +function getStatusDot(node: any): string { + if (node.total_alarm > 0) return '#f5222d'; + if (node.total_offline > 0 && node.total_online > 0) return '#faad14'; + if (node.total_online > 0) return '#52c41a'; + if (node.total_device_count === 0) return '#d9d9d9'; + return '#999'; +} + +function buildTreeNodes(nodes: any[]): DataNode[] { + return nodes.map((node: any) => { + const dotColor = getStatusDot(node); + const title = ( + + + {node.name} + {node.location ? ({node.location}) : null} + + + ); + return { + title, + key: `group-${node.id}`, + children: buildTreeNodes(node.children || []), + isLeaf: !node.children || node.children.length === 0, + }; + }); +} + +export default function Topology() { + const [treeData, setTreeData] = useState([]); + const [topologyData, setTopologyData] = useState([]); + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [selectedGroupName, setSelectedGroupName] = useState(''); + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(false); + const [expandedKeys, setExpandedKeys] = useState([]); + + useEffect(() => { + loadTopology(); + }, []); + + const loadTopology = async () => { + try { + const data = await getDeviceTopology() as any[]; + setTopologyData(data); + setTreeData(buildTreeNodes(data)); + // Expand all by default + const allKeys = collectKeys(data); + setExpandedKeys(allKeys); + } catch (e) { + console.error(e); + } + }; + + const collectKeys = (nodes: any[]): string[] => { + const keys: string[] = []; + nodes.forEach(n => { + keys.push(`group-${n.id}`); + if (n.children) { + keys.push(...collectKeys(n.children)); + } + }); + return keys; + }; + + const findGroupName = (nodes: any[], id: number): string => { + for (const n of nodes) { + if (n.id === id) return n.name; + if (n.children) { + const found = findGroupName(n.children, id); + if (found) return found; + } + } + return ''; + }; + + const handleSelect = async (selectedKeys: React.Key[]) => { + if (selectedKeys.length === 0) return; + const key = selectedKeys[0] as string; + const groupId = parseInt(key.replace('group-', '')); + setSelectedGroupId(groupId); + setSelectedGroupName(findGroupName(topologyData, groupId)); + setLoading(true); + try { + const res = await getDevices({ group_id: groupId, page_size: 100 }) as any; + setDevices(res.items || []); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + const handleExpandAll = () => { + setExpandedKeys(collectKeys(topologyData)); + }; + + const handleCollapseAll = () => { + setExpandedKeys([]); + }; + + const columns = [ + { title: '设备名称', dataIndex: 'name', width: 160 }, + { title: '设备编号', dataIndex: 'code', width: 130 }, + { title: '类型', dataIndex: 'device_type', width: 100 }, + { + title: '状态', dataIndex: 'status', width: 80, + render: (s: string) => { + const st = statusMap[s] || { color: 'default', text: s || '-' }; + return {st.text}; + }, + }, + { title: '位置', dataIndex: 'location', width: 120, ellipsis: true }, + { title: '额定功率(kW)', dataIndex: 'rated_power', width: 110, render: (v: number) => v != null ? v : '-' }, + { title: '最近数据时间', dataIndex: 'last_data_time', width: 170 }, + ]; + + return ( + + + 设备拓扑} + size="small" + extra={ + + + + + } + bodyStyle={{ maxHeight: 'calc(100vh - 200px)', overflow: 'auto' }} + > + setExpandedKeys(keys)} + onSelect={handleSelect} + showLine + blockNode + /> + + + + + + {selectedGroupId ? ( +
`共 ${total} 台` }} + /> + ) : ( + + )} + + + + ); +} diff --git a/frontend/src/pages/Devices/index.tsx b/frontend/src/pages/Devices/index.tsx new file mode 100644 index 0000000..ecdacd0 --- /dev/null +++ b/frontend/src/pages/Devices/index.tsx @@ -0,0 +1,312 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber, Space, Row, Col, Statistic, Switch, message } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, WarningOutlined, AppstoreOutlined } from '@ant-design/icons'; +import { getDevices, getDeviceTypes, getDeviceGroups, getDeviceStats, createDevice, updateDevice } from '../../services/api'; +import { getDevicePhoto } from '../../utils/devicePhoto'; + +const statusMap: Record = { + online: { color: 'green', text: '在线' }, + offline: { color: 'default', text: '离线' }, + alarm: { color: 'red', text: '告警' }, + maintenance: { color: 'orange', text: '维护' }, +}; + +const protocolOptions = [ + { label: 'Modbus TCP', value: 'modbus_tcp' }, + { label: 'Modbus RTU', value: 'modbus_rtu' }, + { label: 'OPC UA', value: 'opc_ua' }, + { label: 'MQTT', value: 'mqtt' }, + { label: 'HTTP API', value: 'http_api' }, + { label: 'DL/T 645', value: 'dlt645' }, + { label: '图像采集', value: 'image' }, +]; + +export default function Devices() { + const navigate = useNavigate(); + const [data, setData] = useState({ total: 0, items: [] }); + const [stats, setStats] = useState({ online: 0, offline: 0, alarm: 0, total: 0 }); + const [deviceTypes, setDeviceTypes] = useState([]); + const [deviceGroups, setDeviceGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [editingDevice, setEditingDevice] = useState(null); + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({ page: 1, page_size: 20 }); + + const loadDevices = useCallback(async (params?: Record) => { + setLoading(true); + try { + const query = params || filters; + // Remove empty values + const cleanQuery: Record = {}; + Object.entries(query).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') cleanQuery[k] = v; + }); + const res = await getDevices(cleanQuery); + setData(res as any); + } catch (e) { console.error(e); } + finally { setLoading(false); } + }, [filters]); + + const loadMeta = async () => { + try { + const [types, groups, st] = await Promise.all([ + getDeviceTypes(), getDeviceGroups(), getDeviceStats(), + ]); + setDeviceTypes(types as any[]); + setDeviceGroups(groups as any[]); + setStats(st as any); + } catch (e) { console.error(e); } + }; + + useEffect(() => { loadMeta(); }, []); + useEffect(() => { loadDevices(); }, [filters, loadDevices]); + + const handleFilterChange = (key: string, value: any) => { + setFilters(prev => ({ ...prev, [key]: value, page: 1 })); + }; + + const handlePageChange = (page: number, pageSize: number) => { + setFilters(prev => ({ ...prev, page, page_size: pageSize })); + }; + + const openAddModal = () => { + setEditingDevice(null); + form.resetFields(); + form.setFieldsValue({ collect_interval: 15, is_active: true }); + setShowModal(true); + }; + + const openEditModal = (record: any) => { + setEditingDevice(record); + form.setFieldsValue({ + ...record, + device_type_id: record.device_type_id, + device_group_id: record.device_group_id, + connection_params: record.connection_params ? JSON.stringify(record.connection_params, null, 2) : '', + }); + setShowModal(true); + }; + + const handleSubmit = async (values: any) => { + try { + // Parse connection_params if provided as string + if (values.connection_params && typeof values.connection_params === 'string') { + try { + values.connection_params = JSON.parse(values.connection_params); + } catch { + message.error('连接参数JSON格式错误'); + return; + } + } + if (editingDevice) { + await updateDevice(editingDevice.id, values); + message.success('设备更新成功'); + } else { + await createDevice(values); + message.success('设备创建成功'); + } + setShowModal(false); + form.resetFields(); + loadDevices(); + loadMeta(); + } catch (e: any) { + message.error(e?.detail || '操作失败'); + } + }; + + const columns = [ + { title: '', dataIndex: 'device_type', width: 50, render: (_: any, record: any) => ( + + )}, + { title: '设备名称', dataIndex: 'name', width: 160, ellipsis: true, render: (name: string, record: any) => ( + navigate(`/devices/${record.id}`)}>{name} + )}, + { title: '设备编号', dataIndex: 'code', width: 130 }, + { title: '设备类型', dataIndex: 'device_type_name', width: 120, render: (v: string) => v ? } color="blue">{v} : '-' }, + { title: '设备分组', dataIndex: 'device_group_name', width: 120 }, + { title: '型号', dataIndex: 'model', width: 120, ellipsis: true }, + { title: '厂商', dataIndex: 'manufacturer', width: 120, ellipsis: true }, + { title: '额定功率(kW)', dataIndex: 'rated_power', width: 110, render: (v: number) => v != null ? v : '-' }, + { title: '位置', dataIndex: 'location', width: 120, ellipsis: true }, + { title: '协议', dataIndex: 'protocol', width: 100 }, + { title: '状态', dataIndex: 'status', width: 80, render: (s: string) => { + const st = statusMap[s] || { color: 'default', text: s || '-' }; + return {st.text}; + }}, + { title: '最近数据时间', dataIndex: 'last_data_time', width: 170 }, + { title: '操作', key: 'action', width: 120, fixed: 'right' as const, render: (_: any, record: any) => ( + + + + )}, + ]; + + return ( +
+ {/* Stats Cards */} + +
+ + } /> + + + + + } valueStyle={{ color: '#52c41a' }} /> + + + + + } valueStyle={{ color: '#999' }} /> + + + + + } valueStyle={{ color: '#ff4d4f' }} /> + + + + + {/* Device Table */} + } onClick={openAddModal}>添加设备 + }> + {/* Filters */} + + ({ label: g.name, value: g.id }))} + onChange={v => handleFilterChange('device_group', v)} + /> +
`共 ${total} 台设备`, + onChange: handlePageChange, + }} + /> + + + {/* Add/Edit Modal */} + { setShowModal(false); form.resetFields(); }} + onOk={() => form.submit()} + okText={editingDevice ? '保存' : '创建'} + cancelText="取消" + width={640} + destroyOnClose + > + + + + + + + + + + + + + + + + + ({ label: g.name, value: g.id }))} /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ({ label: `${y}年`, value: y }))} + /> +
( +
+ + + {(record.periods || []).map((p: any, i: number) => ( +
+ + {p.period_label || PERIOD_LABELS[p.period_type]} {p.start_time}-{p.end_time} ¥{p.price_yuan_per_kwh} + + + ))} + + + ), + }} + /> + + setShowCreate(false)} + onOk={() => createForm.submit()} okText="创建"> + + + + + + + + + + + + + + + + + setShowPeriods(false)} onOk={handleSavePeriods} okText="保存" + width={700}> + {periods.map((p, idx) => ( + + + + { const np = [...periods]; np[idx].start_time = e.target.value; setPeriods(np); }} + /> + + + { const np = [...periods]; np[idx].end_time = e.target.value; setPeriods(np); }} + /> + + + { const np = [...periods]; np[idx].price_yuan_per_kwh = v; setPeriods(np); }} + addonAfter="元" + /> + + + + + + ); +} diff --git a/frontend/src/pages/EnergyStrategy/SavingsReport.tsx b/frontend/src/pages/EnergyStrategy/SavingsReport.tsx new file mode 100644 index 0000000..5afe7a8 --- /dev/null +++ b/frontend/src/pages/EnergyStrategy/SavingsReport.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; +import { Row, Col, Card, Select, Statistic, Table, Empty, message } from 'antd'; +import { ArrowUpOutlined } from '@ant-design/icons'; +import ReactECharts from 'echarts-for-react'; +import { getStrategySavingsReport } from '../../services/api'; + +export default function SavingsReport() { + const [data, setData] = useState(null); + const [year, setYear] = useState(2026); + const [loading, setLoading] = useState(true); + + useEffect(() => { loadData(); }, [year]); + + const loadData = async () => { + setLoading(true); + try { + const d = await getStrategySavingsReport({ year }); + setData(d); + } catch { message.error('加载节约报告失败'); } + finally { setLoading(false); } + }; + + const getChartOption = () => { + if (!data?.monthly_reports?.length) return {}; + const months = data.monthly_reports.map((r: any) => r.year_month); + return { + tooltip: { trigger: 'axis' }, + legend: { data: ['基准费用', '优化费用', '节约金额'] }, + xAxis: { type: 'category', data: months }, + yAxis: { type: 'value', name: '元' }, + series: [ + { name: '基准费用', type: 'bar', stack: 'compare', data: data.monthly_reports.map((r: any) => r.baseline_cost), itemStyle: { color: '#ff7875' } }, + { name: '优化费用', type: 'bar', stack: 'optimized', data: data.monthly_reports.map((r: any) => r.optimized_cost), itemStyle: { color: '#69c0ff' } }, + { name: '节约金额', type: 'line', data: data.monthly_reports.map((r: any) => r.savings_yuan), itemStyle: { color: '#52c41a' }, lineStyle: { width: 3 } }, + ], + grid: { left: 60, right: 20, top: 40, bottom: 30 }, + }; + }; + + const columns = [ + { title: '月份', dataIndex: 'year_month' }, + { title: '总用电(kWh)', dataIndex: 'total_consumption_kwh', render: (v: number) => v?.toFixed(1) || '-' }, + { title: '总电费(元)', dataIndex: 'total_cost_yuan', render: (v: number) => v?.toFixed(2) || '-' }, + { title: '基准费用(元)', dataIndex: 'baseline_cost', render: (v: number) => v?.toFixed(2) || '-' }, + { title: '优化费用(元)', dataIndex: 'optimized_cost', render: (v: number) => v?.toFixed(2) || '-' }, + { title: '节约(元)', dataIndex: 'savings_yuan', render: (v: number) => {v?.toFixed(2) || '0.00'} }, + ]; + + return ( +
+ +
+
+ + + ) : ( + + + + )} + + ); +} diff --git a/frontend/src/pages/EnergyStrategy/StrategyManager.tsx b/frontend/src/pages/EnergyStrategy/StrategyManager.tsx new file mode 100644 index 0000000..67e7187 --- /dev/null +++ b/frontend/src/pages/EnergyStrategy/StrategyManager.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from 'react'; +import { Card, Row, Col, Switch, Tag, Space, Button, Descriptions, Alert, message } from 'antd'; +import { ThunderboltOutlined, FireOutlined, BulbOutlined, SwapOutlined } from '@ant-design/icons'; +import { getStrategies, getStrategyRecommendations } from '../../services/api'; + +const STRATEGY_ICONS: Record = { + heat_storage: , + pv_priority: , + load_shift: , +}; + +const TYPE_LABELS: Record = { + heat_storage: '谷电蓄热', + pv_priority: '光伏自消纳', + load_shift: '负荷转移', +}; + +export default function StrategyManager() { + const [strategies, setStrategies] = useState([]); + const [recommendations, setRecommendations] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { loadData(); }, []); + + const loadData = async () => { + setLoading(true); + try { + const [s, r] = await Promise.all([getStrategies(), getStrategyRecommendations()]); + setStrategies(s as any[]); + setRecommendations(r as any[]); + } catch { message.error('加载策略数据失败'); } + finally { setLoading(false); } + }; + + const handleToggle = async (strategy: any) => { + // For demo: toggle locally + setStrategies(strategies.map(s => + s.strategy_type === strategy.strategy_type ? { ...s, is_enabled: !s.is_enabled } : s + )); + message.success(strategy.is_enabled ? '策略已停用' : '策略已启用'); + }; + + return ( +
+ {recommendations.length > 0 && ( +
+ {recommendations.map((r, i) => ( + + ))} +
+ )} + + + {strategies.map((strategy, idx) => ( +
+ + {STRATEGY_ICONS[strategy.strategy_type] || } + {strategy.name} + + } + extra={ + handleToggle(strategy)} + checkedChildren="启用" unCheckedChildren="停用" /> + } + > +

{strategy.description}

+ + {strategy.is_enabled ? '运行中' : '未启用'} + + {TYPE_LABELS[strategy.strategy_type] || strategy.strategy_type} + {strategy.priority > 0 && 优先级: {strategy.priority}} + + {strategy.parameters && Object.keys(strategy.parameters).length > 0 && ( + + {Object.entries(strategy.parameters).map(([k, v]) => ( + {String(v)} + ))} + + )} +
+ + ))} + + + ); +} diff --git a/frontend/src/pages/EnergyStrategy/StrategySimulator.tsx b/frontend/src/pages/EnergyStrategy/StrategySimulator.tsx new file mode 100644 index 0000000..fabd0d6 --- /dev/null +++ b/frontend/src/pages/EnergyStrategy/StrategySimulator.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react'; +import { Card, Row, Col, InputNumber, Checkbox, Button, Statistic, Divider, message, Space, Tag } from 'antd'; +import { ExperimentOutlined, ThunderboltOutlined } from '@ant-design/icons'; +import ReactECharts from 'echarts-for-react'; +import { simulateStrategy } from '../../services/api'; + +export default function StrategySimulator() { + const [params, setParams] = useState({ + daily_consumption_kwh: 2000, + pv_daily_kwh: 800, + strategies: ['heat_storage', 'pv_priority', 'load_shift'], + }); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSimulate = async () => { + setLoading(true); + try { + const r = await simulateStrategy(params); + setResult(r); + } catch { message.error('模拟失败'); } + finally { setLoading(false); } + }; + + const strategyOptions = [ + { label: '谷电蓄热', value: 'heat_storage' }, + { label: '光伏自消纳', value: 'pv_priority' }, + { label: '负荷转移', value: 'load_shift' }, + ]; + + const getCompareOption = () => { + if (!result) return {}; + return { + tooltip: { trigger: 'axis' }, + xAxis: { type: 'category', data: ['基准费用', '优化费用'] }, + yAxis: { type: 'value', name: '元/天' }, + series: [{ + type: 'bar', + barWidth: '40%', + data: [ + { value: result.baseline_cost_per_day, itemStyle: { color: '#ff7875' } }, + { value: result.optimized_cost_per_day, itemStyle: { color: '#52c41a' } }, + ], + label: { show: true, position: 'top', formatter: '¥{c}' }, + }], + grid: { left: 60, right: 20, top: 30, bottom: 30 }, + }; + }; + + const getSavingsBreakdownOption = () => { + if (!result?.details?.length) return {}; + return { + tooltip: { trigger: 'item', formatter: '{b}: ¥{c}/天' }, + series: [{ + type: 'pie', + radius: ['40%', '70%'], + data: result.details.map((d: any) => ({ + name: d.strategy, + value: d.savings_per_day, + })), + label: { formatter: '{b}\n¥{c}/天' }, + }], + }; + }; + + return ( +
+ 策略模拟器}> + +
+
日均用电量 (kWh)
+ setParams({ ...params, daily_consumption_kwh: v || 2000 })} + /> + + +
日均光伏发电 (kWh)
+ setParams({ ...params, pv_daily_kwh: v || 800 })} + /> + + +
优化策略
+ setParams({ ...params, strategies: v as string[] })} + /> + + + + + + + + {result && ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {result.details?.map((d: any, i: number) => ( +
+ +
+ {d.strategy} + {d.description} + + + + + + + + + + ))} + + + + 节约比例: {result.savings_percentage}% + + + + + )} + + ); +} diff --git a/frontend/src/pages/EnergyStrategy/WeatherPanel.tsx b/frontend/src/pages/EnergyStrategy/WeatherPanel.tsx new file mode 100644 index 0000000..9990b89 --- /dev/null +++ b/frontend/src/pages/EnergyStrategy/WeatherPanel.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react'; +import { Row, Col, Card, Statistic, Space, Tag, message } from 'antd'; +import { CloudOutlined, FireOutlined } from '@ant-design/icons'; +import ReactECharts from 'echarts-for-react'; +import { getWeatherCurrent, getWeatherForecast, getWeatherImpact } from '../../services/api'; + +export default function WeatherPanel() { + const [current, setCurrent] = useState(null); + const [forecast, setForecast] = useState([]); + const [impact, setImpact] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { loadData(); }, []); + + const loadData = async () => { + setLoading(true); + try { + const [c, f, imp] = await Promise.all([ + getWeatherCurrent(), + getWeatherForecast(), + getWeatherImpact(), + ]); + setCurrent(c); + setForecast(f as any[]); + setImpact(imp); + } catch { message.error('加载天气数据失败'); } + finally { setLoading(false); } + }; + + const getForecastOption = () => { + if (!forecast.length) return {}; + const times = forecast.map(f => { + const d = new Date(f.timestamp); + return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:00`; + }); + return { + tooltip: { trigger: 'axis' }, + legend: { data: ['温度(C)', '太阳辐射(W/m2)', '风速(m/s)'] }, + xAxis: { type: 'category', data: times, axisLabel: { rotate: 45, fontSize: 10 } }, + yAxis: [ + { type: 'value', name: 'C / m/s', position: 'left' }, + { type: 'value', name: 'W/m2', position: 'right' }, + ], + series: [ + { name: '温度(C)', type: 'line', data: forecast.map(f => f.temperature), smooth: true, itemStyle: { color: '#ff7875' } }, + { name: '太阳辐射(W/m2)', type: 'area', yAxisIndex: 1, data: forecast.map(f => f.solar_radiation), smooth: true, itemStyle: { color: '#faad14' }, areaStyle: { opacity: 0.3 } }, + { name: '风速(m/s)', type: 'line', data: forecast.map(f => f.wind_speed), smooth: true, itemStyle: { color: '#1890ff' } }, + ], + grid: { left: 50, right: 50, top: 40, bottom: 60 }, + }; + }; + + const getImpactOption = () => { + if (!impact?.temperature_impact) return {}; + const data = impact.temperature_impact; + return { + tooltip: { trigger: 'axis' }, + legend: { data: ['能耗(kWh)', '光伏发电(kWh)'] }, + xAxis: { type: 'category', data: data.map((d: any) => d.range) }, + yAxis: { type: 'value', name: 'kWh' }, + series: [ + { name: '能耗(kWh)', type: 'bar', data: data.map((d: any) => d.avg_consumption), itemStyle: { color: '#ff7875' } }, + { name: '光伏发电(kWh)', type: 'bar', data: data.map((d: any) => d.pv_generation), itemStyle: { color: '#52c41a' } }, + ], + grid: { left: 50, right: 20, top: 40, bottom: 30 }, + }; + }; + + const tempColor = (t: number) => { + if (t < 0) return '#1890ff'; + if (t < 15) return '#69c0ff'; + if (t < 25) return '#52c41a'; + if (t < 35) return '#faad14'; + return '#f5222d'; + }; + + return ( +
+ {/* Current weather */} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + {current?.source && ( +
+ + 数据来源: {current.source === 'api' ? '气象API' : '模拟数据'} + +
+ )} + + {/* Forecast */} + + + + + {/* Weather impact */} + + + + + + + + + {impact?.key_findings?.map((f: string, i: number) => ( +
+ + + {f} + +
+ ))} +
+ + + + ); +} diff --git a/frontend/src/pages/EnergyStrategy/index.tsx b/frontend/src/pages/EnergyStrategy/index.tsx new file mode 100644 index 0000000..2f67643 --- /dev/null +++ b/frontend/src/pages/EnergyStrategy/index.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { Tabs, Card } from 'antd'; +import { + DollarOutlined, ThunderboltOutlined, BarChartOutlined, + FundOutlined, ExperimentOutlined, CloudOutlined, +} from '@ant-design/icons'; +import PricingConfig from './PricingConfig'; +import StrategyManager from './StrategyManager'; +import CostAnalysis from './CostAnalysis'; +import SavingsReport from './SavingsReport'; +import StrategySimulator from './StrategySimulator'; +import WeatherPanel from './WeatherPanel'; + +export default function EnergyStrategy() { + const [activeTab, setActiveTab] = useState('pricing'); + + const items = [ + { + key: 'pricing', + label: 电价配置, + children: , + }, + { + key: 'strategies', + label: 策略管理, + children: , + }, + { + key: 'cost', + label: 费用分析, + children: , + }, + { + key: 'savings', + label: 节约报告, + children: , + }, + { + key: 'simulator', + label: 策略模拟, + children: , + }, + { + key: 'weather', + label: 气象数据, + children: , + }, + ]; + + return ( +
+ + + +
+ ); +} diff --git a/frontend/src/pages/Login/index.tsx b/frontend/src/pages/Login/index.tsx new file mode 100644 index 0000000..e204cf9 --- /dev/null +++ b/frontend/src/pages/Login/index.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { Form, Input, Button, Card, message, Typography } from 'antd'; +import { UserOutlined, LockOutlined, ThunderboltOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import { login } from '../../services/api'; +import { setToken, setUser } from '../../utils/auth'; + +const { Title, Text } = Typography; + +export default function LoginPage() { + const [loading, setLoading] = useState(false); + const [guestLoading, setGuestLoading] = useState(false); + const navigate = useNavigate(); + + const doLogin = async (username: string, password: string) => { + const res: any = await login(username, password); + setToken(res.access_token); + setUser(res.user); + return res; + }; + + const onFinish = async (values: { username: string; password: string }) => { + setLoading(true); + try { + await doLogin(values.username, values.password); + message.success('登录成功'); + navigate('/'); + } catch { + message.error('用户名或密码错误'); + } finally { + setLoading(false); + } + }; + + const onGuestLogin = async () => { + setGuestLoading(true); + try { + await doLogin('visitor', 'visitor123'); + message.success('访客登录成功'); + navigate('/'); + } catch { + message.error('访客登录失败,请联系管理员'); + } finally { + setGuestLoading(false); + } + }; + + return ( +
+ +
+ + + 天普智慧能源管理平台 + + 零碳园区 · 智慧运维 +
+
+ + } placeholder="用户名" /> + + + } placeholder="密码" /> + + + + + + + +
+ + 访客仅可浏览数据,无管理权限 + +
+ +
+
+ ); +} diff --git a/frontend/src/pages/Maintenance/index.tsx b/frontend/src/pages/Maintenance/index.tsx new file mode 100644 index 0000000..369bca1 --- /dev/null +++ b/frontend/src/pages/Maintenance/index.tsx @@ -0,0 +1,399 @@ +import { useEffect, useState } from 'react'; +import { + Card, Table, Tag, Button, Tabs, Modal, Form, Input, Select, InputNumber, + Space, message, Row, Col, Statistic, DatePicker, Badge, +} from 'antd'; +import { + PlusOutlined, ToolOutlined, CheckOutlined, WarningOutlined, + ClockCircleOutlined, UserOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { + getMaintenanceDashboard, getMaintenancePlans, createMaintenancePlan, + triggerMaintenancePlan, getMaintenanceRecords, getMaintenanceOrders, + createMaintenanceOrder, assignMaintenanceOrder, completeMaintenanceOrder, + getMaintenanceDuty, createMaintenanceDuty, +} from '../../services/api'; + +const priorityMap: Record = { + critical: { color: 'red', text: '紧急' }, + high: { color: 'orange', text: '高' }, + medium: { color: 'blue', text: '中' }, + low: { color: 'default', text: '低' }, +}; + +const orderStatusMap: Record = { + open: { color: 'red', text: '待处理' }, + assigned: { color: 'orange', text: '已指派' }, + in_progress: { color: 'processing', text: '处理中' }, + completed: { color: 'green', text: '已完成' }, + verified: { color: 'cyan', text: '已验证' }, + closed: { color: 'default', text: '已关闭' }, +}; + +const recordStatusMap: Record = { + pending: { color: 'default', text: '待执行' }, + in_progress: { color: 'processing', text: '执行中' }, + completed: { color: 'green', text: '已完成' }, + issues_found: { color: 'orange', text: '发现问题' }, +}; + +const shiftMap: Record = { + day: '白班', night: '夜班', on_call: '值班', +}; + +// ── Tab 1: Dashboard ─────────────────────────────────────────────── + +function DashboardTab() { + const [dashboard, setDashboard] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + (async () => { + try { + setDashboard(await getMaintenanceDashboard()); + } catch { message.error('加载运维概览失败'); } + finally { setLoading(false); } + })(); + }, []); + + const orderColumns = [ + { title: '工单号', dataIndex: 'code', width: 160 }, + { title: '标题', dataIndex: 'title' }, + { title: '优先级', dataIndex: 'priority', width: 80, render: (v: string) => { + const p = priorityMap[v] || { color: 'default', text: v }; + return {p.text}; + }}, + { title: '状态', dataIndex: 'status', width: 90, render: (v: string) => { + const s = orderStatusMap[v] || { color: 'default', text: v }; + return ; + }}, + { title: '创建时间', dataIndex: 'created_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' }, + ]; + + return ( +
+ +
+ } /> + + + } valueStyle={{ color: dashboard?.overdue_count > 0 ? '#f5222d' : undefined }} /> + + + } /> + + + } /> + + + +
+ + + ); +} + +// ── Tab 2: Inspection Plans ──────────────────────────────────────── + +function PlansTab() { + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [form] = Form.useForm(); + + const loadPlans = async () => { + setLoading(true); + try { setPlans(await getMaintenancePlans() as any[]); } + catch { message.error('加载巡检计划失败'); } + finally { setLoading(false); } + }; + + useEffect(() => { loadPlans(); }, []); + + const handleCreate = async (values: any) => { + try { + await createMaintenancePlan(values); + message.success('巡检计划创建成功'); + setShowModal(false); + form.resetFields(); + loadPlans(); + } catch { message.error('创建失败'); } + }; + + const handleTrigger = async (id: number) => { + try { + await triggerMaintenancePlan(id); + message.success('已触发巡检'); + } catch { message.error('触发失败'); } + }; + + const columns = [ + { title: '计划名称', dataIndex: 'name' }, + { title: '巡检周期', dataIndex: 'schedule_type', render: (v: string) => { + const map: Record = { daily: '每日', weekly: '每周', monthly: '每月', custom: '自定义' }; + return map[v] || v || '-'; + }}, + { title: '状态', dataIndex: 'is_active', width: 80, render: (v: boolean) => {v ? '启用' : '停用'} }, + { title: '下次执行', dataIndex: 'next_run_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' }, + { title: '操作', key: 'action', width: 120, render: (_: any, r: any) => ( + + )}, + ]; + + return ( + } onClick={() => setShowModal(true)}>新建计划}> +
+ setShowModal(false)} onOk={() => form.submit()} okText="创建" cancelText="取消"> +
+ + + + + + + + + + +
+ + ); +} + +// ── Tab 3: Inspection Records ────────────────────────────────────── + +function RecordsTab() { + const [records, setRecords] = useState({ total: 0, items: [] }); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState(); + + const loadRecords = async () => { + setLoading(true); + try { + setRecords(await getMaintenanceRecords({ status: statusFilter })); + } catch { message.error('加载巡检记录失败'); } + finally { setLoading(false); } + }; + + useEffect(() => { loadRecords(); }, [statusFilter]); + + const columns = [ + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: '计划ID', dataIndex: 'plan_id', width: 80 }, + { title: '巡检人', dataIndex: 'inspector_id', width: 80 }, + { title: '状态', dataIndex: 'status', width: 100, render: (v: string) => { + const s = recordStatusMap[v] || { color: 'default', text: v }; + return {s.text}; + }}, + { title: '开始时间', dataIndex: 'started_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' }, + { title: '完成时间', dataIndex: 'completed_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' }, + ]; + + return ( + ({ label: v.text, value: k }))} /> + }> +
+ + ); +} + +// ── Tab 4: Repair Orders ─────────────────────────────────────────── + +function OrdersTab() { + const [orders, setOrders] = useState({ total: 0, items: [] }); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [assignModal, setAssignModal] = useState<{ open: boolean; orderId: number | null }>({ open: false, orderId: null }); + const [form] = Form.useForm(); + + const loadOrders = async () => { + setLoading(true); + try { setOrders(await getMaintenanceOrders({})); } + catch { message.error('加载工单失败'); } + finally { setLoading(false); } + }; + + useEffect(() => { loadOrders(); }, []); + + const handleCreate = async (values: any) => { + try { + await createMaintenanceOrder(values); + message.success('工单创建成功'); + setShowModal(false); + form.resetFields(); + loadOrders(); + } catch { message.error('创建失败'); } + }; + + const handleAssign = async (userId: number) => { + if (!assignModal.orderId) return; + try { + await assignMaintenanceOrder(assignModal.orderId, userId); + message.success('已指派'); + setAssignModal({ open: false, orderId: null }); + loadOrders(); + } catch { message.error('指派失败'); } + }; + + const handleComplete = async (id: number) => { + try { + await completeMaintenanceOrder(id); + message.success('已完成'); + loadOrders(); + } catch { message.error('操作失败'); } + }; + + const columns = [ + { title: '工单号', dataIndex: 'code', width: 160 }, + { title: '标题', dataIndex: 'title' }, + { title: '优先级', dataIndex: 'priority', width: 80, render: (v: string) => { + const p = priorityMap[v] || { color: 'default', text: v }; + return {p.text}; + }}, + { title: '状态', dataIndex: 'status', width: 90, render: (v: string) => { + const s = orderStatusMap[v] || { color: 'default', text: v }; + return ; + }}, + { title: '创建时间', dataIndex: 'created_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' }, + { title: '操作', key: 'action', width: 200, render: (_: any, r: any) => ( + + {r.status === 'open' && } + {['assigned', 'in_progress'].includes(r.status) && } + + )}, + ]; + + return ( + } onClick={() => setShowModal(true)}>新建工单}> +
+ + setShowModal(false)} onOk={() => form.submit()} okText="创建" cancelText="取消"> +
+ + + + + + + + + + +
+ + setShowModal(false)} onOk={() => form.submit()} okText="创建" cancelText="取消"> + + + + + + + + + setFilters(prev => ({ ...prev, category: v, page: 1 }))} /> +
`共 ${t} 条`, + onChange: (p, ps) => setFilters(prev => ({ ...prev, page: p, page_size: ps })), + }} /> + { setShowModal(false); form.resetFields(); }} + onOk={() => form.submit()} destroyOnClose width={600}> + + + ({ label: s.label, value: s.value }))} /> + + +