Преглед изворни кода

feat(report): 周报增加7段每日折线对比图;按老师表收窄姓名列、加宽学情列

Made-with: Cursor
yemeishu пре 1 месец
родитељ
комит
9a504c7250
2 измењених фајлова са 202 додато и 16 уклоњено
  1. 193 16
      scripts/report_teacher_weekly_stats.php
  2. 9 0
      scripts/report_teacher_weekly_stats_pdf.php

+ 193 - 16
scripts/report_teacher_weekly_stats.php

@@ -209,6 +209,170 @@ $windowPrev = sprintf(
 $generatedAt = $endCurrent->format('Y-m-d H:i:s');
 $tz = (string) config('app.timezone', 'UTC');
 
+/**
+ * 将 [from, to) 均分为 7 段,返回每段内组卷数、学情去重套数。
+ *
+ * @return array{labels: list<string>, papers: list<int>, analysis: list<int>}
+ */
+$dailySlices = static function (\Carbon\Carbon $from, \Carbon\Carbon $toExclusive) use ($db): array {
+    $totalSec = max(1, $toExclusive->getTimestamp() - $from->getTimestamp());
+    $sliceSec = $totalSec / 7;
+    $labels = [];
+    $papers = [];
+    $analysis = [];
+    for ($i = 0; $i < 7; $i++) {
+        $sliceFrom = $from->copy()->addSeconds((int) floor($sliceSec * $i));
+        $sliceTo = $i < 6
+            ? $from->copy()->addSeconds((int) floor($sliceSec * ($i + 1)))
+            : $toExclusive;
+        $labels[] = $sliceFrom->format('n/j');
+        $papers[] = (int) $db::table('papers')
+            ->whereNotNull('teacher_id')
+            ->where('teacher_id', '!=', '')
+            ->where('created_at', '>=', $sliceFrom)
+            ->where('created_at', '<', $sliceTo)
+            ->count();
+        $analysis[] = (int) $db::table('exam_analysis_results as ear')
+            ->join('papers as p', 'p.paper_id', '=', 'ear.paper_id')
+            ->whereNotNull('p.teacher_id')
+            ->where('p.teacher_id', '!=', '')
+            ->where('ear.created_at', '>=', $sliceFrom)
+            ->where('ear.created_at', '<', $sliceTo)
+            ->distinct()
+            ->count('ear.paper_id');
+    }
+
+    return ['labels' => $labels, 'papers' => $papers, 'analysis' => $analysis];
+};
+
+$curDaily = $dailySlices($startCurrent, $endCurrent);
+$prevDaily = $dailySlices($startPrev, $endPrev);
+
+$buildChartSvg = static function (array $curDaily, array $prevDaily): string {
+    $labels = $curDaily['labels'];
+    $pc = $curDaily['papers'];
+    $ac = $curDaily['analysis'];
+    $pp = $prevDaily['papers'];
+    $ap = $prevDaily['analysis'];
+    $maxY = max(1, ...$pc, ...$ac, ...$pp, ...$ap);
+    $W = 480;
+    $H = 200;
+    $padL = 44;
+    $padR = 12;
+    $padT = 16;
+    $padB = 36;
+    $gw = $W - $padL - $padR;
+    $gh = $H - $padT - $padB;
+    $n = 7;
+    $xAt = static function (int $i) use ($padL, $gw, $n): float {
+        return $padL + ($n <= 1 ? $gw / 2 : $gw * $i / ($n - 1));
+    };
+    $yAt = static function (int $v) use ($padT, $gh, $maxY): float {
+        return $padT + $gh - ($v / $maxY) * $gh;
+    };
+    $lineWithDots = static function (array $vals, string $stroke, string $dash, float $sw = 1.6) use ($xAt, $yAt, $n): string {
+        $pts = [];
+        $circles = '';
+        for ($i = 0; $i < $n; $i++) {
+            $x = $xAt($i);
+            $y = $yAt((int) $vals[$i]);
+            $pts[] = round($x, 1).','.round($y, 1);
+            $circles .= sprintf(
+                '<circle cx="%.1f" cy="%.1f" r="3.2" fill="%s" stroke="#fff" stroke-width="1"/>',
+                $x,
+                $y,
+                $stroke
+            );
+        }
+
+        $poly = implode(' ', $pts);
+        $dashAttr = $dash !== '' ? ' stroke-dasharray="'.$dash.'"' : '';
+
+        return '<polyline fill="none" stroke="'.$stroke.'" stroke-width="'.$sw.'"'.$dashAttr.' points="'.$poly.'" />'.$circles;
+    };
+
+    $xAxisY = $padT + $gh;
+    $tickTxt = '';
+    for ($i = 0; $i < $n; $i++) {
+        $x = $xAt($i);
+        $lab = htmlspecialchars((string) ($labels[$i] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+        $tickTxt .= sprintf(
+            '<text x="%.1f" y="%d" text-anchor="middle" font-size="9" fill="#374151" font-family="sun-exta,sans-serif">%s</text>',
+            $x,
+            $H - 10,
+            $lab
+        );
+    }
+
+    $yTick = '';
+    for ($k = 0; $k <= 4; $k++) {
+        $v = (int) round($maxY * $k / 4);
+        $y = $yAt($v);
+        $yTick .= sprintf(
+            '<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="#e5e7eb" stroke-width="1"/>',
+            $padL,
+            $y,
+            $padL + $gw,
+            $y
+        );
+        $yTick .= sprintf(
+            '<text x="%d" y="%.1f" font-size="8" fill="#6b7280" font-family="sun-exta,sans-serif">%d</text>',
+            4,
+            $y + 3,
+            $v
+        );
+    }
+
+    $legend = '<g font-family="sun-exta,sans-serif" font-size="9">';
+    $lx = $padL + $gw - 168;
+    $ly = $padT + 4;
+    $items = [
+        ['#2563eb', '组卷·本周期', ''],
+        ['#ea580c', '学情·本周期', ''],
+        ['#93c5fd', '组卷·上周期', '6,4'],
+        ['#fdba74', '学情·上周期', '6,4'],
+    ];
+    foreach ($items as $idx => $it) {
+        $yy = $ly + $idx * 13;
+        $legend .= sprintf(
+            '<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2" %s/>',
+            $lx,
+            $yy,
+            $lx + 18,
+            $yy,
+            $it[0],
+            $it[2] !== '' ? 'stroke-dasharray="'.$it[2].'"' : ''
+        );
+        $legend .= sprintf(
+            '<text x="%d" y="%d" fill="#111">%s</text>',
+            $lx + 24,
+            $yy + 4,
+            htmlspecialchars($it[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
+        );
+    }
+    $legend .= '</g>';
+
+    $svg = sprintf(
+        '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="100%%" height="auto" style="max-width:520px;">',
+        $W,
+        $H
+    );
+    $svg .= sprintf('<rect x="0" y="0" width="%d" height="%d" fill="#fafafa"/>', $W, $H);
+    $svg .= $yTick;
+    $svg .= sprintf('<line x1="%d" y1="%.1f" x2="%.1f" y2="%.1f" stroke="#9ca3af" stroke-width="1"/>', $padL, $xAxisY, $padL + $gw, $xAxisY);
+    $svg .= $lineWithDots($pp, '#93c5fd', '6,4', 1.4);
+    $svg .= $lineWithDots($ap, '#fdba74', '6,4', 1.4);
+    $svg .= $lineWithDots($pc, '#2563eb', '', 1.8);
+    $svg .= $lineWithDots($ac, '#ea580c', '', 1.8);
+    $svg .= $tickTxt;
+    $svg .= $legend;
+    $svg .= '</svg>';
+
+    return $svg;
+};
+
+$chartSvg = $buildChartSvg($curDaily, $prevDaily);
+
 echo "## 老师组卷与学情分析(近7天)\n\n";
 echo "> 生成 {$generatedAt} · {$tz} · 本 {$windowCur} · 上 {$windowPrev}\n\n";
 
@@ -219,30 +383,43 @@ echo sprintf("| 组卷总套数 | %d | %d | %s |\n", $totalPapersCur, $totalPape
 echo sprintf("| 学情分析套数(卷去重) | %d | %d | %s |\n", $totalAnalysisCur, $totalAnalysisPrev, $wowLineHtml($totalAnalysisCur, $totalAnalysisPrev));
 echo sprintf("| 有组卷老师数 | %d | %d | %s |\n", $teachersCur, $teachersPrev, $wowLineHtml($teachersCur, $teachersPrev));
 
-echo "\n### 按老师\n\n";
+echo "\n### 近7段每日对比(本周期与上周期时间轴对齐)\n\n";
+echo '<div class="weekly-chart">';
+echo '<p style="font-size:9pt;color:#4b5563;margin:0 0 6px 0;">横轴为统计窗口均分的 7 段,数字为每段「组卷套数 / 学情分析套数(卷去重)」;实线本周期,虚线上一同期。</p>';
+echo $chartSvg;
+echo "</div>\n\n";
+
+echo "### 按老师\n\n";
 
-echo "| 排名 | 老师 | teacher_id | 组卷·本 | 组卷·上 | 组卷·环比 | 学情·本 | 学情·上 | 学情·环比 |\n";
-echo "| ---: | --- | --- | ---: | ---: | --- | ---: | ---: | --- |\n";
+echo '<table class="weekly-teacher-table">';
+echo '<colgroup>';
+echo '<col style="width:5%" /><col style="width:6%" /><col style="width:8%" />';
+echo '<col style="width:8%" /><col style="width:8%" /><col style="width:10%" />';
+echo '<col class="col-an" style="width:11%" /><col class="col-an" style="width:11%" /><col style="width:9%" />';
+echo '</colgroup>';
+echo '<thead><tr>';
+echo '<th>排名</th><th>老师</th><th>teacher_id</th><th>组卷·本</th><th>组卷·上</th><th>组卷·环比</th><th>学情·本</th><th>学情·上</th><th>学情·环比</th>';
+echo "</tr></thead>\n<tbody>\n";
 
 $i = 1;
 foreach ($rows as $r) {
-    $nm = str_replace(['|', "\n", '<', '>'], ['/', ' ', '', ''], $r['name']);
+    $nm = htmlspecialchars((string) $r['name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
     $pc = $r['papers'];
     $pp = $r['papers_prev'];
     $ac = $r['analysis_sets'];
     $ap = $r['analysis_sets_prev'];
-    echo sprintf(
-        "| %d | %s | %s | %d | %d | %s | %d | %d | %s |\n",
-        $i++,
-        $nm,
-        $r['teacher_id'],
-        $pc,
-        $pp,
-        $compareCellHtml($pc, $pp),
-        $ac,
-        $ap,
-        $compareCellHtml($ac, $ap)
-    );
+    echo '<tr>';
+    echo '<td style="text-align:right">'.((string) $i++).'</td>';
+    echo '<td class="td-name">'.$nm.'</td>';
+    echo '<td>'.htmlspecialchars((string) $r['teacher_id'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'</td>';
+    echo '<td style="text-align:right">'.((string) $pc).'</td>';
+    echo '<td style="text-align:right">'.((string) $pp).'</td>';
+    echo '<td>'.$compareCellHtml($pc, $pp).'</td>';
+    echo '<td style="text-align:right" class="td-an">'.((string) $ac).'</td>';
+    echo '<td style="text-align:right" class="td-an">'.((string) $ap).'</td>';
+    echo '<td>'.$compareCellHtml($ac, $ap).'</td>';
+    echo "</tr>\n";
 }
+echo "</tbody></table>\n";
 
 echo sprintf("\n本周期有组卷 **%d** 人。\n", count($rows));

+ 9 - 0
scripts/report_teacher_weekly_stats_pdf.php

@@ -42,6 +42,15 @@ blockquote { margin: 0 0 10px 0; padding: 6px 10px; background: #f9fafb; border-
 table { border-collapse: collapse; width: 100%; margin: 8px 0 12px 0; }
 th, td { border: 1px solid #d1d5db; padding: 4px 6px; vertical-align: top; }
 th { background: #f3f4f6; font-weight: 600; }
+.weekly-chart { margin: 8px 0 14px 0; page-break-inside: avoid; }
+.weekly-chart p { margin: 0 0 6px 0; }
+.weekly-teacher-table { table-layout: fixed; width: 100%; font-size: 9pt; }
+.weekly-teacher-table col.col-rank { width: 5%; }
+.weekly-teacher-table col.col-name { width: 6%; }
+.weekly-teacher-table col.col-tid { width: 8%; }
+.weekly-teacher-table col.col-an { width: 11%; }
+.weekly-teacher-table .td-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; }
+.weekly-teacher-table .td-an { font-variant-numeric: tabular-nums; }
 </style>
 CSS;