|
@@ -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()
|
|
|
|
|
+
|