feat: add system settings, audit log, device detail, dark mode, i18n, email notifications

System Management:
- System Settings page with 8 configurable parameters (admin only)
- Audit Log page with filterable table (user, action, resource, date range)
- Audit logging wired into auth, devices, users, alarms, reports API handlers
- SystemSetting model + migration (002)

Device Detail:
- Dedicated /devices/:id page with 4 tabs (realtime, historical trends, alarm history, device info)
- ECharts historical charts with granularity/time range selectors
- Device name clickable in Devices and Monitoring tables → navigates to detail

Email & Scheduling:
- Email service with SMTP support (STARTTLS/SSL/plain)
- Alarm email notification with professional HTML template
- Report scheduler using APScheduler for cron-based auto-generation
- Scheduled report task seeded (daily at 8am)

UI Enhancements:
- Dark mode toggle (persisted to localStorage, Ant Design darkAlgorithm)
- Data comparison view in Analysis page (dual date range, side-by-side metrics)
- i18n framework (i18next) with zh/en translations for menu and common UI
- Language switcher in header (中文/English)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Du Wenbo
2026-04-02 19:42:22 +08:00
parent 895af4caf9
commit ef9b5d055f
36 changed files with 2196 additions and 113 deletions

View File

@@ -0,0 +1,41 @@
"""Add system_settings table
Revision ID: 002_system_settings
Revises: 001_initial
Create Date: 2026-04-02
"""
from alembic import op
import sqlalchemy as sa
revision = "002_system_settings"
down_revision = "001_initial"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"system_settings",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("key", sa.String(100), unique=True, nullable=False, index=True),
sa.Column("value", sa.Text, nullable=False, server_default=""),
sa.Column("description", sa.String(255)),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# Seed default settings
op.execute("""
INSERT INTO system_settings (key, value, description) VALUES
('platform_name', '天普零碳园区智慧能源管理平台', '平台名称'),
('data_retention_days', '365', '数据保留天数'),
('alarm_auto_resolve_minutes', '30', '告警自动解除时间(分钟)'),
('simulator_interval_seconds', '15', '模拟器采集间隔(秒)'),
('notification_email_enabled', 'false', '是否启用邮件通知'),
('notification_email_smtp', '', 'SMTP服务器地址'),
('report_auto_schedule_enabled', 'false', '是否启用自动报表'),
('timezone', 'Asia/Shanghai', '系统时区')
""")
def downgrade() -> None:
op.drop_table("system_settings")

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings
api_router = APIRouter(prefix="/api/v1")
@@ -14,3 +14,5 @@ api_router.include_router(carbon.router)
api_router.include_router(dashboard.router)
api_router.include_router(collectors.router)
api_router.include_router(websocket.router)
api_router.include_router(audit.router)
api_router.include_router(settings.router)

View File

@@ -7,6 +7,7 @@ from app.core.database import get_db
from app.core.deps import get_current_user, require_roles
from app.models.alarm import AlarmRule, AlarmEvent
from app.models.user import User
from app.services.audit import log_audit
router = APIRouter(prefix="/alarms", tags=["告警管理"])
@@ -107,6 +108,7 @@ async def acknowledge_event(event_id: int, db: AsyncSession = Depends(get_db), u
event.status = "acknowledged"
event.acknowledged_by = user.id
event.acknowledged_at = datetime.now(timezone.utc)
await log_audit(db, user.id, "acknowledge", "alarm", detail=f"确认告警 #{event_id}")
return {"message": "已确认"}
@@ -119,6 +121,7 @@ async def resolve_event(event_id: int, note: str = "", db: AsyncSession = Depend
event.status = "resolved"
event.resolved_at = datetime.now(timezone.utc)
event.resolve_note = note
await log_audit(db, user.id, "resolve", "alarm", detail=f"解决告警 #{event_id}")
return {"message": "已解决"}

View File

@@ -0,0 +1,76 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from datetime import datetime
from app.core.database import get_db
from app.core.deps import get_current_user, require_roles
from app.models.user import User, AuditLog
router = APIRouter(prefix="/audit", tags=["审计日志"])
@router.get("/logs")
async def list_audit_logs(
user_id: int | None = None,
action: str | None = None,
resource: str | None = None,
start_time: str | None = None,
end_time: str | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("admin", "energy_manager")),
):
"""Return paginated audit logs with optional filters."""
query = select(
AuditLog.id,
AuditLog.user_id,
User.username,
AuditLog.action,
AuditLog.resource,
AuditLog.detail,
AuditLog.ip_address,
AuditLog.created_at,
).outerjoin(User, AuditLog.user_id == User.id)
if user_id is not None:
query = query.where(AuditLog.user_id == user_id)
if action:
query = query.where(AuditLog.action == action)
if resource:
query = query.where(AuditLog.resource == resource)
if start_time:
try:
st = datetime.fromisoformat(start_time)
query = query.where(AuditLog.created_at >= st)
except ValueError:
pass
if end_time:
try:
et = datetime.fromisoformat(end_time)
query = query.where(AuditLog.created_at <= et)
except ValueError:
pass
# Count
count_q = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_q)).scalar()
# Paginate
query = query.order_by(AuditLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
items = []
for row in result.all():
items.append({
"id": row.id,
"user_id": row.user_id,
"username": row.username or "-",
"action": row.action,
"resource": row.resource,
"detail": row.detail,
"ip_address": row.ip_address,
"created_at": str(row.created_at) if row.created_at else None,
})
return {"total": total, "items": items}

View File

@@ -1,5 +1,5 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -8,6 +8,7 @@ from app.core.database import get_db
from app.core.security import verify_password, create_access_token, hash_password
from app.core.deps import get_current_user
from app.models.user import User
from app.services.audit import log_audit
router = APIRouter(prefix="/auth", tags=["认证"])
@@ -27,7 +28,7 @@ class RegisterRequest(BaseModel):
@router.post("/login", response_model=Token)
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
async def login(request: Request, form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == form.username))
user = result.scalar_one_or_none()
if not user or not verify_password(form.password, user.hashed_password):
@@ -36,6 +37,8 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession =
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
user.last_login = datetime.now(timezone.utc)
token = create_access_token({"sub": str(user.id), "role": user.role})
client_ip = request.client.host if request.client else None
await log_audit(db, user.id, "login", "auth", detail=f"用户 {user.username} 登录", ip_address=client_ip)
return Token(
access_token=token,
user={"id": user.id, "username": user.username, "full_name": user.full_name, "role": user.role}

View File

@@ -6,6 +6,7 @@ from app.core.database import get_db
from app.core.deps import get_current_user, require_roles
from app.models.device import Device, DeviceType, DeviceGroup
from app.models.user import User
from app.services.audit import log_audit
router = APIRouter(prefix="/devices", tags=["设备管理"])
@@ -98,6 +99,7 @@ async def create_device(data: DeviceCreate, db: AsyncSession = Depends(get_db),
device = Device(**data.model_dump())
db.add(device)
await db.flush()
await log_audit(db, user.id, "create", "device", detail=f"创建设备 {data.name} ({data.code})")
return _device_to_dict(device)
@@ -107,8 +109,10 @@ async def update_device(device_id: int, data: DeviceUpdate, db: AsyncSession = D
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():
updates = data.model_dump(exclude_unset=True)
for k, v in updates.items():
setattr(device, k, v)
await log_audit(db, user.id, "update", "device", detail=f"更新设备 {device.name}: {', '.join(updates.keys())}")
return _device_to_dict(device)

View File

@@ -13,6 +13,7 @@ from app.core.deps import get_current_user
from app.models.report import ReportTemplate, ReportTask
from app.models.user import User
from app.services.report_generator import ReportGenerator, REPORTS_DIR
from app.services.audit import log_audit
logger = logging.getLogger(__name__)
@@ -184,6 +185,7 @@ async def run_task(
task.file_path = filepath
task.last_run = datetime.now()
await db.flush()
await log_audit(db, user.id, "export", "report", detail=f"运行报表任务 #{task_id}")
logger.info(f"Report task {task_id} completed: {filepath}")
return {"message": "报表生成完成", "task_id": task.id, "mode": "sync", "status": "completed"}
except Exception as e:
@@ -286,6 +288,7 @@ async def generate_quick_report(
raise HTTPException(status_code=500, detail=f"报表生成失败: {str(e)}")
filename = Path(filepath).name
await log_audit(db, user.id, "export", "report", detail=f"生成报表: {req.report_type} ({req.export_format})")
return {
"message": "报表生成成功",
"filename": filename,

View File

@@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from app.core.database import get_db
from app.core.deps import get_current_user, require_roles
from app.models.user import User
from app.models.setting import SystemSetting
from app.services.audit import log_audit
router = APIRouter(prefix="/settings", tags=["系统设置"])
# Default settings — used when keys are missing from DB
DEFAULTS: dict[str, str] = {
"platform_name": "天普零碳园区智慧能源管理平台",
"data_retention_days": "365",
"alarm_auto_resolve_minutes": "30",
"simulator_interval_seconds": "15",
"notification_email_enabled": "false",
"notification_email_smtp": "",
"report_auto_schedule_enabled": "false",
"timezone": "Asia/Shanghai",
}
class SettingsUpdate(BaseModel):
platform_name: str | None = None
data_retention_days: int | None = None
alarm_auto_resolve_minutes: int | None = None
simulator_interval_seconds: int | None = None
notification_email_enabled: bool | None = None
notification_email_smtp: str | None = None
report_auto_schedule_enabled: bool | None = None
timezone: str | None = None
@router.get("")
async def get_settings(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return all platform settings as a flat dict."""
result = await db.execute(select(SystemSetting))
db_settings = {s.key: s.value for s in result.scalars().all()}
# Merge defaults with DB values
merged = {**DEFAULTS, **db_settings}
# Cast types for frontend
return {
"platform_name": merged["platform_name"],
"data_retention_days": int(merged["data_retention_days"]),
"alarm_auto_resolve_minutes": int(merged["alarm_auto_resolve_minutes"]),
"simulator_interval_seconds": int(merged["simulator_interval_seconds"]),
"notification_email_enabled": merged["notification_email_enabled"] == "true",
"notification_email_smtp": merged["notification_email_smtp"],
"report_auto_schedule_enabled": merged["report_auto_schedule_enabled"] == "true",
"timezone": merged["timezone"],
}
@router.put("")
async def update_settings(
data: SettingsUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_roles("admin")),
):
"""Update platform settings (admin only)."""
updates = data.model_dump(exclude_unset=True)
changed_keys = []
for key, value in updates.items():
str_value = str(value).lower() if isinstance(value, bool) else str(value)
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = str_value
else:
db.add(SystemSetting(key=key, value=str_value))
changed_keys.append(key)
await log_audit(
db, user.id, "update", "system",
detail=f"更新系统设置: {', '.join(changed_keys)}",
)
return {"message": "设置已更新"}

View File

@@ -6,6 +6,7 @@ from app.core.database import get_db
from app.core.deps import get_current_user, require_roles
from app.core.security import hash_password
from app.models.user import User, Role
from app.services.audit import log_audit
router = APIRouter(prefix="/users", tags=["用户管理"])
@@ -58,6 +59,7 @@ async def create_user(data: UserCreate, db: AsyncSession = Depends(get_db), user
)
db.add(new_user)
await db.flush()
await log_audit(db, user.id, "create", "user", detail=f"创建用户 {data.username}")
return {"id": new_user.id, "username": new_user.username}
@@ -67,8 +69,10 @@ async def update_user(user_id: int, data: UserUpdate, db: AsyncSession = Depends
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():
updates = data.model_dump(exclude_unset=True)
for k, v in updates.items():
setattr(target, k, v)
await log_audit(db, admin.id, "update", "user", detail=f"更新用户 {target.username}: {', '.join(updates.keys())}")
return {"message": "已更新"}

View File

@@ -23,6 +23,17 @@ class Settings(BaseSettings):
CELERY_ENABLED: bool = False # Set True when Celery worker is running
USE_SIMULATOR: bool = True # True=simulator mode, False=real IoT collectors
# SMTP Email settings
SMTP_HOST: str = ""
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
SMTP_FROM: str = "noreply@tianpu-ems.com"
SMTP_ENABLED: bool = False
# Platform URL for links in emails
PLATFORM_URL: str = "http://localhost:3000"
@property
def DATABASE_URL_SYNC(self) -> str:
"""Derive synchronous URL from async DATABASE_URL for Alembic."""

View File

@@ -8,6 +8,7 @@ from app.api.router import api_router
from app.api.v1.websocket import start_broadcast_task, stop_broadcast_task
from app.core.config import get_settings
from app.services.simulator import DataSimulator
from app.services.report_scheduler import start_scheduler, stop_scheduler
from app.collectors.manager import CollectorManager
settings = get_settings()
@@ -28,7 +29,9 @@ async def lifespan(app: FastAPI):
collector_manager = CollectorManager()
await collector_manager.start()
start_broadcast_task()
await start_scheduler()
yield
await stop_scheduler()
stop_broadcast_task()
if settings.USE_SIMULATOR:
await simulator.stop()

View File

@@ -4,6 +4,7 @@ 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
from app.models.setting import SystemSetting
__all__ = [
"User", "Role", "AuditLog",
@@ -12,4 +13,5 @@ __all__ = [
"AlarmRule", "AlarmEvent",
"CarbonEmission", "EmissionFactor",
"ReportTemplate", "ReportTask",
"SystemSetting",
]

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String, Text, DateTime
from sqlalchemy.sql import func
from app.core.database import Base
class SystemSetting(Base):
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(Text, nullable=False, default="")
description = Column(String(255))
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -1,13 +1,111 @@
"""告警检测服务 - 根据告警规则检查最新数据,生成/自动恢复告警事件"""
import asyncio
import logging
from datetime import datetime, timezone, timedelta
from pathlib import Path
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.alarm import AlarmRule, AlarmEvent
from app.models.energy import EnergyData
from app.models.device import Device
logger = logging.getLogger("alarm_checker")
# Alarm email template path
_ALARM_TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "alarm_email.html"
# Severity display config
_SEVERITY_CONFIG = {
"critical": {
"label": "紧急告警",
"badge_color": "#d32f2f",
"bg_color": "#ffebee",
"text_color": "#c62828",
},
"major": {
"label": "重要告警",
"badge_color": "#e65100",
"bg_color": "#fff3e0",
"text_color": "#e65100",
},
"warning": {
"label": "一般告警",
"badge_color": "#f9a825",
"bg_color": "#fffde7",
"text_color": "#f57f17",
},
}
async def _send_alarm_email(
rule: AlarmRule, event: AlarmEvent, device_id: int, session: AsyncSession
):
"""Send alarm notification email if configured."""
from app.services.email_service import send_email
from app.core.config import get_settings
# Check if email is in notify_channels
channels = rule.notify_channels or []
if "email" not in channels:
return
# Get email targets from notify_targets
targets = rule.notify_targets or {}
emails = targets.get("emails", []) if isinstance(targets, dict) else []
# If notify_targets is a list of strings (emails directly)
if isinstance(targets, list):
emails = [t for t in targets if isinstance(t, str) and "@" in t]
if not emails:
logger.debug(f"No email recipients for alarm rule '{rule.name}', skipping.")
return
# Fetch device info
dev_result = await session.execute(select(Device).where(Device.id == device_id))
device = dev_result.scalar_one_or_none()
device_name = device.name if device else f"设备#{device_id}"
device_code = device.code if device else "N/A"
settings = get_settings()
severity_cfg = _SEVERITY_CONFIG.get(rule.severity, _SEVERITY_CONFIG["warning"])
# Build threshold string
if rule.condition == "range_out":
threshold_str = f"[{rule.threshold_low}, {rule.threshold_high}]"
else:
threshold_str = str(rule.threshold)
# Format triggered time in Beijing timezone
triggered_time = event.triggered_at or datetime.now(timezone.utc)
triggered_beijing = triggered_time + timedelta(hours=8)
triggered_str = triggered_beijing.strftime("%Y-%m-%d %H:%M:%S")
# Load and render template
try:
template_html = _ALARM_TEMPLATE_PATH.read_text(encoding="utf-8")
except FileNotFoundError:
logger.error("Alarm email template not found, skipping email.")
return
body_html = template_html.format(
severity_label=severity_cfg["label"],
severity_badge_color=severity_cfg["badge_color"],
severity_bg_color=severity_cfg["bg_color"],
severity_text_color=severity_cfg["text_color"],
title=event.title,
device_name=device_name,
device_code=device_code,
data_type=rule.data_type,
current_value=str(event.value),
threshold_str=threshold_str,
triggered_at=triggered_str,
description=event.description or "",
platform_url=settings.PLATFORM_URL,
)
subject = f"[{severity_cfg['label']}] {event.title} - 天普EMS告警通知"
asyncio.create_task(send_email(to=emails, subject=subject, body_html=body_html))
# Rate limit: don't create duplicate events for the same rule+device within this window
RATE_LIMIT_MINUTES = 5
@@ -140,6 +238,9 @@ async def check_alarms(session: AsyncSession):
f"value={dp.value} threshold={threshold_str}"
)
# Send email notification (non-blocking)
await _send_alarm_email(rule, event, device_id, session)
elif not triggered and active_event:
# Auto-resolve
active_event.status = "resolved"

View File

@@ -0,0 +1,32 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import AuditLog
async def log_audit(
db: AsyncSession,
user_id: int | None,
action: str,
resource: str,
detail: str = "",
ip_address: str | None = None,
):
"""Write an audit log entry.
Args:
db: async database session (must be flushed/committed by caller)
user_id: ID of the acting user (None for system actions)
action: one of login, create, update, delete, export, view,
acknowledge, resolve
resource: one of user, device, alarm, report, system, auth
detail: human-readable description
ip_address: client IP if available
"""
entry = AuditLog(
user_id=user_id,
action=action,
resource=resource,
detail=detail,
ip_address=ip_address,
)
db.add(entry)
# Don't flush here — let the caller's transaction handle it

View File

@@ -0,0 +1,105 @@
"""邮件发送服务 - SMTP email sending for alarm notifications and report delivery."""
import logging
import smtplib
import ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
from pathlib import Path
from typing import Optional
from app.core.config import get_settings
logger = logging.getLogger("email_service")
async def send_email(
to: list[str],
subject: str,
body_html: str,
attachments: Optional[list[str]] = None,
) -> bool:
"""
Send an email via SMTP.
Args:
to: List of recipient email addresses.
subject: Email subject line.
body_html: HTML body content.
attachments: Optional list of file paths to attach.
Returns:
True if sent successfully, False otherwise.
"""
settings = get_settings()
if not settings.SMTP_ENABLED:
logger.warning("SMTP is not enabled (SMTP_ENABLED=False). Skipping email send.")
return False
if not settings.SMTP_HOST:
logger.warning("SMTP_HOST is not configured. Skipping email send.")
return False
if not to:
logger.warning("No recipients specified. Skipping email send.")
return False
try:
msg = MIMEMultipart("mixed")
msg["From"] = settings.SMTP_FROM
msg["To"] = ", ".join(to)
msg["Subject"] = subject
# HTML body
html_part = MIMEText(body_html, "html", "utf-8")
msg.attach(html_part)
# Attachments
if attachments:
for filepath in attachments:
path = Path(filepath)
if not path.exists():
logger.warning(f"Attachment not found, skipping: {filepath}")
continue
with open(path, "rb") as f:
part = MIMEBase("application", "octet-stream")
part.set_payload(f.read())
encoders.encode_base64(part)
part.add_header(
"Content-Disposition",
f'attachment; filename="{path.name}"',
)
msg.attach(part)
# Send via SMTP
context = ssl.create_default_context()
if settings.SMTP_PORT == 465:
# SSL connection
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, context=context) as server:
if settings.SMTP_USER and settings.SMTP_PASSWORD:
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.sendmail(settings.SMTP_FROM, to, msg.as_string())
else:
# STARTTLS connection (port 587 or 25)
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
server.ehlo()
if settings.SMTP_PORT == 587:
server.starttls(context=context)
server.ehlo()
if settings.SMTP_USER and settings.SMTP_PASSWORD:
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.sendmail(settings.SMTP_FROM, to, msg.as_string())
logger.info(f"Email sent successfully to {to}, subject: {subject}")
return True
except smtplib.SMTPException as e:
logger.error(f"SMTP error sending email to {to}: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error sending email to {to}: {e}")
return False

View File

@@ -0,0 +1,192 @@
"""报表定时调度服务 - Schedule report tasks via APScheduler and send results by email."""
import logging
from datetime import date, timedelta, datetime, timezone
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import select
from app.core.database import async_session
from app.models.report import ReportTask, ReportTemplate
from app.services.report_generator import ReportGenerator
from app.services.email_service import send_email
logger = logging.getLogger("report_scheduler")
_scheduler: AsyncIOScheduler | None = None
def _parse_cron(cron_expr: str) -> dict:
"""Parse a 5-field cron expression into APScheduler CronTrigger kwargs."""
parts = cron_expr.strip().split()
if len(parts) != 5:
raise ValueError(f"Invalid cron expression (need 5 fields): {cron_expr}")
return {
"minute": parts[0],
"hour": parts[1],
"day": parts[2],
"month": parts[3],
"day_of_week": parts[4],
}
async def _run_report_task(task_id: int):
"""Execute a single report task: generate the report and email it to recipients."""
logger.info(f"Running scheduled report task id={task_id}")
async with async_session() as session:
# Load task
task_result = await session.execute(
select(ReportTask).where(ReportTask.id == task_id)
)
task = task_result.scalar_one_or_none()
if not task:
logger.warning(f"Report task id={task_id} not found, skipping.")
return
if not task.is_active:
logger.info(f"Report task id={task_id} is inactive, skipping.")
return
# Update status
task.status = "running"
task.last_run = datetime.now(timezone.utc)
await session.flush()
# Load template to determine report type
tmpl_result = await session.execute(
select(ReportTemplate).where(ReportTemplate.id == task.template_id)
)
template = tmpl_result.scalar_one_or_none()
if not template:
logger.error(f"Template id={task.template_id} not found for task id={task_id}")
task.status = "failed"
await session.commit()
return
try:
generator = ReportGenerator(session)
today = date.today()
export_format = task.export_format or "xlsx"
# Choose generation method based on template report_type
if template.report_type == "daily":
yesterday = today - timedelta(days=1)
filepath = await generator.generate_energy_daily_report(
start_date=yesterday, end_date=yesterday, export_format=export_format
)
elif template.report_type == "monthly":
# Generate for previous month
first_of_month = today.replace(day=1)
last_month_end = first_of_month - timedelta(days=1)
last_month_start = last_month_end.replace(day=1)
filepath = await generator.generate_monthly_summary(
month=last_month_start.month,
year=last_month_start.year,
export_format=export_format,
)
elif template.report_type == "custom" and "device" in template.name.lower():
filepath = await generator.generate_device_status_report(
export_format=export_format
)
else:
# Default: daily report for yesterday
yesterday = today - timedelta(days=1)
filepath = await generator.generate_energy_daily_report(
start_date=yesterday, end_date=yesterday, export_format=export_format
)
task.file_path = filepath
task.status = "completed"
logger.info(f"Report task id={task_id} completed: {filepath}")
# Send email with attachment if recipients configured
recipients = task.recipients or []
if isinstance(recipients, list) and recipients:
report_name = task.name or template.name
subject = f"{report_name} - 天普EMS自动报表"
body_html = f"""
<div style="font-family: 'Microsoft YaHei', sans-serif; padding: 20px;">
<h2 style="color: #1a73e8;">天普零碳园区智慧能源管理平台</h2>
<p>您好,</p>
<p>系统已自动生成 <strong>{report_name}</strong>,请查收附件。</p>
<p style="color: #666; font-size: 13px;">
生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}<br>
报表类型: {template.report_type}<br>
格式: {export_format.upper()}
</p>
<hr style="border: none; border-top: 1px solid #e8e8e8; margin: 20px 0;">
<p style="font-size: 12px; color: #999;">此为系统自动发送,请勿回复。</p>
</div>
"""
await send_email(
to=recipients,
subject=subject,
body_html=body_html,
attachments=[filepath],
)
except Exception as e:
logger.error(f"Report task id={task_id} failed: {e}", exc_info=True)
task.status = "failed"
await session.commit()
async def _load_and_schedule_tasks():
"""Load all active report tasks with schedules and register them with APScheduler."""
global _scheduler
if not _scheduler:
return
async with async_session() as session:
result = await session.execute(
select(ReportTask).where(
ReportTask.is_active == True,
ReportTask.schedule != None,
ReportTask.schedule != "",
)
)
tasks = result.scalars().all()
for task in tasks:
try:
cron_kwargs = _parse_cron(task.schedule)
_scheduler.add_job(
_run_report_task,
CronTrigger(**cron_kwargs),
args=[task.id],
id=f"report_task_{task.id}",
replace_existing=True,
misfire_grace_time=3600,
)
logger.info(
f"Scheduled report task id={task.id} name='{task.name}' "
f"cron='{task.schedule}'"
)
except Exception as e:
logger.error(f"Failed to schedule report task id={task.id}: {e}")
logger.info(f"Report scheduler loaded {len(tasks)} task(s).")
async def start_scheduler():
"""Start the APScheduler-based report scheduler."""
global _scheduler
if _scheduler and _scheduler.running:
logger.warning("Report scheduler is already running.")
return
_scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
_scheduler.start()
logger.info("Report scheduler started.")
await _load_and_schedule_tasks()
async def stop_scheduler():
"""Stop the report scheduler gracefully."""
global _scheduler
if _scheduler and _scheduler.running:
_scheduler.shutdown(wait=False)
logger.info("Report scheduler stopped.")
_scheduler = None

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0; padding:0; background-color:#f4f5f7; font-family: 'Microsoft YaHei', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7; padding:20px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, #1a73e8, #0d47a1); padding:24px 32px; text-align:center;">
<div style="font-size:12px; color:rgba(255,255,255,0.8); margin-bottom:4px;">TIANPU EMS</div>
<div style="font-size:20px; font-weight:bold; color:#ffffff; letter-spacing:1px;">天普零碳园区智慧能源管理平台</div>
</td>
</tr>
<!-- Alert Banner -->
<tr>
<td style="padding:0;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="background-color:{severity_bg_color}; padding:16px 32px; text-align:center;">
<span style="display:inline-block; background-color:{severity_badge_color}; color:#ffffff; font-size:13px; font-weight:bold; padding:4px 16px; border-radius:12px; letter-spacing:1px;">{severity_label}</span>
<div style="color:{severity_text_color}; font-size:16px; font-weight:bold; margin-top:8px;">{title}</div>
</td>
</tr>
</table>
</td>
</tr>
<!-- Alarm Details -->
<tr>
<td style="padding:24px 32px;">
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e8e8e8; border-radius:6px; overflow:hidden;">
<tr>
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; width:120px; border-bottom:1px solid #e8e8e8;">设备名称</td>
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{device_name}</td>
</tr>
<tr>
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">设备编号</td>
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{device_code}</td>
</tr>
<tr>
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">监控指标</td>
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{data_type}</td>
</tr>
<tr>
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">当前值</td>
<td style="padding:10px 16px; font-size:14px; color:{severity_badge_color}; font-weight:bold; border-bottom:1px solid #e8e8e8;">{current_value}</td>
</tr>
<tr>
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">告警阈值</td>
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{threshold_str}</td>
</tr>
<tr>
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666;">触发时间</td>
<td style="padding:10px 16px; font-size:14px; color:#333;">{triggered_at}</td>
</tr>
</table>
</td>
</tr>
<!-- Description -->
<tr>
<td style="padding:0 32px 24px;">
<div style="background-color:#fff8e1; border-left:4px solid #ffa000; padding:12px 16px; border-radius:0 4px 4px 0; font-size:13px; color:#795548;">
{description}
</div>
</td>
</tr>
<!-- Action Button -->
<tr>
<td style="padding:0 32px 24px; text-align:center;">
<a href="{platform_url}/alarms" style="display:inline-block; background-color:#1a73e8; color:#ffffff; text-decoration:none; padding:12px 32px; border-radius:6px; font-size:14px; font-weight:bold;">查看告警详情</a>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color:#f8f9fa; padding:16px 32px; border-top:1px solid #e8e8e8;">
<div style="font-size:12px; color:#999; text-align:center; line-height:1.6;">
此为系统自动发送,请勿回复。<br>
天普零碳园区智慧能源管理平台 &copy; 2026
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -18,8 +18,10 @@
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"i18next": "^24.2.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^15.4.1",
"react-router-dom": "^6.30.3",
"three": "^0.183.2"
},
@@ -1069,9 +1071,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -3628,6 +3630,47 @@
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
"license": "Apache-2.0"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "24.2.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz",
"integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.26.10"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5140,6 +5183,32 @@
"react": "^19.2.4"
}
},
"node_modules/react-i18next": {
"version": "15.7.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.4.0",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -5617,7 +5686,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
@@ -5797,6 +5866,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",

View File

@@ -20,6 +20,8 @@
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"i18next": "^24.2.2",
"react-i18next": "^15.4.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^6.30.3",

View File

@@ -1,6 +1,10 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import { ConfigProvider, theme } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import enUS from 'antd/locale/en_US';
import { useTranslation } from 'react-i18next';
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
import './i18n';
import MainLayout from './layouts/MainLayout';
import LoginPage from './pages/Login';
import Dashboard from './pages/Dashboard';
@@ -10,6 +14,7 @@ import Alarms from './pages/Alarms';
import Carbon from './pages/Carbon';
import Reports from './pages/Reports';
import Devices from './pages/Devices';
import DeviceDetail from './pages/DeviceDetail';
import SystemManagement from './pages/System';
import BigScreen from './pages/BigScreen';
import BigScreen3D from './pages/BigScreen3D';
@@ -20,11 +25,18 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
export default function App() {
function AppContent() {
const { darkMode } = useTheme();
const { i18n } = useTranslation();
return (
<ConfigProvider locale={zhCN} theme={{
token: { colorPrimary: '#1890ff', borderRadius: 6 },
}}>
<ConfigProvider
locale={i18n.language === 'en' ? enUS : zhCN}
theme={{
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: { colorPrimary: '#1890ff', borderRadius: 6 },
}}
>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
@@ -34,6 +46,7 @@ export default function App() {
<Route index element={<Dashboard />} />
<Route path="monitoring" element={<Monitoring />} />
<Route path="devices" element={<Devices />} />
<Route path="devices/:id" element={<DeviceDetail />} />
<Route path="analysis" element={<Analysis />} />
<Route path="alarms" element={<Alarms />} />
<Route path="carbon" element={<Carbon />} />
@@ -45,3 +58,11 @@ export default function App() {
</ConfigProvider>
);
}
export default function App() {
return (
<ThemeProvider>
<AppContent />
</ThemeProvider>
);
}

View File

@@ -0,0 +1,33 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
interface ThemeContextType {
darkMode: boolean;
toggleDarkMode: () => void;
}
const ThemeContext = createContext<ThemeContextType>({
darkMode: false,
toggleDarkMode: () => {},
});
export function ThemeProvider({ children }: { children: ReactNode }) {
const [darkMode, setDarkMode] = useState(() => {
const saved = localStorage.getItem('tianpu-dark-mode');
return saved === 'true';
});
useEffect(() => {
localStorage.setItem('tianpu-dark-mode', String(darkMode));
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
}, [darkMode]);
const toggleDarkMode = () => setDarkMode(prev => !prev);
return (
<ThemeContext.Provider value={{ darkMode, toggleDarkMode }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);

View File

@@ -0,0 +1,18 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import zh from './locales/zh.json';
import en from './locales/en.json';
i18n.use(initReactI18next).init({
resources: {
zh: { translation: zh },
en: { translation: en },
},
lng: localStorage.getItem('tianpu-lang') || 'zh',
fallbackLng: 'zh',
interpolation: {
escapeValue: false,
},
});
export default i18n;

View File

@@ -0,0 +1,58 @@
{
"menu": {
"dashboard": "Energy Overview",
"monitoring": "Real-time Monitoring",
"devices": "Device Management",
"analysis": "Energy Analysis",
"alarms": "Alarm Management",
"carbon": "Carbon Management",
"reports": "Reports",
"bigscreen": "Visual Dashboard",
"bigscreen2d": "2D Energy Screen",
"bigscreen3d": "3D Park Screen",
"system": "System Management",
"users": "User Management",
"roles": "Roles & Permissions",
"settings": "System Settings",
"audit": "Audit Log"
},
"header": {
"alarmNotification": "Alarm Notifications",
"activeAlarms": "active",
"noActiveAlarms": "No active alarms",
"viewAllAlarms": "View all alarms",
"profile": "Profile",
"logout": "Sign Out",
"brandName": "Tianpu EMS"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"search": "Search",
"reset": "Reset",
"export": "Export",
"import": "Import",
"loading": "Loading",
"success": "Success",
"error": "Error",
"noData": "No data"
},
"analysis": {
"dataComparison": "Data Comparison",
"energyTrend": "Energy Trend",
"dailySummary": "Daily Energy Summary",
"period1": "Period 1",
"period2": "Period 2",
"totalConsumption": "Total Consumption",
"peakPower": "Peak Power",
"avgLoad": "Average Load",
"carbonEmission": "Carbon Emission",
"change": "Change",
"compare": "Compare",
"selectDateRange": "Select date range"
}
}

View File

@@ -0,0 +1,58 @@
{
"menu": {
"dashboard": "能源总览",
"monitoring": "实时监控",
"devices": "设备管理",
"analysis": "能耗分析",
"alarms": "告警管理",
"carbon": "碳排放管理",
"reports": "报表管理",
"bigscreen": "可视化大屏",
"bigscreen2d": "2D 能源大屏",
"bigscreen3d": "3D 园区大屏",
"system": "系统管理",
"users": "用户管理",
"roles": "角色权限",
"settings": "系统设置",
"audit": "审计日志"
},
"header": {
"alarmNotification": "告警通知",
"activeAlarms": "条活跃",
"noActiveAlarms": "暂无活跃告警",
"viewAllAlarms": "查看全部告警",
"profile": "个人信息",
"logout": "退出登录",
"brandName": "天普EMS"
},
"common": {
"save": "保存",
"cancel": "取消",
"confirm": "确认",
"delete": "删除",
"edit": "编辑",
"add": "新增",
"search": "搜索",
"reset": "重置",
"export": "导出",
"import": "导入",
"loading": "加载中",
"success": "操作成功",
"error": "操作失败",
"noData": "暂无数据"
},
"analysis": {
"dataComparison": "数据对比",
"energyTrend": "能耗趋势",
"dailySummary": "每日能耗汇总",
"period1": "时段一",
"period2": "时段二",
"totalConsumption": "总用电量",
"peakPower": "峰值功率",
"avgLoad": "平均负荷",
"carbonEmission": "碳排放",
"change": "变化",
"compare": "对比",
"selectDateRange": "选择日期范围"
}
}

View File

@@ -45,6 +45,30 @@ body {
}
}
/* ============================================
Dark mode support
============================================ */
[data-theme='dark'] body {
background: #141414;
color: rgba(255, 255, 255, 0.85);
}
[data-theme='dark'] .ant-layout-content {
background: #1f1f1f !important;
}
[data-theme='dark'] .ant-card {
background: #1f1f1f;
border-color: #303030;
}
[data-theme='dark'] .ant-table {
background: #1f1f1f;
}
/* BigScreen pages are already dark themed, no overrides needed */
/* Mobile: tighter spacing */
@media (max-width: 375px) {
.ant-layout-header {

View File

@@ -1,42 +1,22 @@
import { useState, useEffect, useCallback } from 'react';
import { Layout, Menu, Avatar, Dropdown, Typography, Badge, Popover, List, Tag, Empty } from 'antd';
import { Layout, Menu, Avatar, Dropdown, Typography, Badge, Popover, List, Tag, Empty, Select } from 'antd';
import {
DashboardOutlined, MonitorOutlined, BarChartOutlined, AlertOutlined,
FileTextOutlined, CloudOutlined, SettingOutlined, UserOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, BellOutlined,
ThunderboltOutlined, AppstoreOutlined, WarningOutlined, CloseCircleOutlined,
InfoCircleOutlined, FundProjectionScreenOutlined, GlobalOutlined,
BulbOutlined, BulbFilled,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { getUser, removeToken } from '../utils/auth';
import { getAlarmStats, getAlarmEvents } from '../services/api';
import { useTheme } from '../contexts/ThemeContext';
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: '能源总览' },
{ key: '/monitoring', icon: <MonitorOutlined />, label: '实时监控' },
{ key: '/devices', icon: <AppstoreOutlined />, label: '设备管理' },
{ key: '/analysis', icon: <BarChartOutlined />, label: '能耗分析' },
{ key: '/alarms', icon: <AlertOutlined />, label: '告警管理' },
{ key: '/carbon', icon: <CloudOutlined />, label: '碳排放管理' },
{ key: '/reports', icon: <FileTextOutlined />, label: '报表管理' },
{ key: 'bigscreen-group', icon: <FundProjectionScreenOutlined />, label: '可视化大屏',
children: [
{ key: '/bigscreen', icon: <FundProjectionScreenOutlined />, label: '2D 能源大屏' },
{ key: '/bigscreen-3d', icon: <GlobalOutlined />, label: '3D 园区大屏' },
],
},
{ key: '/system', icon: <SettingOutlined />, label: '系统管理',
children: [
{ key: '/system/users', label: '用户管理' },
{ key: '/system/roles', label: '角色权限' },
{ key: '/system/settings', label: '系统设置' },
],
},
];
const SEVERITY_CONFIG: Record<string, { icon: React.ReactNode; color: string }> = {
critical: { icon: <CloseCircleOutlined style={{ color: '#f5222d' }} />, color: 'red' },
warning: { icon: <WarningOutlined style={{ color: '#faad14' }} />, color: 'orange' },
@@ -50,6 +30,32 @@ export default function MainLayout() {
const navigate = useNavigate();
const location = useLocation();
const user = getUser();
const { darkMode, toggleDarkMode } = useTheme();
const { t, i18n } = useTranslation();
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: t('menu.dashboard') },
{ key: '/monitoring', icon: <MonitorOutlined />, label: t('menu.monitoring') },
{ key: '/devices', icon: <AppstoreOutlined />, label: t('menu.devices') },
{ key: '/analysis', icon: <BarChartOutlined />, label: t('menu.analysis') },
{ key: '/alarms', icon: <AlertOutlined />, label: t('menu.alarms') },
{ key: '/carbon', icon: <CloudOutlined />, label: t('menu.carbon') },
{ key: '/reports', icon: <FileTextOutlined />, label: t('menu.reports') },
{ key: 'bigscreen-group', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen'),
children: [
{ key: '/bigscreen', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen2d') },
{ key: '/bigscreen-3d', icon: <GlobalOutlined />, label: t('menu.bigscreen3d') },
],
},
{ key: '/system', icon: <SettingOutlined />, label: t('menu.system'),
children: [
{ key: '/system/users', label: t('menu.users') },
{ key: '/system/roles', label: t('menu.roles') },
{ key: '/system/settings', label: t('menu.settings') },
{ key: '/system/audit', label: t('menu.audit', '审计日志') },
],
},
];
const fetchAlarms = useCallback(async () => {
try {
@@ -57,7 +63,6 @@ export default function MainLayout() {
getAlarmStats(),
getAlarmEvents({ status: 'active', page_size: 5 }),
]);
// Stats shape: { severity: { status: count } } — sum all "active" counts
const statsData = (stats as any) || {};
let activeTotal = 0;
for (const severity of Object.values(statsData)) {
@@ -85,24 +90,29 @@ export default function MainLayout() {
navigate('/login');
};
const handleLanguageChange = (lang: string) => {
i18n.changeLanguage(lang);
localStorage.setItem('tianpu-lang', lang);
};
const userMenu = {
items: [
{ key: 'profile', icon: <UserOutlined />, label: '个人信息' },
{ key: 'profile', icon: <UserOutlined />, label: t('header.profile') },
{ type: 'divider' as const },
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout },
{ key: 'logout', icon: <LogoutOutlined />, label: t('header.logout'), onClick: handleLogout },
],
};
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible collapsed={collapsed} width={220}
style={{ background: '#001529' }}>
style={{ background: darkMode ? '#141414' : '#001529' }}>
<div style={{
height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderBottom: '1px solid rgba(255,255,255,0.1)',
}}>
<ThunderboltOutlined style={{ fontSize: 24, color: '#1890ff', marginRight: collapsed ? 0 : 8 }} />
{!collapsed && <Text strong style={{ color: '#fff', fontSize: 16 }}>EMS</Text>}
{!collapsed && <Text strong style={{ color: '#fff', fontSize: 16 }}>{t('header.brandName')}</Text>}
</div>
<Menu
theme="dark" mode="inline"
@@ -120,9 +130,9 @@ export default function MainLayout() {
</Sider>
<Layout>
<Header style={{
padding: '0 24px', background: '#fff', display: 'flex',
padding: '0 24px', background: darkMode ? '#141414' : '#fff', display: 'flex',
alignItems: 'center', justifyContent: 'space-between',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
boxShadow: darkMode ? '0 1px 4px rgba(0,0,0,0.3)' : '0 1px 4px rgba(0,0,0,0.08)',
}}>
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
onClick={() => setCollapsed(!collapsed)}>
@@ -130,17 +140,34 @@ export default function MainLayout() {
<MenuFoldOutlined style={{ fontSize: 18 }} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Select
value={i18n.language}
onChange={handleLanguageChange}
size="small"
style={{ width: 90 }}
options={[
{ label: '中文', value: 'zh' },
{ label: 'English', value: 'en' },
]}
/>
<div
style={{ cursor: 'pointer', fontSize: 18, display: 'flex', alignItems: 'center' }}
onClick={toggleDarkMode}
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
>
{darkMode ? <BulbFilled style={{ color: '#faad14' }} /> : <BulbOutlined />}
</div>
<Popover
trigger="click"
placement="bottomRight"
title={<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span></span>
{alarmCount > 0 && <Tag color="red">{alarmCount} </Tag>}
<span>{t('header.alarmNotification')}</span>
{alarmCount > 0 && <Tag color="red">{alarmCount} {t('header.activeAlarms')}</Tag>}
</div>}
content={
<div style={{ width: 320, maxHeight: 360, overflow: 'auto' }}>
{recentAlarms.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无活跃告警" />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('header.noActiveAlarms')} />
) : (
<List size="small" dataSource={recentAlarms} renderItem={(alarm: any) => {
const sev = SEVERITY_CONFIG[alarm.severity] || SEVERITY_CONFIG.info;
@@ -162,7 +189,7 @@ export default function MainLayout() {
}} />
)}
<div style={{ textAlign: 'center', padding: '8px 0', borderTop: '1px solid #f0f0f0' }}>
<a onClick={() => navigate('/alarms')} style={{ fontSize: 13 }}></a>
<a onClick={() => navigate('/alarms')} style={{ fontSize: 13 }}>{t('header.viewAllAlarms')}</a>
</div>
</div>
}
@@ -179,7 +206,7 @@ export default function MainLayout() {
</Dropdown>
</div>
</Header>
<Content style={{ margin: 16, padding: 24, background: '#f5f5f5', minHeight: 280 }}>
<Content style={{ margin: 16, padding: 24, background: darkMode ? '#1f1f1f' : '#f5f5f5', minHeight: 280 }}>
<Outlet />
</Content>
</Layout>

View File

@@ -1,16 +1,196 @@
import { useEffect, useState } from 'react';
import { Card, Row, Col, DatePicker, Select, Statistic, Table, Button, Space, message } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, DownloadOutlined } from '@ant-design/icons';
import { Card, Row, Col, DatePicker, Select, Statistic, Table, Button, Space, message, Tabs } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, DownloadOutlined, SwapOutlined } from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import dayjs from 'dayjs';
import dayjs, { type Dayjs } from 'dayjs';
import { getEnergyHistory, getEnergyComparison, getEnergyDailySummary, exportEnergyData } from '../../services/api';
const { RangePicker } = DatePicker;
function ComparisonView() {
const [range1, setRange1] = useState<[Dayjs, Dayjs]>([
dayjs().subtract(30, 'day'), dayjs(),
]);
const [range2, setRange2] = useState<[Dayjs, Dayjs]>([
dayjs().subtract(60, 'day'), dayjs().subtract(30, 'day'),
]);
const [data1, setData1] = useState<any[]>([]);
const [data2, setData2] = useState<any[]>([]);
const [summary1, setSummary1] = useState<any[]>([]);
const [summary2, setSummary2] = useState<any[]>([]);
const [comparison, setComparison] = useState<any>(null);
const [loading, setLoading] = useState(false);
const loadComparisonData = async () => {
setLoading(true);
try {
const [hist1, hist2, daily1, daily2, comp] = await Promise.all([
getEnergyHistory({
data_type: 'power', granularity: 'day',
start_time: range1[0].format('YYYY-MM-DD'),
end_time: range1[1].format('YYYY-MM-DD'),
}),
getEnergyHistory({
data_type: 'power', granularity: 'day',
start_time: range2[0].format('YYYY-MM-DD'),
end_time: range2[1].format('YYYY-MM-DD'),
}),
getEnergyDailySummary({
energy_type: 'electricity',
start_date: range1[0].format('YYYY-MM-DD'),
end_date: range1[1].format('YYYY-MM-DD'),
}),
getEnergyDailySummary({
energy_type: 'electricity',
start_date: range2[0].format('YYYY-MM-DD'),
end_date: range2[1].format('YYYY-MM-DD'),
}),
getEnergyComparison({ energy_type: 'electricity', period: 'month' }),
]);
setData1(hist1 as any[]);
setData2(hist2 as any[]);
setSummary1(daily1 as any[]);
setSummary2(daily2 as any[]);
setComparison(comp);
} catch (e) {
console.error(e);
message.error('加载对比数据失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadComparisonData();
}, []);
const calcMetrics = (summaryData: any[]) => {
if (!summaryData || summaryData.length === 0) {
return { totalConsumption: 0, peakPower: 0, avgLoad: 0, carbonEmission: 0 };
}
const totalConsumption = summaryData.reduce((s, d) => s + (d.consumption || 0), 0);
const peakPower = Math.max(...summaryData.map(d => d.peak_power || 0));
const avgLoad = summaryData.reduce((s, d) => s + (d.avg_power || 0), 0) / summaryData.length;
const carbonEmission = summaryData.reduce((s, d) => s + (d.carbon_emission || 0), 0);
return { totalConsumption, peakPower, avgLoad, carbonEmission };
};
const m1 = calcMetrics(summary1);
const m2 = calcMetrics(summary2);
const pctChange = (v1: number, v2: number) => {
if (v2 === 0) return 0;
return ((v1 - v2) / v2) * 100;
};
const comparisonChartOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['时段一', '时段二'] },
grid: { top: 50, right: 40, bottom: 30, left: 60 },
xAxis: {
type: 'category',
data: (data1.length >= data2.length ? data1 : data2).map((_, i) => `Day ${i + 1}`),
},
yAxis: [
{ type: 'value', name: 'kW', position: 'left' },
{ type: 'value', name: 'kW', position: 'right' },
],
series: [
{
name: '时段一',
type: 'line',
smooth: true,
data: data1.map(d => d.avg),
lineStyle: { color: '#1890ff' },
itemStyle: { color: '#1890ff' },
yAxisIndex: 0,
},
{
name: '时段二',
type: 'line',
smooth: true,
data: data2.map(d => d.avg),
lineStyle: { color: '#faad14' },
itemStyle: { color: '#faad14' },
yAxisIndex: 1,
},
],
};
const renderMetricCard = (
label: string, v1: number, v2: number, unit: string, precision = 1,
) => {
const change = pctChange(v1, v2);
const isImproved = change < 0; // less consumption = improvement
return (
<Col xs={24} sm={12} md={6} key={label}>
<Card size="small">
<div style={{ marginBottom: 8, fontWeight: 500, color: '#666' }}>{label}</div>
<Row gutter={16}>
<Col span={12}>
<div style={{ fontSize: 12, color: '#999' }}></div>
<div style={{ fontSize: 18, fontWeight: 600 }}>{v1.toFixed(precision)}</div>
<div style={{ fontSize: 12, color: '#999' }}>{unit}</div>
</Col>
<Col span={12}>
<div style={{ fontSize: 12, color: '#999' }}></div>
<div style={{ fontSize: 18, fontWeight: 600 }}>{v2.toFixed(precision)}</div>
<div style={{ fontSize: 12, color: '#999' }}>{unit}</div>
</Col>
</Row>
<div style={{
marginTop: 8, fontSize: 13, fontWeight: 500,
color: isImproved ? '#52c41a' : change > 0 ? '#f5222d' : '#666',
}}>
{change > 0 ? <ArrowUpOutlined /> : change < 0 ? <ArrowDownOutlined /> : null}
{' '}{Math.abs(change).toFixed(1)}% {isImproved ? '减少' : change > 0 ? '增加' : '持平'}
</div>
</Card>
</Col>
);
};
return (
<div>
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<span>:</span>
<RangePicker
value={range1}
onChange={(dates) => dates && setRange1(dates as [Dayjs, Dayjs])}
/>
<span>:</span>
<RangePicker
value={range2}
onChange={(dates) => dates && setRange2(dates as [Dayjs, Dayjs])}
/>
<Button type="primary" icon={<SwapOutlined />} loading={loading} onClick={loadComparisonData}>
</Button>
</Space>
</Card>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
{renderMetricCard('总用电量', m1.totalConsumption, m2.totalConsumption, 'kWh')}
{renderMetricCard('峰值功率', m1.peakPower, m2.peakPower, 'kW')}
{renderMetricCard('平均负荷', m1.avgLoad, m2.avgLoad, 'kW')}
{renderMetricCard('碳排放', m1.carbonEmission, m2.carbonEmission, 'kg', 2)}
</Row>
<Card title="能耗趋势对比" size="small">
<ReactECharts option={comparisonChartOption} style={{ height: 350 }} />
</Card>
</div>
);
}
export default function Analysis() {
const [historyData, setHistoryData] = useState<any[]>([]);
const [comparison, setComparison] = useState<any>(null);
const [dailySummary, setDailySummary] = useState<any[]>([]);
const [granularity, setGranularity] = useState('hour');
const [exporting, setExporting] = useState(false);
const [activeTab, setActiveTab] = useState('overview');
useEffect(() => {
loadData();
@@ -76,7 +256,7 @@ export default function Analysis() {
{ title: '碳排放(kg)', dataIndex: 'carbon_emission', render: (v: number) => v?.toFixed(2) },
];
return (
const overviewContent = (
<div>
<Row justify="end" style={{ marginBottom: 16 }}>
<Col>
@@ -130,4 +310,17 @@ export default function Analysis() {
</Card>
</div>
);
return (
<div>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{ key: 'overview', label: '能耗概览', children: overviewContent },
{ key: 'comparison', label: '数据对比', children: <ComparisonView /> },
]}
/>
</div>
);
}

View File

@@ -0,0 +1,490 @@
import { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Tabs, Tag, Button, Statistic, Row, Col, Table, Descriptions, Select,
DatePicker, Space, Badge, Spin, message, Empty,
} from 'antd';
import {
ArrowLeftOutlined, ReloadOutlined, ThunderboltOutlined,
DashboardOutlined, FireOutlined, ExperimentOutlined,
} from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import dayjs from 'dayjs';
import { getDevice, getDeviceRealtime, getEnergyHistory, getAlarmEvents } from '../../services/api';
import { getDevicePhoto } from '../../utils/devicePhoto';
const { RangePicker } = DatePicker;
const statusMap: Record<string, { color: string; text: string }> = {
online: { color: 'green', text: '在线' },
offline: { color: 'default', text: '离线' },
alarm: { color: 'red', text: '告警' },
maintenance: { color: 'orange', text: '维护' },
};
const severityMap: Record<string, { color: string; text: string }> = {
critical: { color: 'red', text: '严重' },
warning: { color: 'orange', text: '警告' },
info: { color: 'blue', text: '信息' },
};
const alarmStatusMap: Record<string, { color: string; text: string }> = {
active: { color: 'red', text: '活跃' },
acknowledged: { color: 'orange', text: '已确认' },
resolved: { color: 'green', text: '已解决' },
};
const protocolLabels: Record<string, string> = {
modbus_tcp: 'Modbus TCP',
modbus_rtu: 'Modbus RTU',
opc_ua: 'OPC UA',
mqtt: 'MQTT',
http_api: 'HTTP API',
dlt645: 'DL/T 645',
image: '图像采集',
};
const dataTypeOptions = [
{ label: '功率 (kW)', value: 'power' },
{ label: '电量 (kWh)', value: 'energy' },
{ label: '温度 (°C)', value: 'temperature' },
{ label: 'COP', value: 'cop' },
{ label: '电流 (A)', value: 'current' },
{ label: '电压 (V)', value: 'voltage' },
{ label: '频率 (Hz)', value: 'frequency' },
{ label: '功率因数', value: 'power_factor' },
{ label: '流量 (m³/h)', value: 'flow_rate' },
{ label: '湿度 (%)', value: 'humidity' },
];
const granularityOptions = [
{ label: '原始数据', value: 'raw' },
{ label: '5分钟', value: '5min' },
{ label: '小时', value: 'hour' },
{ label: '天', value: 'day' },
];
const timeRangePresets = [
{ label: '24小时', value: '24h' },
{ label: '7天', value: '7d' },
{ label: '30天', value: '30d' },
];
function getTimeRange(preset: string): [dayjs.Dayjs, dayjs.Dayjs] {
const now = dayjs();
switch (preset) {
case '24h': return [now.subtract(24, 'hour'), now];
case '7d': return [now.subtract(7, 'day'), now];
case '30d': return [now.subtract(30, 'day'), now];
default: return [now.subtract(24, 'hour'), now];
}
}
export default function DeviceDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const deviceId = Number(id);
const [device, setDevice] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('realtime');
// Realtime state
const [realtimeData, setRealtimeData] = useState<any>(null);
const [realtimeLoading, setRealtimeLoading] = useState(false);
// History state
const [historyData, setHistoryData] = useState<any[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [dataType, setDataType] = useState('power');
const [granularity, setGranularity] = useState('hour');
const [timePreset, setTimePreset] = useState('24h');
const [timeRange, setTimeRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>(getTimeRange('24h'));
// Alarm state
const [alarmData, setAlarmData] = useState<any>({ total: 0, items: [] });
const [alarmLoading, setAlarmLoading] = useState(false);
const [alarmPage, setAlarmPage] = useState(1);
// Load device info
useEffect(() => {
if (!deviceId) return;
setLoading(true);
getDevice(deviceId)
.then((res: any) => setDevice(res))
.catch(() => message.error('加载设备信息失败'))
.finally(() => setLoading(false));
}, [deviceId]);
// Load realtime data
const loadRealtime = useCallback(async () => {
if (!deviceId) return;
setRealtimeLoading(true);
try {
const res = await getDeviceRealtime(deviceId);
setRealtimeData(res);
} catch { setRealtimeData(null); }
finally { setRealtimeLoading(false); }
}, [deviceId]);
// Auto-refresh realtime every 15s
useEffect(() => {
if (activeTab !== 'realtime') return;
loadRealtime();
const timer = setInterval(loadRealtime, 15000);
return () => clearInterval(timer);
}, [activeTab, loadRealtime]);
// Load history data
const loadHistory = useCallback(async () => {
if (!deviceId) return;
setHistoryLoading(true);
try {
const res = await getEnergyHistory({
device_id: deviceId,
data_type: dataType,
granularity,
start_time: timeRange[0].format('YYYY-MM-DD HH:mm:ss'),
end_time: timeRange[1].format('YYYY-MM-DD HH:mm:ss'),
page_size: 1000,
});
setHistoryData(res as any[]);
} catch { setHistoryData([]); }
finally { setHistoryLoading(false); }
}, [deviceId, dataType, granularity, timeRange]);
useEffect(() => {
if (activeTab === 'history') loadHistory();
}, [activeTab, loadHistory]);
// Load alarm events
const loadAlarms = useCallback(async () => {
if (!deviceId) return;
setAlarmLoading(true);
try {
const res = await getAlarmEvents({ device_id: deviceId, page: alarmPage, page_size: 20 });
setAlarmData(res as any);
} catch { setAlarmData({ total: 0, items: [] }); }
finally { setAlarmLoading(false); }
}, [deviceId, alarmPage]);
useEffect(() => {
if (activeTab === 'alarms') loadAlarms();
}, [activeTab, loadAlarms]);
const handleTimePreset = (preset: string) => {
setTimePreset(preset);
setTimeRange(getTimeRange(preset));
};
const handleRangeChange = (dates: any) => {
if (dates && dates[0] && dates[1]) {
setTimePreset('');
setTimeRange([dates[0], dates[1]]);
}
};
// ---- Chart options ----
const getChartOption = () => {
const isRaw = granularity === 'raw';
const times = historyData.map(d => isRaw ? d.timestamp : d.time);
const typeLabel = dataTypeOptions.find(o => o.value === dataType)?.label || dataType;
if (isRaw) {
return {
tooltip: { trigger: 'axis' },
legend: { data: [typeLabel] },
grid: { left: 60, right: 30, top: 40, bottom: 40 },
xAxis: { type: 'category', data: times, axisLabel: { rotate: 30 } },
yAxis: { type: 'value', name: typeLabel },
series: [{
name: typeLabel,
type: 'line',
data: historyData.map(d => d.value),
smooth: true,
lineStyle: { width: 2 },
areaStyle: { opacity: 0.1 },
}],
};
}
// Aggregated data with avg/max/min
const avgData = historyData.map(d => d.avg);
const maxData = historyData.map(d => d.max);
const minData = historyData.map(d => d.min);
const avgVal = avgData.length ? (avgData.reduce((a, b) => a + b, 0) / avgData.length).toFixed(2) : '-';
const maxVal = maxData.length ? Math.max(...maxData).toFixed(2) : '-';
const minVal = minData.length ? Math.min(...minData).toFixed(2) : '-';
return {
tooltip: { trigger: 'axis' },
legend: {
data: [
`平均 (${avgVal})`,
`最大 (${maxVal})`,
`最小 (${minVal})`,
],
},
grid: { left: 60, right: 30, top: 50, bottom: 40 },
xAxis: { type: 'category', data: times, axisLabel: { rotate: 30 } },
yAxis: { type: 'value', name: typeLabel },
series: [
{
name: `平均 (${avgVal})`,
type: 'line',
data: avgData,
smooth: true,
lineStyle: { width: 2, color: '#1890ff' },
itemStyle: { color: '#1890ff' },
areaStyle: { opacity: 0.08, color: '#1890ff' },
},
{
name: `最大 (${maxVal})`,
type: 'line',
data: maxData,
smooth: true,
lineStyle: { width: 1, type: 'dashed', color: '#ff4d4f' },
itemStyle: { color: '#ff4d4f' },
},
{
name: `最小 (${minVal})`,
type: 'line',
data: minData,
smooth: true,
lineStyle: { width: 1, type: 'dashed', color: '#52c41a' },
itemStyle: { color: '#52c41a' },
},
],
};
};
// ---- Alarm columns ----
const alarmColumns = [
{
title: '时间', dataIndex: 'triggered_at', width: 170,
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{ title: '标题', dataIndex: 'title', width: 200, ellipsis: true },
{
title: '严重程度', dataIndex: 'severity', width: 90,
render: (v: string) => {
const s = severityMap[v] || { color: 'default', text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '状态', dataIndex: 'status', width: 90,
render: (v: string) => {
const s = alarmStatusMap[v] || { color: 'default', text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '实际值', dataIndex: 'value', width: 100,
render: (v: number) => v != null ? v.toFixed(2) : '-',
},
{
title: '阈值', dataIndex: 'threshold', width: 100,
render: (v: number) => v != null ? v.toFixed(2) : '-',
},
{
title: '解决时间', dataIndex: 'resolved_at', width: 170,
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-',
},
];
// ---- Render metric cards for realtime ----
const renderRealtimeMetrics = () => {
if (!realtimeData?.data) return <Empty description="暂无实时数据" />;
const entries = Object.entries(realtimeData.data) as [string, any][];
return (
<Row gutter={[16, 16]}>
{entries.map(([key, val]) => {
let icon = <DashboardOutlined />;
let color: string | undefined;
if (key.includes('power') || key.includes('功率')) { icon = <ThunderboltOutlined />; color = '#1890ff'; }
else if (key.includes('temp') || key.includes('温度')) { icon = <FireOutlined />; color = '#fa541c'; }
else if (key.includes('cop') || key.includes('COP')) { icon = <ExperimentOutlined />; color = '#52c41a'; }
return (
<Col xs={12} sm={8} md={6} key={key}>
<Card size="small" hoverable>
<Statistic
title={key}
value={val.value}
suffix={val.unit || ''}
prefix={icon}
valueStyle={color ? { color } : undefined}
/>
</Card>
</Col>
);
})}
</Row>
);
};
if (loading) {
return <div style={{ textAlign: 'center', padding: 100 }}><Spin size="large" tip="加载中..." /></div>;
}
if (!device) {
return <Empty description="设备不存在" style={{ padding: 100 }}>
<Button type="primary" onClick={() => navigate('/devices')}></Button>
</Empty>;
}
const st = statusMap[device.status] || { color: 'default', text: device.status || '-' };
return (
<div>
{/* Header */}
<Card size="small" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
<img
src={getDevicePhoto(device.device_type)}
alt={device.name}
style={{ width: 160, height: 120, borderRadius: 12, objectFit: 'cover', border: '1px solid #f0f0f0', flexShrink: 0 }}
/>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<h2 style={{ margin: 0, fontSize: 20 }}>{device.name}</h2>
<Tag color={st.color}>{st.text}</Tag>
<Tag>{device.code}</Tag>
</div>
<Row gutter={[24, 8]}>
<Col span={6}><span style={{ color: '#999' }}></span>{device.model || '-'}</Col>
<Col span={6}><span style={{ color: '#999' }}></span>{device.manufacturer || '-'}</Col>
<Col span={6}><span style={{ color: '#999' }}></span>{device.rated_power != null ? `${device.rated_power} kW` : '-'}</Col>
<Col span={6}><span style={{ color: '#999' }}></span>{device.location || '-'}</Col>
<Col span={6}><span style={{ color: '#999' }}></span>{protocolLabels[device.protocol] || device.protocol || '-'}</Col>
<Col span={6}><span style={{ color: '#999' }}></span>{device.collect_interval ? `${device.collect_interval}s` : '-'}</Col>
<Col span={6}><span style={{ color: '#999' }}></span>{device.last_data_time || '-'}</Col>
</Row>
</div>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/devices')}>
</Button>
</div>
</Card>
{/* Tabs */}
<Card size="small">
<Tabs activeKey={activeTab} onChange={setActiveTab} items={[
{
key: 'realtime',
label: '实时数据',
children: (
<Spin spinning={realtimeLoading}>
<div style={{ marginBottom: 16 }}>
<Button icon={<ReloadOutlined />} onClick={loadRealtime} size="small"></Button>
<span style={{ marginLeft: 12, color: '#999', fontSize: 12 }}>15</span>
</div>
{renderRealtimeMetrics()}
</Spin>
),
},
{
key: 'history',
label: '历史趋势',
children: (
<div>
<Space wrap style={{ marginBottom: 16 }}>
<Select
value={dataType}
onChange={setDataType}
options={dataTypeOptions}
style={{ width: 150 }}
/>
<Select
value={granularity}
onChange={setGranularity}
options={granularityOptions}
style={{ width: 120 }}
/>
{timeRangePresets.map(p => (
<Button
key={p.value}
type={timePreset === p.value ? 'primary' : 'default'}
size="small"
onClick={() => handleTimePreset(p.value)}
>
{p.label}
</Button>
))}
<RangePicker
showTime
value={timeRange}
onChange={handleRangeChange}
size="small"
/>
<Button icon={<ReloadOutlined />} size="small" onClick={loadHistory}></Button>
</Space>
<Spin spinning={historyLoading}>
{historyData.length > 0 ? (
<ReactECharts option={getChartOption()} style={{ height: 400 }} />
) : (
<Empty description="暂无历史数据" style={{ padding: 60 }} />
)}
</Spin>
</div>
),
},
{
key: 'alarms',
label: '告警记录',
children: (
<Table
columns={alarmColumns}
dataSource={alarmData.items}
rowKey="id"
loading={alarmLoading}
size="small"
pagination={{
current: alarmPage,
pageSize: 20,
total: alarmData.total,
showTotal: (total: number) => `${total} 条告警`,
onChange: (page: number) => setAlarmPage(page),
}}
/>
),
},
{
key: 'info',
label: '设备信息',
children: (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="设备名称">{device.name}</Descriptions.Item>
<Descriptions.Item label="设备编号">{device.code}</Descriptions.Item>
<Descriptions.Item label="设备类型">{device.device_type || '-'}</Descriptions.Item>
<Descriptions.Item label="设备分组ID">{device.group_id || '-'}</Descriptions.Item>
<Descriptions.Item label="型号">{device.model || '-'}</Descriptions.Item>
<Descriptions.Item label="厂商">{device.manufacturer || '-'}</Descriptions.Item>
<Descriptions.Item label="序列号">{device.serial_number || '-'}</Descriptions.Item>
<Descriptions.Item label="额定功率">{device.rated_power != null ? `${device.rated_power} kW` : '-'}</Descriptions.Item>
<Descriptions.Item label="位置">{device.location || '-'}</Descriptions.Item>
<Descriptions.Item label="通信协议">{protocolLabels[device.protocol] || device.protocol || '-'}</Descriptions.Item>
<Descriptions.Item label="采集间隔">{device.collect_interval ? `${device.collect_interval}` : '-'}</Descriptions.Item>
<Descriptions.Item label="状态">
<Badge status={device.status === 'online' ? 'success' : device.status === 'alarm' ? 'error' : 'default'}
text={st.text} />
</Descriptions.Item>
<Descriptions.Item label="是否启用">
<Tag color={device.is_active ? 'green' : 'default'}>{device.is_active ? '启用' : '停用'}</Tag>
</Descriptions.Item>
<Descriptions.Item label="最近数据时间">{device.last_data_time || '-'}</Descriptions.Item>
<Descriptions.Item label="连接参数" span={2}>
{device.connection_params ? (
<pre style={{ margin: 0, fontSize: 12, background: '#f5f5f5', padding: 8, borderRadius: 4, maxHeight: 200, overflow: 'auto' }}>
{JSON.stringify(device.connection_params, null, 2)}
</pre>
) : '-'}
</Descriptions.Item>
</Descriptions>
),
},
]} />
</Card>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber, Space, Row, Col, Statistic, Switch, message } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, WarningOutlined, AppstoreOutlined } from '@ant-design/icons';
import { getDevices, getDeviceTypes, getDeviceGroups, getDeviceStats, createDevice, updateDevice } from '../../services/api';
@@ -22,6 +23,7 @@ const protocolOptions = [
];
export default function Devices() {
const navigate = useNavigate();
const [data, setData] = useState<any>({ total: 0, items: [] });
const [stats, setStats] = useState<any>({ online: 0, offline: 0, alarm: 0, total: 0 });
const [deviceTypes, setDeviceTypes] = useState<any[]>([]);
@@ -118,7 +120,9 @@ export default function Devices() {
{ title: '', dataIndex: 'device_type', width: 50, render: (_: any, record: any) => (
<img src={getDevicePhoto(record.device_type || record.device_type_id)} alt="" style={{ width: 40, height: 40, borderRadius: 8, objectFit: 'cover' }} />
)},
{ title: '设备名称', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '设备名称', dataIndex: 'name', width: 160, ellipsis: true, render: (name: string, record: any) => (
<a onClick={() => navigate(`/devices/${record.id}`)}>{name}</a>
)},
{ title: '设备编号', dataIndex: 'code', width: 130 },
{ title: '设备类型', dataIndex: 'device_type_name', width: 120, render: (v: string) => v ? <Tag icon={<AppstoreOutlined />} color="blue">{v}</Tag> : '-' },
{ title: '设备分组', dataIndex: 'device_group_name', width: 120 },

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Card, Table, Tag, Input, Select, Descriptions, Modal, Badge, message } from 'antd';
import { getDevices, getDeviceRealtime } from '../../services/api';
import { useNavigate } from 'react-router-dom';
import { Card, Table, Tag, Input, Select, message } from 'antd';
import { getDevices } from '../../services/api';
import { getDevicePhoto } from '../../utils/devicePhoto';
const statusMap: Record<string, { color: string; text: string }> = {
@@ -16,10 +17,9 @@ const typeMap: Record<string, string> = {
};
export default function Monitoring() {
const navigate = useNavigate();
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(() => {
@@ -36,14 +36,6 @@ export default function Monitoring() {
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;
@@ -64,7 +56,7 @@ export default function Monitoring() {
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> },
{ title: '操作', key: 'action', render: (_: any, r: any) => <a onClick={() => navigate(`/devices/${r.id}`)}></a> },
];
return (
@@ -82,35 +74,6 @@ export default function Monitoring() {
loading={loading} size="small" pagination={{ pageSize: 15 }} />
</Card>
<Modal title={selectedDevice?.name} open={!!selectedDevice} onCancel={() => setSelectedDevice(null)}
footer={null} width={700}>
{deviceData?.device && (
<>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<img src={getDevicePhoto(deviceData.device.device_type)} alt={deviceData.device.name}
style={{ width: 200, height: 150, borderRadius: 12, objectFit: 'cover', border: '1px solid #f0f0f0' }} />
</div>
<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,174 @@
import { useEffect, useState } from 'react';
import { Card, Table, Tag, Select, DatePicker, Space, Input } from 'antd';
import { getAuditLogs, getUsers } from '../../services/api';
import dayjs from 'dayjs';
const { RangePicker } = DatePicker;
const ACTION_COLORS: Record<string, string> = {
login: 'blue',
create: 'green',
update: 'orange',
delete: 'red',
export: 'purple',
view: 'default',
acknowledge: 'cyan',
resolve: 'geekblue',
};
const ACTION_LABELS: Record<string, string> = {
login: '登录',
create: '创建',
update: '更新',
delete: '删除',
export: '导出',
view: '查看',
acknowledge: '确认',
resolve: '解决',
};
const RESOURCE_LABELS: Record<string, string> = {
user: '用户',
device: '设备',
alarm: '告警',
report: '报表',
system: '系统',
auth: '认证',
};
export default function AuditLog() {
const [data, setData] = useState<{ total: number; items: any[] }>({ total: 0, items: [] });
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<Record<string, any>>({
page: 1,
page_size: 20,
});
useEffect(() => {
loadUsers();
}, []);
useEffect(() => {
loadData();
}, [filters]);
const loadUsers = async () => {
try {
const res = await getUsers({ page_size: 100 });
setUsers((res as any)?.items || []);
} catch {
// ignore
}
};
const loadData = async () => {
setLoading(true);
try {
const res = await getAuditLogs(filters);
setData(res as any);
} catch {
// ignore
} finally {
setLoading(false);
}
};
const columns = [
{
title: '时间',
dataIndex: 'created_at',
width: 180,
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{
title: '用户',
dataIndex: 'username',
width: 120,
},
{
title: '操作',
dataIndex: 'action',
width: 100,
render: (v: string) => (
<Tag color={ACTION_COLORS[v] || 'default'}>{ACTION_LABELS[v] || v}</Tag>
),
},
{
title: '资源',
dataIndex: 'resource',
width: 100,
render: (v: string) => RESOURCE_LABELS[v] || v || '-',
},
{
title: '详情',
dataIndex: 'detail',
ellipsis: true,
},
{
title: 'IP地址',
dataIndex: 'ip_address',
width: 140,
render: (v: string) => v || '-',
},
];
return (
<Card
title="审计日志"
size="small"
extra={
<Space wrap>
<Select
placeholder="用户"
allowClear
style={{ width: 140 }}
options={users.map((u: any) => ({ label: u.full_name || u.username, value: u.id }))}
onChange={(v) => setFilters({ ...filters, user_id: v, page: 1 })}
/>
<Select
placeholder="操作类型"
allowClear
style={{ width: 120 }}
options={Object.entries(ACTION_LABELS).map(([k, v]) => ({ label: v, value: k }))}
onChange={(v) => setFilters({ ...filters, action: v, page: 1 })}
/>
<Select
placeholder="资源类型"
allowClear
style={{ width: 120 }}
options={Object.entries(RESOURCE_LABELS).map(([k, v]) => ({ label: v, value: k }))}
onChange={(v) => setFilters({ ...filters, resource: v, page: 1 })}
/>
<RangePicker
showTime
onChange={(dates) => {
setFilters({
...filters,
start_time: dates?.[0]?.toISOString(),
end_time: dates?.[1]?.toISOString(),
page: 1,
});
}}
/>
</Space>
}
>
<Table
columns={columns}
dataSource={data.items}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: filters.page,
pageSize: filters.page_size,
total: data.total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => setFilters({ ...filters, page, page_size: pageSize }),
}}
/>
</Card>
);
}

View File

@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { Card, Form, Input, InputNumber, Switch, Select, Button, message, Spin, Row, Col } from 'antd';
import { SaveOutlined } from '@ant-design/icons';
import { getSettings, updateSettings } from '../../services/api';
import { getUser } from '../../utils/auth';
const TIMEZONE_OPTIONS = [
{ label: 'Asia/Shanghai (UTC+8)', value: 'Asia/Shanghai' },
{ label: 'Asia/Tokyo (UTC+9)', value: 'Asia/Tokyo' },
{ label: 'Asia/Singapore (UTC+8)', value: 'Asia/Singapore' },
{ label: 'UTC', value: 'UTC' },
{ label: 'America/New_York (UTC-5)', value: 'America/New_York' },
{ label: 'Europe/London (UTC+0)', value: 'Europe/London' },
];
export default function SystemSettings() {
const [form] = Form.useForm();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const user = getUser();
const isAdmin = user?.role === 'admin';
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
setLoading(true);
try {
const res = await getSettings();
form.setFieldsValue(res as any);
} catch {
message.error('加载设置失败');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await updateSettings(values);
message.success('设置已保存');
} catch (e: any) {
if (e?.detail) message.error(e.detail);
} finally {
setSaving(false);
}
};
if (loading) return <Spin style={{ display: 'block', margin: '80px auto' }} />;
return (
<Form form={form} layout="vertical" disabled={!isAdmin}>
<Row gutter={16}>
<Col xs={24} lg={12}>
<Card title="基本设置" size="small" style={{ marginBottom: 16 }}>
<Form.Item name="platform_name" label="平台名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="timezone" label="时区">
<Select options={TIMEZONE_OPTIONS} />
</Form.Item>
</Card>
<Card title="数据管理" size="small" style={{ marginBottom: 16 }}>
<Form.Item name="data_retention_days" label="数据保留天数">
<InputNumber min={30} max={3650} addonAfter="天" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="simulator_interval_seconds" label="模拟器采集间隔">
<InputNumber min={5} max={300} addonAfter="秒" style={{ width: '100%' }} />
</Form.Item>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="告警设置" size="small" style={{ marginBottom: 16 }}>
<Form.Item name="alarm_auto_resolve_minutes" label="告警自动解除时间">
<InputNumber min={5} max={1440} addonAfter="分钟" style={{ width: '100%' }} />
</Form.Item>
</Card>
<Card title="通知设置" size="small" style={{ marginBottom: 16 }}>
<Form.Item name="notification_email_enabled" label="邮件通知" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="notification_email_smtp" label="SMTP 服务器">
<Input placeholder="smtp.example.com:465" />
</Form.Item>
</Card>
<Card title="报表设置" size="small" style={{ marginBottom: 16 }}>
<Form.Item name="report_auto_schedule_enabled" label="自动生成报表" valuePropName="checked">
<Switch />
</Form.Item>
</Card>
</Col>
</Row>
{isAdmin && (
<div style={{ textAlign: 'right', marginTop: 8 }}>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
</Button>
</div>
)}
</Form>
);
}

View File

@@ -1,7 +1,10 @@
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 { useNavigate, useLocation } from 'react-router-dom';
import { getUsers, createUser, updateUser, getRoles } from '../../services/api';
import AuditLog from './AuditLog';
import SystemSettings from './Settings';
export default function SystemManagement() {
const [users, setUsers] = useState<any>({ total: 0, items: [] });
@@ -10,6 +13,18 @@ export default function SystemManagement() {
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<any>(null);
const [form] = Form.useForm();
const navigate = useNavigate();
const location = useLocation();
// Derive active tab from URL path
const pathSegment = location.pathname.split('/').pop() || 'users';
const tabKeyMap: Record<string, string> = {
users: 'users',
roles: 'roles',
settings: 'settings',
audit: 'audit',
};
const activeTab = tabKeyMap[pathSegment] || 'users';
useEffect(() => { loadData(); }, []);
@@ -70,21 +85,31 @@ export default function SystemManagement() {
{ title: '描述', dataIndex: 'description' },
];
const handleTabChange = (key: string) => {
navigate(`/system/${key}`);
};
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>
)},
]} />
<Tabs
activeKey={activeTab}
onChange={handleTabChange}
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>
)},
{ key: 'settings', label: '系统设置', children: <SystemSettings /> },
{ key: 'audit', label: '审计日志', children: <AuditLog /> },
]}
/>
<Modal title={editingUser ? '编辑用户' : '新建用户'} open={showModal}
onCancel={() => { setShowModal(false); setEditingUser(null); }}
onOk={() => form.submit()} okText="确定">

View File

@@ -131,4 +131,11 @@ 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');
// Audit Logs
export const getAuditLogs = (params?: Record<string, any>) => api.get('/audit/logs', { params });
// System Settings
export const getSettings = () => api.get('/settings');
export const updateSettings = (data: any) => api.put('/settings', data);
export default api;

View File

@@ -14,7 +14,7 @@ from app.models.user import User, Role
from app.models.device import Device, DeviceType, DeviceGroup
from app.models.alarm import AlarmRule, AlarmEvent
from app.models.carbon import EmissionFactor
from app.models.report import ReportTemplate
from app.models.report import ReportTemplate, ReportTask
async def seed():
@@ -283,7 +283,8 @@ async def seed():
threshold=0.0,
duration=3600,
severity="critical",
notify_channels=["app", "sms", "wechat"],
notify_channels=["app", "sms", "wechat", "email"],
notify_targets={"emails": ["admin@tianpu.com", "energy@tianpu.com"]},
is_active=True,
),
AlarmRule(
@@ -294,7 +295,8 @@ async def seed():
threshold=38.0,
duration=300,
severity="critical",
notify_channels=["app", "sms"],
notify_channels=["app", "sms", "email"],
notify_targets={"emails": ["admin@tianpu.com"]},
is_active=True,
),
AlarmRule(
@@ -376,6 +378,24 @@ async def seed():
await session.flush()
# =====================================================================
# 8b. 定时报表任务 (daily report at 8am)
# =====================================================================
report_tasks = [
ReportTask(
template_id=1, # 日报模板
name="每日能源运行日报",
schedule="0 8 * * *",
recipients=["admin@tianpu.com"],
export_format="xlsx",
status="pending",
is_active=True,
created_by=1,
),
]
session.add_all(report_tasks)
await session.flush()
# =====================================================================
# 9. 历史告警事件 (15 events over last 7 days)
# =====================================================================
@@ -545,6 +565,7 @@ async def seed():
print(f" - {len(alarm_rules)} alarm rules")
print(f" - {len(factors)} emission factors")
print(f" - {len(templates)} report templates")
print(f" - {len(report_tasks)} report tasks (scheduled)")
print(f" - {len(alarm_events)} alarm events (historical)")
await engine.dispose()