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')}}
setCollapsed(!collapsed)}> @@ -130,17 +140,34 @@ export default function MainLayout() { }
+ + ({ label: u.full_name || u.username, value: u.id }))} + onChange={(v) => setFilters({ ...filters, user_id: v, page: 1 })} + /> + ({ label: v, value: k }))} + onChange={(v) => setFilters({ ...filters, resource: v, page: 1 })} + /> + { + setFilters({ + ...filters, + start_time: dates?.[0]?.toISOString(), + end_time: dates?.[1]?.toISOString(), + page: 1, + }); + }} + /> + + } + > + `共 ${total} 条`, + onChange: (page, pageSize) => setFilters({ ...filters, page, page_size: pageSize }), + }} + /> + + ); +} diff --git a/frontend/src/pages/System/Settings.tsx b/frontend/src/pages/System/Settings.tsx new file mode 100644 index 0000000..5c9e014 --- /dev/null +++ b/frontend/src/pages/System/Settings.tsx @@ -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 ; + + return ( +
+ +
+ + + + + + + + + + + + + + + + + + {isAdmin && ( +
+ +
+ )} + + ); +} diff --git a/frontend/src/pages/System/index.tsx b/frontend/src/pages/System/index.tsx index ac60bde..9b05b51 100644 --- a/frontend/src/pages/System/index.tsx +++ b/frontend/src/pages/System/index.tsx @@ -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({ total: 0, items: [] }); @@ -10,6 +13,18 @@ export default function SystemManagement() { const [showModal, setShowModal] = useState(false); const [editingUser, setEditingUser] = useState(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 = { + 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 ( <> - } - onClick={() => { setEditingUser(null); form.resetFields(); setShowModal(true); }}>新建用户}> -
- - )}, - { key: 'roles', label: '角色管理', children: ( - -
- - )}, - ]} /> + } + onClick={() => { setEditingUser(null); form.resetFields(); setShowModal(true); }}>新建用户}> +
+ + )}, + { key: 'roles', label: '角色管理', children: ( + +
+ + )}, + { key: 'settings', label: '系统设置', children: }, + { key: 'audit', label: '审计日志', children: }, + ]} + /> { setShowModal(false); setEditingUser(null); }} onOk={() => form.submit()} okText="确定"> diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f0d5aaf..a17e43e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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) => 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; diff --git a/scripts/seed_data.py b/scripts/seed_data.py index a659b66..f165355 100644 --- a/scripts/seed_data.py +++ b/scripts/seed_data.py @@ -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()