report_service.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import os
  2. from datetime import datetime
  3. from reportlab.lib import colors
  4. from reportlab.lib.pagesizes import A4
  5. from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
  6. from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image
  7. from reportlab.lib.units import inch
  8. from reportlab.pdfbase import pdfmetrics
  9. from reportlab.pdfbase.ttfonts import TTFont
  10. from sqlalchemy.orm import Session
  11. from backend.app.models import sql_models
  12. # 尝试注册中文字体 (这里假设系统中有SimHei或者SimSun,实际部署可能需要指定字体文件路径)
  13. # 为了演示,我们先尝试注册一个常见的中文字体,如果没有则使用默认字体(中文会乱码)
  14. try:
  15. # 也可以将字体文件放在项目目录中引用
  16. # pdfmetrics.registerFont(TTFont('SimHei', 'SimHei.ttf'))
  17. # 如果没有字体文件,可以尝试系统字体,或者暂时忽略中文支持问题,但在本需求中显然需要中文
  18. # 这里我们假设运行环境有微软雅黑或者类似字体,或者需要用户提供
  19. # Windows下通常在 C:\Windows\Fonts\msyh.ttc
  20. # Linux下通常在 /usr/share/fonts/...
  21. # 暂时使用默认字体,并在 TODO 中说明需要字体文件
  22. pass
  23. except:
  24. pass
  25. from backend.app.services.video_core import video_manager
  26. class ReportService:
  27. def __init__(self, db: Session, report_id: int):
  28. self.db = db
  29. self.report_id = report_id
  30. self.report = db.query(sql_models.DutyReport).filter(sql_models.DutyReport.id == report_id).first()
  31. def generate_pdf(self):
  32. if not self.report:
  33. return
  34. try:
  35. # 1. 准备数据
  36. start_time = self.report.start_time
  37. end_time = self.report.end_time
  38. # 转换为北京时间 (UTC+8) 用于查询和显示
  39. # 注意:TaskLog 中存储的是本地时间 (datetime.now()),所以查询时也必须用本地时间
  40. from datetime import timedelta
  41. bj_offset = timedelta(hours=8)
  42. local_start = start_time + bj_offset
  43. local_end = end_time + bj_offset
  44. # 摄像头在线情况汇总
  45. cameras = self.db.query(sql_models.Camera).all()
  46. total_cameras = len(cameras)
  47. # 结合 VideoManager 的实时状态统计
  48. online_cameras = 0
  49. for cam in cameras:
  50. # 1. 检查 VideoManager 中的实时流状态
  51. if cam.id in video_manager.streams:
  52. if video_manager.streams[cam.id].status == 1:
  53. online_cameras += 1
  54. continue
  55. # 2. 如果内存中没有(例如服务刚重启还未加载),回退检查数据库字段
  56. if cam.status == 1:
  57. online_cameras += 1
  58. offline_cameras = total_cameras - online_cameras
  59. # 告警日志 (使用本地时间查询)
  60. logs = self.db.query(sql_models.TaskLog).filter(
  61. sql_models.TaskLog.check_time >= local_start,
  62. sql_models.TaskLog.check_time <= local_end,
  63. sql_models.TaskLog.is_alarm == True
  64. ).order_by(sql_models.TaskLog.check_time).all()
  65. # 2. 创建PDF
  66. filename = f"report_{self.report_id}_{datetime.now().strftime('%Y%m%d%H%M%S')}.pdf"
  67. output_path = os.path.join("reports", filename)
  68. os.makedirs("reports", exist_ok=True)
  69. doc = SimpleDocTemplate(output_path, pagesize=A4)
  70. elements = []
  71. # 注册字体 (需要确保字体文件存在,否则会报错或乱码)
  72. # 优化字体加载逻辑:遍历常见中文字体路径
  73. font_name = 'Helvetica' # 默认回退(不支持中文)
  74. # 获取当前文件所在目录 (backend/app/services)
  75. current_dir = os.path.dirname(os.path.abspath(__file__))
  76. # 项目字体目录 (backend/app/static/fonts)
  77. project_font_dir = os.path.join(os.path.dirname(current_dir), 'static', 'fonts')
  78. # 备选字体列表 (注册名, 文件名, 是否TTC)
  79. # 优先尝试开源友好的字体 (如 Noto Sans SC),然后是常见的 Windows 字体
  80. font_candidates = [
  81. ('NotoSansSC', 'NotoSansSC-Regular.ttf', False),
  82. ('NotoSansSC', 'NotoSansSC-Regular.otf', False),
  83. ('SimHei', 'SimHei.ttf', False), # 黑体 (通用性好)
  84. ('SimHei', 'simhei.ttf', False),
  85. ('Microsoft YaHei', 'msyh.ttc', True), # 微软雅黑
  86. ('Microsoft YaHei', 'msyh.ttf', False),
  87. ('SimSun', 'simsun.ttc', True), # 宋体
  88. ('SimSun', 'simsun.ttf', False),
  89. ('WenQuanYi', 'wqy-microhei.ttc', True), # 文泉驿 (Linux常用)
  90. ('WenQuanYi', 'wqy-zenhei.ttc', True),
  91. ('DroidSansFallback', 'DroidSansFallback.ttf', False) # 安卓/老Linux
  92. ]
  93. # 字体搜索目录列表 (优先级:项目目录 -> Windows目录 -> Linux目录 -> 用户目录)
  94. font_dirs = [
  95. project_font_dir,
  96. r'C:\Windows\Fonts',
  97. r'C:\WINNT\Fonts',
  98. '/usr/share/fonts',
  99. '/usr/share/fonts/truetype/noto',
  100. '/usr/share/fonts/opentype/noto',
  101. '/usr/share/fonts/truetype/wqy',
  102. '/usr/local/share/fonts'
  103. ]
  104. # 尝试添加用户本地字体目录
  105. if os.environ.get('LOCALAPPDATA'):
  106. font_dirs.append(os.path.join(os.environ['LOCALAPPDATA'], r'Microsoft\Windows\Fonts'))
  107. if os.environ.get('HOME'):
  108. font_dirs.append(os.path.join(os.environ['HOME'], '.fonts'))
  109. font_dirs.append(os.path.join(os.environ['HOME'], '.local/share/fonts'))
  110. found_font = False
  111. for f_name, f_file, is_ttc in font_candidates:
  112. for f_dir in font_dirs:
  113. f_path = os.path.join(f_dir, f_file)
  114. if os.path.exists(f_path):
  115. try:
  116. if is_ttc:
  117. # TTC 需要指定 subfontIndex
  118. pdfmetrics.registerFont(TTFont(f_name, f_path, subfontIndex=0))
  119. else:
  120. pdfmetrics.registerFont(TTFont(f_name, f_path))
  121. font_name = f_name
  122. found_font = True
  123. print(f"Successfully loaded font: {f_name} from {f_path}")
  124. break
  125. except Exception as e:
  126. print(f"Failed to load font {f_path}: {e}")
  127. if found_font:
  128. break
  129. if not found_font:
  130. print("Warning: No Chinese font found. Text may appear as boxes.")
  131. styles = getSampleStyleSheet()
  132. # 确保 Heading2 也使用中文字体
  133. if 'Heading2' in styles:
  134. styles['Heading2'].fontName = font_name
  135. title_style = ParagraphStyle(
  136. 'TitleStyle',
  137. parent=styles['Heading1'],
  138. fontName=font_name,
  139. fontSize=24,
  140. alignment=1,
  141. spaceAfter=20
  142. )
  143. normal_style = ParagraphStyle(
  144. 'NormalStyle',
  145. parent=styles['Normal'],
  146. fontName=font_name,
  147. fontSize=12,
  148. spaceAfter=10
  149. )
  150. # 标题
  151. title_text = f"{datetime.now().strftime('%Y-%m-%d')} 值班报告"
  152. elements.append(Paragraph(title_text, title_style))
  153. # 报告人信息
  154. elements.append(Paragraph(f"报告人: {self.report.reporter_name}", normal_style))
  155. elements.append(Paragraph(f"值班时间: {local_start.strftime('%Y-%m-%d %H:%M:%S')} 至 {local_end.strftime('%Y-%m-%d %H:%M:%S')}", normal_style))
  156. elements.append(Spacer(1, 20))
  157. # 1. 摄像头在线情况汇总
  158. elements.append(Paragraph("一、摄像头在线情况汇总", styles['Heading2']))
  159. camera_summary = [
  160. ['总数', '在线', '离线'],
  161. [str(total_cameras), str(online_cameras), str(offline_cameras)]
  162. ]
  163. t = Table(camera_summary)
  164. t.setStyle(TableStyle([
  165. ('BACKGROUND', (0, 0), (-1, 0), colors.grey),
  166. ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
  167. ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
  168. ('FONTNAME', (0, 0), (-1, -1), font_name),
  169. ('FONTSIZE', (0, 0), (-1, -1), 12),
  170. ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
  171. ('BACKGROUND', (0, 1), (-1, -1), colors.beige),
  172. ('GRID', (0, 0), (-1, -1), 1, colors.black)
  173. ]))
  174. elements.append(t)
  175. elements.append(Spacer(1, 20))
  176. # 2. 告警列表简述
  177. elements.append(Paragraph("二、告警列表简述", styles['Heading2']))
  178. if logs:
  179. elements.append(Paragraph(f"共发现 {len(logs)} 条告警记录。", normal_style))
  180. log_data = [['时间', '摄像头', '告警类型', '区域']]
  181. for log in logs:
  182. camera_name = log.camera.name if log.camera else "Unknown"
  183. # 日志时间已经是本地时间 (TaskLog存储的就是本地时间),不需要再转换,或者视情况而定
  184. # 如果 TaskLog 存的是 naive datetime (无时区),它就是本地时间,直接显示即可
  185. # 如果之前为了显示加了 offset,现在查询出来的 logs 里的 check_time 是本地时间,
  186. # 那么这里不需要再加 offset 了。
  187. # 验证:TaskLog 写入时用 datetime.now(),即本地时间。
  188. log_time = log.check_time
  189. log_data.append([
  190. log_time.strftime('%H:%M:%S'),
  191. camera_name,
  192. log.alarm_name or "未知",
  193. log.area or "-"
  194. ])
  195. t_logs = Table(log_data)
  196. t_logs.setStyle(TableStyle([
  197. ('BACKGROUND', (0, 0), (-1, 0), colors.grey),
  198. ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
  199. ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
  200. ('FONTNAME', (0, 0), (-1, -1), font_name),
  201. ('GRID', (0, 0), (-1, -1), 1, colors.black)
  202. ]))
  203. elements.append(t_logs)
  204. else:
  205. elements.append(Paragraph("无告警记录。", normal_style))
  206. elements.append(Spacer(1, 20))
  207. # 3. 具体告警内容
  208. elements.append(Paragraph("三、具体告警内容", styles['Heading2']))
  209. if logs:
  210. for i, log in enumerate(logs, 1):
  211. camera_name = log.camera.name if log.camera else "Unknown"
  212. log_time = log.check_time
  213. content = f"{i}. [{log_time.strftime('%H:%M:%S')}] {camera_name} - {log.alarm_name}: {log.alarm_content}"
  214. elements.append(Paragraph(content, normal_style))
  215. # 添加图片
  216. if log.snapshot_path:
  217. # 转换相对路径为绝对路径
  218. # log.snapshot_path 类似 "/static/snapshots/..."
  219. # 我们的 static 目录在 backend/app/static
  220. # 移除开头的斜杠以进行路径拼接
  221. rel_path = log.snapshot_path.lstrip('/')
  222. # 假设运行目录是项目根目录
  223. abs_path = os.path.join(os.getcwd(), "backend", "app", rel_path)
  224. if os.path.exists(abs_path):
  225. try:
  226. # 调整图片大小以适应页面,保持比例
  227. img = Image(abs_path)
  228. # A4 宽度约为 595 points,减去左右边距各72 (1 inch) -> 450
  229. # 或者更保守一点,设置最大宽度为 400
  230. max_width = 400
  231. img_width = img.drawWidth
  232. img_height = img.drawHeight
  233. if img_width > max_width:
  234. ratio = max_width / img_width
  235. img.drawWidth = max_width
  236. img.drawHeight = img_height * ratio
  237. elements.append(img)
  238. elements.append(Spacer(1, 5))
  239. except Exception as img_err:
  240. print(f"Error adding image {abs_path}: {img_err}")
  241. else:
  242. print(f"Image not found: {abs_path}")
  243. elements.append(Spacer(1, 15))
  244. else:
  245. elements.append(Paragraph("无详细内容。", normal_style))
  246. # 生成PDF
  247. doc.build(elements)
  248. # 更新数据库状态
  249. self.report.status = "completed"
  250. self.report.file_path = output_path
  251. self.db.commit()
  252. except Exception as e:
  253. print(f"Error generating PDF: {e}")
  254. self.report.status = "failed"
  255. self.db.commit()