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