feat: add system settings, audit log, device detail, dark mode, i18n, email notifications
System Management: - System Settings page with 8 configurable parameters (admin only) - Audit Log page with filterable table (user, action, resource, date range) - Audit logging wired into auth, devices, users, alarms, reports API handlers - SystemSetting model + migration (002) Device Detail: - Dedicated /devices/:id page with 4 tabs (realtime, historical trends, alarm history, device info) - ECharts historical charts with granularity/time range selectors - Device name clickable in Devices and Monitoring tables → navigates to detail Email & Scheduling: - Email service with SMTP support (STARTTLS/SSL/plain) - Alarm email notification with professional HTML template - Report scheduler using APScheduler for cron-based auto-generation - Scheduled report task seeded (daily at 8am) UI Enhancements: - Dark mode toggle (persisted to localStorage, Ant Design darkAlgorithm) - Data comparison view in Analysis page (dual date range, side-by-side metrics) - i18n framework (i18next) with zh/en translations for menu and common UI - Language switcher in header (中文/English) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
41
backend/alembic/versions/002_add_system_settings.py
Normal file
41
backend/alembic/versions/002_add_system_settings.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Add system_settings table
|
||||
|
||||
Revision ID: 002_system_settings
|
||||
Revises: 001_initial
|
||||
Create Date: 2026-04-02
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "002_system_settings"
|
||||
down_revision = "001_initial"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"system_settings",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("key", sa.String(100), unique=True, nullable=False, index=True),
|
||||
sa.Column("value", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("description", sa.String(255)),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# Seed default settings
|
||||
op.execute("""
|
||||
INSERT INTO system_settings (key, value, description) VALUES
|
||||
('platform_name', '天普零碳园区智慧能源管理平台', '平台名称'),
|
||||
('data_retention_days', '365', '数据保留天数'),
|
||||
('alarm_auto_resolve_minutes', '30', '告警自动解除时间(分钟)'),
|
||||
('simulator_interval_seconds', '15', '模拟器采集间隔(秒)'),
|
||||
('notification_email_enabled', 'false', '是否启用邮件通知'),
|
||||
('notification_email_smtp', '', 'SMTP服务器地址'),
|
||||
('report_auto_schedule_enabled', 'false', '是否启用自动报表'),
|
||||
('timezone', 'Asia/Shanghai', '系统时区')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("system_settings")
|
||||
@@ -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)
|
||||
|
||||
@@ -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": "已解决"}
|
||||
|
||||
|
||||
|
||||
76
backend/app/api/v1/audit.py
Normal file
76
backend/app/api/v1/audit.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from datetime import datetime
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.user import User, AuditLog
|
||||
|
||||
router = APIRouter(prefix="/audit", tags=["审计日志"])
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
async def list_audit_logs(
|
||||
user_id: int | None = None,
|
||||
action: str | None = None,
|
||||
resource: str | None = None,
|
||||
start_time: str | None = None,
|
||||
end_time: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
"""Return paginated audit logs with optional filters."""
|
||||
query = select(
|
||||
AuditLog.id,
|
||||
AuditLog.user_id,
|
||||
User.username,
|
||||
AuditLog.action,
|
||||
AuditLog.resource,
|
||||
AuditLog.detail,
|
||||
AuditLog.ip_address,
|
||||
AuditLog.created_at,
|
||||
).outerjoin(User, AuditLog.user_id == User.id)
|
||||
|
||||
if user_id is not None:
|
||||
query = query.where(AuditLog.user_id == user_id)
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
if resource:
|
||||
query = query.where(AuditLog.resource == resource)
|
||||
if start_time:
|
||||
try:
|
||||
st = datetime.fromisoformat(start_time)
|
||||
query = query.where(AuditLog.created_at >= st)
|
||||
except ValueError:
|
||||
pass
|
||||
if end_time:
|
||||
try:
|
||||
et = datetime.fromisoformat(end_time)
|
||||
query = query.where(AuditLog.created_at <= et)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Count
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
# Paginate
|
||||
query = query.order_by(AuditLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
|
||||
items = []
|
||||
for row in result.all():
|
||||
items.append({
|
||||
"id": row.id,
|
||||
"user_id": row.user_id,
|
||||
"username": row.username or "-",
|
||||
"action": row.action,
|
||||
"resource": row.resource,
|
||||
"detail": row.detail,
|
||||
"ip_address": row.ip_address,
|
||||
"created_at": str(row.created_at) if row.created_at else None,
|
||||
})
|
||||
|
||||
return {"total": total, "items": items}
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
84
backend/app/api/v1/settings.py
Normal file
84
backend/app/api/v1/settings.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.user import User
|
||||
from app.models.setting import SystemSetting
|
||||
from app.services.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["系统设置"])
|
||||
|
||||
# Default settings — used when keys are missing from DB
|
||||
DEFAULTS: dict[str, str] = {
|
||||
"platform_name": "天普零碳园区智慧能源管理平台",
|
||||
"data_retention_days": "365",
|
||||
"alarm_auto_resolve_minutes": "30",
|
||||
"simulator_interval_seconds": "15",
|
||||
"notification_email_enabled": "false",
|
||||
"notification_email_smtp": "",
|
||||
"report_auto_schedule_enabled": "false",
|
||||
"timezone": "Asia/Shanghai",
|
||||
}
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
platform_name: str | None = None
|
||||
data_retention_days: int | None = None
|
||||
alarm_auto_resolve_minutes: int | None = None
|
||||
simulator_interval_seconds: int | None = None
|
||||
notification_email_enabled: bool | None = None
|
||||
notification_email_smtp: str | None = None
|
||||
report_auto_schedule_enabled: bool | None = None
|
||||
timezone: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return all platform settings as a flat dict."""
|
||||
result = await db.execute(select(SystemSetting))
|
||||
db_settings = {s.key: s.value for s in result.scalars().all()}
|
||||
# Merge defaults with DB values
|
||||
merged = {**DEFAULTS, **db_settings}
|
||||
# Cast types for frontend
|
||||
return {
|
||||
"platform_name": merged["platform_name"],
|
||||
"data_retention_days": int(merged["data_retention_days"]),
|
||||
"alarm_auto_resolve_minutes": int(merged["alarm_auto_resolve_minutes"]),
|
||||
"simulator_interval_seconds": int(merged["simulator_interval_seconds"]),
|
||||
"notification_email_enabled": merged["notification_email_enabled"] == "true",
|
||||
"notification_email_smtp": merged["notification_email_smtp"],
|
||||
"report_auto_schedule_enabled": merged["report_auto_schedule_enabled"] == "true",
|
||||
"timezone": merged["timezone"],
|
||||
}
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def update_settings(
|
||||
data: SettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin")),
|
||||
):
|
||||
"""Update platform settings (admin only)."""
|
||||
updates = data.model_dump(exclude_unset=True)
|
||||
changed_keys = []
|
||||
for key, value in updates.items():
|
||||
str_value = str(value).lower() if isinstance(value, bool) else str(value)
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = str_value
|
||||
else:
|
||||
db.add(SystemSetting(key=key, value=str_value))
|
||||
changed_keys.append(key)
|
||||
|
||||
await log_audit(
|
||||
db, user.id, "update", "system",
|
||||
detail=f"更新系统设置: {', '.join(changed_keys)}",
|
||||
)
|
||||
|
||||
return {"message": "设置已更新"}
|
||||
@@ -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": "已更新"}
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
13
backend/app/models/setting.py
Normal file
13
backend/app/models/setting.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
key = Column(String(100), unique=True, nullable=False, index=True)
|
||||
value = Column(Text, nullable=False, default="")
|
||||
description = Column(String(255))
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
@@ -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"
|
||||
|
||||
32
backend/app/services/audit.py
Normal file
32
backend/app/services/audit.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.user import AuditLog
|
||||
|
||||
|
||||
async def log_audit(
|
||||
db: AsyncSession,
|
||||
user_id: int | None,
|
||||
action: str,
|
||||
resource: str,
|
||||
detail: str = "",
|
||||
ip_address: str | None = None,
|
||||
):
|
||||
"""Write an audit log entry.
|
||||
|
||||
Args:
|
||||
db: async database session (must be flushed/committed by caller)
|
||||
user_id: ID of the acting user (None for system actions)
|
||||
action: one of login, create, update, delete, export, view,
|
||||
acknowledge, resolve
|
||||
resource: one of user, device, alarm, report, system, auth
|
||||
detail: human-readable description
|
||||
ip_address: client IP if available
|
||||
"""
|
||||
entry = AuditLog(
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource=resource,
|
||||
detail=detail,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
db.add(entry)
|
||||
# Don't flush here — let the caller's transaction handle it
|
||||
105
backend/app/services/email_service.py
Normal file
105
backend/app/services/email_service.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""邮件发送服务 - SMTP email sending for alarm notifications and report delivery."""
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger("email_service")
|
||||
|
||||
|
||||
async def send_email(
|
||||
to: list[str],
|
||||
subject: str,
|
||||
body_html: str,
|
||||
attachments: Optional[list[str]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send an email via SMTP.
|
||||
|
||||
Args:
|
||||
to: List of recipient email addresses.
|
||||
subject: Email subject line.
|
||||
body_html: HTML body content.
|
||||
attachments: Optional list of file paths to attach.
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.SMTP_ENABLED:
|
||||
logger.warning("SMTP is not enabled (SMTP_ENABLED=False). Skipping email send.")
|
||||
return False
|
||||
|
||||
if not settings.SMTP_HOST:
|
||||
logger.warning("SMTP_HOST is not configured. Skipping email send.")
|
||||
return False
|
||||
|
||||
if not to:
|
||||
logger.warning("No recipients specified. Skipping email send.")
|
||||
return False
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart("mixed")
|
||||
msg["From"] = settings.SMTP_FROM
|
||||
msg["To"] = ", ".join(to)
|
||||
msg["Subject"] = subject
|
||||
|
||||
# HTML body
|
||||
html_part = MIMEText(body_html, "html", "utf-8")
|
||||
msg.attach(html_part)
|
||||
|
||||
# Attachments
|
||||
if attachments:
|
||||
for filepath in attachments:
|
||||
path = Path(filepath)
|
||||
if not path.exists():
|
||||
logger.warning(f"Attachment not found, skipping: {filepath}")
|
||||
continue
|
||||
|
||||
with open(path, "rb") as f:
|
||||
part = MIMEBase("application", "octet-stream")
|
||||
part.set_payload(f.read())
|
||||
encoders.encode_base64(part)
|
||||
part.add_header(
|
||||
"Content-Disposition",
|
||||
f'attachment; filename="{path.name}"',
|
||||
)
|
||||
msg.attach(part)
|
||||
|
||||
# Send via SMTP
|
||||
context = ssl.create_default_context()
|
||||
|
||||
if settings.SMTP_PORT == 465:
|
||||
# SSL connection
|
||||
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, context=context) as server:
|
||||
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.sendmail(settings.SMTP_FROM, to, msg.as_string())
|
||||
else:
|
||||
# STARTTLS connection (port 587 or 25)
|
||||
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
||||
server.ehlo()
|
||||
if settings.SMTP_PORT == 587:
|
||||
server.starttls(context=context)
|
||||
server.ehlo()
|
||||
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.sendmail(settings.SMTP_FROM, to, msg.as_string())
|
||||
|
||||
logger.info(f"Email sent successfully to {to}, subject: {subject}")
|
||||
return True
|
||||
|
||||
except smtplib.SMTPException as e:
|
||||
logger.error(f"SMTP error sending email to {to}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error sending email to {to}: {e}")
|
||||
return False
|
||||
192
backend/app/services/report_scheduler.py
Normal file
192
backend/app/services/report_scheduler.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""报表定时调度服务 - Schedule report tasks via APScheduler and send results by email."""
|
||||
import logging
|
||||
from datetime import date, timedelta, datetime, timezone
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import async_session
|
||||
from app.models.report import ReportTask, ReportTemplate
|
||||
from app.services.report_generator import ReportGenerator
|
||||
from app.services.email_service import send_email
|
||||
|
||||
logger = logging.getLogger("report_scheduler")
|
||||
|
||||
_scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
|
||||
def _parse_cron(cron_expr: str) -> dict:
|
||||
"""Parse a 5-field cron expression into APScheduler CronTrigger kwargs."""
|
||||
parts = cron_expr.strip().split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError(f"Invalid cron expression (need 5 fields): {cron_expr}")
|
||||
return {
|
||||
"minute": parts[0],
|
||||
"hour": parts[1],
|
||||
"day": parts[2],
|
||||
"month": parts[3],
|
||||
"day_of_week": parts[4],
|
||||
}
|
||||
|
||||
|
||||
async def _run_report_task(task_id: int):
|
||||
"""Execute a single report task: generate the report and email it to recipients."""
|
||||
logger.info(f"Running scheduled report task id={task_id}")
|
||||
|
||||
async with async_session() as session:
|
||||
# Load task
|
||||
task_result = await session.execute(
|
||||
select(ReportTask).where(ReportTask.id == task_id)
|
||||
)
|
||||
task = task_result.scalar_one_or_none()
|
||||
if not task:
|
||||
logger.warning(f"Report task id={task_id} not found, skipping.")
|
||||
return
|
||||
if not task.is_active:
|
||||
logger.info(f"Report task id={task_id} is inactive, skipping.")
|
||||
return
|
||||
|
||||
# Update status
|
||||
task.status = "running"
|
||||
task.last_run = datetime.now(timezone.utc)
|
||||
await session.flush()
|
||||
|
||||
# Load template to determine report type
|
||||
tmpl_result = await session.execute(
|
||||
select(ReportTemplate).where(ReportTemplate.id == task.template_id)
|
||||
)
|
||||
template = tmpl_result.scalar_one_or_none()
|
||||
if not template:
|
||||
logger.error(f"Template id={task.template_id} not found for task id={task_id}")
|
||||
task.status = "failed"
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
try:
|
||||
generator = ReportGenerator(session)
|
||||
today = date.today()
|
||||
export_format = task.export_format or "xlsx"
|
||||
|
||||
# Choose generation method based on template report_type
|
||||
if template.report_type == "daily":
|
||||
yesterday = today - timedelta(days=1)
|
||||
filepath = await generator.generate_energy_daily_report(
|
||||
start_date=yesterday, end_date=yesterday, export_format=export_format
|
||||
)
|
||||
elif template.report_type == "monthly":
|
||||
# Generate for previous month
|
||||
first_of_month = today.replace(day=1)
|
||||
last_month_end = first_of_month - timedelta(days=1)
|
||||
last_month_start = last_month_end.replace(day=1)
|
||||
filepath = await generator.generate_monthly_summary(
|
||||
month=last_month_start.month,
|
||||
year=last_month_start.year,
|
||||
export_format=export_format,
|
||||
)
|
||||
elif template.report_type == "custom" and "device" in template.name.lower():
|
||||
filepath = await generator.generate_device_status_report(
|
||||
export_format=export_format
|
||||
)
|
||||
else:
|
||||
# Default: daily report for yesterday
|
||||
yesterday = today - timedelta(days=1)
|
||||
filepath = await generator.generate_energy_daily_report(
|
||||
start_date=yesterday, end_date=yesterday, export_format=export_format
|
||||
)
|
||||
|
||||
task.file_path = filepath
|
||||
task.status = "completed"
|
||||
logger.info(f"Report task id={task_id} completed: {filepath}")
|
||||
|
||||
# Send email with attachment if recipients configured
|
||||
recipients = task.recipients or []
|
||||
if isinstance(recipients, list) and recipients:
|
||||
report_name = task.name or template.name
|
||||
subject = f"{report_name} - 天普EMS自动报表"
|
||||
body_html = f"""
|
||||
<div style="font-family: 'Microsoft YaHei', sans-serif; padding: 20px;">
|
||||
<h2 style="color: #1a73e8;">天普零碳园区智慧能源管理平台</h2>
|
||||
<p>您好,</p>
|
||||
<p>系统已自动生成 <strong>{report_name}</strong>,请查收附件。</p>
|
||||
<p style="color: #666; font-size: 13px;">
|
||||
生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}<br>
|
||||
报表类型: {template.report_type}<br>
|
||||
格式: {export_format.upper()}
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #e8e8e8; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #999;">此为系统自动发送,请勿回复。</p>
|
||||
</div>
|
||||
"""
|
||||
await send_email(
|
||||
to=recipients,
|
||||
subject=subject,
|
||||
body_html=body_html,
|
||||
attachments=[filepath],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Report task id={task_id} failed: {e}", exc_info=True)
|
||||
task.status = "failed"
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _load_and_schedule_tasks():
|
||||
"""Load all active report tasks with schedules and register them with APScheduler."""
|
||||
global _scheduler
|
||||
if not _scheduler:
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(ReportTask).where(
|
||||
ReportTask.is_active == True,
|
||||
ReportTask.schedule != None,
|
||||
ReportTask.schedule != "",
|
||||
)
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
for task in tasks:
|
||||
try:
|
||||
cron_kwargs = _parse_cron(task.schedule)
|
||||
_scheduler.add_job(
|
||||
_run_report_task,
|
||||
CronTrigger(**cron_kwargs),
|
||||
args=[task.id],
|
||||
id=f"report_task_{task.id}",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
logger.info(
|
||||
f"Scheduled report task id={task.id} name='{task.name}' "
|
||||
f"cron='{task.schedule}'"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule report task id={task.id}: {e}")
|
||||
|
||||
logger.info(f"Report scheduler loaded {len(tasks)} task(s).")
|
||||
|
||||
|
||||
async def start_scheduler():
|
||||
"""Start the APScheduler-based report scheduler."""
|
||||
global _scheduler
|
||||
if _scheduler and _scheduler.running:
|
||||
logger.warning("Report scheduler is already running.")
|
||||
return
|
||||
|
||||
_scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
|
||||
_scheduler.start()
|
||||
logger.info("Report scheduler started.")
|
||||
|
||||
await _load_and_schedule_tasks()
|
||||
|
||||
|
||||
async def stop_scheduler():
|
||||
"""Stop the report scheduler gracefully."""
|
||||
global _scheduler
|
||||
if _scheduler and _scheduler.running:
|
||||
_scheduler.shutdown(wait=False)
|
||||
logger.info("Report scheduler stopped.")
|
||||
_scheduler = None
|
||||
98
backend/app/templates/alarm_email.html
Normal file
98
backend/app/templates/alarm_email.html
Normal file
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f4f5f7; font-family: 'Microsoft YaHei', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7; padding:20px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 8px rgba(0,0,0,0.08);">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background: linear-gradient(135deg, #1a73e8, #0d47a1); padding:24px 32px; text-align:center;">
|
||||
<div style="font-size:12px; color:rgba(255,255,255,0.8); margin-bottom:4px;">TIANPU EMS</div>
|
||||
<div style="font-size:20px; font-weight:bold; color:#ffffff; letter-spacing:1px;">天普零碳园区智慧能源管理平台</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Alert Banner -->
|
||||
<tr>
|
||||
<td style="padding:0;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="background-color:{severity_bg_color}; padding:16px 32px; text-align:center;">
|
||||
<span style="display:inline-block; background-color:{severity_badge_color}; color:#ffffff; font-size:13px; font-weight:bold; padding:4px 16px; border-radius:12px; letter-spacing:1px;">{severity_label}</span>
|
||||
<div style="color:{severity_text_color}; font-size:16px; font-weight:bold; margin-top:8px;">{title}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Alarm Details -->
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e8e8e8; border-radius:6px; overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; width:120px; border-bottom:1px solid #e8e8e8;">设备名称</td>
|
||||
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{device_name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">设备编号</td>
|
||||
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{device_code}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">监控指标</td>
|
||||
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{data_type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">当前值</td>
|
||||
<td style="padding:10px 16px; font-size:14px; color:{severity_badge_color}; font-weight:bold; border-bottom:1px solid #e8e8e8;">{current_value}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">告警阈值</td>
|
||||
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{threshold_str}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666;">触发时间</td>
|
||||
<td style="padding:10px 16px; font-size:14px; color:#333;">{triggered_at}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Description -->
|
||||
<tr>
|
||||
<td style="padding:0 32px 24px;">
|
||||
<div style="background-color:#fff8e1; border-left:4px solid #ffa000; padding:12px 16px; border-radius:0 4px 4px 0; font-size:13px; color:#795548;">
|
||||
{description}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Action Button -->
|
||||
<tr>
|
||||
<td style="padding:0 32px 24px; text-align:center;">
|
||||
<a href="{platform_url}/alarms" style="display:inline-block; background-color:#1a73e8; color:#ffffff; text-decoration:none; padding:12px 32px; border-radius:6px; font-size:14px; font-weight:bold;">查看告警详情</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color:#f8f9fa; padding:16px 32px; border-top:1px solid #e8e8e8;">
|
||||
<div style="font-size:12px; color:#999; text-align:center; line-height:1.6;">
|
||||
此为系统自动发送,请勿回复。<br>
|
||||
天普零碳园区智慧能源管理平台 © 2026
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
86
frontend/package-lock.json
generated
86
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { ConfigProvider, theme } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import enUS from 'antd/locale/en_US';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
|
||||
import './i18n';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import LoginPage from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
@@ -10,6 +14,7 @@ import Alarms from './pages/Alarms';
|
||||
import Carbon from './pages/Carbon';
|
||||
import Reports from './pages/Reports';
|
||||
import Devices from './pages/Devices';
|
||||
import DeviceDetail from './pages/DeviceDetail';
|
||||
import SystemManagement from './pages/System';
|
||||
import BigScreen from './pages/BigScreen';
|
||||
import BigScreen3D from './pages/BigScreen3D';
|
||||
@@ -20,11 +25,18 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
function AppContent() {
|
||||
const { darkMode } = useTheme();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN} theme={{
|
||||
token: { colorPrimary: '#1890ff', borderRadius: 6 },
|
||||
}}>
|
||||
<ConfigProvider
|
||||
locale={i18n.language === 'en' ? enUS : zhCN}
|
||||
theme={{
|
||||
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
token: { colorPrimary: '#1890ff', borderRadius: 6 },
|
||||
}}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
@@ -34,6 +46,7 @@ export default function App() {
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="monitoring" element={<Monitoring />} />
|
||||
<Route path="devices" element={<Devices />} />
|
||||
<Route path="devices/:id" element={<DeviceDetail />} />
|
||||
<Route path="analysis" element={<Analysis />} />
|
||||
<Route path="alarms" element={<Alarms />} />
|
||||
<Route path="carbon" element={<Carbon />} />
|
||||
@@ -45,3 +58,11 @@ export default function App() {
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AppContent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
33
frontend/src/contexts/ThemeContext.tsx
Normal file
33
frontend/src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
|
||||
interface ThemeContextType {
|
||||
darkMode: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
darkMode: false,
|
||||
toggleDarkMode: () => {},
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
const saved = localStorage.getItem('tianpu-dark-mode');
|
||||
return saved === 'true';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('tianpu-dark-mode', String(darkMode));
|
||||
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
|
||||
}, [darkMode]);
|
||||
|
||||
const toggleDarkMode = () => setDarkMode(prev => !prev);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ darkMode, toggleDarkMode }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
18
frontend/src/i18n/index.ts
Normal file
18
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import zh from './locales/zh.json';
|
||||
import en from './locales/en.json';
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
zh: { translation: zh },
|
||||
en: { translation: en },
|
||||
},
|
||||
lng: localStorage.getItem('tianpu-lang') || 'zh',
|
||||
fallbackLng: 'zh',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
58
frontend/src/i18n/locales/en.json
Normal file
58
frontend/src/i18n/locales/en.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"menu": {
|
||||
"dashboard": "Energy Overview",
|
||||
"monitoring": "Real-time Monitoring",
|
||||
"devices": "Device Management",
|
||||
"analysis": "Energy Analysis",
|
||||
"alarms": "Alarm Management",
|
||||
"carbon": "Carbon Management",
|
||||
"reports": "Reports",
|
||||
"bigscreen": "Visual Dashboard",
|
||||
"bigscreen2d": "2D Energy Screen",
|
||||
"bigscreen3d": "3D Park Screen",
|
||||
"system": "System Management",
|
||||
"users": "User Management",
|
||||
"roles": "Roles & Permissions",
|
||||
"settings": "System Settings",
|
||||
"audit": "Audit Log"
|
||||
},
|
||||
"header": {
|
||||
"alarmNotification": "Alarm Notifications",
|
||||
"activeAlarms": "active",
|
||||
"noActiveAlarms": "No active alarms",
|
||||
"viewAllAlarms": "View all alarms",
|
||||
"profile": "Profile",
|
||||
"logout": "Sign Out",
|
||||
"brandName": "Tianpu EMS"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"search": "Search",
|
||||
"reset": "Reset",
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
"loading": "Loading",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"noData": "No data"
|
||||
},
|
||||
"analysis": {
|
||||
"dataComparison": "Data Comparison",
|
||||
"energyTrend": "Energy Trend",
|
||||
"dailySummary": "Daily Energy Summary",
|
||||
"period1": "Period 1",
|
||||
"period2": "Period 2",
|
||||
"totalConsumption": "Total Consumption",
|
||||
"peakPower": "Peak Power",
|
||||
"avgLoad": "Average Load",
|
||||
"carbonEmission": "Carbon Emission",
|
||||
"change": "Change",
|
||||
"compare": "Compare",
|
||||
"selectDateRange": "Select date range"
|
||||
}
|
||||
}
|
||||
58
frontend/src/i18n/locales/zh.json
Normal file
58
frontend/src/i18n/locales/zh.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"menu": {
|
||||
"dashboard": "能源总览",
|
||||
"monitoring": "实时监控",
|
||||
"devices": "设备管理",
|
||||
"analysis": "能耗分析",
|
||||
"alarms": "告警管理",
|
||||
"carbon": "碳排放管理",
|
||||
"reports": "报表管理",
|
||||
"bigscreen": "可视化大屏",
|
||||
"bigscreen2d": "2D 能源大屏",
|
||||
"bigscreen3d": "3D 园区大屏",
|
||||
"system": "系统管理",
|
||||
"users": "用户管理",
|
||||
"roles": "角色权限",
|
||||
"settings": "系统设置",
|
||||
"audit": "审计日志"
|
||||
},
|
||||
"header": {
|
||||
"alarmNotification": "告警通知",
|
||||
"activeAlarms": "条活跃",
|
||||
"noActiveAlarms": "暂无活跃告警",
|
||||
"viewAllAlarms": "查看全部告警",
|
||||
"profile": "个人信息",
|
||||
"logout": "退出登录",
|
||||
"brandName": "天普EMS"
|
||||
},
|
||||
"common": {
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"add": "新增",
|
||||
"search": "搜索",
|
||||
"reset": "重置",
|
||||
"export": "导出",
|
||||
"import": "导入",
|
||||
"loading": "加载中",
|
||||
"success": "操作成功",
|
||||
"error": "操作失败",
|
||||
"noData": "暂无数据"
|
||||
},
|
||||
"analysis": {
|
||||
"dataComparison": "数据对比",
|
||||
"energyTrend": "能耗趋势",
|
||||
"dailySummary": "每日能耗汇总",
|
||||
"period1": "时段一",
|
||||
"period2": "时段二",
|
||||
"totalConsumption": "总用电量",
|
||||
"peakPower": "峰值功率",
|
||||
"avgLoad": "平均负荷",
|
||||
"carbonEmission": "碳排放",
|
||||
"change": "变化",
|
||||
"compare": "对比",
|
||||
"selectDateRange": "选择日期范围"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,42 +1,22 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Layout, Menu, Avatar, Dropdown, Typography, Badge, Popover, List, Tag, Empty } from 'antd';
|
||||
import { Layout, Menu, Avatar, Dropdown, Typography, Badge, Popover, List, Tag, Empty, Select } from 'antd';
|
||||
import {
|
||||
DashboardOutlined, MonitorOutlined, BarChartOutlined, AlertOutlined,
|
||||
FileTextOutlined, CloudOutlined, SettingOutlined, UserOutlined,
|
||||
MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, BellOutlined,
|
||||
ThunderboltOutlined, AppstoreOutlined, WarningOutlined, CloseCircleOutlined,
|
||||
InfoCircleOutlined, FundProjectionScreenOutlined, GlobalOutlined,
|
||||
BulbOutlined, BulbFilled,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getUser, removeToken } from '../utils/auth';
|
||||
import { getAlarmStats, getAlarmEvents } from '../services/api';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Text } = Typography;
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '能源总览' },
|
||||
{ key: '/monitoring', icon: <MonitorOutlined />, label: '实时监控' },
|
||||
{ key: '/devices', icon: <AppstoreOutlined />, label: '设备管理' },
|
||||
{ key: '/analysis', icon: <BarChartOutlined />, label: '能耗分析' },
|
||||
{ key: '/alarms', icon: <AlertOutlined />, label: '告警管理' },
|
||||
{ key: '/carbon', icon: <CloudOutlined />, label: '碳排放管理' },
|
||||
{ key: '/reports', icon: <FileTextOutlined />, label: '报表管理' },
|
||||
{ key: 'bigscreen-group', icon: <FundProjectionScreenOutlined />, label: '可视化大屏',
|
||||
children: [
|
||||
{ key: '/bigscreen', icon: <FundProjectionScreenOutlined />, label: '2D 能源大屏' },
|
||||
{ key: '/bigscreen-3d', icon: <GlobalOutlined />, label: '3D 园区大屏' },
|
||||
],
|
||||
},
|
||||
{ key: '/system', icon: <SettingOutlined />, label: '系统管理',
|
||||
children: [
|
||||
{ key: '/system/users', label: '用户管理' },
|
||||
{ key: '/system/roles', label: '角色权限' },
|
||||
{ key: '/system/settings', label: '系统设置' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const SEVERITY_CONFIG: Record<string, { icon: React.ReactNode; color: string }> = {
|
||||
critical: { icon: <CloseCircleOutlined style={{ color: '#f5222d' }} />, color: 'red' },
|
||||
warning: { icon: <WarningOutlined style={{ color: '#faad14' }} />, color: 'orange' },
|
||||
@@ -50,6 +30,32 @@ export default function MainLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = getUser();
|
||||
const { darkMode, toggleDarkMode } = useTheme();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: t('menu.dashboard') },
|
||||
{ key: '/monitoring', icon: <MonitorOutlined />, label: t('menu.monitoring') },
|
||||
{ key: '/devices', icon: <AppstoreOutlined />, label: t('menu.devices') },
|
||||
{ key: '/analysis', icon: <BarChartOutlined />, label: t('menu.analysis') },
|
||||
{ key: '/alarms', icon: <AlertOutlined />, label: t('menu.alarms') },
|
||||
{ key: '/carbon', icon: <CloudOutlined />, label: t('menu.carbon') },
|
||||
{ key: '/reports', icon: <FileTextOutlined />, label: t('menu.reports') },
|
||||
{ key: 'bigscreen-group', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen'),
|
||||
children: [
|
||||
{ key: '/bigscreen', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen2d') },
|
||||
{ key: '/bigscreen-3d', icon: <GlobalOutlined />, label: t('menu.bigscreen3d') },
|
||||
],
|
||||
},
|
||||
{ key: '/system', icon: <SettingOutlined />, label: t('menu.system'),
|
||||
children: [
|
||||
{ key: '/system/users', label: t('menu.users') },
|
||||
{ key: '/system/roles', label: t('menu.roles') },
|
||||
{ key: '/system/settings', label: t('menu.settings') },
|
||||
{ key: '/system/audit', label: t('menu.audit', '审计日志') },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fetchAlarms = useCallback(async () => {
|
||||
try {
|
||||
@@ -57,7 +63,6 @@ export default function MainLayout() {
|
||||
getAlarmStats(),
|
||||
getAlarmEvents({ status: 'active', page_size: 5 }),
|
||||
]);
|
||||
// Stats shape: { severity: { status: count } } — sum all "active" counts
|
||||
const statsData = (stats as any) || {};
|
||||
let activeTotal = 0;
|
||||
for (const severity of Object.values(statsData)) {
|
||||
@@ -85,24 +90,29 @@ export default function MainLayout() {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleLanguageChange = (lang: string) => {
|
||||
i18n.changeLanguage(lang);
|
||||
localStorage.setItem('tianpu-lang', lang);
|
||||
};
|
||||
|
||||
const userMenu = {
|
||||
items: [
|
||||
{ key: 'profile', icon: <UserOutlined />, label: '个人信息' },
|
||||
{ key: 'profile', icon: <UserOutlined />, label: t('header.profile') },
|
||||
{ type: 'divider' as const },
|
||||
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout },
|
||||
{ key: 'logout', icon: <LogoutOutlined />, label: t('header.logout'), onClick: handleLogout },
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider trigger={null} collapsible collapsed={collapsed} width={220}
|
||||
style={{ background: '#001529' }}>
|
||||
style={{ background: darkMode ? '#141414' : '#001529' }}>
|
||||
<div style={{
|
||||
height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.1)',
|
||||
}}>
|
||||
<ThunderboltOutlined style={{ fontSize: 24, color: '#1890ff', marginRight: collapsed ? 0 : 8 }} />
|
||||
{!collapsed && <Text strong style={{ color: '#fff', fontSize: 16 }}>天普EMS</Text>}
|
||||
{!collapsed && <Text strong style={{ color: '#fff', fontSize: 16 }}>{t('header.brandName')}</Text>}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark" mode="inline"
|
||||
@@ -120,9 +130,9 @@ export default function MainLayout() {
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{
|
||||
padding: '0 24px', background: '#fff', display: 'flex',
|
||||
padding: '0 24px', background: darkMode ? '#141414' : '#fff', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'space-between',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
|
||||
boxShadow: darkMode ? '0 1px 4px rgba(0,0,0,0.3)' : '0 1px 4px rgba(0,0,0,0.08)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||
onClick={() => setCollapsed(!collapsed)}>
|
||||
@@ -130,17 +140,34 @@ export default function MainLayout() {
|
||||
<MenuFoldOutlined style={{ fontSize: 18 }} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Select
|
||||
value={i18n.language}
|
||||
onChange={handleLanguageChange}
|
||||
size="small"
|
||||
style={{ width: 90 }}
|
||||
options={[
|
||||
{ label: '中文', value: 'zh' },
|
||||
{ label: 'English', value: 'en' },
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
style={{ cursor: 'pointer', fontSize: 18, display: 'flex', alignItems: 'center' }}
|
||||
onClick={toggleDarkMode}
|
||||
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{darkMode ? <BulbFilled style={{ color: '#faad14' }} /> : <BulbOutlined />}
|
||||
</div>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
title={<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>告警通知</span>
|
||||
{alarmCount > 0 && <Tag color="red">{alarmCount} 条活跃</Tag>}
|
||||
<span>{t('header.alarmNotification')}</span>
|
||||
{alarmCount > 0 && <Tag color="red">{alarmCount} {t('header.activeAlarms')}</Tag>}
|
||||
</div>}
|
||||
content={
|
||||
<div style={{ width: 320, maxHeight: 360, overflow: 'auto' }}>
|
||||
{recentAlarms.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无活跃告警" />
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('header.noActiveAlarms')} />
|
||||
) : (
|
||||
<List size="small" dataSource={recentAlarms} renderItem={(alarm: any) => {
|
||||
const sev = SEVERITY_CONFIG[alarm.severity] || SEVERITY_CONFIG.info;
|
||||
@@ -162,7 +189,7 @@ export default function MainLayout() {
|
||||
}} />
|
||||
)}
|
||||
<div style={{ textAlign: 'center', padding: '8px 0', borderTop: '1px solid #f0f0f0' }}>
|
||||
<a onClick={() => navigate('/alarms')} style={{ fontSize: 13 }}>查看全部告警</a>
|
||||
<a onClick={() => navigate('/alarms')} style={{ fontSize: 13 }}>{t('header.viewAllAlarms')}</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -179,7 +206,7 @@ export default function MainLayout() {
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Header>
|
||||
<Content style={{ margin: 16, padding: 24, background: '#f5f5f5', minHeight: 280 }}>
|
||||
<Content style={{ margin: 16, padding: 24, background: darkMode ? '#1f1f1f' : '#f5f5f5', minHeight: 280 }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
@@ -1,16 +1,196 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, DatePicker, Select, Statistic, Table, Button, Space, message } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import { Card, Row, Col, DatePicker, Select, Statistic, Table, Button, Space, message, Tabs } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, DownloadOutlined, SwapOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs from 'dayjs';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { getEnergyHistory, getEnergyComparison, getEnergyDailySummary, exportEnergyData } from '../../services/api';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
function ComparisonView() {
|
||||
const [range1, setRange1] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(30, 'day'), dayjs(),
|
||||
]);
|
||||
const [range2, setRange2] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(60, 'day'), dayjs().subtract(30, 'day'),
|
||||
]);
|
||||
const [data1, setData1] = useState<any[]>([]);
|
||||
const [data2, setData2] = useState<any[]>([]);
|
||||
const [summary1, setSummary1] = useState<any[]>([]);
|
||||
const [summary2, setSummary2] = useState<any[]>([]);
|
||||
const [comparison, setComparison] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadComparisonData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [hist1, hist2, daily1, daily2, comp] = await Promise.all([
|
||||
getEnergyHistory({
|
||||
data_type: 'power', granularity: 'day',
|
||||
start_time: range1[0].format('YYYY-MM-DD'),
|
||||
end_time: range1[1].format('YYYY-MM-DD'),
|
||||
}),
|
||||
getEnergyHistory({
|
||||
data_type: 'power', granularity: 'day',
|
||||
start_time: range2[0].format('YYYY-MM-DD'),
|
||||
end_time: range2[1].format('YYYY-MM-DD'),
|
||||
}),
|
||||
getEnergyDailySummary({
|
||||
energy_type: 'electricity',
|
||||
start_date: range1[0].format('YYYY-MM-DD'),
|
||||
end_date: range1[1].format('YYYY-MM-DD'),
|
||||
}),
|
||||
getEnergyDailySummary({
|
||||
energy_type: 'electricity',
|
||||
start_date: range2[0].format('YYYY-MM-DD'),
|
||||
end_date: range2[1].format('YYYY-MM-DD'),
|
||||
}),
|
||||
getEnergyComparison({ energy_type: 'electricity', period: 'month' }),
|
||||
]);
|
||||
setData1(hist1 as any[]);
|
||||
setData2(hist2 as any[]);
|
||||
setSummary1(daily1 as any[]);
|
||||
setSummary2(daily2 as any[]);
|
||||
setComparison(comp);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载对比数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadComparisonData();
|
||||
}, []);
|
||||
|
||||
const calcMetrics = (summaryData: any[]) => {
|
||||
if (!summaryData || summaryData.length === 0) {
|
||||
return { totalConsumption: 0, peakPower: 0, avgLoad: 0, carbonEmission: 0 };
|
||||
}
|
||||
const totalConsumption = summaryData.reduce((s, d) => s + (d.consumption || 0), 0);
|
||||
const peakPower = Math.max(...summaryData.map(d => d.peak_power || 0));
|
||||
const avgLoad = summaryData.reduce((s, d) => s + (d.avg_power || 0), 0) / summaryData.length;
|
||||
const carbonEmission = summaryData.reduce((s, d) => s + (d.carbon_emission || 0), 0);
|
||||
return { totalConsumption, peakPower, avgLoad, carbonEmission };
|
||||
};
|
||||
|
||||
const m1 = calcMetrics(summary1);
|
||||
const m2 = calcMetrics(summary2);
|
||||
|
||||
const pctChange = (v1: number, v2: number) => {
|
||||
if (v2 === 0) return 0;
|
||||
return ((v1 - v2) / v2) * 100;
|
||||
};
|
||||
|
||||
const comparisonChartOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['时段一', '时段二'] },
|
||||
grid: { top: 50, right: 40, bottom: 30, left: 60 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: (data1.length >= data2.length ? data1 : data2).map((_, i) => `Day ${i + 1}`),
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: 'kW', position: 'left' },
|
||||
{ type: 'value', name: 'kW', position: 'right' },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '时段一',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: data1.map(d => d.avg),
|
||||
lineStyle: { color: '#1890ff' },
|
||||
itemStyle: { color: '#1890ff' },
|
||||
yAxisIndex: 0,
|
||||
},
|
||||
{
|
||||
name: '时段二',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: data2.map(d => d.avg),
|
||||
lineStyle: { color: '#faad14' },
|
||||
itemStyle: { color: '#faad14' },
|
||||
yAxisIndex: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const renderMetricCard = (
|
||||
label: string, v1: number, v2: number, unit: string, precision = 1,
|
||||
) => {
|
||||
const change = pctChange(v1, v2);
|
||||
const isImproved = change < 0; // less consumption = improvement
|
||||
return (
|
||||
<Col xs={24} sm={12} md={6} key={label}>
|
||||
<Card size="small">
|
||||
<div style={{ marginBottom: 8, fontWeight: 500, color: '#666' }}>{label}</div>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>时段一</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{v1.toFixed(precision)}</div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>{unit}</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>时段二</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{v2.toFixed(precision)}</div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>{unit}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{
|
||||
marginTop: 8, fontSize: 13, fontWeight: 500,
|
||||
color: isImproved ? '#52c41a' : change > 0 ? '#f5222d' : '#666',
|
||||
}}>
|
||||
{change > 0 ? <ArrowUpOutlined /> : change < 0 ? <ArrowDownOutlined /> : null}
|
||||
{' '}{Math.abs(change).toFixed(1)}% {isImproved ? '减少' : change > 0 ? '增加' : '持平'}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<span>时段一:</span>
|
||||
<RangePicker
|
||||
value={range1}
|
||||
onChange={(dates) => dates && setRange1(dates as [Dayjs, Dayjs])}
|
||||
/>
|
||||
<span>时段二:</span>
|
||||
<RangePicker
|
||||
value={range2}
|
||||
onChange={(dates) => dates && setRange2(dates as [Dayjs, Dayjs])}
|
||||
/>
|
||||
<Button type="primary" icon={<SwapOutlined />} loading={loading} onClick={loadComparisonData}>
|
||||
对比
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
{renderMetricCard('总用电量', m1.totalConsumption, m2.totalConsumption, 'kWh')}
|
||||
{renderMetricCard('峰值功率', m1.peakPower, m2.peakPower, 'kW')}
|
||||
{renderMetricCard('平均负荷', m1.avgLoad, m2.avgLoad, 'kW')}
|
||||
{renderMetricCard('碳排放', m1.carbonEmission, m2.carbonEmission, 'kg', 2)}
|
||||
</Row>
|
||||
|
||||
<Card title="能耗趋势对比" size="small">
|
||||
<ReactECharts option={comparisonChartOption} style={{ height: 350 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Analysis() {
|
||||
const [historyData, setHistoryData] = useState<any[]>([]);
|
||||
const [comparison, setComparison] = useState<any>(null);
|
||||
const [dailySummary, setDailySummary] = useState<any[]>([]);
|
||||
const [granularity, setGranularity] = useState('hour');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -76,7 +256,7 @@ export default function Analysis() {
|
||||
{ title: '碳排放(kg)', dataIndex: 'carbon_emission', render: (v: number) => v?.toFixed(2) },
|
||||
];
|
||||
|
||||
return (
|
||||
const overviewContent = (
|
||||
<div>
|
||||
<Row justify="end" style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
@@ -130,4 +310,17 @@ export default function Analysis() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{ key: 'overview', label: '能耗概览', children: overviewContent },
|
||||
{ key: 'comparison', label: '数据对比', children: <ComparisonView /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
490
frontend/src/pages/DeviceDetail/index.tsx
Normal file
490
frontend/src/pages/DeviceDetail/index.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card, Tabs, Tag, Button, Statistic, Row, Col, Table, Descriptions, Select,
|
||||
DatePicker, Space, Badge, Spin, message, Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined, ReloadOutlined, ThunderboltOutlined,
|
||||
DashboardOutlined, FireOutlined, ExperimentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { getDevice, getDeviceRealtime, getEnergyHistory, getAlarmEvents } from '../../services/api';
|
||||
import { getDevicePhoto } from '../../utils/devicePhoto';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
online: { color: 'green', text: '在线' },
|
||||
offline: { color: 'default', text: '离线' },
|
||||
alarm: { color: 'red', text: '告警' },
|
||||
maintenance: { color: 'orange', text: '维护' },
|
||||
};
|
||||
|
||||
const severityMap: Record<string, { color: string; text: string }> = {
|
||||
critical: { color: 'red', text: '严重' },
|
||||
warning: { color: 'orange', text: '警告' },
|
||||
info: { color: 'blue', text: '信息' },
|
||||
};
|
||||
|
||||
const alarmStatusMap: Record<string, { color: string; text: string }> = {
|
||||
active: { color: 'red', text: '活跃' },
|
||||
acknowledged: { color: 'orange', text: '已确认' },
|
||||
resolved: { color: 'green', text: '已解决' },
|
||||
};
|
||||
|
||||
const protocolLabels: Record<string, string> = {
|
||||
modbus_tcp: 'Modbus TCP',
|
||||
modbus_rtu: 'Modbus RTU',
|
||||
opc_ua: 'OPC UA',
|
||||
mqtt: 'MQTT',
|
||||
http_api: 'HTTP API',
|
||||
dlt645: 'DL/T 645',
|
||||
image: '图像采集',
|
||||
};
|
||||
|
||||
const dataTypeOptions = [
|
||||
{ label: '功率 (kW)', value: 'power' },
|
||||
{ label: '电量 (kWh)', value: 'energy' },
|
||||
{ label: '温度 (°C)', value: 'temperature' },
|
||||
{ label: 'COP', value: 'cop' },
|
||||
{ label: '电流 (A)', value: 'current' },
|
||||
{ label: '电压 (V)', value: 'voltage' },
|
||||
{ label: '频率 (Hz)', value: 'frequency' },
|
||||
{ label: '功率因数', value: 'power_factor' },
|
||||
{ label: '流量 (m³/h)', value: 'flow_rate' },
|
||||
{ label: '湿度 (%)', value: 'humidity' },
|
||||
];
|
||||
|
||||
const granularityOptions = [
|
||||
{ label: '原始数据', value: 'raw' },
|
||||
{ label: '5分钟', value: '5min' },
|
||||
{ label: '小时', value: 'hour' },
|
||||
{ label: '天', value: 'day' },
|
||||
];
|
||||
|
||||
const timeRangePresets = [
|
||||
{ label: '24小时', value: '24h' },
|
||||
{ label: '7天', value: '7d' },
|
||||
{ label: '30天', value: '30d' },
|
||||
];
|
||||
|
||||
function getTimeRange(preset: string): [dayjs.Dayjs, dayjs.Dayjs] {
|
||||
const now = dayjs();
|
||||
switch (preset) {
|
||||
case '24h': return [now.subtract(24, 'hour'), now];
|
||||
case '7d': return [now.subtract(7, 'day'), now];
|
||||
case '30d': return [now.subtract(30, 'day'), now];
|
||||
default: return [now.subtract(24, 'hour'), now];
|
||||
}
|
||||
}
|
||||
|
||||
export default function DeviceDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const deviceId = Number(id);
|
||||
|
||||
const [device, setDevice] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('realtime');
|
||||
|
||||
// Realtime state
|
||||
const [realtimeData, setRealtimeData] = useState<any>(null);
|
||||
const [realtimeLoading, setRealtimeLoading] = useState(false);
|
||||
|
||||
// History state
|
||||
const [historyData, setHistoryData] = useState<any[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [dataType, setDataType] = useState('power');
|
||||
const [granularity, setGranularity] = useState('hour');
|
||||
const [timePreset, setTimePreset] = useState('24h');
|
||||
const [timeRange, setTimeRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>(getTimeRange('24h'));
|
||||
|
||||
// Alarm state
|
||||
const [alarmData, setAlarmData] = useState<any>({ total: 0, items: [] });
|
||||
const [alarmLoading, setAlarmLoading] = useState(false);
|
||||
const [alarmPage, setAlarmPage] = useState(1);
|
||||
|
||||
// Load device info
|
||||
useEffect(() => {
|
||||
if (!deviceId) return;
|
||||
setLoading(true);
|
||||
getDevice(deviceId)
|
||||
.then((res: any) => setDevice(res))
|
||||
.catch(() => message.error('加载设备信息失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [deviceId]);
|
||||
|
||||
// Load realtime data
|
||||
const loadRealtime = useCallback(async () => {
|
||||
if (!deviceId) return;
|
||||
setRealtimeLoading(true);
|
||||
try {
|
||||
const res = await getDeviceRealtime(deviceId);
|
||||
setRealtimeData(res);
|
||||
} catch { setRealtimeData(null); }
|
||||
finally { setRealtimeLoading(false); }
|
||||
}, [deviceId]);
|
||||
|
||||
// Auto-refresh realtime every 15s
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'realtime') return;
|
||||
loadRealtime();
|
||||
const timer = setInterval(loadRealtime, 15000);
|
||||
return () => clearInterval(timer);
|
||||
}, [activeTab, loadRealtime]);
|
||||
|
||||
// Load history data
|
||||
const loadHistory = useCallback(async () => {
|
||||
if (!deviceId) return;
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await getEnergyHistory({
|
||||
device_id: deviceId,
|
||||
data_type: dataType,
|
||||
granularity,
|
||||
start_time: timeRange[0].format('YYYY-MM-DD HH:mm:ss'),
|
||||
end_time: timeRange[1].format('YYYY-MM-DD HH:mm:ss'),
|
||||
page_size: 1000,
|
||||
});
|
||||
setHistoryData(res as any[]);
|
||||
} catch { setHistoryData([]); }
|
||||
finally { setHistoryLoading(false); }
|
||||
}, [deviceId, dataType, granularity, timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'history') loadHistory();
|
||||
}, [activeTab, loadHistory]);
|
||||
|
||||
// Load alarm events
|
||||
const loadAlarms = useCallback(async () => {
|
||||
if (!deviceId) return;
|
||||
setAlarmLoading(true);
|
||||
try {
|
||||
const res = await getAlarmEvents({ device_id: deviceId, page: alarmPage, page_size: 20 });
|
||||
setAlarmData(res as any);
|
||||
} catch { setAlarmData({ total: 0, items: [] }); }
|
||||
finally { setAlarmLoading(false); }
|
||||
}, [deviceId, alarmPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'alarms') loadAlarms();
|
||||
}, [activeTab, loadAlarms]);
|
||||
|
||||
const handleTimePreset = (preset: string) => {
|
||||
setTimePreset(preset);
|
||||
setTimeRange(getTimeRange(preset));
|
||||
};
|
||||
|
||||
const handleRangeChange = (dates: any) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setTimePreset('');
|
||||
setTimeRange([dates[0], dates[1]]);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Chart options ----
|
||||
const getChartOption = () => {
|
||||
const isRaw = granularity === 'raw';
|
||||
const times = historyData.map(d => isRaw ? d.timestamp : d.time);
|
||||
const typeLabel = dataTypeOptions.find(o => o.value === dataType)?.label || dataType;
|
||||
|
||||
if (isRaw) {
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: [typeLabel] },
|
||||
grid: { left: 60, right: 30, top: 40, bottom: 40 },
|
||||
xAxis: { type: 'category', data: times, axisLabel: { rotate: 30 } },
|
||||
yAxis: { type: 'value', name: typeLabel },
|
||||
series: [{
|
||||
name: typeLabel,
|
||||
type: 'line',
|
||||
data: historyData.map(d => d.value),
|
||||
smooth: true,
|
||||
lineStyle: { width: 2 },
|
||||
areaStyle: { opacity: 0.1 },
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// Aggregated data with avg/max/min
|
||||
const avgData = historyData.map(d => d.avg);
|
||||
const maxData = historyData.map(d => d.max);
|
||||
const minData = historyData.map(d => d.min);
|
||||
const avgVal = avgData.length ? (avgData.reduce((a, b) => a + b, 0) / avgData.length).toFixed(2) : '-';
|
||||
const maxVal = maxData.length ? Math.max(...maxData).toFixed(2) : '-';
|
||||
const minVal = minData.length ? Math.min(...minData).toFixed(2) : '-';
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: [
|
||||
`平均 (${avgVal})`,
|
||||
`最大 (${maxVal})`,
|
||||
`最小 (${minVal})`,
|
||||
],
|
||||
},
|
||||
grid: { left: 60, right: 30, top: 50, bottom: 40 },
|
||||
xAxis: { type: 'category', data: times, axisLabel: { rotate: 30 } },
|
||||
yAxis: { type: 'value', name: typeLabel },
|
||||
series: [
|
||||
{
|
||||
name: `平均 (${avgVal})`,
|
||||
type: 'line',
|
||||
data: avgData,
|
||||
smooth: true,
|
||||
lineStyle: { width: 2, color: '#1890ff' },
|
||||
itemStyle: { color: '#1890ff' },
|
||||
areaStyle: { opacity: 0.08, color: '#1890ff' },
|
||||
},
|
||||
{
|
||||
name: `最大 (${maxVal})`,
|
||||
type: 'line',
|
||||
data: maxData,
|
||||
smooth: true,
|
||||
lineStyle: { width: 1, type: 'dashed', color: '#ff4d4f' },
|
||||
itemStyle: { color: '#ff4d4f' },
|
||||
},
|
||||
{
|
||||
name: `最小 (${minVal})`,
|
||||
type: 'line',
|
||||
data: minData,
|
||||
smooth: true,
|
||||
lineStyle: { width: 1, type: 'dashed', color: '#52c41a' },
|
||||
itemStyle: { color: '#52c41a' },
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
// ---- Alarm columns ----
|
||||
const alarmColumns = [
|
||||
{
|
||||
title: '时间', dataIndex: 'triggered_at', width: 170,
|
||||
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-',
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', width: 200, ellipsis: true },
|
||||
{
|
||||
title: '严重程度', dataIndex: 'severity', width: 90,
|
||||
render: (v: string) => {
|
||||
const s = severityMap[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 90,
|
||||
render: (v: string) => {
|
||||
const s = alarmStatusMap[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '实际值', dataIndex: 'value', width: 100,
|
||||
render: (v: number) => v != null ? v.toFixed(2) : '-',
|
||||
},
|
||||
{
|
||||
title: '阈值', dataIndex: 'threshold', width: 100,
|
||||
render: (v: number) => v != null ? v.toFixed(2) : '-',
|
||||
},
|
||||
{
|
||||
title: '解决时间', dataIndex: 'resolved_at', width: 170,
|
||||
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-',
|
||||
},
|
||||
];
|
||||
|
||||
// ---- Render metric cards for realtime ----
|
||||
const renderRealtimeMetrics = () => {
|
||||
if (!realtimeData?.data) return <Empty description="暂无实时数据" />;
|
||||
const entries = Object.entries(realtimeData.data) as [string, any][];
|
||||
return (
|
||||
<Row gutter={[16, 16]}>
|
||||
{entries.map(([key, val]) => {
|
||||
let icon = <DashboardOutlined />;
|
||||
let color: string | undefined;
|
||||
if (key.includes('power') || key.includes('功率')) { icon = <ThunderboltOutlined />; color = '#1890ff'; }
|
||||
else if (key.includes('temp') || key.includes('温度')) { icon = <FireOutlined />; color = '#fa541c'; }
|
||||
else if (key.includes('cop') || key.includes('COP')) { icon = <ExperimentOutlined />; color = '#52c41a'; }
|
||||
return (
|
||||
<Col xs={12} sm={8} md={6} key={key}>
|
||||
<Card size="small" hoverable>
|
||||
<Statistic
|
||||
title={key}
|
||||
value={val.value}
|
||||
suffix={val.unit || ''}
|
||||
prefix={icon}
|
||||
valueStyle={color ? { color } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: 100 }}><Spin size="large" tip="加载中..." /></div>;
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
return <Empty description="设备不存在" style={{ padding: 100 }}>
|
||||
<Button type="primary" onClick={() => navigate('/devices')}>返回设备列表</Button>
|
||||
</Empty>;
|
||||
}
|
||||
|
||||
const st = statusMap[device.status] || { color: 'default', text: device.status || '-' };
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
|
||||
<img
|
||||
src={getDevicePhoto(device.device_type)}
|
||||
alt={device.name}
|
||||
style={{ width: 160, height: 120, borderRadius: 12, objectFit: 'cover', border: '1px solid #f0f0f0', flexShrink: 0 }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20 }}>{device.name}</h2>
|
||||
<Tag color={st.color}>{st.text}</Tag>
|
||||
<Tag>{device.code}</Tag>
|
||||
</div>
|
||||
<Row gutter={[24, 8]}>
|
||||
<Col span={6}><span style={{ color: '#999' }}>型号:</span>{device.model || '-'}</Col>
|
||||
<Col span={6}><span style={{ color: '#999' }}>厂商:</span>{device.manufacturer || '-'}</Col>
|
||||
<Col span={6}><span style={{ color: '#999' }}>额定功率:</span>{device.rated_power != null ? `${device.rated_power} kW` : '-'}</Col>
|
||||
<Col span={6}><span style={{ color: '#999' }}>位置:</span>{device.location || '-'}</Col>
|
||||
<Col span={6}><span style={{ color: '#999' }}>协议:</span>{protocolLabels[device.protocol] || device.protocol || '-'}</Col>
|
||||
<Col span={6}><span style={{ color: '#999' }}>采集间隔:</span>{device.collect_interval ? `${device.collect_interval}s` : '-'}</Col>
|
||||
<Col span={6}><span style={{ color: '#999' }}>最近数据:</span>{device.last_data_time || '-'}</Col>
|
||||
</Row>
|
||||
</div>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/devices')}>
|
||||
返回设备列表
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Card size="small">
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={[
|
||||
{
|
||||
key: 'realtime',
|
||||
label: '实时数据',
|
||||
children: (
|
||||
<Spin spinning={realtimeLoading}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadRealtime} size="small">刷新</Button>
|
||||
<span style={{ marginLeft: 12, color: '#999', fontSize: 12 }}>每15秒自动刷新</span>
|
||||
</div>
|
||||
{renderRealtimeMetrics()}
|
||||
</Spin>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'history',
|
||||
label: '历史趋势',
|
||||
children: (
|
||||
<div>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
value={dataType}
|
||||
onChange={setDataType}
|
||||
options={dataTypeOptions}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Select
|
||||
value={granularity}
|
||||
onChange={setGranularity}
|
||||
options={granularityOptions}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
{timeRangePresets.map(p => (
|
||||
<Button
|
||||
key={p.value}
|
||||
type={timePreset === p.value ? 'primary' : 'default'}
|
||||
size="small"
|
||||
onClick={() => handleTimePreset(p.value)}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
<RangePicker
|
||||
showTime
|
||||
value={timeRange}
|
||||
onChange={handleRangeChange}
|
||||
size="small"
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} size="small" onClick={loadHistory}>刷新</Button>
|
||||
</Space>
|
||||
<Spin spinning={historyLoading}>
|
||||
{historyData.length > 0 ? (
|
||||
<ReactECharts option={getChartOption()} style={{ height: 400 }} />
|
||||
) : (
|
||||
<Empty description="暂无历史数据" style={{ padding: 60 }} />
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'alarms',
|
||||
label: '告警记录',
|
||||
children: (
|
||||
<Table
|
||||
columns={alarmColumns}
|
||||
dataSource={alarmData.items}
|
||||
rowKey="id"
|
||||
loading={alarmLoading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: alarmPage,
|
||||
pageSize: 20,
|
||||
total: alarmData.total,
|
||||
showTotal: (total: number) => `共 ${total} 条告警`,
|
||||
onChange: (page: number) => setAlarmPage(page),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'info',
|
||||
label: '设备信息',
|
||||
children: (
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="设备名称">{device.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="设备编号">{device.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="设备类型">{device.device_type || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="设备分组ID">{device.group_id || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="型号">{device.model || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="厂商">{device.manufacturer || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="序列号">{device.serial_number || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="额定功率">{device.rated_power != null ? `${device.rated_power} kW` : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="位置">{device.location || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="通信协议">{protocolLabels[device.protocol] || device.protocol || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="采集间隔">{device.collect_interval ? `${device.collect_interval} 秒` : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Badge status={device.status === 'online' ? 'success' : device.status === 'alarm' ? 'error' : 'default'}
|
||||
text={st.text} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="是否启用">
|
||||
<Tag color={device.is_active ? 'green' : 'default'}>{device.is_active ? '启用' : '停用'}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最近数据时间">{device.last_data_time || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="连接参数" span={2}>
|
||||
{device.connection_params ? (
|
||||
<pre style={{ margin: 0, fontSize: 12, background: '#f5f5f5', padding: 8, borderRadius: 4, maxHeight: 200, overflow: 'auto' }}>
|
||||
{JSON.stringify(device.connection_params, null, 2)}
|
||||
</pre>
|
||||
) : '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber, Space, Row, Col, Statistic, Switch, message } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, WarningOutlined, AppstoreOutlined } from '@ant-design/icons';
|
||||
import { getDevices, getDeviceTypes, getDeviceGroups, getDeviceStats, createDevice, updateDevice } from '../../services/api';
|
||||
@@ -22,6 +23,7 @@ const protocolOptions = [
|
||||
];
|
||||
|
||||
export default function Devices() {
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<any>({ total: 0, items: [] });
|
||||
const [stats, setStats] = useState<any>({ online: 0, offline: 0, alarm: 0, total: 0 });
|
||||
const [deviceTypes, setDeviceTypes] = useState<any[]>([]);
|
||||
@@ -118,7 +120,9 @@ export default function Devices() {
|
||||
{ title: '', dataIndex: 'device_type', width: 50, render: (_: any, record: any) => (
|
||||
<img src={getDevicePhoto(record.device_type || record.device_type_id)} alt="" style={{ width: 40, height: 40, borderRadius: 8, objectFit: 'cover' }} />
|
||||
)},
|
||||
{ title: '设备名称', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '设备名称', dataIndex: 'name', width: 160, ellipsis: true, render: (name: string, record: any) => (
|
||||
<a onClick={() => navigate(`/devices/${record.id}`)}>{name}</a>
|
||||
)},
|
||||
{ title: '设备编号', dataIndex: 'code', width: 130 },
|
||||
{ title: '设备类型', dataIndex: 'device_type_name', width: 120, render: (v: string) => v ? <Tag icon={<AppstoreOutlined />} color="blue">{v}</Tag> : '-' },
|
||||
{ title: '设备分组', dataIndex: 'device_group_name', width: 120 },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Table, Tag, Input, Select, Descriptions, Modal, Badge, message } from 'antd';
|
||||
import { getDevices, getDeviceRealtime } from '../../services/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Table, Tag, Input, Select, message } from 'antd';
|
||||
import { getDevices } from '../../services/api';
|
||||
import { getDevicePhoto } from '../../utils/devicePhoto';
|
||||
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
@@ -16,10 +17,9 @@ const typeMap: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function Monitoring() {
|
||||
const navigate = useNavigate();
|
||||
const [devices, setDevices] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedDevice, setSelectedDevice] = useState<any>(null);
|
||||
const [deviceData, setDeviceData] = useState<any>(null);
|
||||
const [filter, setFilter] = useState({ type: '', search: '' });
|
||||
|
||||
useEffect(() => {
|
||||
@@ -36,14 +36,6 @@ export default function Monitoring() {
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const showDetail = async (device: any) => {
|
||||
setSelectedDevice(device);
|
||||
try {
|
||||
const data = await getDeviceRealtime(device.id);
|
||||
setDeviceData(data);
|
||||
} catch { setDeviceData(null); }
|
||||
};
|
||||
|
||||
const filteredDevices = devices.filter(d => {
|
||||
if (filter.type && d.device_type !== filter.type) return false;
|
||||
if (filter.search && !d.name.includes(filter.search) && !d.code.includes(filter.search)) return false;
|
||||
@@ -64,7 +56,7 @@ export default function Monitoring() {
|
||||
const st = statusMap[s] || { color: 'default', text: s };
|
||||
return <Tag color={st.color}>{st.text}</Tag>;
|
||||
}},
|
||||
{ title: '操作', key: 'action', render: (_: any, r: any) => <a onClick={() => showDetail(r)}>查看详情</a> },
|
||||
{ title: '操作', key: 'action', render: (_: any, r: any) => <a onClick={() => navigate(`/devices/${r.id}`)}>查看详情</a> },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -82,35 +74,6 @@ export default function Monitoring() {
|
||||
loading={loading} size="small" pagination={{ pageSize: 15 }} />
|
||||
</Card>
|
||||
|
||||
<Modal title={selectedDevice?.name} open={!!selectedDevice} onCancel={() => setSelectedDevice(null)}
|
||||
footer={null} width={700}>
|
||||
{deviceData?.device && (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<img src={getDevicePhoto(deviceData.device.device_type)} alt={deviceData.device.name}
|
||||
style={{ width: 200, height: 150, borderRadius: 12, objectFit: 'cover', border: '1px solid #f0f0f0' }} />
|
||||
</div>
|
||||
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="编号">{deviceData.device.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{typeMap[deviceData.device.device_type]}</Descriptions.Item>
|
||||
<Descriptions.Item label="型号">{deviceData.device.model || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Badge status={deviceData.device.status === 'online' ? 'success' : 'error'}
|
||||
text={statusMap[deviceData.device.status]?.text} />
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>
|
||||
)}
|
||||
{deviceData?.data && (
|
||||
<Descriptions column={2} size="small" bordered title="实时数据">
|
||||
{Object.entries(deviceData.data).map(([key, val]: any) => (
|
||||
<Descriptions.Item key={key} label={key}>
|
||||
{val.value} {val.unit}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
174
frontend/src/pages/System/AuditLog.tsx
Normal file
174
frontend/src/pages/System/AuditLog.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Table, Tag, Select, DatePicker, Space, Input } from 'antd';
|
||||
import { getAuditLogs, getUsers } from '../../services/api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
login: 'blue',
|
||||
create: 'green',
|
||||
update: 'orange',
|
||||
delete: 'red',
|
||||
export: 'purple',
|
||||
view: 'default',
|
||||
acknowledge: 'cyan',
|
||||
resolve: 'geekblue',
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
login: '登录',
|
||||
create: '创建',
|
||||
update: '更新',
|
||||
delete: '删除',
|
||||
export: '导出',
|
||||
view: '查看',
|
||||
acknowledge: '确认',
|
||||
resolve: '解决',
|
||||
};
|
||||
|
||||
const RESOURCE_LABELS: Record<string, string> = {
|
||||
user: '用户',
|
||||
device: '设备',
|
||||
alarm: '告警',
|
||||
report: '报表',
|
||||
system: '系统',
|
||||
auth: '认证',
|
||||
};
|
||||
|
||||
export default function AuditLog() {
|
||||
const [data, setData] = useState<{ total: number; items: any[] }>({ total: 0, items: [] });
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<Record<string, any>>({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [filters]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const res = await getUsers({ page_size: 100 });
|
||||
setUsers((res as any)?.items || []);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getAuditLogs(filters);
|
||||
setData(res as any);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-',
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'username',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
width: 100,
|
||||
render: (v: string) => (
|
||||
<Tag color={ACTION_COLORS[v] || 'default'}>{ACTION_LABELS[v] || v}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '资源',
|
||||
dataIndex: 'resource',
|
||||
width: 100,
|
||||
render: (v: string) => RESOURCE_LABELS[v] || v || '-',
|
||||
},
|
||||
{
|
||||
title: '详情',
|
||||
dataIndex: 'detail',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'IP地址',
|
||||
dataIndex: 'ip_address',
|
||||
width: 140,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="审计日志"
|
||||
size="small"
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Select
|
||||
placeholder="用户"
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={users.map((u: any) => ({ label: u.full_name || u.username, value: u.id }))}
|
||||
onChange={(v) => setFilters({ ...filters, user_id: v, page: 1 })}
|
||||
/>
|
||||
<Select
|
||||
placeholder="操作类型"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={Object.entries(ACTION_LABELS).map(([k, v]) => ({ label: v, value: k }))}
|
||||
onChange={(v) => setFilters({ ...filters, action: v, page: 1 })}
|
||||
/>
|
||||
<Select
|
||||
placeholder="资源类型"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={Object.entries(RESOURCE_LABELS).map(([k, v]) => ({ label: v, value: k }))}
|
||||
onChange={(v) => setFilters({ ...filters, resource: v, page: 1 })}
|
||||
/>
|
||||
<RangePicker
|
||||
showTime
|
||||
onChange={(dates) => {
|
||||
setFilters({
|
||||
...filters,
|
||||
start_time: dates?.[0]?.toISOString(),
|
||||
end_time: dates?.[1]?.toISOString(),
|
||||
page: 1,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data.items}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: filters.page,
|
||||
pageSize: filters.page_size,
|
||||
total: data.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => setFilters({ ...filters, page, page_size: pageSize }),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
110
frontend/src/pages/System/Settings.tsx
Normal file
110
frontend/src/pages/System/Settings.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Form, Input, InputNumber, Switch, Select, Button, message, Spin, Row, Col } from 'antd';
|
||||
import { SaveOutlined } from '@ant-design/icons';
|
||||
import { getSettings, updateSettings } from '../../services/api';
|
||||
import { getUser } from '../../utils/auth';
|
||||
|
||||
const TIMEZONE_OPTIONS = [
|
||||
{ label: 'Asia/Shanghai (UTC+8)', value: 'Asia/Shanghai' },
|
||||
{ label: 'Asia/Tokyo (UTC+9)', value: 'Asia/Tokyo' },
|
||||
{ label: 'Asia/Singapore (UTC+8)', value: 'Asia/Singapore' },
|
||||
{ label: 'UTC', value: 'UTC' },
|
||||
{ label: 'America/New_York (UTC-5)', value: 'America/New_York' },
|
||||
{ label: 'Europe/London (UTC+0)', value: 'Europe/London' },
|
||||
];
|
||||
|
||||
export default function SystemSettings() {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const user = getUser();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getSettings();
|
||||
form.setFieldsValue(res as any);
|
||||
} catch {
|
||||
message.error('加载设置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
await updateSettings(values);
|
||||
message.success('设置已保存');
|
||||
} catch (e: any) {
|
||||
if (e?.detail) message.error(e.detail);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Spin style={{ display: 'block', margin: '80px auto' }} />;
|
||||
|
||||
return (
|
||||
<Form form={form} layout="vertical" disabled={!isAdmin}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="基本设置" size="small" style={{ marginBottom: 16 }}>
|
||||
<Form.Item name="platform_name" label="平台名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="timezone" label="时区">
|
||||
<Select options={TIMEZONE_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card title="数据管理" size="small" style={{ marginBottom: 16 }}>
|
||||
<Form.Item name="data_retention_days" label="数据保留天数">
|
||||
<InputNumber min={30} max={3650} addonAfter="天" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="simulator_interval_seconds" label="模拟器采集间隔">
|
||||
<InputNumber min={5} max={300} addonAfter="秒" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="告警设置" size="small" style={{ marginBottom: 16 }}>
|
||||
<Form.Item name="alarm_auto_resolve_minutes" label="告警自动解除时间">
|
||||
<InputNumber min={5} max={1440} addonAfter="分钟" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card title="通知设置" size="small" style={{ marginBottom: 16 }}>
|
||||
<Form.Item name="notification_email_enabled" label="邮件通知" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="notification_email_smtp" label="SMTP 服务器">
|
||||
<Input placeholder="smtp.example.com:465" />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card title="报表设置" size="small" style={{ marginBottom: 16 }}>
|
||||
<Form.Item name="report_auto_schedule_enabled" label="自动生成报表" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{isAdmin && (
|
||||
<div style={{ textAlign: 'right', marginTop: 8 }}>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Table, Button, Tag, Modal, Form, Input, Select, Switch, message, Tabs } from 'antd';
|
||||
import { PlusOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { getUsers, createUser, updateUser, getRoles } from '../../services/api';
|
||||
import AuditLog from './AuditLog';
|
||||
import SystemSettings from './Settings';
|
||||
|
||||
export default function SystemManagement() {
|
||||
const [users, setUsers] = useState<any>({ total: 0, items: [] });
|
||||
@@ -10,6 +13,18 @@ export default function SystemManagement() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Derive active tab from URL path
|
||||
const pathSegment = location.pathname.split('/').pop() || 'users';
|
||||
const tabKeyMap: Record<string, string> = {
|
||||
users: 'users',
|
||||
roles: 'roles',
|
||||
settings: 'settings',
|
||||
audit: 'audit',
|
||||
};
|
||||
const activeTab = tabKeyMap[pathSegment] || 'users';
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
@@ -70,21 +85,31 @@ export default function SystemManagement() {
|
||||
{ title: '描述', dataIndex: 'description' },
|
||||
];
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
navigate(`/system/${key}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs items={[
|
||||
{ key: 'users', label: '用户管理', children: (
|
||||
<Card size="small" extra={<Button type="primary" icon={<PlusOutlined />}
|
||||
onClick={() => { setEditingUser(null); form.resetFields(); setShowModal(true); }}>新建用户</Button>}>
|
||||
<Table columns={columns} dataSource={users.items} rowKey="id" loading={loading} size="small" />
|
||||
</Card>
|
||||
)},
|
||||
{ key: 'roles', label: '角色管理', children: (
|
||||
<Card size="small">
|
||||
<Table columns={roleColumns} dataSource={roles} rowKey="id" loading={loading} size="small" />
|
||||
</Card>
|
||||
)},
|
||||
]} />
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
items={[
|
||||
{ key: 'users', label: '用户管理', children: (
|
||||
<Card size="small" extra={<Button type="primary" icon={<PlusOutlined />}
|
||||
onClick={() => { setEditingUser(null); form.resetFields(); setShowModal(true); }}>新建用户</Button>}>
|
||||
<Table columns={columns} dataSource={users.items} rowKey="id" loading={loading} size="small" />
|
||||
</Card>
|
||||
)},
|
||||
{ key: 'roles', label: '角色管理', children: (
|
||||
<Card size="small">
|
||||
<Table columns={roleColumns} dataSource={roles} rowKey="id" loading={loading} size="small" />
|
||||
</Card>
|
||||
)},
|
||||
{ key: 'settings', label: '系统设置', children: <SystemSettings /> },
|
||||
{ key: 'audit', label: '审计日志', children: <AuditLog /> },
|
||||
]}
|
||||
/>
|
||||
<Modal title={editingUser ? '编辑用户' : '新建用户'} open={showModal}
|
||||
onCancel={() => { setShowModal(false); setEditingUser(null); }}
|
||||
onOk={() => form.submit()} okText="确定">
|
||||
|
||||
@@ -131,4 +131,11 @@ export const createUser = (data: any) => api.post('/users', data);
|
||||
export const updateUser = (id: number, data: any) => api.put(`/users/${id}`, data);
|
||||
export const getRoles = () => api.get('/users/roles');
|
||||
|
||||
// Audit Logs
|
||||
export const getAuditLogs = (params?: Record<string, any>) => api.get('/audit/logs', { params });
|
||||
|
||||
// System Settings
|
||||
export const getSettings = () => api.get('/settings');
|
||||
export const updateSettings = (data: any) => api.put('/settings', data);
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user