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