Эх сурвалжийг харах

feat(report): 老师周报图表双栏、配色与表格居中

- 每日对比:左学案/右学情独立纵轴;SVG 并排
- 学案/学情单元格强调色对齐图表蓝橙,学生列保持绿色
- 总量环比与按老师表文字居中;Markdown 总量表列居中
- PDF:weekly-chart-pair 无边框穿透;教师表居中样式

Made-with: Cursor
yemeishu 1 сар өмнө
parent
commit
11d1ff8c86

+ 148 - 130
scripts/report_teacher_weekly_stats.php

@@ -86,18 +86,18 @@ $wowLine = static function (int $cur, int $prev): string {
     return sprintf('%s%d(%s%.2f%%)%s', $sign, $delta, $sign, $pct, $dir);
     return sprintf('%s%d(%s%.2f%%)%s', $sign, $delta, $sign, $pct, $dir);
 };
 };
 
 
-/** 总量表环比列:仅增长标绿 */
-$wowLineHtml = static function (int $cur, int $prev) use ($wowLine): string {
+/** 总量表环比列:正增长着色(学案蓝 / 学情橙 / 其余绿) */
+$wowLineHtml = static function (int $cur, int $prev, string $posColor = '#16a34a') use ($wowLine): string {
     $plain = $wowLine($cur, $prev);
     $plain = $wowLine($cur, $prev);
     if ($cur > $prev) {
     if ($cur > $prev) {
-        return '<span style="color:#16a34a;font-weight:600;">'.$plain.'</span>';
+        return '<span style="color:'.$posColor.';font-weight:600;">'.$plain.'</span>';
     }
     }
 
 
     return $plain;
     return $plain;
 };
 };
 
 
-/** 明细环比列:仅增长标绿 */
-$compareCellHtml = static function (int $cur, int $prev): string {
+/** 环比列:正增长用 $posColor(学案蓝 / 学情橙 / 默认绿) */
+$compareCellHtml = static function (int $cur, int $prev, string $posColor = '#16a34a'): string {
     $d = $cur - $prev;
     $d = $cur - $prev;
     if ($d === 0) {
     if ($d === 0) {
         return '0';
         return '0';
@@ -107,28 +107,33 @@ $compareCellHtml = static function (int $cur, int $prev): string {
             return '0';
             return '0';
         }
         }
 
 
-        return '<span style="color:#16a34a;font-weight:600;">+'.$d.'(上0)</span>';
+        return '<span style="color:'.$posColor.';font-weight:600;">+'.$d.'(上0)</span>';
     }
     }
     $pct = round(($d / $prev) * 100, 1);
     $pct = round(($d / $prev) * 100, 1);
     $sign = $d > 0 ? '+' : '';
     $sign = $d > 0 ? '+' : '';
     $text = sprintf('%s%d(%s%.1f%%)', $sign, $d, $sign, $pct);
     $text = sprintf('%s%d(%s%.1f%%)', $sign, $d, $sign, $pct);
     if ($d > 0) {
     if ($d > 0) {
-        return '<span style="color:#16a34a;font-weight:600;">'.$text.'</span>';
+        return '<span style="color:'.$posColor.';font-weight:600;">'.$text.'</span>';
     }
     }
 
 
     return $text;
     return $text;
 };
 };
 
 
-/** 「本 / 上」并列:仅本侧数字可绿加粗;「 / 」与上周期数字强制黑色(避免 PDF 引擎继承样式) */
-$slashPairGreenHtml = static function (int $cur, int $prev): string {
+/** 「本 / 上」并列:本>上时本侧用强调色(与上方左/右图折线一致);「 / 」与上周期数字固定深灰黑 */
+$slashPairAccentHtml = static function (int $cur, int $prev, string $accent): string {
     $rest = '<span style="color:#111827;font-weight:normal;"> / '.$prev.'</span>';
     $rest = '<span style="color:#111827;font-weight:normal;"> / '.$prev.'</span>';
     if ($cur > $prev) {
     if ($cur > $prev) {
-        return '<span style="color:#16a34a;font-weight:700;">'.$cur.'</span>'.$rest;
+        return '<span style="color:'.$accent.';font-weight:700;">'.$cur.'</span>'.$rest;
     }
     }
 
 
     return '<span style="color:#111827;font-weight:normal;">'.$cur.'</span>'.$rest;
     return '<span style="color:#111827;font-weight:normal;">'.$cur.'</span>'.$rest;
 };
 };
 
 
+/** 与每日对比图一致:学案蓝、学情橙、学生绿 */
+$colorPapers = '#2563eb';
+$colorAnalysis = '#ea580c';
+$colorStudents = '#16a34a';
+
 $byTeacher = \Illuminate\Support\Facades\DB::table('papers')
 $byTeacher = \Illuminate\Support\Facades\DB::table('papers')
     ->whereNotNull('teacher_id')
     ->whereNotNull('teacher_id')
     ->where('teacher_id', '!=', '')
     ->where('teacher_id', '!=', '')
@@ -290,149 +295,162 @@ $dailySlices = static function (\Carbon\Carbon $from, \Carbon\Carbon $toExclusiv
 $curDaily = $dailySlices($startCurrent, $endCurrent);
 $curDaily = $dailySlices($startCurrent, $endCurrent);
 $prevDaily = $dailySlices($startPrev, $endPrev);
 $prevDaily = $dailySlices($startPrev, $endPrev);
 
 
-$buildChartSvg = static function (array $curDaily, array $prevDaily): string {
+/** 左侧:学案(组卷)套数;右侧:学情分析套数(卷去重)。实线本周期,虚线上周期。 */
+$buildDualChartsHtml = static function (array $curDaily, array $prevDaily): string {
     $labels = $curDaily['labels'];
     $labels = $curDaily['labels'];
     $pc = $curDaily['papers'];
     $pc = $curDaily['papers'];
     $ac = $curDaily['analysis'];
     $ac = $curDaily['analysis'];
     $pp = $prevDaily['papers'];
     $pp = $prevDaily['papers'];
     $ap = $prevDaily['analysis'];
     $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 = '';
+
+    $halfSvg = static function (
+        array $labels,
+        array $cur,
+        array $prev,
+        string $strokeCur,
+        string $strokePrev,
+        string $legCur,
+        string $legPrev
+    ): string {
+        $maxY = max(1, ...$cur, ...$prev);
+        $W = 248;
+        $H = 200;
+        $padL = 38;
+        $padR = 8;
+        $padT = 14;
+        $padB = 34;
+        $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++) {
         for ($i = 0; $i < $n; $i++) {
             $x = $xAt($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"/>',
+            $lab = htmlspecialchars((string) ($labels[$i] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+            $tickTxt .= sprintf(
+                '<text x="%.1f" y="%d" text-anchor="middle" font-size="8.5" fill="#374151" font-family="sun-exta,sans-serif">%s</text>',
                 $x,
                 $x,
-                $y,
-                $stroke
+                $H - 8,
+                $lab
             );
             );
         }
         }
 
 
-        $poly = implode(' ', $pts);
-        $dashAttr = $dash !== '' ? ' stroke-dasharray="'.$dash.'"' : '';
+        $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="7.5" fill="#6b7280" font-family="sun-exta,sans-serif">%d</text>',
+                2,
+                $y + 3,
+                $v
+            );
+        }
 
 
-        return '<polyline fill="none" stroke="'.$stroke.'" stroke-width="'.$sw.'"'.$dashAttr.' points="'.$poly.'" />'.$circles;
-    };
+        $legend = '<g font-family="sun-exta,sans-serif" font-size="8.5">';
+        $lx = max($padL + 4, $padL + $gw - 118);
+        $ly = $padT + 2;
+        $items = [
+            [$strokeCur, $legCur, ''],
+            [$strokePrev, $legPrev, '6,4'],
+        ];
+        foreach ($items as $idx => $it) {
+            $yy = $ly + $idx * 12;
+            $legend .= sprintf(
+                '<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2" %s/>',
+                $lx,
+                $yy,
+                $lx + 16,
+                $yy,
+                $it[0],
+                $it[2] !== '' ? 'stroke-dasharray="'.$it[2].'"' : ''
+            );
+            $legend .= sprintf(
+                '<text x="%d" y="%d" fill="#111">%s</text>',
+                $lx + 20,
+                $yy + 4,
+                htmlspecialchars($it[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
+            );
+        }
+        $legend .= '</g>';
 
 
-    $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
+        $svg = sprintf(
+            '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="100%%" height="auto" style="max-width:100%%;">',
+            $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($prev, $strokePrev, '6,4', 1.4);
+        $svg .= $lineWithDots($cur, $strokeCur, '', 1.8);
+        $svg .= $tickTxt;
+        $svg .= $legend;
+        $svg .= '</svg>';
+
+        return $svg;
+    };
 
 
-    $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
-        );
-    }
+    $left = $halfSvg($labels, $pc, $pp, '#2563eb', '#93c5fd', '学案·本周期', '学案·上周期');
+    $right = $halfSvg($labels, $ac, $ap, '#ea580c', '#fdba74', '学情·本周期', '学情·上周期');
 
 
-    $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;
+    return '<table class="weekly-chart-pair" style="width:100%;border-collapse:collapse;margin:0;"><tr>'
+        .'<td style="width:50%;vertical-align:top;padding:2px 5px 2px 0;">'.$left.'</td>'
+        .'<td style="width:50%;vertical-align:top;padding:2px 0 2px 5px;">'.$right.'</td>'
+        .'</tr></table>';
 };
 };
 
 
-$chartSvg = $buildChartSvg($curDaily, $prevDaily);
+$chartSvg = $buildDualChartsHtml($curDaily, $prevDaily);
 
 
 echo "## 老师组卷与学情分析(近7天)\n\n";
 echo "## 老师组卷与学情分析(近7天)\n\n";
 echo "> 生成 {$generatedAt} · {$tz} · 本 {$windowCur} · 上 {$windowPrev}\n\n";
 echo "> 生成 {$generatedAt} · {$tz} · 本 {$windowCur} · 上 {$windowPrev}\n\n";
 
 
 echo "### 总量\n\n";
 echo "### 总量\n\n";
 echo "| 指标 | 本周期 | 上周期 | 环比 |\n";
 echo "| 指标 | 本周期 | 上周期 | 环比 |\n";
-echo "| --- | ---: | ---: | --- |\n";
-echo sprintf("| 组卷总套数 | %d | %d | %s |\n", $totalPapersCur, $totalPapersPrev, $wowLineHtml($totalPapersCur, $totalPapersPrev));
-echo sprintf("| 学情分析套数(卷去重) | %d | %d | %s |\n", $totalAnalysisCur, $totalAnalysisPrev, $wowLineHtml($totalAnalysisCur, $totalAnalysisPrev));
-echo sprintf("| 有组卷老师数 | %d | %d | %s |\n", $teachersCur, $teachersPrev, $wowLineHtml($teachersCur, $teachersPrev));
+echo "| :---: | :---: | :---: | :---: |\n";
+echo sprintf("| 组卷总套数 | %d | %d | %s |\n", $totalPapersCur, $totalPapersPrev, $wowLineHtml($totalPapersCur, $totalPapersPrev, $colorPapers));
+echo sprintf("| 学情分析套数(卷去重) | %d | %d | %s |\n", $totalAnalysisCur, $totalAnalysisPrev, $wowLineHtml($totalAnalysisCur, $totalAnalysisPrev, $colorAnalysis));
+echo sprintf("| 有组卷老师数 | %d | %d | %s |\n", $teachersCur, $teachersPrev, $wowLineHtml($teachersCur, $teachersPrev, $colorStudents));
 
 
-echo "\n### 近7段每日对比(本周期与上周期时间轴对齐)\n\n";
+echo "\n### 近7段每日对比(时间轴对齐;左:学案 · 右:学情)\n\n";
 echo '<div class="weekly-chart">';
 echo '<div class="weekly-chart">';
 echo $chartSvg;
 echo $chartSvg;
 echo "</div>\n\n";
 echo "</div>\n\n";
 
 
-echo "### 按老师\n\n";
-
-echo sprintf("本周期有组卷 **%d** 人。\n\n", count($rows));
+echo sprintf("### 按老师 本周期有组卷 **%d** 人。\n\n", count($rows));
 
 
 echo '<table class="weekly-teacher-table">';
 echo '<table class="weekly-teacher-table">';
 echo '<colgroup>';
 echo '<colgroup>';
@@ -459,13 +477,13 @@ foreach ($rows as $r) {
     $stuC = (int) ($studentUnionCurMap[$tidKey] ?? 0);
     $stuC = (int) ($studentUnionCurMap[$tidKey] ?? 0);
     $stuP = (int) ($studentUnionPrevMap[$tidKey] ?? 0);
     $stuP = (int) ($studentUnionPrevMap[$tidKey] ?? 0);
     echo '<tr>';
     echo '<tr>';
-    echo '<td style="text-align:right">'.((string) $i++).'</td>';
+    echo '<td>'.((string) $i++).'</td>';
     echo '<td class="td-name">'.$nameWithId.'</td>';
     echo '<td class="td-name">'.$nameWithId.'</td>';
-    echo '<td style="text-align:right" class="td-slash" title="本周期 / 上周期:组卷套数">'.$slashPairGreenHtml($pc, $pp).'</td>';
-    echo '<td>'.$compareCellHtml($pc, $pp).'</td>';
-    echo '<td style="text-align:right" class="td-slash" title="本周期 / 上周期:学情分析套数(卷去重)">'.$slashPairGreenHtml($ac, $ap).'</td>';
-    echo '<td>'.$compareCellHtml($ac, $ap).'</td>';
-    echo '<td style="text-align:right" class="td-slash td-stu" title="本周期 / 上周期:组卷∪学情学生合并去重">'.$slashPairGreenHtml($stuC, $stuP).'</td>';
+    echo '<td class="td-slash td-slash-papers" title="本周期 / 上周期:组卷套数">'.$slashPairAccentHtml($pc, $pp, $colorPapers).'</td>';
+    echo '<td class="td-wow-papers">'.$compareCellHtml($pc, $pp, $colorPapers).'</td>';
+    echo '<td class="td-slash td-slash-analysis" title="本周期 / 上周期:学情分析套数(卷去重)">'.$slashPairAccentHtml($ac, $ap, $colorAnalysis).'</td>';
+    echo '<td class="td-wow-analysis">'.$compareCellHtml($ac, $ap, $colorAnalysis).'</td>';
+    echo '<td class="td-slash td-slash-stu td-stu" title="本周期 / 上周期:组卷∪学情学生合并去重">'.$slashPairAccentHtml($stuC, $stuP, $colorStudents).'</td>';
     echo "</tr>\n";
     echo "</tr>\n";
 }
 }
 echo "</tbody></table>\n";
 echo "</tbody></table>\n";

+ 7 - 1
scripts/report_teacher_weekly_stats_pdf.php

@@ -40,11 +40,17 @@ h2 { font-size: 14pt; margin: 0 0 8px 0; }
 h3 { font-size: 11pt; margin: 16px 0 8px 0; }
 h3 { font-size: 11pt; margin: 16px 0 8px 0; }
 blockquote { margin: 0 0 10px 0; padding: 6px 10px; background: #f9fafb; border-left: 3px solid #d1d5db; font-size: 9pt; color: #4b5563; }
 blockquote { margin: 0 0 10px 0; padding: 6px 10px; background: #f9fafb; border-left: 3px solid #d1d5db; font-size: 9pt; color: #4b5563; }
 table { border-collapse: collapse; width: 100%; margin: 8px 0 12px 0; }
 table { border-collapse: collapse; width: 100%; margin: 8px 0 12px 0; }
-th, td { border: 1px solid #d1d5db; padding: 4px 6px; vertical-align: top; }
+th, td { border: 1px solid #d1d5db; padding: 4px 6px; text-align: center; vertical-align: middle; }
 th { background: #f3f4f6; font-weight: 600; }
 th { background: #f3f4f6; font-weight: 600; }
 .weekly-chart { margin: 8px 0 14px 0; page-break-inside: avoid; }
 .weekly-chart { margin: 8px 0 14px 0; page-break-inside: avoid; }
 .weekly-chart p { margin: 0 0 6px 0; }
 .weekly-chart p { margin: 0 0 6px 0; }
+/* 每日对比:左学案、右学情,各占 50%,独立纵轴刻度 */
+.weekly-chart-pair { width: 100%; border-collapse: collapse; margin: 0; page-break-inside: avoid; }
+.weekly-chart-pair td,
+.weekly-chart-pair th { border: none !important; padding: 2px 4px !important; vertical-align: top !important; text-align: center !important; background: transparent !important; }
 .weekly-teacher-table { table-layout: fixed; width: 100%; font-size: 9pt; }
 .weekly-teacher-table { table-layout: fixed; width: 100%; font-size: 9pt; }
+.weekly-teacher-table th,
+.weekly-teacher-table td { text-align: center; vertical-align: middle; }
 .weekly-teacher-table col.col-name { width: 18mm !important; max-width: 18mm !important; }
 .weekly-teacher-table col.col-name { width: 18mm !important; max-width: 18mm !important; }
 .weekly-teacher-table col.col-slash { width: 11%; }
 .weekly-teacher-table col.col-slash { width: 11%; }
 .weekly-teacher-table th:nth-child(2),
 .weekly-teacher-table th:nth-child(2),