diff --git a/backend/alembic/versions/002_add_system_settings.py b/backend/alembic/versions/002_add_system_settings.py
new file mode 100644
index 0000000..850e03a
--- /dev/null
+++ b/backend/alembic/versions/002_add_system_settings.py
@@ -0,0 +1,41 @@
+"""Add system_settings table
+
+Revision ID: 002_system_settings
+Revises: 001_initial
+Create Date: 2026-04-02
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = "002_system_settings"
+down_revision = "001_initial"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "system_settings",
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
+ sa.Column("key", sa.String(100), unique=True, nullable=False, index=True),
+ sa.Column("value", sa.Text, nullable=False, server_default=""),
+ sa.Column("description", sa.String(255)),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ )
+
+ # Seed default settings
+ op.execute("""
+ INSERT INTO system_settings (key, value, description) VALUES
+ ('platform_name', '天普零碳园区智慧能源管理平台', '平台名称'),
+ ('data_retention_days', '365', '数据保留天数'),
+ ('alarm_auto_resolve_minutes', '30', '告警自动解除时间(分钟)'),
+ ('simulator_interval_seconds', '15', '模拟器采集间隔(秒)'),
+ ('notification_email_enabled', 'false', '是否启用邮件通知'),
+ ('notification_email_smtp', '', 'SMTP服务器地址'),
+ ('report_auto_schedule_enabled', 'false', '是否启用自动报表'),
+ ('timezone', 'Asia/Shanghai', '系统时区')
+ """)
+
+
+def downgrade() -> None:
+ op.drop_table("system_settings")
diff --git a/backend/app/api/router.py b/backend/app/api/router.py
index 4303105..aad9ae6 100644
--- a/backend/app/api/router.py
+++ b/backend/app/api/router.py
@@ -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)
diff --git a/backend/app/api/v1/alarms.py b/backend/app/api/v1/alarms.py
index 7023fdb..aaf6722 100644
--- a/backend/app/api/v1/alarms.py
+++ b/backend/app/api/v1/alarms.py
@@ -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": "已解决"}
diff --git a/backend/app/api/v1/audit.py b/backend/app/api/v1/audit.py
new file mode 100644
index 0000000..7a13178
--- /dev/null
+++ b/backend/app/api/v1/audit.py
@@ -0,0 +1,76 @@
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func
+from datetime import datetime
+from app.core.database import get_db
+from app.core.deps import get_current_user, require_roles
+from app.models.user import User, AuditLog
+
+router = APIRouter(prefix="/audit", tags=["审计日志"])
+
+
+@router.get("/logs")
+async def list_audit_logs(
+ user_id: int | None = None,
+ action: str | None = None,
+ resource: str | None = None,
+ start_time: str | None = None,
+ end_time: str | None = None,
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(require_roles("admin", "energy_manager")),
+):
+ """Return paginated audit logs with optional filters."""
+ query = select(
+ AuditLog.id,
+ AuditLog.user_id,
+ User.username,
+ AuditLog.action,
+ AuditLog.resource,
+ AuditLog.detail,
+ AuditLog.ip_address,
+ AuditLog.created_at,
+ ).outerjoin(User, AuditLog.user_id == User.id)
+
+ if user_id is not None:
+ query = query.where(AuditLog.user_id == user_id)
+ if action:
+ query = query.where(AuditLog.action == action)
+ if resource:
+ query = query.where(AuditLog.resource == resource)
+ if start_time:
+ try:
+ st = datetime.fromisoformat(start_time)
+ query = query.where(AuditLog.created_at >= st)
+ except ValueError:
+ pass
+ if end_time:
+ try:
+ et = datetime.fromisoformat(end_time)
+ query = query.where(AuditLog.created_at <= et)
+ except ValueError:
+ pass
+
+ # Count
+ count_q = select(func.count()).select_from(query.subquery())
+ total = (await db.execute(count_q)).scalar()
+
+ # Paginate
+ query = query.order_by(AuditLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
+ result = await db.execute(query)
+
+ items = []
+ for row in result.all():
+ items.append({
+ "id": row.id,
+ "user_id": row.user_id,
+ "username": row.username or "-",
+ "action": row.action,
+ "resource": row.resource,
+ "detail": row.detail,
+ "ip_address": row.ip_address,
+ "created_at": str(row.created_at) if row.created_at else None,
+ })
+
+ return {"total": total, "items": items}
diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py
index 5936659..38bdf90 100644
--- a/backend/app/api/v1/auth.py
+++ b/backend/app/api/v1/auth.py
@@ -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}
diff --git a/backend/app/api/v1/devices.py b/backend/app/api/v1/devices.py
index 383cd90..4397b60 100644
--- a/backend/app/api/v1/devices.py
+++ b/backend/app/api/v1/devices.py
@@ -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)
diff --git a/backend/app/api/v1/reports.py b/backend/app/api/v1/reports.py
index 9e0ac14..411b7ca 100644
--- a/backend/app/api/v1/reports.py
+++ b/backend/app/api/v1/reports.py
@@ -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,
diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py
new file mode 100644
index 0000000..fcede7e
--- /dev/null
+++ b/backend/app/api/v1/settings.py
@@ -0,0 +1,84 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from pydantic import BaseModel
+from app.core.database import get_db
+from app.core.deps import get_current_user, require_roles
+from app.models.user import User
+from app.models.setting import SystemSetting
+from app.services.audit import log_audit
+
+router = APIRouter(prefix="/settings", tags=["系统设置"])
+
+# Default settings — used when keys are missing from DB
+DEFAULTS: dict[str, str] = {
+ "platform_name": "天普零碳园区智慧能源管理平台",
+ "data_retention_days": "365",
+ "alarm_auto_resolve_minutes": "30",
+ "simulator_interval_seconds": "15",
+ "notification_email_enabled": "false",
+ "notification_email_smtp": "",
+ "report_auto_schedule_enabled": "false",
+ "timezone": "Asia/Shanghai",
+}
+
+
+class SettingsUpdate(BaseModel):
+ platform_name: str | None = None
+ data_retention_days: int | None = None
+ alarm_auto_resolve_minutes: int | None = None
+ simulator_interval_seconds: int | None = None
+ notification_email_enabled: bool | None = None
+ notification_email_smtp: str | None = None
+ report_auto_schedule_enabled: bool | None = None
+ timezone: str | None = None
+
+
+@router.get("")
+async def get_settings(
+ db: AsyncSession = Depends(get_db),
+ user: User = Depends(get_current_user),
+):
+ """Return all platform settings as a flat dict."""
+ result = await db.execute(select(SystemSetting))
+ db_settings = {s.key: s.value for s in result.scalars().all()}
+ # Merge defaults with DB values
+ merged = {**DEFAULTS, **db_settings}
+ # Cast types for frontend
+ return {
+ "platform_name": merged["platform_name"],
+ "data_retention_days": int(merged["data_retention_days"]),
+ "alarm_auto_resolve_minutes": int(merged["alarm_auto_resolve_minutes"]),
+ "simulator_interval_seconds": int(merged["simulator_interval_seconds"]),
+ "notification_email_enabled": merged["notification_email_enabled"] == "true",
+ "notification_email_smtp": merged["notification_email_smtp"],
+ "report_auto_schedule_enabled": merged["report_auto_schedule_enabled"] == "true",
+ "timezone": merged["timezone"],
+ }
+
+
+@router.put("")
+async def update_settings(
+ data: SettingsUpdate,
+ db: AsyncSession = Depends(get_db),
+ user: User = Depends(require_roles("admin")),
+):
+ """Update platform settings (admin only)."""
+ updates = data.model_dump(exclude_unset=True)
+ changed_keys = []
+ for key, value in updates.items():
+ str_value = str(value).lower() if isinstance(value, bool) else str(value)
+ result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
+ setting = result.scalar_one_or_none()
+ if setting:
+ setting.value = str_value
+ else:
+ db.add(SystemSetting(key=key, value=str_value))
+ changed_keys.append(key)
+
+ await log_audit(
+ db, user.id, "update", "system",
+ detail=f"更新系统设置: {', '.join(changed_keys)}",
+ )
+
+ return {"message": "设置已更新"}
diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py
index 04f60aa..1cb6b39 100644
--- a/backend/app/api/v1/users.py
+++ b/backend/app/api/v1/users.py
@@ -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": "已更新"}
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 8d191f5..02a215a 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -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."""
diff --git a/backend/app/main.py b/backend/app/main.py
index 6c44814..77c80d5 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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()
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 64d9395..d340bc6 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -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",
]
diff --git a/backend/app/models/setting.py b/backend/app/models/setting.py
new file mode 100644
index 0000000..ac0c89e
--- /dev/null
+++ b/backend/app/models/setting.py
@@ -0,0 +1,13 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime
+from sqlalchemy.sql import func
+from app.core.database import Base
+
+
+class SystemSetting(Base):
+ __tablename__ = "system_settings"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ key = Column(String(100), unique=True, nullable=False, index=True)
+ value = Column(Text, nullable=False, default="")
+ description = Column(String(255))
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
diff --git a/backend/app/services/alarm_checker.py b/backend/app/services/alarm_checker.py
index 2309b29..fdb7678 100644
--- a/backend/app/services/alarm_checker.py
+++ b/backend/app/services/alarm_checker.py
@@ -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"
diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py
new file mode 100644
index 0000000..feffea8
--- /dev/null
+++ b/backend/app/services/audit.py
@@ -0,0 +1,32 @@
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.models.user import AuditLog
+
+
+async def log_audit(
+ db: AsyncSession,
+ user_id: int | None,
+ action: str,
+ resource: str,
+ detail: str = "",
+ ip_address: str | None = None,
+):
+ """Write an audit log entry.
+
+ Args:
+ db: async database session (must be flushed/committed by caller)
+ user_id: ID of the acting user (None for system actions)
+ action: one of login, create, update, delete, export, view,
+ acknowledge, resolve
+ resource: one of user, device, alarm, report, system, auth
+ detail: human-readable description
+ ip_address: client IP if available
+ """
+ entry = AuditLog(
+ user_id=user_id,
+ action=action,
+ resource=resource,
+ detail=detail,
+ ip_address=ip_address,
+ )
+ db.add(entry)
+ # Don't flush here — let the caller's transaction handle it
diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py
new file mode 100644
index 0000000..bd3d1d4
--- /dev/null
+++ b/backend/app/services/email_service.py
@@ -0,0 +1,105 @@
+"""邮件发送服务 - SMTP email sending for alarm notifications and report delivery."""
+import logging
+import smtplib
+import ssl
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.mime.base import MIMEBase
+from email import encoders
+from pathlib import Path
+from typing import Optional
+
+from app.core.config import get_settings
+
+logger = logging.getLogger("email_service")
+
+
+async def send_email(
+ to: list[str],
+ subject: str,
+ body_html: str,
+ attachments: Optional[list[str]] = None,
+) -> bool:
+ """
+ Send an email via SMTP.
+
+ Args:
+ to: List of recipient email addresses.
+ subject: Email subject line.
+ body_html: HTML body content.
+ attachments: Optional list of file paths to attach.
+
+ Returns:
+ True if sent successfully, False otherwise.
+ """
+ settings = get_settings()
+
+ if not settings.SMTP_ENABLED:
+ logger.warning("SMTP is not enabled (SMTP_ENABLED=False). Skipping email send.")
+ return False
+
+ if not settings.SMTP_HOST:
+ logger.warning("SMTP_HOST is not configured. Skipping email send.")
+ return False
+
+ if not to:
+ logger.warning("No recipients specified. Skipping email send.")
+ return False
+
+ try:
+ msg = MIMEMultipart("mixed")
+ msg["From"] = settings.SMTP_FROM
+ msg["To"] = ", ".join(to)
+ msg["Subject"] = subject
+
+ # HTML body
+ html_part = MIMEText(body_html, "html", "utf-8")
+ msg.attach(html_part)
+
+ # Attachments
+ if attachments:
+ for filepath in attachments:
+ path = Path(filepath)
+ if not path.exists():
+ logger.warning(f"Attachment not found, skipping: {filepath}")
+ continue
+
+ with open(path, "rb") as f:
+ part = MIMEBase("application", "octet-stream")
+ part.set_payload(f.read())
+ encoders.encode_base64(part)
+ part.add_header(
+ "Content-Disposition",
+ f'attachment; filename="{path.name}"',
+ )
+ msg.attach(part)
+
+ # Send via SMTP
+ context = ssl.create_default_context()
+
+ if settings.SMTP_PORT == 465:
+ # SSL connection
+ with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, context=context) as server:
+ if settings.SMTP_USER and settings.SMTP_PASSWORD:
+ server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
+ server.sendmail(settings.SMTP_FROM, to, msg.as_string())
+ else:
+ # STARTTLS connection (port 587 or 25)
+ with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
+ server.ehlo()
+ if settings.SMTP_PORT == 587:
+ server.starttls(context=context)
+ server.ehlo()
+ if settings.SMTP_USER and settings.SMTP_PASSWORD:
+ server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
+ server.sendmail(settings.SMTP_FROM, to, msg.as_string())
+
+ logger.info(f"Email sent successfully to {to}, subject: {subject}")
+ return True
+
+ except smtplib.SMTPException as e:
+ logger.error(f"SMTP error sending email to {to}: {e}")
+ return False
+ except Exception as e:
+ logger.error(f"Unexpected error sending email to {to}: {e}")
+ return False
diff --git a/backend/app/services/report_scheduler.py b/backend/app/services/report_scheduler.py
new file mode 100644
index 0000000..f286ace
--- /dev/null
+++ b/backend/app/services/report_scheduler.py
@@ -0,0 +1,192 @@
+"""报表定时调度服务 - Schedule report tasks via APScheduler and send results by email."""
+import logging
+from datetime import date, timedelta, datetime, timezone
+
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+from apscheduler.triggers.cron import CronTrigger
+from sqlalchemy import select
+
+from app.core.database import async_session
+from app.models.report import ReportTask, ReportTemplate
+from app.services.report_generator import ReportGenerator
+from app.services.email_service import send_email
+
+logger = logging.getLogger("report_scheduler")
+
+_scheduler: AsyncIOScheduler | None = None
+
+
+def _parse_cron(cron_expr: str) -> dict:
+ """Parse a 5-field cron expression into APScheduler CronTrigger kwargs."""
+ parts = cron_expr.strip().split()
+ if len(parts) != 5:
+ raise ValueError(f"Invalid cron expression (need 5 fields): {cron_expr}")
+ return {
+ "minute": parts[0],
+ "hour": parts[1],
+ "day": parts[2],
+ "month": parts[3],
+ "day_of_week": parts[4],
+ }
+
+
+async def _run_report_task(task_id: int):
+ """Execute a single report task: generate the report and email it to recipients."""
+ logger.info(f"Running scheduled report task id={task_id}")
+
+ async with async_session() as session:
+ # Load task
+ task_result = await session.execute(
+ select(ReportTask).where(ReportTask.id == task_id)
+ )
+ task = task_result.scalar_one_or_none()
+ if not task:
+ logger.warning(f"Report task id={task_id} not found, skipping.")
+ return
+ if not task.is_active:
+ logger.info(f"Report task id={task_id} is inactive, skipping.")
+ return
+
+ # Update status
+ task.status = "running"
+ task.last_run = datetime.now(timezone.utc)
+ await session.flush()
+
+ # Load template to determine report type
+ tmpl_result = await session.execute(
+ select(ReportTemplate).where(ReportTemplate.id == task.template_id)
+ )
+ template = tmpl_result.scalar_one_or_none()
+ if not template:
+ logger.error(f"Template id={task.template_id} not found for task id={task_id}")
+ task.status = "failed"
+ await session.commit()
+ return
+
+ try:
+ generator = ReportGenerator(session)
+ today = date.today()
+ export_format = task.export_format or "xlsx"
+
+ # Choose generation method based on template report_type
+ if template.report_type == "daily":
+ yesterday = today - timedelta(days=1)
+ filepath = await generator.generate_energy_daily_report(
+ start_date=yesterday, end_date=yesterday, export_format=export_format
+ )
+ elif template.report_type == "monthly":
+ # Generate for previous month
+ first_of_month = today.replace(day=1)
+ last_month_end = first_of_month - timedelta(days=1)
+ last_month_start = last_month_end.replace(day=1)
+ filepath = await generator.generate_monthly_summary(
+ month=last_month_start.month,
+ year=last_month_start.year,
+ export_format=export_format,
+ )
+ elif template.report_type == "custom" and "device" in template.name.lower():
+ filepath = await generator.generate_device_status_report(
+ export_format=export_format
+ )
+ else:
+ # Default: daily report for yesterday
+ yesterday = today - timedelta(days=1)
+ filepath = await generator.generate_energy_daily_report(
+ start_date=yesterday, end_date=yesterday, export_format=export_format
+ )
+
+ task.file_path = filepath
+ task.status = "completed"
+ logger.info(f"Report task id={task_id} completed: {filepath}")
+
+ # Send email with attachment if recipients configured
+ recipients = task.recipients or []
+ if isinstance(recipients, list) and recipients:
+ report_name = task.name or template.name
+ subject = f"{report_name} - 天普EMS自动报表"
+ body_html = f"""
+
+
天普零碳园区智慧能源管理平台
+
您好,
+
系统已自动生成 {report_name},请查收附件。
+
+ 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+ 报表类型: {template.report_type}
+ 格式: {export_format.upper()}
+
+
+
此为系统自动发送,请勿回复。
+
+ """
+ await send_email(
+ to=recipients,
+ subject=subject,
+ body_html=body_html,
+ attachments=[filepath],
+ )
+
+ except Exception as e:
+ logger.error(f"Report task id={task_id} failed: {e}", exc_info=True)
+ task.status = "failed"
+
+ await session.commit()
+
+
+async def _load_and_schedule_tasks():
+ """Load all active report tasks with schedules and register them with APScheduler."""
+ global _scheduler
+ if not _scheduler:
+ return
+
+ async with async_session() as session:
+ result = await session.execute(
+ select(ReportTask).where(
+ ReportTask.is_active == True,
+ ReportTask.schedule != None,
+ ReportTask.schedule != "",
+ )
+ )
+ tasks = result.scalars().all()
+
+ for task in tasks:
+ try:
+ cron_kwargs = _parse_cron(task.schedule)
+ _scheduler.add_job(
+ _run_report_task,
+ CronTrigger(**cron_kwargs),
+ args=[task.id],
+ id=f"report_task_{task.id}",
+ replace_existing=True,
+ misfire_grace_time=3600,
+ )
+ logger.info(
+ f"Scheduled report task id={task.id} name='{task.name}' "
+ f"cron='{task.schedule}'"
+ )
+ except Exception as e:
+ logger.error(f"Failed to schedule report task id={task.id}: {e}")
+
+ logger.info(f"Report scheduler loaded {len(tasks)} task(s).")
+
+
+async def start_scheduler():
+ """Start the APScheduler-based report scheduler."""
+ global _scheduler
+ if _scheduler and _scheduler.running:
+ logger.warning("Report scheduler is already running.")
+ return
+
+ _scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
+ _scheduler.start()
+ logger.info("Report scheduler started.")
+
+ await _load_and_schedule_tasks()
+
+
+async def stop_scheduler():
+ """Stop the report scheduler gracefully."""
+ global _scheduler
+ if _scheduler and _scheduler.running:
+ _scheduler.shutdown(wait=False)
+ logger.info("Report scheduler stopped.")
+ _scheduler = None
diff --git a/backend/app/templates/alarm_email.html b/backend/app/templates/alarm_email.html
new file mode 100644
index 0000000..e2da3e0
--- /dev/null
+++ b/backend/app/templates/alarm_email.html
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ TIANPU EMS
+ 天普零碳园区智慧能源管理平台
+ |
+
+
+
+
+
+
+
+ |
+ {severity_label}
+ {title}
+ |
+
+
+ |
+
+
+
+
+
+
+
+ | 设备名称 |
+ {device_name} |
+
+
+ | 设备编号 |
+ {device_code} |
+
+
+ | 监控指标 |
+ {data_type} |
+
+
+ | 当前值 |
+ {current_value} |
+
+
+ | 告警阈值 |
+ {threshold_str} |
+
+
+ | 触发时间 |
+ {triggered_at} |
+
+
+ |
+
+
+
+
+ |
+
+ {description}
+
+ |
+
+
+
+
+ |
+ 查看告警详情
+ |
+
+
+
+
+ |
+
+ 此为系统自动发送,请勿回复。
+ 天普零碳园区智慧能源管理平台 © 2026
+
+ |
+
+
+
+ |
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index f82a37b..e26b189 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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",
diff --git a/frontend/package.json b/frontend/package.json
index b83f659..1a97f80 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 2755d6a..d89fa5b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 (
-
+
} />
@@ -34,6 +46,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
@@ -45,3 +58,11 @@ export default function App() {
);
}
+
+export default function App() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx
new file mode 100644
index 0000000..f20eb9e
--- /dev/null
+++ b/frontend/src/contexts/ThemeContext.tsx
@@ -0,0 +1,33 @@
+import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
+
+interface ThemeContextType {
+ darkMode: boolean;
+ toggleDarkMode: () => void;
+}
+
+const ThemeContext = createContext({
+ darkMode: false,
+ toggleDarkMode: () => {},
+});
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const [darkMode, setDarkMode] = useState(() => {
+ const saved = localStorage.getItem('tianpu-dark-mode');
+ return saved === 'true';
+ });
+
+ useEffect(() => {
+ localStorage.setItem('tianpu-dark-mode', String(darkMode));
+ document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
+ }, [darkMode]);
+
+ const toggleDarkMode = () => setDarkMode(prev => !prev);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const useTheme = () => useContext(ThemeContext);
diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts
new file mode 100644
index 0000000..99508f2
--- /dev/null
+++ b/frontend/src/i18n/index.ts
@@ -0,0 +1,18 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+import zh from './locales/zh.json';
+import en from './locales/en.json';
+
+i18n.use(initReactI18next).init({
+ resources: {
+ zh: { translation: zh },
+ en: { translation: en },
+ },
+ lng: localStorage.getItem('tianpu-lang') || 'zh',
+ fallbackLng: 'zh',
+ interpolation: {
+ escapeValue: false,
+ },
+});
+
+export default i18n;
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
new file mode 100644
index 0000000..2fdc79a
--- /dev/null
+++ b/frontend/src/i18n/locales/en.json
@@ -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"
+ }
+}
diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json
new file mode 100644
index 0000000..5a04165
--- /dev/null
+++ b/frontend/src/i18n/locales/zh.json
@@ -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": "选择日期范围"
+ }
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 683bf51..ccd2059 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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 {
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx
index c02b143..60d6aae 100644
--- a/frontend/src/layouts/MainLayout.tsx
+++ b/frontend/src/layouts/MainLayout.tsx
@@ -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: , label: '能源总览' },
- { key: '/monitoring', icon: , label: '实时监控' },
- { key: '/devices', icon: , label: '设备管理' },
- { key: '/analysis', icon: , label: '能耗分析' },
- { key: '/alarms', icon: , label: '告警管理' },
- { key: '/carbon', icon: , label: '碳排放管理' },
- { key: '/reports', icon: , label: '报表管理' },
- { key: 'bigscreen-group', icon: , label: '可视化大屏',
- children: [
- { key: '/bigscreen', icon: , label: '2D 能源大屏' },
- { key: '/bigscreen-3d', icon: , label: '3D 园区大屏' },
- ],
- },
- { key: '/system', icon: , label: '系统管理',
- children: [
- { key: '/system/users', label: '用户管理' },
- { key: '/system/roles', label: '角色权限' },
- { key: '/system/settings', label: '系统设置' },
- ],
- },
-];
-
const SEVERITY_CONFIG: Record = {
critical: { icon: , color: 'red' },
warning: { icon: , 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: , label: t('menu.dashboard') },
+ { key: '/monitoring', icon: , label: t('menu.monitoring') },
+ { key: '/devices', icon: , label: t('menu.devices') },
+ { key: '/analysis', icon: , label: t('menu.analysis') },
+ { key: '/alarms', icon: , label: t('menu.alarms') },
+ { key: '/carbon', icon: , label: t('menu.carbon') },
+ { key: '/reports', icon: , label: t('menu.reports') },
+ { key: 'bigscreen-group', icon: , label: t('menu.bigscreen'),
+ children: [
+ { key: '/bigscreen', icon: , label: t('menu.bigscreen2d') },
+ { key: '/bigscreen-3d', icon: , label: t('menu.bigscreen3d') },
+ ],
+ },
+ { key: '/system', icon: , label: t('menu.system'),
+ children: [
+ { key: '/system/users', label: t('menu.users') },
+ { key: '/system/roles', label: t('menu.roles') },
+ { key: '/system/settings', label: t('menu.settings') },
+ { key: '/system/audit', label: t('menu.audit', '审计日志') },
+ ],
+ },
+ ];
const fetchAlarms = useCallback(async () => {
try {
@@ -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: , label: '个人信息' },
+ { key: 'profile', icon: , label: t('header.profile') },
{ type: 'divider' as const },
- { key: 'logout', icon: , label: '退出登录', onClick: handleLogout },
+ { key: 'logout', icon: , label: t('header.logout'), onClick: handleLogout },
],
};
return (
+ style={{ background: darkMode ? '#141414' : '#001529' }}>
- {!collapsed && 天普EMS}
+ {!collapsed && {t('header.brandName')}}