소스 검색

增强:综合报告改为 HTML 网页导出

DESKTOP-74CLTRG\Leol 4 일 전
부모
커밋
49c1fffa7c
3개의 변경된 파일248개의 추가작업 그리고 48개의 파일을 삭제
  1. 33 48
      app.py
  2. 188 0
      app_utils.py
  3. 27 0
      tests/test_app_utils.py

+ 33 - 48
app.py

@@ -29,6 +29,7 @@ from defect_analysis.cases import (
 from app_utils import (
     apply_defect_filters,
     build_diagnostic_dashboard,
+    build_html_report,
     build_ml_factor_insights,
     calculate_kpis,
     calculate_spc_metrics,
@@ -2538,63 +2539,39 @@ if current_config["show_export"]:
 
     # 综合报告导出
     st.subheader("📋 一键导出综合报告")
-    st.markdown("包含所有分析模块的关键结论,适合汇报和存档。")
-
-    report_parts = []
-    report_parts.append("# 缺陷集中性分析综合报告\n")
-    report_parts.append(f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
-    report_parts.append(f"**数据范围**: {start_date.strftime('%Y-%m-%d')} ~ {end_date.strftime('%Y-%m-%d')}")
-    report_parts.append(f"**筛选后缺陷数**: {len(filtered_df)} 条")
-    report_parts.append(f"**涉及面板**: {filtered_df['panel_id'].nunique()} 块")
-    report_parts.append(f"**视图模式**: {view_mode}\n")
+    st.markdown("包含所有分析模块的关键结论,下载后可直接用浏览器打开、归档或打印为 PDF。")
 
     # 1. KPI 摘要
-    report_parts.append("## 1. KPI 摘要\n")
     report_kpis = calculate_kpis(df, filtered_df)
     total_panels_inspected_r = report_kpis["total_panels_inspected"]
     defective_panels_r = report_kpis["defective_panels"]
     yield_rate_r = report_kpis["yield_rate"]
-    report_parts.append(f"- 检测面板数: {total_panels_inspected_r} 块")
     defective_rate_r = defective_panels_r / max(total_panels_inspected_r, 1) * 100
-    report_parts.append(f"- 不良面板数: {defective_panels_r} 块 ({defective_rate_r:.1f}%)")
-    report_parts.append(f"- 综合良率: {yield_rate_r:.1f}%")
-    report_parts.append(f"- 缺陷总数: {len(filtered_df)} 个")
-    report_parts.append(f"- 严重缺陷: {(filtered_df['severity']=='严重').sum()} 个\n")
 
     # 2. 缺陷类型
-    report_parts.append("## 2. 缺陷类型分布\n")
     type_counts_r = filtered_df["defect_type"].value_counts()
-    for t, c in type_counts_r.items():
-        report_parts.append(f"- {t}: {c} ({c/len(filtered_df)*100:.1f}%)")
-    report_parts.append("")
 
     # 3. 设备/座号
+    eq_counts = pd.Series(dtype=int)
+    seat_top = pd.Series(dtype=int)
     if "equipment_id" in filtered_df.columns:
-        report_parts.append("## 3. 设备与座号分布\n")
         eq_counts = filtered_df["equipment_id"].value_counts()
-        for e, c in eq_counts.items():
-            report_parts.append(f"- {e}: {c} 个缺陷")
         seat_top = filtered_df["seat_id"].value_counts().head(5)
-        report_parts.append(f"\n**缺陷座号 TOP5**:")
-        for i, (s, c) in enumerate(seat_top.items(), 1):
-            report_parts.append(f"  {i}. {s}: {c} 个")
-        report_parts.append("")
 
     # 4. 趋势
-    report_parts.append("## 4. 趋势分析\n")
+    trend_summary = "缺陷数趋势: 样本天数不足,暂不判断趋势"
     daily_r = filtered_df.groupby("day").size()
     if len(daily_r) >= 2:
         x_r = np.arange(len(daily_r))
         coeffs_r = np.polyfit(x_r, daily_r.values.astype(float), 1)
         slope_r = coeffs_r[0]
         if slope_r > 0:
-            report_parts.append(f"- 缺陷数趋势: **上升** (斜率 {slope_r:.1f}/天)")
+            trend_summary = f"缺陷数趋势: 上升 (斜率 {slope_r:.1f}/天)"
         else:
-            report_parts.append(f"- 缺陷数趋势: **下降** (斜率 {slope_r:.1f}/天)")
-    report_parts.append("")
+            trend_summary = f"缺陷数趋势: 下降 (斜率 {slope_r:.1f}/天)"
 
     # 5. 异常座号
-    report_parts.append("## 5. 异常检测\n")
+    anomaly_rows = []
     if "seat_id" in filtered_df.columns:
         all_seat_stats_r = filtered_df.groupby(["equipment_id", "seat_id"]).size()
         mean_r = all_seat_stats_r.mean()
@@ -2602,33 +2579,41 @@ if current_config["show_export"]:
         threshold_2x_r = mean_r + 2 * std_r
         critical_r = all_seat_stats_r[all_seat_stats_r > threshold_2x_r]
         if len(critical_r) > 0:
-            report_parts.append(f"- ⚠️ 2σ 异常座号: {len(critical_r)} 个")
             for (eq, seat), count in critical_r.items():
-                report_parts.append(f"  - {eq}/{seat}: {count} 个缺陷")
-        else:
-            report_parts.append("- ✅ 无 2σ 异常座号")
-    report_parts.append("")
+                anomaly_rows.append({"equipment": eq, "seat": seat, "count": count})
 
     # 6. 建议
-    report_parts.append("## 6. 建议\n")
     top_type = type_counts_r.index[0] if len(type_counts_r) > 0 else "-"
     top_eq = eq_counts.index[0] if len(eq_counts) > 0 else "-"
-    report_parts.append(f"- 重点关注缺陷类型: **{top_type}**")
-    report_parts.append(f"- 重点关注设备: **{top_eq}**")
-    report_parts.append("- 建议查看 SPC 控制图确认趋势状态")
-    report_parts.append("- 建议检查设备健康评分\n")
-
-    report_parts.append("---\n*本报告由缺陷集中性分析系统自动生成*")
+    recommendations_r = [
+        f"重点关注缺陷类型: {top_type}",
+        f"重点关注设备: {top_eq}",
+        "建议查看 SPC 控制图确认趋势状态",
+        "建议检查设备健康评分",
+    ]
 
-    full_report = "\n".join(report_parts)
+    full_report_html = build_html_report(
+        generated_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+        date_range_text=f"{start_date.strftime('%Y-%m-%d')} ~ {end_date.strftime('%Y-%m-%d')}",
+        view_mode=view_mode,
+        defect_count=len(filtered_df),
+        panel_count=filtered_df["panel_id"].nunique(),
+        kpis=report_kpis,
+        type_counts=type_counts_r,
+        equipment_counts=eq_counts,
+        seat_top=seat_top,
+        trend_summary=trend_summary,
+        anomaly_rows=anomaly_rows,
+        recommendations=recommendations_r,
+    )
 
     col_exp1, col_exp2, col_exp3 = st.columns(3)
     with col_exp1:
         st.download_button(
-            label="📥 综合报告 (MD)",
-            data=full_report.encode("utf-8"),
-            file_name=f"defect_report_{datetime.now().strftime('%Y%m%d')}.md",
-            mime="text/markdown",
+            label="📥 综合报告 (HTML网页)",
+            data=full_report_html.encode("utf-8"),
+            file_name=f"defect_report_{datetime.now().strftime('%Y%m%d')}.html",
+            mime="text/html",
             use_container_width=True
         )
     with col_exp2:

+ 188 - 0
app_utils.py

@@ -1,5 +1,6 @@
 """缺陷分析页面的可测试业务逻辑。"""
 
+import html
 import numpy as np
 import pandas as pd
 
@@ -376,3 +377,190 @@ def build_ml_factor_insights(
     base["validation_metrics"] = bundle["validation_metrics"]
     base["feature_importance"] = bundle["feature_importance"]
     return base
+
+
+def _escape(value):
+    return html.escape(str(value), quote=True)
+
+
+def _series_rows(series):
+    if series is None:
+        return []
+    return list(series.items())
+
+
+def build_html_report(
+    *,
+    generated_at,
+    date_range_text,
+    view_mode,
+    defect_count,
+    panel_count,
+    kpis,
+    type_counts,
+    equipment_counts=None,
+    seat_top=None,
+    trend_summary="-",
+    anomaly_rows=None,
+    recommendations=None,
+):
+    """生成可直接在浏览器打开的自包含综合 HTML 报告。"""
+    anomaly_rows = anomaly_rows or []
+    recommendations = recommendations or []
+    type_rows = _series_rows(type_counts)
+    equipment_rows = _series_rows(equipment_counts)
+    seat_rows = _series_rows(seat_top)
+
+    type_total = max(sum(int(count) for _, count in type_rows), 1)
+    type_items = "\n".join(
+        f"""
+        <tr>
+          <td>{_escape(name)}</td>
+          <td>{int(count)}</td>
+          <td>{count / type_total:.1%}</td>
+        </tr>
+        """
+        for name, count in type_rows
+    ) or '<tr><td colspan="3">暂无数据</td></tr>'
+    equipment_items = "\n".join(
+        f"<tr><td>{_escape(name)}</td><td>{int(count)}</td></tr>"
+        for name, count in equipment_rows
+    ) or '<tr><td colspan="2">暂无数据</td></tr>'
+    seat_items = "\n".join(
+        f"<tr><td>{_escape(name)}</td><td>{int(count)}</td></tr>"
+        for name, count in seat_rows
+    ) or '<tr><td colspan="2">暂无数据</td></tr>'
+    anomaly_items = "\n".join(
+        f"<tr><td>{_escape(row['equipment'])}</td><td>{_escape(row['seat'])}</td><td>{int(row['count'])}</td></tr>"
+        for row in anomaly_rows
+    ) or '<tr><td colspan="3">无 2σ 异常座号</td></tr>'
+    recommendation_items = "\n".join(
+        f"<li>{_escape(item)}</li>" for item in recommendations
+    ) or "<li>暂无建议</li>"
+
+    return f"""<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>缺陷集中性分析综合报告</title>
+  <style>
+    :root {{
+      --ink: #10202f;
+      --muted: #617386;
+      --line: #dbe5ee;
+      --card: #ffffff;
+      --bg: #eef4f7;
+      --brand: #0f766e;
+      --warn: #b45309;
+    }}
+    * {{ box-sizing: border-box; }}
+    body {{
+      margin: 0;
+      color: var(--ink);
+      font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", Arial, sans-serif;
+      background:
+        radial-gradient(circle at 12% 8%, rgba(15, 118, 110, .18), transparent 28%),
+        linear-gradient(135deg, #f8fbfc 0%, var(--bg) 100%);
+    }}
+    .page {{ max-width: 1180px; margin: 0 auto; padding: 36px 28px 48px; }}
+    .hero {{
+      padding: 30px;
+      border-radius: 28px;
+      color: white;
+      background: linear-gradient(135deg, #0f172a 0%, #115e59 58%, #365314 100%);
+      box-shadow: 0 22px 55px rgba(15, 23, 42, .18);
+    }}
+    .hero h1 {{ margin: 0 0 10px; font-size: 34px; letter-spacing: .03em; }}
+    .hero p {{ margin: 0; color: #d8eef0; }}
+    .grid {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin: 22px 0; }}
+    .card {{
+      padding: 18px;
+      border-radius: 20px;
+      border: 1px solid var(--line);
+      background: rgba(255, 255, 255, .92);
+      box-shadow: 0 12px 28px rgba(15, 23, 42, .07);
+    }}
+    .label {{ color: var(--muted); font-size: 13px; margin-bottom: 8px; }}
+    .value {{ font-size: 28px; font-weight: 800; }}
+    section {{ margin-top: 22px; }}
+    h2 {{ font-size: 21px; margin: 0 0 12px; }}
+    table {{ width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 16px; background: white; }}
+    th, td {{ padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; }}
+    th {{ background: #e8f3f2; color: #134e4a; font-size: 13px; }}
+    .two {{ display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }}
+    .note {{ color: var(--muted); font-size: 13px; margin-top: 8px; }}
+    .recommend {{ border-left: 5px solid var(--brand); }}
+    li {{ margin: 8px 0; }}
+    @media print {{
+      body {{ background: white; }}
+      .page {{ max-width: none; padding: 20px; }}
+      .card {{ box-shadow: none; }}
+    }}
+    @media (max-width: 860px) {{
+      .grid, .two {{ grid-template-columns: 1fr; }}
+    }}
+  </style>
+</head>
+<body>
+  <main class="page">
+    <header class="hero">
+      <h1>缺陷集中性分析综合报告</h1>
+      <p>生成时间:{_escape(generated_at)} | 数据范围:{_escape(date_range_text)} | 视图模式:{_escape(view_mode)}</p>
+    </header>
+
+    <div class="grid">
+      <div class="card"><div class="label">筛选后缺陷数</div><div class="value">{int(defect_count)}</div></div>
+      <div class="card"><div class="label">涉及面板</div><div class="value">{int(panel_count)}</div></div>
+      <div class="card"><div class="label">综合良率</div><div class="value">{float(kpis.get('yield_rate', 0)):.1f}%</div></div>
+      <div class="card"><div class="label">严重缺陷</div><div class="value">{int(kpis.get('critical_defects', 0))}</div></div>
+    </div>
+
+    <section class="two">
+      <div class="card">
+        <h2>1. KPI 摘要</h2>
+        <table>
+          <tr><th>指标</th><th>数值</th></tr>
+          <tr><td>检测面板数</td><td>{int(kpis.get('total_panels_inspected', 0))} 块</td></tr>
+          <tr><td>不良面板数</td><td>{int(kpis.get('defective_panels', 0))} 块</td></tr>
+          <tr><td>严重缺陷</td><td>{int(kpis.get('critical_defects', 0))} 个</td></tr>
+        </table>
+      </div>
+      <div class="card">
+        <h2>2. 趋势分析</h2>
+        <p>{_escape(trend_summary)}</p>
+        <p class="note">建议结合 SPC 控制图确认是否越过预警线或控制线。</p>
+      </div>
+    </section>
+
+    <section class="card">
+      <h2>3. 缺陷类型分布</h2>
+      <table><tr><th>缺陷类型</th><th>缺陷数</th><th>占比</th></tr>{type_items}</table>
+    </section>
+
+    <section class="two">
+      <div class="card">
+        <h2>4. 设备分布</h2>
+        <table><tr><th>设备</th><th>缺陷数</th></tr>{equipment_items}</table>
+      </div>
+      <div class="card">
+        <h2>5. 座号 TOP</h2>
+        <table><tr><th>座号</th><th>缺陷数</th></tr>{seat_items}</table>
+      </div>
+    </section>
+
+    <section class="card">
+      <h2>6. 异常检测</h2>
+      <table><tr><th>设备</th><th>座号</th><th>缺陷数</th></tr>{anomaly_items}</table>
+    </section>
+
+    <section class="card recommend">
+      <h2>7. 排查建议</h2>
+      <ul>{recommendation_items}</ul>
+    </section>
+
+    <p class="note">本报告由缺陷集中性分析系统自动生成,可直接归档、邮件发送或浏览器打印为 PDF。</p>
+  </main>
+</body>
+</html>
+"""

+ 27 - 0
tests/test_app_utils.py

@@ -5,6 +5,7 @@ import pandas as pd
 
 from app_utils import (
     apply_defect_filters,
+    build_html_report,
     build_ml_factor_insights,
     build_diagnostic_dashboard,
     classify_panel_zone,
@@ -245,6 +246,32 @@ class AppUtilsTest(unittest.TestCase):
         self.assertIn("validation_auc", insights["validation_metrics"])
         self.assertGreater(len(insights["feature_importance"]), 0)
 
+    def test_build_html_report_creates_self_contained_safe_webpage(self):
+        html = build_html_report(
+            generated_at="2026-05-18 10:00:00",
+            date_range_text="2026-04-01 ~ 2026-04-30",
+            view_mode="工程师",
+            defect_count=12,
+            panel_count=8,
+            kpis={
+                "total_panels_inspected": 8,
+                "defective_panels": 3,
+                "yield_rate": 62.5,
+                "critical_defects": 2,
+            },
+            type_counts=pd.Series({"气泡<script>": 7, "划痕": 5}),
+            equipment_counts=pd.Series({"LAM-A01": 9}),
+            seat_top=pd.Series({"R1C1": 4}),
+            trend_summary="缺陷数趋势: 上升",
+            anomaly_rows=[{"equipment": "LAM-A01", "seat": "R1C1", "count": 4}],
+            recommendations=["重点关注气泡"],
+        )
+
+        self.assertIn("<!doctype html>", html.lower())
+        self.assertIn("缺陷集中性分析综合报告", html)
+        self.assertIn("气泡&lt;script&gt;", html)
+        self.assertNotIn("气泡<script>", html)
+
 
 if __name__ == "__main__":
     unittest.main()