Initial commit: Tianpu Zero-Carbon EMS Platform

Full-stack energy management system for Tianpu Daxing campus.
- Frontend: React 19 + TypeScript + Ant Design + ECharts
- Backend: FastAPI + SQLAlchemy + PostgreSQL/TimescaleDB
- Features: PV monitoring, heat pump management, carbon tracking, alarms, reports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Du Wenbo
2026-04-01 13:36:06 +08:00
commit f53a610a19
77 changed files with 8904 additions and 0 deletions

19
.claude/launch.json Normal file
View File

@@ -0,0 +1,19 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000,
"cwd": "frontend"
},
{
"name": "backend",
"runtimeExecutable": "uvicorn",
"runtimeArgs": ["app.main:app", "--reload", "--port", "8000"],
"port": 8000,
"cwd": "backend"
}
]
}

19
.env Normal file
View File

@@ -0,0 +1,19 @@
# Database
DATABASE_URL=postgresql+asyncpg://tianpu:tianpu2026@postgres:5432/tianpu_ems
DATABASE_URL_SYNC=postgresql://tianpu:tianpu2026@postgres:5432/tianpu_ems
DATABASE_URL_LOCAL=postgresql+asyncpg://tianpu:tianpu2026@localhost:5432/tianpu_ems
DATABASE_URL_LOCAL_SYNC=postgresql://tianpu:tianpu2026@localhost:5432/tianpu_ems
# Redis
REDIS_URL=redis://redis:6379/0
REDIS_URL_LOCAL=redis://localhost:6379/0
# JWT
SECRET_KEY=tianpu-ems-secret-key-change-in-production-2026
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=480
# App
APP_NAME=TianpuEMS
DEBUG=true
API_V1_PREFIX=/api/v1

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
__pycache__/
*.pyc
.env.local
node_modules/
dist/
.next/
*.egg-info/
.venv/
venv/
*.log
.DS_Store
*.db

9
backend/.env Normal file
View File

@@ -0,0 +1,9 @@
# Local development with SQLite
DATABASE_URL=sqlite+aiosqlite:///./tianpu_ems.db
DATABASE_URL_SYNC=sqlite:///./tianpu_ems.db
SECRET_KEY=tianpu-ems-secret-key-change-in-production-2026
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=480
APP_NAME=TianpuEMS
DEBUG=true
API_V1_PREFIX=/api/v1

10
backend/Dockerfile Normal file
View File

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

36
backend/alembic.ini Normal file
View File

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

40
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,40 @@
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.database import Base
from app.models import * # noqa
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
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)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

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

0
backend/app/__init__.py Normal file
View File

View File

14
backend/app/api/router.py Normal file
View File

@@ -0,0 +1,14 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard
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)

View File

View File

@@ -0,0 +1,146 @@
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.alarm import AlarmRule, AlarmEvent
from app.models.user import User
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)
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
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
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,
}

View File

@@ -0,0 +1,50 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, 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
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(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})
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,
}

View File

@@ -0,0 +1,74 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, text
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.carbon import CarbonEmission, EmissionFactor
from app.models.user import User
router = APIRouter(prefix="/carbon", tags=["碳排放管理"])
@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)
result = await db.execute(
select(
func.date_trunc('day', CarbonEmission.date).label('day'),
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()]

View File

@@ -0,0 +1,139 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, text
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, 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)
result = await db.execute(
select(
func.date_trunc('hour', EnergyData.timestamp).label('hour'),
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()]

View File

@@ -0,0 +1,122 @@
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
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()
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="设备不存在")
for k, v in data.model_dump(exclude_unset=True).items():
setattr(device, k, v)
return _device_to_dict(device)
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,
}

View File

@@ -0,0 +1,144 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, text
from pydantic import BaseModel
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.energy import EnergyData, EnergyDailySummary
from app.models.user import User
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:
if granularity == "5min":
time_bucket = func.to_timestamp(
func.floor(func.extract('epoch', EnergyData.timestamp) / 300) * 300
).label('time_bucket')
elif granularity == "hour":
time_bucket = func.date_trunc('hour', EnergyData.timestamp).label('time_bucket')
else: # day
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("/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,
}

View File

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

View File

@@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException, Query
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
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"
@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}
@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}
@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="任务不存在")
task.status = "running"
# TODO: trigger Celery task
return {"message": "报表生成中", "task_id": task.id}

View File

@@ -0,0 +1,79 @@
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
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()
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="用户不存在")
for k, v in data.model_dump(exclude_unset=True).items():
setattr(target, k, v)
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()]

View File

View File

View File

@@ -0,0 +1,27 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
import os
class Settings(BaseSettings):
APP_NAME: str = "TianpuEMS"
DEBUG: bool = True
API_V1_PREFIX: str = "/api/v1"
DATABASE_URL: str = "sqlite+aiosqlite:///./tianpu_ems.db"
DATABASE_URL_LOCAL: str = ""
DATABASE_URL_SYNC: str = "sqlite:///./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
class Config:
env_file = ".env"
extra = "ignore"
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,29 @@
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()
db_url = settings.DATABASE_URL_LOCAL if settings.DATABASE_URL_LOCAL else settings.DATABASE_URL
engine_kwargs = {"echo": settings.DEBUG}
if "sqlite" not in db_url:
engine_kwargs["pool_size"] = 20
engine_kwargs["max_overflow"] = 10
engine = create_async_engine(db_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

34
backend/app/core/deps.py Normal file
View File

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

View File

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

39
backend/app/main.py Normal file
View File

@@ -0,0 +1,39 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import get_settings
from app.services.simulator import DataSimulator
settings = get_settings()
simulator = DataSimulator()
@asynccontextmanager
async def lifespan(app: FastAPI):
await simulator.start()
yield
await simulator.stop()
app = FastAPI(
title="天普零碳园区智慧能源管理平台",
description="Tianpu Zero-Carbon Park Smart Energy Management System",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
@app.get("/health")
async def health():
return {"status": "ok", "app": settings.APP_NAME}

View File

@@ -0,0 +1,15 @@
from app.models.user import User, Role, AuditLog
from app.models.device import Device, DeviceGroup, DeviceType
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
__all__ = [
"User", "Role", "AuditLog",
"Device", "DeviceGroup", "DeviceType",
"EnergyData", "EnergyDailySummary",
"AlarmRule", "AlarmEvent",
"CarbonEmission", "EmissionFactor",
"ReportTemplate", "ReportTask",
]

View File

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

View File

@@ -0,0 +1,36 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, Text
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 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())

View File

@@ -0,0 +1,50 @@
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) # 采集间隔(秒)
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())

View File

@@ -0,0 +1,38 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, JSON
from sqlalchemy.sql import func
from app.core.database import Base
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())

View File

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

View File

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

View File

View File

@@ -0,0 +1,144 @@
"""模拟数据生成器 - 为天普园区设备生成真实感的模拟数据"""
import asyncio
import random
import math
from datetime import datetime, timezone
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
class DataSimulator:
def __init__(self):
self._task = None
self._running = False
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:
print(f"Simulator error: {e}")
await asyncio.sleep(15) # 每15秒生成一次
async def _generate_data(self):
now = datetime.now(timezone.utc)
hour = (now.hour + 8) % 24 # 北京时间
async with async_session() as session:
result = await session.execute(select(Device).where(Device.is_active == True))
devices = result.scalars().all()
data_points = []
for device in devices:
points = self._generate_device_data(device, now, hour)
data_points.extend(points)
device.status = "online"
device.last_data_time = now
if data_points:
session.add_all(data_points)
await session.commit()
def _generate_device_data(self, device: Device, now: datetime, hour: int) -> list[EnergyData]:
points = []
if device.device_type == "pv_inverter":
points = self._gen_pv_data(device, now, hour)
elif device.device_type == "heat_pump":
points = self._gen_heatpump_data(device, now, hour)
elif device.device_type == "meter":
points = self._gen_meter_data(device, now, hour)
elif device.device_type == "sensor":
points = self._gen_sensor_data(device, now, hour)
return points
def _gen_pv_data(self, device: Device, now: datetime, hour: int) -> list[EnergyData]:
"""光伏逆变器数据 - 基于日照模型"""
rated = device.rated_power or 110 # kW
# 日照模型: 6-18点有发电, 12点最大
if 6 <= hour <= 18:
solar_factor = math.sin(math.pi * (hour - 6) / 12)
weather_factor = random.uniform(0.6, 1.0) # 天气影响
power = rated * solar_factor * weather_factor * random.uniform(0.85, 0.95)
else:
power = 0
daily_energy = rated * 4.5 * random.uniform(0.8, 1.1) # 日发电量约4.5等效小时
cumulative_energy = 170 + random.uniform(0, 5) # 累计发电MWh
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(daily_energy * hour / 24, 2), unit="kWh"),
EnergyData(device_id=device.id, timestamp=now, data_type="total_energy", value=round(cumulative_energy * 1000, 1), unit="kWh"),
EnergyData(device_id=device.id, timestamp=now, data_type="dc_voltage", value=round(random.uniform(250, 800), 1), unit="V"),
EnergyData(device_id=device.id, timestamp=now, data_type="ac_voltage", value=round(random.uniform(218, 222), 1), unit="V"),
EnergyData(device_id=device.id, timestamp=now, data_type="temperature", value=round(random.uniform(25, 45), 1), unit=""),
]
def _gen_heatpump_data(self, device: Device, now: datetime, hour: int) -> list[EnergyData]:
"""热泵机组数据"""
# 冬季供暖模式
is_heating_season = now.month in [1, 2, 3, 10, 11, 12]
if is_heating_season:
outdoor_temp = random.uniform(-5, 10)
cop = random.uniform(2.5, 3.8)
inlet_temp = random.uniform(35, 42)
outlet_temp = inlet_temp + random.uniform(5, 10)
power = random.uniform(20, 35)
flow_rate = random.uniform(8, 15)
else:
outdoor_temp = random.uniform(15, 35)
cop = random.uniform(3.5, 5.0)
inlet_temp = random.uniform(8, 12)
outlet_temp = inlet_temp - random.uniform(3, 6)
power = random.uniform(15, 28)
flow_rate = random.uniform(8, 15)
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="cop", value=round(cop, 2), unit=""),
EnergyData(device_id=device.id, timestamp=now, data_type="inlet_temp", value=round(inlet_temp, 1), unit=""),
EnergyData(device_id=device.id, timestamp=now, data_type="outlet_temp", value=round(outlet_temp, 1), unit=""),
EnergyData(device_id=device.id, timestamp=now, data_type="flow_rate", value=round(flow_rate, 1), unit="m³/h"),
EnergyData(device_id=device.id, timestamp=now, data_type="outdoor_temp", value=round(outdoor_temp, 1), unit=""),
]
def _gen_meter_data(self, device: Device, now: datetime, hour: int) -> list[EnergyData]:
"""电表数据"""
# 负荷曲线: 白天高, 夜间低
base_load = 50 # 基础负荷kW
if 8 <= hour <= 18:
load_factor = random.uniform(1.2, 2.0)
elif 18 <= hour <= 22:
load_factor = random.uniform(0.8, 1.3)
else:
load_factor = random.uniform(0.3, 0.6)
power = base_load * load_factor + random.uniform(-5, 5)
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="voltage", value=round(random.uniform(218, 225), 1), unit="V"),
EnergyData(device_id=device.id, timestamp=now, data_type="current", value=round(power / 0.38 / random.uniform(0.85, 0.95), 1), unit="A"),
EnergyData(device_id=device.id, timestamp=now, data_type="power_factor", value=round(random.uniform(0.88, 0.98), 3), unit=""),
]
def _gen_sensor_data(self, device: Device, now: datetime, hour: int) -> list[EnergyData]:
"""温湿度传感器数据"""
# 室内温度根据供暖状态
indoor_temp = random.uniform(20, 24) if now.month in [1, 2, 3, 10, 11, 12] else random.uniform(22, 28)
humidity = random.uniform(35, 65)
return [
EnergyData(device_id=device.id, timestamp=now, data_type="temperature", value=round(indoor_temp, 1), unit=""),
EnergyData(device_id=device.id, timestamp=now, data_type="humidity", value=round(humidity, 1), unit="%"),
]

View File

18
backend/requirements.txt Normal file
View File

@@ -0,0 +1,18 @@
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==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

63
docker-compose.yml Normal file
View File

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

24
frontend/.gitignore vendored Normal file
View File

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

11
frontend/Dockerfile Normal file
View File

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

73
frontend/README.md Normal file
View File

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

23
frontend/eslint.config.js Normal file
View File

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

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>天普智慧能源管理平台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5298
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
frontend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "frontend",
"private": true,
"version": "0.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",
"antd": "^5.29.3",
"axios": "^1.14.0",
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^6.30.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@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"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

41
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
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 SystemManagement from './pages/System';
import { isLoggedIn } from './utils/auth';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
if (!isLoggedIn()) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export default function App() {
return (
<ConfigProvider locale={zhCN} theme={{
token: { colorPrimary: '#1890ff', borderRadius: 6 },
}}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Dashboard />} />
<Route path="monitoring" element={<Monitoring />} />
<Route path="analysis" element={<Analysis />} />
<Route path="alarms" element={<Alarms />} />
<Route path="carbon" element={<Carbon />} />
<Route path="reports" element={<Reports />} />
<Route path="system/*" element={<SystemManagement />} />
</Route>
</Routes>
</BrowserRouter>
</ConfigProvider>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

16
frontend/src/index.css Normal file
View File

@@ -0,0 +1,16 @@
* {
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;
}

View File

@@ -0,0 +1,100 @@
import { useState } from 'react';
import { Layout, Menu, Avatar, Dropdown, Typography, Badge } from 'antd';
import {
DashboardOutlined, MonitorOutlined, BarChartOutlined, AlertOutlined,
FileTextOutlined, CloudOutlined, SettingOutlined, UserOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, BellOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { getUser, removeToken } from '../utils/auth';
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: '能源总览' },
{ key: '/monitoring', icon: <MonitorOutlined />, label: '实时监控' },
{ key: '/analysis', icon: <BarChartOutlined />, label: '能耗分析' },
{ key: '/alarms', icon: <AlertOutlined />, label: '告警管理' },
{ key: '/carbon', icon: <CloudOutlined />, label: '碳排放管理' },
{ key: '/reports', icon: <FileTextOutlined />, label: '报表管理' },
{ key: '/system', icon: <SettingOutlined />, label: '系统管理',
children: [
{ key: '/system/users', label: '用户管理' },
{ key: '/system/roles', label: '角色权限' },
{ key: '/system/settings', label: '系统设置' },
],
},
];
export default function MainLayout() {
const [collapsed, setCollapsed] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const user = getUser();
const handleLogout = () => {
removeToken();
localStorage.removeItem('user');
navigate('/login');
};
const userMenu = {
items: [
{ key: 'profile', icon: <UserOutlined />, label: '个人信息' },
{ type: 'divider' as const },
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout },
],
};
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible collapsed={collapsed} width={220}
style={{ background: '#001529' }}>
<div style={{
height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderBottom: '1px solid rgba(255,255,255,0.1)',
}}>
<ThunderboltOutlined style={{ fontSize: 24, color: '#1890ff', marginRight: collapsed ? 0 : 8 }} />
{!collapsed && <Text strong style={{ color: '#fff', fontSize: 16 }}>EMS</Text>}
</div>
<Menu
theme="dark" mode="inline"
selectedKeys={[location.pathname]}
defaultOpenKeys={['/system']}
items={menuItems}
onClick={({ key }) => navigate(key)}
/>
</Sider>
<Layout>
<Header style={{
padding: '0 24px', background: '#fff', display: 'flex',
alignItems: 'center', justifyContent: 'space-between',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
}}>
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
onClick={() => setCollapsed(!collapsed)}>
{collapsed ? <MenuUnfoldOutlined style={{ fontSize: 18 }} /> :
<MenuFoldOutlined style={{ fontSize: 18 }} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
{/* TODO: fetch notification count from API */}
<Badge count={0} size="small">
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
</Badge>
<Dropdown menu={userMenu} placement="bottomRight">
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar size="small" icon={<UserOutlined />} style={{ background: '#1890ff' }} />
<Text>{user?.full_name || user?.username || '用户'}</Text>
</div>
</Dropdown>
</div>
</Header>
<Content style={{ margin: 16, padding: 24, background: '#f5f5f5', minHeight: 280 }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,140 @@
import { useEffect, useState } from 'react';
import { Card, Table, Tag, Button, Tabs, Modal, Form, Input, Select, InputNumber, Space, message } from 'antd';
import { PlusOutlined, CheckOutlined, ToolOutlined } from '@ant-design/icons';
import { getAlarmEvents, getAlarmRules, createAlarmRule, acknowledgeAlarm, resolveAlarm } from '../../services/api';
const severityMap: Record<string, { color: string; text: string }> = {
critical: { color: 'red', text: '紧急' },
major: { color: 'orange', text: '重要' },
warning: { color: 'gold', text: '一般' },
};
const statusMap: Record<string, { color: string; text: string }> = {
active: { color: 'red', text: '活跃' },
acknowledged: { color: 'orange', text: '已确认' },
resolved: { color: 'green', text: '已解决' },
};
export default function Alarms() {
const [events, setEvents] = useState<any>({ total: 0, items: [] });
const [rules, setRules] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showRuleModal, setShowRuleModal] = useState(false);
const [form] = Form.useForm();
useEffect(() => { loadData(); }, []);
const loadData = async () => {
setLoading(true);
try {
const [ev, ru] = await Promise.all([getAlarmEvents({}), getAlarmRules()]);
setEvents(ev);
setRules(ru as any[]);
} catch (e) { console.error(e); }
finally { setLoading(false); }
};
const handleAcknowledge = async (id: number) => {
await acknowledgeAlarm(id);
message.success('已确认');
loadData();
};
const handleResolve = async (id: number) => {
await resolveAlarm(id);
message.success('已解决');
loadData();
};
const handleCreateRule = async (values: any) => {
await createAlarmRule(values);
message.success('规则创建成功');
setShowRuleModal(false);
form.resetFields();
loadData();
};
const eventColumns = [
{ title: '级别', dataIndex: 'severity', width: 80, render: (s: string) => {
const sv = severityMap[s] || { color: 'default', text: s };
return <Tag color={sv.color}>{sv.text}</Tag>;
}},
{ title: '告警标题', dataIndex: 'title' },
{ title: '触发值', dataIndex: 'value', render: (v: number) => v?.toFixed(2) },
{ title: '阈值', dataIndex: 'threshold', render: (v: number) => v?.toFixed(2) },
{ title: '状态', dataIndex: 'status', render: (s: string) => {
const st = statusMap[s] || { color: 'default', text: s };
return <Tag color={st.color}>{st.text}</Tag>;
}},
{ title: '触发时间', dataIndex: 'triggered_at', width: 180 },
{ title: '操作', key: 'action', width: 180, render: (_: any, r: any) => (
<Space>
{r.status === 'active' && <Button size="small" icon={<CheckOutlined />} onClick={() => handleAcknowledge(r.id)}></Button>}
{r.status !== 'resolved' && <Button size="small" type="primary" icon={<ToolOutlined />} onClick={() => handleResolve(r.id)}></Button>}
</Space>
)},
];
const ruleColumns = [
{ title: '规则名称', dataIndex: 'name' },
{ title: '数据类型', dataIndex: 'data_type' },
{ title: '条件', dataIndex: 'condition' },
{ title: '阈值', dataIndex: 'threshold' },
{ title: '级别', dataIndex: 'severity', render: (s: string) => <Tag color={severityMap[s]?.color}>{severityMap[s]?.text}</Tag> },
{ title: '状态', dataIndex: 'is_active', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag> },
];
return (
<div>
<Tabs items={[
{ key: 'events', label: `告警事件 (${events.total})`, children: (
<Card size="small">
<Table columns={eventColumns} dataSource={events.items} rowKey="id"
loading={loading} size="small" pagination={{ pageSize: 15 }} />
</Card>
)},
{ key: 'rules', label: '告警规则', children: (
<Card size="small" extra={<Button type="primary" icon={<PlusOutlined />}
onClick={() => setShowRuleModal(true)}></Button>}>
<Table columns={ruleColumns} dataSource={rules} rowKey="id"
loading={loading} size="small" />
</Card>
)},
]} />
<Modal title="新建告警规则" open={showRuleModal} onCancel={() => setShowRuleModal(false)}
onOk={() => form.submit()} okText="创建" cancelText="取消">
<Form form={form} layout="vertical" onFinish={handleCreateRule}>
<Form.Item name="name" label="规则名称" rules={[{ required: true }]}>
<Input placeholder="例: 热泵出水温度过高" />
</Form.Item>
<Form.Item name="data_type" label="监控数据" rules={[{ required: true }]}>
<Select options={[
{ label: '功率 (power)', value: 'power' },
{ label: '温度 (temperature)', value: 'temperature' },
{ label: 'COP', value: 'cop' },
{ label: '电压 (voltage)', value: 'voltage' },
]} />
</Form.Item>
<Form.Item name="condition" label="条件" rules={[{ required: true }]}>
<Select options={[
{ label: '大于', value: 'gt' }, { label: '小于', value: 'lt' },
{ label: '等于', value: 'eq' }, { label: '范围外', value: 'range_out' },
]} />
</Form.Item>
<Form.Item name="threshold" label="阈值">
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="severity" label="告警级别" initialValue="warning">
<Select options={[
{ label: '紧急', value: 'critical' }, { label: '重要', value: 'major' }, { label: '一般', value: 'warning' },
]} />
</Form.Item>
<Form.Item name="duration" label="持续时间(秒)" initialValue={0}>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react';
import { Card, Row, Col, DatePicker, Select, Statistic, Table } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import dayjs from 'dayjs';
import { getEnergyHistory, getEnergyComparison, getEnergyDailySummary } from '../../services/api';
export default function Analysis() {
const [historyData, setHistoryData] = useState<any[]>([]);
const [comparison, setComparison] = useState<any>(null);
const [dailySummary, setDailySummary] = useState<any[]>([]);
const [granularity, setGranularity] = useState('hour');
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 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) },
];
return (
<div>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Card>
<Statistic title="本月用电量" value={comparison?.current || 0} suffix="kWh" precision={1} />
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Statistic title="环比变化" value={Math.abs(comparison?.mom_change || 0)} suffix="%"
prefix={comparison?.mom_change >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
valueStyle={{ color: comparison?.mom_change >= 0 ? '#f5222d' : '#52c41a' }} precision={1} />
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Statistic title="同比变化" value={Math.abs(comparison?.yoy_change || 0)} suffix="%"
prefix={comparison?.yoy_change >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
valueStyle={{ color: comparison?.yoy_change >= 0 ? '#f5222d' : '#52c41a' }} precision={1} />
</Card>
</Col>
</Row>
<Card title="能耗趋势" size="small" style={{ marginTop: 16 }}
extra={
<Select value={granularity} onChange={setGranularity} style={{ width: 120 }}
options={[
{ label: '按小时', value: 'hour' },
{ label: '按天', value: 'day' },
{ label: '5分钟', value: '5min' },
]} />
}>
<ReactECharts option={historyChartOption} style={{ height: 350 }} />
</Card>
<Card title="每日能耗汇总" size="small" style={{ marginTop: 16 }}>
<Table columns={dailyColumns} dataSource={dailySummary} rowKey="date"
size="small" pagination={{ pageSize: 10 }} />
</Card>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react';
import { Card, Row, Col, Statistic, Table, Select } from 'antd';
import { CloudOutlined, FallOutlined, RiseOutlined } from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import { getCarbonOverview, getCarbonTrend, getEmissionFactors } from '../../services/api';
export default function Carbon() {
const [overview, setOverview] = useState<any>(null);
const [trend, setTrend] = useState<any[]>([]);
const [factors, setFactors] = useState<any[]>([]);
const [days, setDays] = useState(30);
useEffect(() => { loadData(); }, [days]);
const loadData = async () => {
try {
const [ov, tr, fa] = await Promise.all([
getCarbonOverview(), getCarbonTrend(days), getEmissionFactors(),
]);
setOverview(ov);
setTrend(tr as any[]);
setFactors(fa as any[]);
} catch (e) { console.error(e); }
};
const trendOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['碳排放', '碳减排'] },
grid: { top: 40, right: 20, bottom: 30, left: 60 },
xAxis: {
type: 'category',
data: trend.map(d => {
const t = new Date(d.date);
return `${t.getMonth() + 1}/${t.getDate()}`;
}),
},
yAxis: { type: 'value', name: 'kgCO₂' },
series: [
{ name: '碳排放', type: 'bar', data: trend.map(d => d.emission), itemStyle: { color: '#f5222d' } },
{ name: '碳减排', type: 'bar', data: trend.map(d => 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 factorColumns = [
{ title: '名称', dataIndex: 'name' },
{ title: '能源类型', dataIndex: 'energy_type' },
{ title: '排放因子', dataIndex: 'factor', render: (v: number) => `${v} kgCO₂/${factors.find(f => f.factor === v)?.unit || ''}` },
{ title: 'Scope', dataIndex: 'scope' },
{ title: '区域', dataIndex: 'region' },
{ title: '数据来源', dataIndex: 'source' },
];
return (
<div>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Card>
<Statistic title="今日碳排放" value={overview?.today?.emission || 0} suffix="kgCO₂"
prefix={<RiseOutlined style={{ color: '#f5222d' }} />} precision={2} />
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Statistic title="今日碳减排" value={overview?.today?.reduction || 0} suffix="kgCO₂"
prefix={<FallOutlined style={{ color: '#52c41a' }} />} precision={2}
valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Statistic title="年度碳排放" value={overview?.year?.emission || 0} suffix="kgCO₂"
prefix={<CloudOutlined />} precision={1} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={16}>
<Card title="碳排放趋势" size="small" extra={
<Select value={days} onChange={setDays} style={{ width: 120 }}
options={[{ label: '近7天', value: 7 }, { label: '近30天', value: 30 }, { label: '近90天', value: 90 }]} />
}>
<ReactECharts option={trendOption} style={{ height: 300 }} />
</Card>
</Col>
<Col xs={24} lg={8}>
<Card title="排放范围分布" size="small">
<ReactECharts option={scopeOption} style={{ height: 300 }} />
</Card>
</Col>
</Row>
<Card title="排放因子" size="small" style={{ marginTop: 16 }}>
<Table columns={factorColumns} dataSource={factors} rowKey="id" size="small" pagination={false} />
</Card>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import ReactECharts from 'echarts-for-react';
interface Props {
stats: { online?: number; offline?: number; alarm?: number; total?: number };
}
export default function DeviceStatus({ stats }: Props) {
const option = {
tooltip: { trigger: 'item' },
legend: { bottom: 0, textStyle: { fontSize: 12 } },
series: [{
type: 'pie',
radius: ['45%', '70%'],
center: ['50%', '45%'],
avoidLabelOverlap: false,
label: { show: true, position: 'outside', fontSize: 12 },
data: [
{ value: stats.online || 0, name: '在线', itemStyle: { color: '#52c41a' } },
{ value: stats.offline || 0, name: '离线', itemStyle: { color: '#d9d9d9' } },
{ value: stats.alarm || 0, name: '告警', itemStyle: { color: '#f5222d' } },
],
}],
};
return <ReactECharts option={option} style={{ height: 280 }} />;
}

View File

@@ -0,0 +1,90 @@
import { Typography, Space } from 'antd';
import { ThunderboltOutlined, HomeOutlined, CloudServerOutlined, FireOutlined } from '@ant-design/icons';
const { Text } = Typography;
interface Props {
realtime?: {
pv_power: number;
heatpump_power: number;
total_load: number;
grid_power: number;
};
}
export default function EnergyFlow({ realtime }: Props) {
const pv = realtime?.pv_power || 0;
const hp = realtime?.heatpump_power || 0;
const load = realtime?.total_load || 0;
const grid = realtime?.grid_power || 0;
return (
<div style={{ padding: '16px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-around', alignItems: 'center', minHeight: 200 }}>
{/* 光伏 */}
<div style={{ textAlign: 'center' }}>
<div style={{
width: 80, height: 80, borderRadius: '50%', background: '#fff7e6',
display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 8px',
border: '2px solid #faad14',
}}>
<ThunderboltOutlined style={{ fontSize: 32, color: '#faad14' }} />
</div>
<Text strong></Text>
<br />
<Text style={{ fontSize: 20, color: '#faad14' }}>{pv.toFixed(1)}</Text>
<Text type="secondary"> kW</Text>
</div>
{/* 箭头 */}
<div style={{ textAlign: 'center' }}>
<Text type="secondary"></Text>
</div>
{/* 建筑负荷 */}
<div style={{ textAlign: 'center' }}>
<div style={{
width: 80, height: 80, borderRadius: '50%', background: '#e6f7ff',
display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 8px',
border: '2px solid #1890ff',
}}>
<HomeOutlined style={{ fontSize: 32, color: '#1890ff' }} />
</div>
<Text strong></Text>
<br />
<Text style={{ fontSize: 20, color: '#1890ff' }}>{load.toFixed(1)}</Text>
<Text type="secondary"> kW</Text>
</div>
{/* 箭头 */}
<div style={{ textAlign: 'center' }}>
<Text type="secondary"></Text>
</div>
{/* 电网 */}
<div style={{ textAlign: 'center' }}>
<div style={{
width: 80, height: 80, borderRadius: '50%', background: '#f6ffed',
display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 8px',
border: '2px solid #52c41a',
}}>
<CloudServerOutlined style={{ fontSize: 32, color: '#52c41a' }} />
</div>
<Text strong></Text>
<br />
<Text style={{ fontSize: 20, color: '#52c41a' }}>{grid.toFixed(1)}</Text>
<Text type="secondary"> kW</Text>
</div>
</div>
<div style={{ textAlign: 'center', marginTop: 16, padding: '8px', background: '#fafafa', borderRadius: 8 }}>
<Space size={24}>
<span><FireOutlined style={{ color: '#f5222d' }} /> : <Text strong>{hp.toFixed(1)} kW</Text></span>
<span>: <Text strong style={{ color: '#52c41a' }}>
{load > 0 ? ((Math.min(pv, load) / load) * 100).toFixed(1) : 0}%
</Text></span>
</Space>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import ReactECharts from 'echarts-for-react';
interface Props {
energyToday?: Record<string, { consumption: number; generation: number }>;
}
const LABELS: Record<string, string> = {
electricity: '用电',
heat: '供热',
water: '用水',
gas: '用气',
};
export default function EnergyOverview({ energyToday }: Props) {
const data = energyToday || {};
const categories = Object.keys(data).map(k => LABELS[k] || k);
const consumption = Object.values(data).map(v => v.consumption);
const generation = Object.values(data).map(v => v.generation);
const option = {
tooltip: { trigger: 'axis' },
legend: { data: ['消耗', '产出'], bottom: 0 },
grid: { top: 20, right: 20, bottom: 40, left: 50 },
xAxis: { type: 'category', data: categories },
yAxis: { type: 'value', name: 'kWh' },
series: [
{ name: '消耗', type: 'bar', data: consumption, itemStyle: { color: '#1890ff' } },
{ name: '产出', type: 'bar', data: generation, itemStyle: { color: '#52c41a' } },
],
};
return <ReactECharts option={option} style={{ height: 250 }} />;
}

View File

@@ -0,0 +1,40 @@
import ReactECharts from 'echarts-for-react';
interface Props {
data: { time: string; power: number }[];
}
export default function LoadCurve({ data }: Props) {
const option = {
tooltip: { trigger: 'axis' },
grid: { top: 30, right: 20, bottom: 30, left: 50 },
xAxis: {
type: 'category',
data: data.map(d => {
const t = new Date(d.time);
return `${t.getHours().toString().padStart(2, '0')}:00`;
}),
axisLabel: { fontSize: 11 },
},
yAxis: { type: 'value', name: 'kW', axisLabel: { fontSize: 11 } },
series: [{
name: '负荷功率',
type: 'line',
smooth: true,
data: data.map(d => d.power),
areaStyle: {
color: {
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(24,144,255,0.3)' },
{ offset: 1, color: 'rgba(24,144,255,0.02)' },
],
},
},
lineStyle: { width: 2, color: '#1890ff' },
itemStyle: { color: '#1890ff' },
}],
};
return <ReactECharts option={option} style={{ height: 280 }} />;
}

View File

@@ -0,0 +1,45 @@
import { Row, Col, Statistic, Progress, Typography } from 'antd';
import { ThunderboltOutlined } from '@ant-design/icons';
const { Text } = Typography;
interface Props {
realtime?: { pv_power: number; total_load: number };
energyToday?: { consumption: number; generation: number };
}
export default function PowerGeneration({ realtime, energyToday }: Props) {
const pvPower = realtime?.pv_power || 0;
const ratedPower = 375.035; // 总装机容量 kW
const utilization = ratedPower > 0 ? (pvPower / ratedPower) * 100 : 0;
const generation = energyToday?.generation || 0;
const selfUseRate = energyToday && energyToday.generation > 0
? Math.min(100, (energyToday.consumption / energyToday.generation) * 100) : 0;
return (
<div>
<Row gutter={16}>
<Col span={12}>
<Statistic title="实时发电功率" value={pvPower} suffix="kW" precision={1}
prefix={<ThunderboltOutlined style={{ color: '#faad14' }} />} />
</Col>
<Col span={12}>
<Statistic title="今日发电量" value={generation} suffix="kWh" precision={1} />
</Col>
</Row>
<div style={{ marginTop: 16 }}>
<Text type="secondary"></Text>
<Progress percent={Number(utilization.toFixed(1))} strokeColor="#faad14" />
</div>
<div style={{ marginTop: 8 }}>
<Text type="secondary"></Text>
<Progress percent={Number(selfUseRate.toFixed(1))} strokeColor="#52c41a" />
</div>
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
: {ratedPower} kW | 3SUN2000-110KTL-M0
</Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
import { useEffect, useState } from 'react';
import { Row, Col, Card, Statistic, Tag, List, Typography, Spin } from 'antd';
import {
ThunderboltOutlined, FireOutlined, CloudOutlined, AlertOutlined,
WarningOutlined, CloseCircleOutlined,
} from '@ant-design/icons';
import { getDashboardOverview, getRealtimeData, getLoadCurve } from '../../services/api';
import EnergyOverview from './components/EnergyOverview';
import PowerGeneration from './components/PowerGeneration';
import LoadCurve from './components/LoadCurve';
import DeviceStatus from './components/DeviceStatus';
import EnergyFlow from './components/EnergyFlow';
const { Title } = Typography;
export default function Dashboard() {
const [overview, setOverview] = useState<any>(null);
const [realtime, setRealtime] = useState<any>(null);
const [loadData, setLoadData] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = async () => {
try {
const [ov, rt, lc] = await Promise.all([
getDashboardOverview(),
getRealtimeData(),
getLoadCurve(24),
]);
setOverview(ov);
setRealtime(rt);
setLoadData(lc as any);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
const timer = setInterval(fetchData, 15000);
return () => clearInterval(timer);
}, []);
if (loading) return <Spin size="large" style={{ display: 'block', margin: '200px auto' }} />;
const ds = overview?.device_stats || {};
const carbon = overview?.carbon || {};
const elec = overview?.energy_today?.electricity || {};
return (
<div>
<Title level={4} style={{ marginBottom: 16 }}>
<ThunderboltOutlined style={{ color: '#1890ff', marginRight: 8 }} />
</Title>
{/* 核心指标卡片 */}
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<Card hoverable>
<Statistic title="实时光伏功率" value={realtime?.pv_power || 0} suffix="kW"
prefix={<ThunderboltOutlined style={{ color: '#faad14' }} />} precision={1} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card hoverable>
<Statistic title="实时热泵功率" value={realtime?.heatpump_power || 0} suffix="kW"
prefix={<FireOutlined style={{ color: '#f5222d' }} />} precision={1} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card hoverable>
<Statistic title="今日碳减排" value={carbon.reduction || 0} suffix="kgCO₂"
prefix={<CloudOutlined style={{ color: '#52c41a' }} />} precision={1} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card hoverable>
<Statistic title="活跃告警" value={overview?.active_alarms || 0}
prefix={<AlertOutlined style={{ color: '#f5222d' }} />}
valueStyle={{ color: overview?.active_alarms > 0 ? '#f5222d' : '#52c41a' }} />
</Card>
</Col>
</Row>
{/* 图表区域 */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={16}>
<Card title="负荷曲线 (24h)" size="small">
<LoadCurve data={loadData} />
</Card>
</Col>
<Col xs={24} lg={8}>
<Card title="设备状态" size="small">
<DeviceStatus stats={ds} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={12}>
<Card title="能量流向" size="small">
<EnergyFlow realtime={realtime} />
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="今日能耗概览" size="small">
<EnergyOverview energyToday={overview?.energy_today} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={12}>
<Card title="光伏发电" size="small">
<PowerGeneration realtime={realtime} energyToday={elec} />
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="最近告警" size="small"
extra={<Tag color={overview?.active_alarms > 0 ? 'red' : 'green'}>
{overview?.active_alarms || 0}
</Tag>}>
<List size="small" dataSource={overview?.recent_alarms || []}
locale={{ emptyText: '暂无告警' }}
renderItem={(item: any) => (
<List.Item>
<List.Item.Meta
avatar={item.severity === 'critical' ?
<CloseCircleOutlined style={{ color: '#f5222d' }} /> :
<WarningOutlined style={{ color: '#faad14' }} />}
title={item.title}
description={item.triggered_at}
/>
</List.Item>
)} />
</Card>
</Col>
</Row>
</div>
);
}

View File

@@ -0,0 +1,61 @@
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 navigate = useNavigate();
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true);
try {
const res: any = await login(values.username, values.password);
setToken(res.access_token);
setUser(res.user);
message.success('登录成功');
navigate('/');
} catch {
message.error('用户名或密码错误');
} finally {
setLoading(false);
}
};
return (
<div style={{
minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center',
background: 'linear-gradient(135deg, #0a1628 0%, #1a3a5c 50%, #0d2137 100%)',
}}>
<Card style={{ width: 400, borderRadius: 12, boxShadow: '0 8px 32px rgba(0,0,0,0.3)' }}>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<ThunderboltOutlined style={{ fontSize: 48, color: '#1890ff' }} />
<Title level={3} style={{ marginTop: 12, marginBottom: 4 }}>
</Title>
<Text type="secondary"> · </Text>
</div>
<Form onFinish={onFinish} size="large">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
</Button>
</Form.Item>
{import.meta.env.DEV && <Text type="secondary" style={{ fontSize: 12 }}>
默认账号: admin / admin123
</Text>}
</Form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react';
import { Card, Table, Tag, Input, Select, Descriptions, Modal, Badge } from 'antd';
import { getDevices, getDeviceRealtime } from '../../services/api';
const statusMap: Record<string, { color: string; text: string }> = {
online: { color: 'green', text: '在线' },
offline: { color: 'default', text: '离线' },
alarm: { color: 'red', text: '告警' },
maintenance: { color: 'orange', text: '维护' },
};
const typeMap: Record<string, string> = {
pv_inverter: '光伏逆变器', heat_pump: '空气源热泵', meter: '智能电表',
sensor: '温湿度传感器', heat_meter: '热量表', water_meter: '水表',
};
export default function Monitoring() {
const [devices, setDevices] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedDevice, setSelectedDevice] = useState<any>(null);
const [deviceData, setDeviceData] = useState<any>(null);
const [filter, setFilter] = useState({ type: '', search: '' });
useEffect(() => {
loadDevices();
const timer = setInterval(loadDevices, 30000);
return () => clearInterval(timer);
}, []);
const loadDevices = async () => {
try {
const res: any = await getDevices({ page_size: 100 });
setDevices(res.items || []);
} catch (e) { console.error(e); }
finally { setLoading(false); }
};
const showDetail = async (device: any) => {
setSelectedDevice(device);
try {
const data = await getDeviceRealtime(device.id);
setDeviceData(data);
} catch { setDeviceData(null); }
};
const filteredDevices = devices.filter(d => {
if (filter.type && d.device_type !== filter.type) return false;
if (filter.search && !d.name.includes(filter.search) && !d.code.includes(filter.search)) return false;
return true;
});
const columns = [
{ title: '设备名称', dataIndex: 'name', key: 'name' },
{ title: '设备编号', dataIndex: 'code', key: 'code' },
{ title: '类型', dataIndex: 'device_type', key: 'type', render: (t: string) => typeMap[t] || t },
{ title: '型号', dataIndex: 'model', key: 'model' },
{ title: '额定功率', dataIndex: 'rated_power', key: 'power', render: (v: number) => v ? `${v} kW` : '-' },
{ title: '位置', dataIndex: 'location', key: 'location' },
{ title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => {
const st = statusMap[s] || { color: 'default', text: s };
return <Tag color={st.color}>{st.text}</Tag>;
}},
{ title: '操作', key: 'action', render: (_: any, r: any) => <a onClick={() => showDetail(r)}></a> },
];
return (
<div>
<Card title="实时监控" size="small" extra={
<div style={{ display: 'flex', gap: 8 }}>
<Select allowClear placeholder="设备类型" style={{ width: 160 }}
onChange={v => setFilter(f => ({ ...f, type: v || '' }))}
options={Object.entries(typeMap).map(([k, v]) => ({ label: v, value: k }))} />
<Input.Search placeholder="搜索设备" style={{ width: 200 }}
onSearch={v => setFilter(f => ({ ...f, search: v }))} allowClear />
</div>
}>
<Table columns={columns} dataSource={filteredDevices} rowKey="id"
loading={loading} size="small" pagination={{ pageSize: 15 }} />
</Card>
<Modal title={selectedDevice?.name} open={!!selectedDevice} onCancel={() => setSelectedDevice(null)}
footer={null} width={700}>
{deviceData?.device && (
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="编号">{deviceData.device.code}</Descriptions.Item>
<Descriptions.Item label="类型">{typeMap[deviceData.device.device_type]}</Descriptions.Item>
<Descriptions.Item label="型号">{deviceData.device.model || '-'}</Descriptions.Item>
<Descriptions.Item label="状态">
<Badge status={deviceData.device.status === 'online' ? 'success' : 'error'}
text={statusMap[deviceData.device.status]?.text} />
</Descriptions.Item>
</Descriptions>
)}
{deviceData?.data && (
<Descriptions column={2} size="small" bordered title="实时数据">
{Object.entries(deviceData.data).map(([key, val]: any) => (
<Descriptions.Item key={key} label={key}>
{val.value} {val.unit}
</Descriptions.Item>
))}
</Descriptions>
)}
</Modal>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { useEffect, useState } from 'react';
import { Card, Table, Button, Tabs, Tag, Modal, Form, Select, Input, message, Space } from 'antd';
import { PlusOutlined, PlayCircleOutlined, DownloadOutlined } from '@ant-design/icons';
import { getReportTemplates, getReportTasks, createReportTask, runReportTask } from '../../services/api';
export default function Reports() {
const [templates, setTemplates] = useState<any[]>([]);
const [tasks, setTasks] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [form] = Form.useForm();
useEffect(() => { loadData(); }, []);
const loadData = async () => {
setLoading(true);
try {
const [t, ts] = await Promise.all([getReportTemplates(), getReportTasks()]);
setTemplates(t as any[]);
setTasks(ts as any[]);
} catch (e) { console.error(e); }
finally { setLoading(false); }
};
const handleRun = async (id: number) => {
await runReportTask(id);
message.success('报表生成中');
loadData();
};
const handleCreate = async (values: any) => {
await createReportTask(values);
message.success('任务创建成功');
setShowModal(false);
form.resetFields();
loadData();
};
const templateColumns = [
{ title: '模板名称', dataIndex: 'name' },
{ title: '类型', dataIndex: 'report_type' },
{ title: '时间粒度', dataIndex: 'time_granularity' },
{ title: '聚合方式', dataIndex: 'aggregation' },
{ title: '系统预置', dataIndex: 'is_system', render: (v: boolean) => v ? <Tag color="blue"></Tag> : <Tag></Tag> },
];
const taskColumns = [
{ title: '任务名称', dataIndex: 'name', render: (v: string) => v || '-' },
{ title: '报表格式', dataIndex: 'export_format', render: (v: string) => v?.toUpperCase() },
{ title: '定时计划', dataIndex: 'schedule', render: (v: string) => v || '手动' },
{ title: '状态', dataIndex: 'status', render: (s: string) => {
const colors: Record<string, string> = { pending: 'default', running: 'blue', completed: 'green', failed: 'red' };
return <Tag color={colors[s]}>{s}</Tag>;
}},
{ title: '上次执行', dataIndex: 'last_run', render: (v: string) => v || '-' },
{ title: '操作', key: 'action', render: (_: any, r: any) => (
<Space>
<Button size="small" icon={<PlayCircleOutlined />} onClick={() => handleRun(r.id)}></Button>
{r.file_path && <Button size="small" icon={<DownloadOutlined />}></Button>}
</Space>
)},
];
return (
<>
<Tabs items={[
{ key: 'tasks', label: '报表任务', children: (
<Card size="small" extra={<Button type="primary" icon={<PlusOutlined />}
onClick={() => setShowModal(true)}></Button>}>
<Table columns={taskColumns} dataSource={tasks} rowKey="id" loading={loading} size="small" />
</Card>
)},
{ key: 'templates', label: '报表模板', children: (
<Card size="small">
<Table columns={templateColumns} dataSource={templates} rowKey="id" loading={loading} size="small" />
</Card>
)},
]} />
<Modal title="新建报表任务" open={showModal} onCancel={() => setShowModal(false)}
onOk={() => form.submit()} okText="创建">
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item name="template_id" label="选择模板" rules={[{ required: true }]}>
<Select options={templates.map(t => ({ label: t.name, value: t.id }))} />
</Form.Item>
<Form.Item name="name" label="任务名称">
<Input placeholder="可选" />
</Form.Item>
<Form.Item name="export_format" label="导出格式" initialValue="xlsx">
<Select options={[
{ label: 'Excel (.xlsx)', value: 'xlsx' },
{ label: 'CSV', value: 'csv' },
{ label: 'PDF', value: 'pdf' },
]} />
</Form.Item>
</Form>
</Modal>
</>
);
}

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { Card, Table, Button, Tag, Modal, Form, Input, Select, Switch, message, Tabs } from 'antd';
import { PlusOutlined, EditOutlined } from '@ant-design/icons';
import { getUsers, createUser, updateUser, getRoles } from '../../services/api';
export default function SystemManagement() {
const [users, setUsers] = useState<any>({ total: 0, items: [] });
const [roles, setRoles] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<any>(null);
const [form] = Form.useForm();
useEffect(() => { loadData(); }, []);
const loadData = async () => {
setLoading(true);
try {
const [u, r] = await Promise.all([getUsers({}), getRoles()]);
setUsers(u);
setRoles(r as any[]);
} catch (e) { console.error(e); }
finally { setLoading(false); }
};
const handleCreate = async (values: any) => {
if (editingUser) {
await updateUser(editingUser.id, values);
message.success('用户已更新');
} else {
await createUser(values);
message.success('用户已创建');
}
setShowModal(false);
setEditingUser(null);
form.resetFields();
loadData();
};
const handleEdit = (user: any) => {
setEditingUser(user);
form.setFieldsValue(user);
setShowModal(true);
};
const roleColorMap: Record<string, string> = {
admin: 'red', energy_manager: 'blue', area_manager: 'cyan',
operator: 'green', analyst: 'purple', visitor: 'default',
};
const columns = [
{ title: '用户名', dataIndex: 'username' },
{ title: '姓名', dataIndex: 'full_name' },
{ title: '邮箱', dataIndex: 'email' },
{ title: '电话', dataIndex: 'phone' },
{ title: '角色', dataIndex: 'role', render: (r: string) => {
const role = roles.find(ro => ro.name === r);
return <Tag color={roleColorMap[r]}>{role?.display_name || r}</Tag>;
}},
{ title: '状态', dataIndex: 'is_active', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag> },
{ title: '最后登录', dataIndex: 'last_login', render: (v: string) => v || '-' },
{ title: '操作', key: 'action', render: (_: any, r: any) => (
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}></Button>
)},
];
const roleColumns = [
{ title: '角色标识', dataIndex: 'name' },
{ title: '显示名称', dataIndex: 'display_name' },
{ title: '描述', dataIndex: 'description' },
];
return (
<>
<Tabs items={[
{ key: 'users', label: '用户管理', children: (
<Card size="small" extra={<Button type="primary" icon={<PlusOutlined />}
onClick={() => { setEditingUser(null); form.resetFields(); setShowModal(true); }}></Button>}>
<Table columns={columns} dataSource={users.items} rowKey="id" loading={loading} size="small" />
</Card>
)},
{ key: 'roles', label: '角色管理', children: (
<Card size="small">
<Table columns={roleColumns} dataSource={roles} rowKey="id" loading={loading} size="small" />
</Card>
)},
]} />
<Modal title={editingUser ? '编辑用户' : '新建用户'} open={showModal}
onCancel={() => { setShowModal(false); setEditingUser(null); }}
onOk={() => form.submit()} okText="确定">
<Form form={form} layout="vertical" onFinish={handleCreate}>
{!editingUser && (
<>
<Form.Item name="username" label="用户名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
</>
)}
<Form.Item name="full_name" label="姓名"><Input /></Form.Item>
<Form.Item name="email" label="邮箱"><Input /></Form.Item>
<Form.Item name="phone" label="电话"><Input /></Form.Item>
<Form.Item name="role" label="角色" initialValue="visitor">
<Select options={roles.map(r => ({ label: r.display_name, value: r.name }))} />
</Form.Item>
{editingUser && (
<Form.Item name="is_active" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
)}
</Form>
</Modal>
</>
);
}

View File

@@ -0,0 +1,83 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api/v1',
timeout: 15000,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(res) => res.data,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(err.response?.data || err);
}
);
// Auth
export const login = (username: string, password: string) =>
api.post('/auth/login', new URLSearchParams({ username, password }), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
export const getMe = () => api.get('/auth/me');
// Dashboard
export const getDashboardOverview = () => api.get('/dashboard/overview');
export const getRealtimeData = () => api.get('/dashboard/realtime');
export const getLoadCurve = (hours = 24) => api.get(`/dashboard/load-curve?hours=${hours}`);
// Devices
export const getDevices = (params?: Record<string, any>) => api.get('/devices', { params });
export const getDeviceStats = () => api.get('/devices/stats');
export const getDeviceTypes = () => api.get('/devices/types');
export const getDeviceGroups = () => api.get('/devices/groups');
export const getDevice = (id: number) => api.get(`/devices/${id}`);
export const createDevice = (data: any) => api.post('/devices', data);
export const updateDevice = (id: number, data: any) => api.put(`/devices/${id}`, data);
// Energy
export const getEnergyHistory = (params: Record<string, any>) => api.get('/energy/history', { params });
export const getEnergyDailySummary = (params?: Record<string, any>) => api.get('/energy/daily-summary', { params });
export const getEnergyComparison = (params?: Record<string, any>) => api.get('/energy/comparison', { params });
// Monitoring
export const getDeviceRealtime = (deviceId: number) => api.get(`/monitoring/devices/${deviceId}/realtime`);
export const getEnergyFlow = () => api.get('/monitoring/energy-flow');
// Alarms
export const getAlarmRules = () => api.get('/alarms/rules');
export const createAlarmRule = (data: any) => api.post('/alarms/rules', data);
export const getAlarmEvents = (params?: Record<string, any>) => api.get('/alarms/events', { params });
export const acknowledgeAlarm = (id: number) => api.post(`/alarms/events/${id}/acknowledge`);
export const resolveAlarm = (id: number, note = '') => api.post(`/alarms/events/${id}/resolve`, null, { params: { note } });
export const getAlarmStats = () => api.get('/alarms/stats');
// Carbon
export const getCarbonOverview = () => api.get('/carbon/overview');
export const getCarbonTrend = (days = 30) => api.get(`/carbon/trend?days=${days}`);
export const getEmissionFactors = () => api.get('/carbon/factors');
// Reports
export const getReportTemplates = () => api.get('/reports/templates');
export const createReportTemplate = (data: any) => api.post('/reports/templates', data);
export const getReportTasks = () => api.get('/reports/tasks');
export const createReportTask = (data: any) => api.post('/reports/tasks', data);
export const runReportTask = (id: number) => api.post(`/reports/tasks/${id}/run`);
// Users
export const getUsers = (params?: Record<string, any>) => api.get('/users', { params });
export const createUser = (data: any) => api.post('/users', data);
export const updateUser = (id: number, data: any) => api.put(`/users/${id}`, data);
export const getRoles = () => api.get('/users/roles');
export default api;

View File

@@ -0,0 +1,9 @@
export const setToken = (token: string) => localStorage.setItem('token', token);
export const getToken = () => localStorage.getItem('token');
export const removeToken = () => localStorage.removeItem('token');
export const setUser = (user: any) => localStorage.setItem('user', JSON.stringify(user));
export const getUser = () => {
const u = localStorage.getItem('user');
return u ? JSON.parse(u) : null;
};
export const isLoggedIn = () => !!getToken();

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

15
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8001',
changeOrigin: true,
},
},
},
})

18
scripts/init_db.py Normal file
View File

@@ -0,0 +1,18 @@
"""数据库初始化 - 创建所有表"""
import asyncio
import sys
sys.path.insert(0, "../backend")
from app.core.database import engine, Base
from app.models import * # noqa: F401, F403
async def init():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
print("All tables created successfully!")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(init())

192
scripts/seed_data.py Normal file
View File

@@ -0,0 +1,192 @@
"""种子数据 - 天普园区设备信息和初始用户"""
import asyncio
import sys
sys.path.insert(0, "../backend")
from app.core.database import async_session
from app.core.security import hash_password
from app.models.user import User, Role
from app.models.device import Device, DeviceType, DeviceGroup
from app.models.carbon import EmissionFactor
from app.models.report import ReportTemplate
async def seed():
async with async_session() as session:
# 1. 角色
roles = [
Role(name="admin", display_name="园区管理员", description="平台最高管理者,负责全局配置和用户管理"),
Role(name="energy_manager", display_name="能源主管", description="负责园区能源运行管理和优化决策"),
Role(name="area_manager", display_name="区域负责人", description="负责特定区域或建筑的能源管理"),
Role(name="operator", display_name="设备运维员", description="负责设备日常运维和故障处理"),
Role(name="analyst", display_name="财务分析员", description="负责能源成本分析和财务报表"),
Role(name="visitor", display_name="普通访客", description="仅查看公开信息"),
]
session.add_all(roles)
# 2. 默认用户
users = [
User(username="admin", hashed_password=hash_password("admin123"), full_name="系统管理员", role="admin", email="admin@tianpu.com"),
User(username="energy_mgr", hashed_password=hash_password("tianpu123"), full_name="能源主管", role="energy_manager", email="energy@tianpu.com"),
User(username="operator1", hashed_password=hash_password("tianpu123"), full_name="运维工程师", role="operator", email="op1@tianpu.com"),
]
session.add_all(users)
# 3. 设备类型
device_types = [
DeviceType(code="pv_inverter", name="光伏逆变器", icon="solar-panel",
data_fields=["power", "daily_energy", "total_energy", "dc_voltage", "ac_voltage", "temperature", "fault_code"]),
DeviceType(code="heat_pump", name="空气源热泵", icon="heat-pump",
data_fields=["power", "cop", "inlet_temp", "outlet_temp", "flow_rate", "outdoor_temp", "mode"]),
DeviceType(code="solar_thermal", name="光热集热器", icon="solar-thermal",
data_fields=["heat_output", "collector_temp", "irradiance", "pump_status"]),
DeviceType(code="battery", name="储能系统", icon="battery",
data_fields=["power", "soc", "voltage", "current", "temperature", "cycle_count"]),
DeviceType(code="meter", name="智能电表", icon="meter",
data_fields=["power", "energy", "voltage", "current", "power_factor", "frequency"]),
DeviceType(code="sensor", name="温湿度传感器", icon="sensor",
data_fields=["temperature", "humidity"]),
DeviceType(code="heat_meter", name="热量表", icon="heat-meter",
data_fields=["heat_power", "heat_energy", "flow_rate", "supply_temp", "return_temp"]),
DeviceType(code="water_meter", name="水表", icon="water-meter",
data_fields=["flow_rate", "total_flow"]),
]
session.add_all(device_types)
# 4. 设备分组
groups = [
DeviceGroup(id=1, name="光伏系统", location="天普大楼屋顶"),
DeviceGroup(id=2, name="热泵系统", location="天普大楼机房"),
DeviceGroup(id=3, name="电力计量", location="天普大楼配电室"),
DeviceGroup(id=4, name="环境监测", location="天普大楼各楼层"),
]
session.add_all(groups)
# Flush to satisfy foreign key constraints before inserting devices
await session.flush()
# 5. 天普实际设备
devices = [
# 光伏逆变器 - 3台华为SUN2000-110KTL-M0
Device(name="东楼逆变器1", code="INV-01", device_type="pv_inverter", group_id=1,
model="SUN2000-110KTL-M0", manufacturer="华为", rated_power=110,
location="东楼屋顶", protocol="http_api", collect_interval=15,
connection_params={"api_type": "fusionsolar", "station_code": "NE=12345"}),
Device(name="东楼逆变器2", code="INV-02", device_type="pv_inverter", group_id=1,
model="SUN2000-110KTL-M0", manufacturer="华为", rated_power=110,
location="东楼屋顶", protocol="http_api", collect_interval=15),
Device(name="西楼逆变器1", code="INV-03", device_type="pv_inverter", group_id=1,
model="SUN2000-110KTL-M0", manufacturer="华为", rated_power=110,
location="西楼屋顶", protocol="http_api", collect_interval=15),
# 热泵机组 - 4台
Device(name="热泵机组1", code="HP-01", device_type="heat_pump", group_id=2,
rated_power=35, location="大楼机房", protocol="modbus_rtu", collect_interval=15,
connection_params={"dtu_id": "2225000009", "slave_id": 1}),
Device(name="热泵机组2", code="HP-02", device_type="heat_pump", group_id=2,
rated_power=35, location="大楼机房", protocol="modbus_rtu", collect_interval=15,
connection_params={"dtu_id": "2225000009", "slave_id": 2}),
Device(name="热泵机组3", code="HP-03", device_type="heat_pump", group_id=2,
rated_power=35, location="大楼机房", protocol="modbus_rtu", collect_interval=15,
connection_params={"dtu_id": "2225000009", "slave_id": 3}),
Device(name="热泵机组4", code="HP-04", device_type="heat_pump", group_id=2,
rated_power=35, location="大楼机房", protocol="modbus_rtu", collect_interval=15,
connection_params={"dtu_id": "2225000009", "slave_id": 4}),
# 电表
Device(name="关口电表(余电上网)", code="METER-GRID", device_type="meter", group_id=3,
model="威胜", serial_number="3462847657", location="配电室", protocol="dlt645", collect_interval=60,
connection_params={"dtu_id": "infrared", "ratio": 1000}),
Device(name="并网电表(光伏总发电)", code="METER-PV", device_type="meter", group_id=3,
model="杭州炬华", serial_number="3422994056", location="配电室", protocol="dlt645", collect_interval=60,
connection_params={"dtu_id": "infrared", "ct_ratio": "600/5"}),
Device(name="热泵电表", code="METER-HP", device_type="meter", group_id=3,
location="机房热泵控制柜", protocol="modbus_rtu", collect_interval=60,
connection_params={"dtu_id": "2225000003"}),
Device(name="循环水泵电表", code="METER-PUMP", device_type="meter", group_id=3,
location="机房水泵配电柜", protocol="modbus_rtu", collect_interval=60,
connection_params={"dtu_id": "2225000002"}),
# 热量表
Device(name="主管热量表", code="HM-01", device_type="heat_meter", group_id=2,
location="机房中部主管", protocol="modbus_rtu", collect_interval=60,
connection_params={"dtu_id": "2225000001"}),
# 温湿度传感器
Device(name="一楼东厅温湿度", code="TH-01", device_type="sensor", group_id=4,
location="大楼一楼东厅", protocol="mqtt", collect_interval=60,
connection_params={"dtu_id": "2225000007"}, metadata_={"area": "一楼东展厅风管上"}),
Device(name="一楼西厅温湿度", code="TH-02", device_type="sensor", group_id=4,
location="大楼一楼西厅", protocol="mqtt", collect_interval=60,
connection_params={"dtu_id": "2225000006"}, metadata_={"area": "一楼西厅中西风管上"}),
Device(name="二楼西厅温湿度", code="TH-03", device_type="sensor", group_id=4,
location="大楼二楼西厅", protocol="mqtt", collect_interval=60,
connection_params={"dtu_id": "2225000005"}, metadata_={"area": "财务门口西侧"}),
Device(name="二楼东厅温湿度", code="TH-04", device_type="sensor", group_id=4,
location="大楼二楼东厅", protocol="mqtt", collect_interval=60,
connection_params={"dtu_id": "2225000004"}, metadata_={"area": "英豪对过"}),
Device(name="机房室外温湿度", code="TH-05", device_type="sensor", group_id=4,
location="机房热泵控制柜", protocol="mqtt", collect_interval=60,
connection_params={"dtu_id": "2225000008"}, metadata_={"area": "机房门口", "type": "outdoor"}),
# 水表
Device(name="补水水表", code="WM-01", device_type="water_meter", group_id=2,
location="机房软水器补水管", protocol="image", collect_interval=300,
connection_params={"type": "smart_capture"}),
]
session.add_all(devices)
# 6. 碳排放因子
factors = [
EmissionFactor(name="华北电网排放因子", energy_type="electricity", factor=0.8843,
unit="kWh", scope=2, region="north_china", source="生态环境部2023", year=2023),
EmissionFactor(name="天然气排放因子", energy_type="natural_gas", factor=2.162,
unit="", scope=1, source="IPCC 2006", year=2006),
EmissionFactor(name="柴油排放因子", energy_type="diesel", factor=2.63,
unit="L", scope=1, source="IPCC 2006", year=2006),
EmissionFactor(name="光伏减排因子", energy_type="pv_generation", factor=0.8843,
unit="kWh", scope=2, region="north_china", source="等量替代电网电力", year=2023),
EmissionFactor(name="热泵节能减排因子", energy_type="heat_pump_saving", factor=0.8843,
unit="kWh", scope=2, region="north_china", source="相比电加热节省的电量", year=2023),
]
session.add_all(factors)
# 7. 预置报表模板
templates = [
ReportTemplate(name="日报", report_type="daily", is_system=True,
fields=[{"key": "total_consumption", "label": "总用电量", "unit": "kWh"},
{"key": "pv_generation", "label": "光伏发电量", "unit": "kWh"},
{"key": "self_use_rate", "label": "自消纳率", "unit": "%"},
{"key": "heatpump_energy", "label": "热泵用电", "unit": "kWh"},
{"key": "avg_cop", "label": "平均COP"},
{"key": "carbon_emission", "label": "碳排放", "unit": "kgCO2"}]),
ReportTemplate(name="月报", report_type="monthly", is_system=True,
fields=[{"key": "total_consumption", "label": "总用电量", "unit": "kWh"},
{"key": "pv_generation", "label": "光伏发电量", "unit": "kWh"},
{"key": "grid_import", "label": "电网购电", "unit": "kWh"},
{"key": "cost", "label": "电费", "unit": ""},
{"key": "carbon_emission", "label": "碳排放", "unit": "tCO2"},
{"key": "carbon_reduction", "label": "碳减排", "unit": "tCO2"}],
time_granularity="day"),
ReportTemplate(name="设备运行报告", report_type="custom", is_system=True,
fields=[{"key": "device_name", "label": "设备名称"},
{"key": "operating_hours", "label": "运行时长", "unit": "h"},
{"key": "energy_consumption", "label": "能耗", "unit": "kWh"},
{"key": "avg_power", "label": "平均功率", "unit": "kW"},
{"key": "alarm_count", "label": "告警次数"}]),
]
session.add_all(templates)
await session.commit()
print("Seed data inserted successfully!")
print(f" - {len(roles)} roles")
print(f" - {len(users)} users (admin/admin123)")
print(f" - {len(device_types)} device types")
print(f" - {len(groups)} device groups")
print(f" - {len(devices)} devices")
print(f" - {len(factors)} emission factors")
print(f" - {len(templates)} report templates")
if __name__ == "__main__":
asyncio.run(seed())