liuq 4 maanden geleden
bovenliggende
commit
811195104d

+ 12 - 0
.dockerignore

@@ -0,0 +1,12 @@
+__pycache__
+*.pyc
+.env
+.venv
+venv
+node_modules
+dist
+.git
+.gitignore
+mysql_data
+reports
+snapshots

+ 1 - 0
.gitignore

@@ -22,6 +22,7 @@ share/python-wheels/
 *.egg
 MANIFEST
 backend/app/static/snapshots/
+reports
 
 # Virtual Environments
 .env

+ 32 - 0
CHANGELOG.md

@@ -0,0 +1,32 @@
+# Changelog
+
+本文件记录了项目的所有重要更改。
+
+格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。
+
+## [1.1.0] - 2025-12-19
+
+### 新增 (Added)
+- **容器化部署支持**:
+    - 新增 `Dockerfile`: 采用多阶段构建,自动编译 Vue 前端并集成到 Python 后端镜像中。
+    - 新增 `docker-compose.yml`: 一键启动应用与 MySQL 8.0 数据库服务。
+    - 新增 `.dockerignore`: 优化镜像构建上下文,减少镜像体积。
+- **数据持久化**: 在 Docker 部署模式下,实现了关键数据的本地持久化存储:
+    - 数据库文件 -> `./mysql_data`
+    - 巡检报告 (PDF) -> `./reports`
+    - 告警截图 -> `./snapshots`
+- **文档更新**: `README.md` 新增了 Docker 部署方式(推荐)、详细的目录结构说明以及环境配置要求。
+
+## [1.0.0] - 2025-10-01
+
+### 新增 (Added)
+- **项目初始化**: 发布 AI 值班 Web 平台 (AI Watch Platform) 初始版本。
+- **实时监控**: 支持多路摄像头的 RTSP 实时视频流播放与状态管理。
+- **AI 智能巡检**: 集成 OpenCV 与 AI 模型(支持接入 OpenAI 接口),实现画面分析与异常识别。
+- **任务调度**: 基于 APScheduler 实现灵活的巡检任务配置与定时执行。
+- **告警系统**:
+    - 异常情况实时记录到数据库。
+    - 自动保存异常时刻的视频截图。
+- **报告生成**: 支持生成包含在线率统计、告警列表及截图的 PDF 值班报告。
+- **用户管理**: 实现管理员账户的初始化、重置与登录认证 (JWT)。
+- **前端界面**: 构建了基于 Vue 3 + TypeScript 的可视化管理控制台,包含仪表盘、监控墙、日志查看等功能。

+ 39 - 0
Dockerfile

@@ -0,0 +1,39 @@
+# Stage 1: Build Frontend
+FROM node:18 AS frontend-builder
+WORKDIR /app/frontend
+COPY frontend/package*.json ./
+RUN npm install
+COPY frontend/ ./
+# Build the frontend. The output should be in frontend/dist
+RUN npm run build
+
+# Stage 2: Backend Runtime
+FROM python:3.10-slim
+
+WORKDIR /app
+
+# Install system dependencies required for OpenCV
+RUN apt-get update && apt-get install -y \
+    libgl1 \
+    libglib2.0-0 \
+    && rm -rf /var/lib/apt/lists/*
+
+# Install Python dependencies
+COPY backend/requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy backend code
+COPY backend/ ./backend/
+COPY run.py .
+
+# Copy built frontend assets to backend/dist (as expected by main.py)
+COPY --from=frontend-builder /app/frontend/dist ./backend/dist
+
+# Create directories for volumes
+RUN mkdir -p reports backend/app/static/snapshots
+
+# Expose the port
+EXPOSE 8000
+
+# Run the application
+CMD ["python", "run.py"]

+ 67 - 5
README.md

@@ -26,12 +26,71 @@
 
 ## 📋 环境要求
 
-*   **Python**: 3.10 或更高版本
-*   **Node.js**: 18.0 或更高版本
-*   **MySQL**: 8.0 或更高版本
+*   **Python**: 3.10 或更高版本 (本地开发)
+*   **Node.js**: 18.0 或更高版本 (本地开发)
+*   **MySQL**: 8.0 或更高版本 (本地开发)
+*   **Docker & Docker Compose**: (容器化部署)
 
 ## 🚀 快速开始
 
+### 方式一:Docker 部署 (推荐)
+
+本项目支持 Docker Compose 一键部署,集成了 MySQL 数据库,并实现了数据持久化。
+
+1.  **启动服务**
+
+    在项目根目录下运行:
+
+    ```bash
+    docker-compose up -d --build
+    ```
+
+2.  **访问服务**
+
+    等待容器启动完成后,访问:[http://localhost:8000](http://localhost:8000)
+
+    *   **默认账号**: `admin`
+    *   **默认密码**: `HNYZ0821`
+
+3.  **数据持久化说明**
+
+    启动后,项目根目录会自动生成以下文件夹用于数据持久化:
+
+    *   `mysql_data/`: MySQL 数据库数据
+    *   `reports/`: 生成的巡检报告 (PDF)
+    *   `snapshots/`: 告警截图文件
+
+#### 手动构建镜像 (可选)
+
+如果你不希望使用 Docker Compose,也可以直接使用 `Dockerfile` 构建和运行镜像。
+
+> **注意**: 这种方式需要你自行准备 MySQL 数据库,并通过环境变量连接。
+
+1.  **构建镜像**
+
+    ```bash
+    docker build -t ai-watch-platform .
+    ```
+
+2.  **运行容器**
+
+    ```bash
+    # 假设你的 MySQL 运行在 192.168.1.100,密码为 root_password
+    docker run -d -p 8000:8000 \
+      -e MYSQL_SERVER=192.168.1.100 \
+      -e MYSQL_PORT=3306 \
+      -e MYSQL_USER=root \
+      -e MYSQL_PASSWORD=root_password \
+      -v $(pwd)/reports:/app/reports \
+      -v $(pwd)/snapshots:/app/backend/app/static/snapshots \
+      --name ai-watch-app \
+      ai-watch-platform
+    ```
+
+---
+
+### 方式二:本地开发环境
+
 ### 1. 克隆项目
 
 ```bash
@@ -105,7 +164,7 @@ python run.py
 
 ## 🔐 默认账号
 
-如果在第 4 步运行了 `reset_admin.py`,可以使用以下默认账号登录:
+如果在第 4 步运行了 `reset_admin.py` (Docker 部署自动包含此步骤),可以使用以下默认账号登录:
 
 *   **用户名**: `admin`
 *   **密码**: `HNYZ0821`
@@ -121,7 +180,10 @@ ai-watch-platform/
 ├── .env                # 环境变量配置 (不要提交到 Git)
 ├── run.py              # 项目启动入口
 ├── init_db_script.py   # 数据库初始化脚本
-└── reset_admin.py      # 管理员重置脚本
+├── reset_admin.py      # 管理员重置脚本
+├── Dockerfile          # Docker 构建文件
+├── docker-compose.yml  # Docker Compose 编排文件
+└── .dockerignore       # Docker 忽略文件
 ```
 
 ## 📄 License

+ 2 - 1
backend/app/api/api.py

@@ -1,5 +1,5 @@
 from fastapi import APIRouter
-from backend.app.api.endpoints import auth, cameras, models_api, tasks, logs
+from backend.app.api.endpoints import auth, cameras, models_api, tasks, logs, reports
 
 api_router = APIRouter()
 api_router.include_router(auth.router, tags=["login"])
@@ -7,4 +7,5 @@ api_router.include_router(cameras.router, prefix="/cameras", tags=["cameras"])
 api_router.include_router(models_api.router, prefix="/models", tags=["models"])
 api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
 api_router.include_router(logs.router, prefix="/logs", tags=["logs"])
+api_router.include_router(reports.router, prefix="/reports", tags=["reports"])
 

+ 82 - 0
backend/app/api/endpoints/reports.py

@@ -0,0 +1,82 @@
+from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
+from fastapi.responses import FileResponse
+from sqlalchemy.orm import Session
+from typing import List
+import os
+
+from backend.app.core.database import get_db
+from backend.app.api import deps
+from backend.app.models import sql_models
+from backend.app.schemas import schemas
+from backend.app.services.report_service import ReportService
+
+router = APIRouter()
+
+def generate_report_task(report_id: int, db: Session):
+    # BackgroundTasks creates a new thread, so we need a new DB session if the passed one is closed?
+    # Actually, Depends(get_db) session is closed after request. 
+    # Better to create a new session or handle it carefully.
+    # For simplicity here, we'll instantiate ReportService which needs a session.
+    # But since this runs in background, we should probably handle session lifecycle manually
+    # or rely on the fact that if we pass the db session, it might be closed.
+    # best practice: create new session scope inside the background task.
+    from backend.app.core.database import SessionLocal
+    db_bg = SessionLocal()
+    try:
+        service = ReportService(db_bg, report_id)
+        service.generate_pdf()
+    finally:
+        db_bg.close()
+
+@router.post("/", response_model=schemas.DutyReport)
+def create_report(
+    report_in: schemas.DutyReportCreate,
+    background_tasks: BackgroundTasks,
+    db: Session = Depends(get_db),
+    current_user: sql_models.User = Depends(deps.get_current_user)
+):
+    # Use current user name if reporter name is not provided
+    if not report_in.reporter_name:
+        report_in.reporter_name = current_user.username
+        
+    db_report = sql_models.DutyReport(
+        reporter_name=report_in.reporter_name,
+        start_time=report_in.start_time,
+        end_time=report_in.end_time,
+        status="pending"
+    )
+    db.add(db_report)
+    db.commit()
+    db.refresh(db_report)
+    
+    background_tasks.add_task(generate_report_task, db_report.id, db)
+    
+    return db_report
+
+@router.get("/", response_model=List[schemas.DutyReport])
+def read_reports(
+    skip: int = 0,
+    limit: int = 10,
+    db: Session = Depends(get_db),
+    current_user: sql_models.User = Depends(deps.get_current_user)
+):
+    reports = db.query(sql_models.DutyReport).order_by(sql_models.DutyReport.created_at.desc()).offset(skip).limit(limit).all()
+    return reports
+
+@router.get("/{report_id}/download")
+def download_report(
+    report_id: int,
+    db: Session = Depends(get_db),
+    current_user: sql_models.User = Depends(deps.get_current_user)
+):
+    report = db.query(sql_models.DutyReport).filter(sql_models.DutyReport.id == report_id).first()
+    if not report:
+        raise HTTPException(status_code=404, detail="Report not found")
+        
+    if report.status != "completed":
+        raise HTTPException(status_code=400, detail="Report is not ready yet")
+        
+    if not report.file_path or not os.path.exists(report.file_path):
+        raise HTTPException(status_code=404, detail="Report file not found")
+        
+    return FileResponse(report.file_path, filename=os.path.basename(report.file_path), media_type='application/pdf')

+ 11 - 0
backend/app/models/sql_models.py

@@ -59,3 +59,14 @@ class TaskLog(Base):
     
     task = relationship("Task")
     camera = relationship("Camera")
+
+class DutyReport(Base):
+    __tablename__ = "duty_reports"
+
+    id = Column(Integer, primary_key=True, index=True)
+    reporter_name = Column(String(100))
+    start_time = Column(DateTime)
+    end_time = Column(DateTime)
+    status = Column(String(20), default="pending")  # pending, completed, failed
+    file_path = Column(String(255), nullable=True)
+    created_at = Column(DateTime, default=datetime.utcnow)

+ 18 - 0
backend/app/schemas/schemas.py

@@ -134,3 +134,21 @@ class TaskLog(TaskLogBase):
     
     class Config:
         from_attributes = True
+
+# Duty Report
+class DutyReportBase(BaseModel):
+    reporter_name: Optional[str] = None
+    start_time: datetime
+    end_time: datetime
+
+class DutyReportCreate(DutyReportBase):
+    pass
+
+class DutyReport(DutyReportBase):
+    id: int
+    status: str
+    file_path: Optional[str] = None
+    created_at: datetime
+    
+    class Config:
+        from_attributes = True

+ 296 - 0
backend/app/services/report_service.py

@@ -0,0 +1,296 @@
+import os
+from datetime import datetime
+from reportlab.lib import colors
+from reportlab.lib.pagesizes import A4
+from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
+from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image
+from reportlab.lib.units import inch
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.ttfonts import TTFont
+from sqlalchemy.orm import Session
+from backend.app.models import sql_models
+
+# 尝试注册中文字体 (这里假设系统中有SimHei或者SimSun,实际部署可能需要指定字体文件路径)
+# 为了演示,我们先尝试注册一个常见的中文字体,如果没有则使用默认字体(中文会乱码)
+try:
+    # 也可以将字体文件放在项目目录中引用
+    # pdfmetrics.registerFont(TTFont('SimHei', 'SimHei.ttf'))
+    # 如果没有字体文件,可以尝试系统字体,或者暂时忽略中文支持问题,但在本需求中显然需要中文
+    # 这里我们假设运行环境有微软雅黑或者类似字体,或者需要用户提供
+    # Windows下通常在 C:\Windows\Fonts\msyh.ttc
+    # Linux下通常在 /usr/share/fonts/...
+    # 暂时使用默认字体,并在 TODO 中说明需要字体文件
+    pass
+except:
+    pass
+
+from backend.app.services.video_core import video_manager
+
+class ReportService:
+    def __init__(self, db: Session, report_id: int):
+        self.db = db
+        self.report_id = report_id
+        self.report = db.query(sql_models.DutyReport).filter(sql_models.DutyReport.id == report_id).first()
+
+    def generate_pdf(self):
+        if not self.report:
+            return
+
+        try:
+            # 1. 准备数据
+            start_time = self.report.start_time
+            end_time = self.report.end_time
+            
+            # 转换为北京时间 (UTC+8) 用于查询和显示
+            # 注意:TaskLog 中存储的是本地时间 (datetime.now()),所以查询时也必须用本地时间
+            from datetime import timedelta
+            bj_offset = timedelta(hours=8)
+            local_start = start_time + bj_offset
+            local_end = end_time + bj_offset
+            
+            # 摄像头在线情况汇总
+            cameras = self.db.query(sql_models.Camera).all()
+            total_cameras = len(cameras)
+            
+            # 结合 VideoManager 的实时状态统计
+            online_cameras = 0
+            for cam in cameras:
+                # 1. 检查 VideoManager 中的实时流状态
+                if cam.id in video_manager.streams:
+                    if video_manager.streams[cam.id].status == 1:
+                        online_cameras += 1
+                        continue
+                
+                # 2. 如果内存中没有(例如服务刚重启还未加载),回退检查数据库字段
+                if cam.status == 1:
+                    online_cameras += 1
+
+            offline_cameras = total_cameras - online_cameras
+            
+            # 告警日志 (使用本地时间查询)
+            logs = self.db.query(sql_models.TaskLog).filter(
+                sql_models.TaskLog.check_time >= local_start,
+                sql_models.TaskLog.check_time <= local_end,
+                sql_models.TaskLog.is_alarm == True
+            ).order_by(sql_models.TaskLog.check_time).all()
+
+            # 2. 创建PDF
+            filename = f"report_{self.report_id}_{datetime.now().strftime('%Y%m%d%H%M%S')}.pdf"
+            output_path = os.path.join("reports", filename)
+            os.makedirs("reports", exist_ok=True)
+            
+            doc = SimpleDocTemplate(output_path, pagesize=A4)
+            elements = []
+            
+            # 注册字体 (需要确保字体文件存在,否则会报错或乱码)
+            # 优化字体加载逻辑:遍历常见中文字体路径
+            font_name = 'Helvetica' # 默认回退(不支持中文)
+            
+            # 获取当前文件所在目录 (backend/app/services)
+            current_dir = os.path.dirname(os.path.abspath(__file__))
+            # 项目字体目录 (backend/app/static/fonts)
+            project_font_dir = os.path.join(os.path.dirname(current_dir), 'static', 'fonts')
+            
+            # 备选字体列表 (注册名, 文件名, 是否TTC)
+            # 优先尝试开源友好的字体 (如 Noto Sans SC),然后是常见的 Windows 字体
+            font_candidates = [
+                ('NotoSansSC', 'NotoSansSC-Regular.ttf', False),
+                ('NotoSansSC', 'NotoSansSC-Regular.otf', False),
+                ('SimHei', 'SimHei.ttf', False),   # 黑体 (通用性好)
+                ('SimHei', 'simhei.ttf', False),   
+                ('Microsoft YaHei', 'msyh.ttc', True), # 微软雅黑
+                ('Microsoft YaHei', 'msyh.ttf', False),
+                ('SimSun', 'simsun.ttc', True),    # 宋体
+                ('SimSun', 'simsun.ttf', False),
+                ('WenQuanYi', 'wqy-microhei.ttc', True), # 文泉驿 (Linux常用)
+                ('WenQuanYi', 'wqy-zenhei.ttc', True),
+                ('DroidSansFallback', 'DroidSansFallback.ttf', False) # 安卓/老Linux
+            ]
+            
+            # 字体搜索目录列表 (优先级:项目目录 -> Windows目录 -> Linux目录 -> 用户目录)
+            font_dirs = [
+                project_font_dir,
+                r'C:\Windows\Fonts',
+                r'C:\WINNT\Fonts',
+                '/usr/share/fonts',
+                '/usr/share/fonts/truetype/noto',
+                '/usr/share/fonts/opentype/noto',
+                '/usr/share/fonts/truetype/wqy',
+                '/usr/local/share/fonts'
+            ]
+            # 尝试添加用户本地字体目录
+            if os.environ.get('LOCALAPPDATA'):
+                 font_dirs.append(os.path.join(os.environ['LOCALAPPDATA'], r'Microsoft\Windows\Fonts'))
+            if os.environ.get('HOME'):
+                font_dirs.append(os.path.join(os.environ['HOME'], '.fonts'))
+                font_dirs.append(os.path.join(os.environ['HOME'], '.local/share/fonts'))
+
+            found_font = False
+            for f_name, f_file, is_ttc in font_candidates:
+                for f_dir in font_dirs:
+                    f_path = os.path.join(f_dir, f_file)
+                    if os.path.exists(f_path):
+                        try:
+                            if is_ttc:
+                                # TTC 需要指定 subfontIndex
+                                pdfmetrics.registerFont(TTFont(f_name, f_path, subfontIndex=0))
+                            else:
+                                pdfmetrics.registerFont(TTFont(f_name, f_path))
+                            
+                            font_name = f_name
+                            found_font = True
+                            print(f"Successfully loaded font: {f_name} from {f_path}")
+                            break
+                        except Exception as e:
+                            print(f"Failed to load font {f_path}: {e}")
+                
+                if found_font:
+                    break
+            
+            if not found_font:
+                print("Warning: No Chinese font found. Text may appear as boxes.")
+
+            styles = getSampleStyleSheet()
+            
+            # 确保 Heading2 也使用中文字体
+            if 'Heading2' in styles:
+                styles['Heading2'].fontName = font_name
+                
+            title_style = ParagraphStyle(
+                'TitleStyle',
+                parent=styles['Heading1'],
+                fontName=font_name,
+                fontSize=24,
+                alignment=1,
+                spaceAfter=20
+            )
+            normal_style = ParagraphStyle(
+                'NormalStyle',
+                parent=styles['Normal'],
+                fontName=font_name,
+                fontSize=12,
+                spaceAfter=10
+            )
+            
+            # 标题
+            title_text = f"{datetime.now().strftime('%Y-%m-%d')} 值班报告"
+            elements.append(Paragraph(title_text, title_style))
+            
+            # 报告人信息
+            elements.append(Paragraph(f"报告人: {self.report.reporter_name}", normal_style))
+            elements.append(Paragraph(f"值班时间: {local_start.strftime('%Y-%m-%d %H:%M:%S')} 至 {local_end.strftime('%Y-%m-%d %H:%M:%S')}", normal_style))
+            elements.append(Spacer(1, 20))
+            
+            # 1. 摄像头在线情况汇总
+            elements.append(Paragraph("一、摄像头在线情况汇总", styles['Heading2']))
+            camera_summary = [
+                ['总数', '在线', '离线'],
+                [str(total_cameras), str(online_cameras), str(offline_cameras)]
+            ]
+            t = Table(camera_summary)
+            t.setStyle(TableStyle([
+                ('BACKGROUND', (0, 0), (-1, 0), colors.grey),
+                ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
+                ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
+                ('FONTNAME', (0, 0), (-1, -1), font_name),
+                ('FONTSIZE', (0, 0), (-1, -1), 12),
+                ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
+                ('BACKGROUND', (0, 1), (-1, -1), colors.beige),
+                ('GRID', (0, 0), (-1, -1), 1, colors.black)
+            ]))
+            elements.append(t)
+            elements.append(Spacer(1, 20))
+            
+            # 2. 告警列表简述
+            elements.append(Paragraph("二、告警列表简述", styles['Heading2']))
+            if logs:
+                elements.append(Paragraph(f"共发现 {len(logs)} 条告警记录。", normal_style))
+                log_data = [['时间', '摄像头', '告警类型', '区域']]
+                for log in logs:
+                    camera_name = log.camera.name if log.camera else "Unknown"
+                    # 日志时间已经是本地时间 (TaskLog存储的就是本地时间),不需要再转换,或者视情况而定
+                    # 如果 TaskLog 存的是 naive datetime (无时区),它就是本地时间,直接显示即可
+                    # 如果之前为了显示加了 offset,现在查询出来的 logs 里的 check_time 是本地时间,
+                    # 那么这里不需要再加 offset 了。
+                    # 验证:TaskLog 写入时用 datetime.now(),即本地时间。
+                    log_time = log.check_time 
+                    log_data.append([
+                        log_time.strftime('%H:%M:%S'),
+                        camera_name,
+                        log.alarm_name or "未知",
+                        log.area or "-"
+                    ])
+                
+                t_logs = Table(log_data)
+                t_logs.setStyle(TableStyle([
+                    ('BACKGROUND', (0, 0), (-1, 0), colors.grey),
+                    ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
+                    ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
+                    ('FONTNAME', (0, 0), (-1, -1), font_name),
+                    ('GRID', (0, 0), (-1, -1), 1, colors.black)
+                ]))
+                elements.append(t_logs)
+            else:
+                elements.append(Paragraph("无告警记录。", normal_style))
+            
+            elements.append(Spacer(1, 20))
+            
+            # 3. 具体告警内容
+            elements.append(Paragraph("三、具体告警内容", styles['Heading2']))
+            if logs:
+                for i, log in enumerate(logs, 1):
+                    camera_name = log.camera.name if log.camera else "Unknown"
+                    log_time = log.check_time
+                    content = f"{i}. [{log_time.strftime('%H:%M:%S')}] {camera_name} - {log.alarm_name}: {log.alarm_content}"
+                    elements.append(Paragraph(content, normal_style))
+                    
+                    # 添加图片
+                    if log.snapshot_path:
+                        # 转换相对路径为绝对路径
+                        # log.snapshot_path 类似 "/static/snapshots/..."
+                        # 我们的 static 目录在 backend/app/static
+                        
+                        # 移除开头的斜杠以进行路径拼接
+                        rel_path = log.snapshot_path.lstrip('/')
+                        # 假设运行目录是项目根目录
+                        abs_path = os.path.join(os.getcwd(), "backend", "app", rel_path)
+                        
+                        if os.path.exists(abs_path):
+                            try:
+                                # 调整图片大小以适应页面,保持比例
+                                img = Image(abs_path)
+                                # A4 宽度约为 595 points,减去左右边距各72 (1 inch) -> 450
+                                # 或者更保守一点,设置最大宽度为 400
+                                max_width = 400
+                                img_width = img.drawWidth
+                                img_height = img.drawHeight
+                                
+                                if img_width > max_width:
+                                    ratio = max_width / img_width
+                                    img.drawWidth = max_width
+                                    img.drawHeight = img_height * ratio
+                                
+                                elements.append(img)
+                                elements.append(Spacer(1, 5))
+                            except Exception as img_err:
+                                print(f"Error adding image {abs_path}: {img_err}")
+                        else:
+                            print(f"Image not found: {abs_path}")
+
+                    elements.append(Spacer(1, 15))
+            else:
+                elements.append(Paragraph("无详细内容。", normal_style))
+
+            # 生成PDF
+            doc.build(elements)
+            
+            # 更新数据库状态
+            self.report.status = "completed"
+            self.report.file_path = output_path
+            self.db.commit()
+            
+        except Exception as e:
+            print(f"Error generating PDF: {e}")
+            self.report.status = "failed"
+            self.db.commit()
+

BIN
backend/app/static/fonts/SimHei.ttf


+ 2 - 0
backend/requirements.txt

@@ -11,4 +11,6 @@ passlib[bcrypt]==1.7.4
 openpyxl==3.1.2
 websockets==12.0
 numpy<2.0.0
+reportlab==4.0.9
+python-dotenv==1.0.1
 

+ 38 - 0
docker-compose.yml

@@ -0,0 +1,38 @@
+version: '3.8'
+
+services:
+  app:
+    build: .
+    container_name: ai_watch_app
+    restart: always
+    ports:
+      - "8000:8000"
+    environment:
+      - MYSQL_SERVER=db
+      - MYSQL_PORT=3306
+      - MYSQL_USER=root
+      - MYSQL_PASSWORD=root_password
+      - MYSQL_DB=ai_watch
+      - TZ=Asia/Shanghai
+    volumes:
+      # Reports volume
+      - ./reports:/app/reports
+      # Snapshots volume (nested inside the app structure)
+      - ./snapshots:/app/backend/app/static/snapshots
+    depends_on:
+      - db
+
+  db:
+    image: mysql:8.0
+    container_name: ai_watch_db
+    restart: always
+    environment:
+      - MYSQL_ROOT_PASSWORD=root_password
+      - MYSQL_DATABASE=ai_watch
+      - TZ=Asia/Shanghai
+    volumes:
+      # Database data volume
+      - ./mysql_data:/var/lib/mysql
+    ports:
+      - "3306:3306"
+    command: --default-authentication-plugin=mysql_native_password

+ 4 - 0
frontend/src/App.vue

@@ -65,6 +65,10 @@ const handleMenuSelect = (index: string) => {
               <el-icon><Document /></el-icon>
               <span>报警日志</span>
             </el-menu-item>
+            <el-menu-item index="/reports">
+              <el-icon><Document /></el-icon>
+              <span>值班报告</span>
+            </el-menu-item>
           </el-menu>
         </el-aside>
         <el-main class="main-content">

+ 3 - 0
frontend/src/api/index.ts

@@ -5,6 +5,9 @@ import router from '../router'
 // Use relative URL for production, or hardcoded for dev
 const baseURL = import.meta.env.PROD ? '/api/v1' : 'http://localhost:8000/api/v1'
 
+export const getBaseURL = () => baseURL
+
+
 const api = axios.create({
   baseURL
 })

+ 8 - 0
frontend/src/router/index.ts

@@ -6,6 +6,7 @@ import Tasks from '../views/Tasks.vue'
 import Logs from '../views/Logs.vue'
 import Cameras from '../views/Cameras.vue'
 import Models from '../views/Models.vue'
+import Reports from '../views/Reports.vue'
 
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
@@ -50,10 +51,17 @@ const router = createRouter({
       name: 'logs',
       component: Logs,
       meta: { requiresAuth: true }
+    },
+    {
+      path: '/reports',
+      name: 'reports',
+      component: Reports,
+      meta: { requiresAuth: true }
     }
   ]
 })
 
+
 router.beforeEach((to, _from, next) => {
   const authStore = useAuthStore()
   if (to.meta.requiresAuth && !authStore.token) {

+ 285 - 0
frontend/src/views/Reports.vue

@@ -0,0 +1,285 @@
+<template>
+  <div class="reports-container">
+    <h2>值班报告管理</h2>
+
+    <div class="report-form">
+      <h3>生成新报告</h3>
+      <div class="form-group">
+        <label>报告人:</label>
+        <input v-model="form.reporter_name" placeholder="默认为当前登录用户" />
+      </div>
+      <div class="form-group">
+        <label>开始时间:</label>
+        <input type="datetime-local" v-model="form.start_time" step="1" />
+      </div>
+      <div class="form-group">
+        <label>结束时间:</label>
+        <input type="datetime-local" v-model="form.end_time" step="1" />
+      </div>
+      <button @click="createReport" :disabled="loading">生成报告</button>
+    </div>
+
+    <div class="report-list">
+      <h3>历史报告</h3>
+      <table>
+        <thead>
+          <tr>
+            <th>ID</th>
+            <th>报告人</th>
+            <th>时间范围</th>
+            <th>状态</th>
+            <th>创建时间</th>
+            <th>操作</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="report in reports" :key="report.id">
+            <td>{{ report.id }}</td>
+            <td>{{ report.reporter_name }}</td>
+            <td>
+              {{ formatDateTime(report.start_time) }} - <br/>
+              {{ formatDateTime(report.end_time) }}
+            </td>
+            <td>
+              <span :class="getStatusClass(report.status)">{{ report.status }}</span>
+            </td>
+            <td>{{ formatDateTime(report.created_at) }}</td>
+            <td>
+              <button 
+                v-if="report.status === 'completed'" 
+                @click="downloadReport(report.id)"
+              >
+                下载 PDF
+              </button>
+              <span v-else-if="report.status === 'failed'">生成失败</span>
+              <span v-else>生成中...</span>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+      
+      <div class="pagination">
+        <button @click="prevPage" :disabled="page === 0">上一页</button>
+        <span>第 {{ page + 1 }} 页</span>
+        <button @click="nextPage" :disabled="reports.length < limit">下一页</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import api from '../api'
+
+interface Report {
+  id: number
+  reporter_name: string
+  start_time: string
+  end_time: string
+  status: string
+  file_path: string | null
+  created_at: string
+}
+
+const reports = ref<Report[]>([])
+const loading = ref(false)
+const page = ref(0)
+const limit = 10
+
+const form = ref({
+  reporter_name: '',
+  start_time: '',
+  end_time: ''
+})
+
+// Set default times (last 24 hours)
+const now = new Date()
+now.setMinutes(now.getMinutes() - now.getTimezoneOffset()) // Adjust to local ISO string
+const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
+
+form.value.end_time = now.toISOString().slice(0, 19)
+form.value.start_time = yesterday.toISOString().slice(0, 19)
+
+const fetchReports = async () => {
+  try {
+    const res = await api.get('/reports/', {
+      params: {
+        skip: page.value * limit,
+        limit: limit
+      }
+    })
+    reports.value = res.data
+  } catch (error) {
+    console.error('Failed to fetch reports:', error)
+  }
+}
+
+const createReport = async () => {
+  if (!form.value.start_time || !form.value.end_time) {
+    alert('请选择时间范围')
+    return
+  }
+
+  loading.value = true
+  try {
+    await api.post('/reports/', {
+      reporter_name: form.value.reporter_name || undefined,
+      start_time: new Date(form.value.start_time).toISOString(),
+      end_time: new Date(form.value.end_time).toISOString()
+    })
+    // Reset form or keep it?
+    fetchReports()
+  } catch (error) {
+    console.error('Failed to create report:', error)
+    alert('生成报告失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+const downloadReport = async (reportId: number) => {
+  try {
+    // Construct download URL
+    // const url = `${getBaseURL()}/reports/${reportId}/download`
+
+    
+    // Create hidden link to trigger download
+    // Using axios for download needs blob handling, simpler to use window.open or link click with token
+    // Since we use bearer token, we can't just open a new window unless we use a cookie or pass token in query (insecure)
+    // So we use axios to get blob
+    
+    const response = await api.get(`/reports/${reportId}/download`, {
+      responseType: 'blob'
+    })
+    
+    const blob = new Blob([response.data], { type: 'application/pdf' })
+    const link = document.createElement('a')
+    link.href = window.URL.createObjectURL(blob)
+    link.download = `report_${reportId}.pdf`
+    link.click()
+    
+  } catch (error) {
+    console.error('Download failed:', error)
+    alert('下载失败')
+  }
+}
+
+const formatDateTime = (dtStr: string) => {
+  if (!dtStr) return ''
+  // 后端返回的是 UTC 时间(ISO 格式,可能不带 Z),前端需要将其转换为本地时间显示
+  // 如果字符串不包含时区信息(Z 或 +),手动添加 Z 视为 UTC
+  if (!dtStr.includes('Z') && !dtStr.includes('+')) {
+    dtStr += 'Z'
+  }
+  return new Date(dtStr).toLocaleString()
+}
+
+const getStatusClass = (status: string) => {
+  return {
+    'status-pending': status === 'pending',
+    'status-completed': status === 'completed',
+    'status-failed': status === 'failed'
+  }
+}
+
+const prevPage = () => {
+  if (page.value > 0) {
+    page.value--
+    fetchReports()
+  }
+}
+
+const nextPage = () => {
+  page.value++
+  fetchReports()
+}
+
+onMounted(() => {
+  fetchReports()
+  // Poll for updates every 5 seconds if there are pending reports
+  setInterval(() => {
+    if (reports.value.some(r => r.status === 'pending')) {
+      fetchReports()
+    }
+  }, 5000)
+})
+</script>
+
+<style scoped>
+.reports-container {
+  padding: 20px;
+}
+
+.report-form {
+  background: #f5f5f5;
+  padding: 20px;
+  border-radius: 8px;
+  margin-bottom: 30px;
+}
+
+.form-group {
+  margin-bottom: 15px;
+  display: flex;
+  align-items: center;
+}
+
+.form-group label {
+  width: 100px;
+  font-weight: bold;
+}
+
+.form-group input {
+  padding: 8px;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  width: 250px;
+}
+
+button {
+  padding: 8px 16px;
+  background-color: #42b983;
+  color: white;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+}
+
+button:disabled {
+  background-color: #ccc;
+  cursor: not-allowed;
+}
+
+table {
+  width: 100%;
+  border-collapse: collapse;
+  margin-top: 15px;
+}
+
+th, td {
+  border: 1px solid #ddd;
+  padding: 12px;
+  text-align: left;
+}
+
+th {
+  background-color: #f2f2f2;
+}
+
+.pagination {
+  margin-top: 20px;
+  display: flex;
+  gap: 10px;
+  align-items: center;
+  justify-content: center;
+}
+
+.status-pending {
+  color: orange;
+}
+.status-completed {
+  color: green;
+}
+.status-failed {
+  color: red;
+}
+</style>