report_teacher_weekly_stats.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. <?php
  2. /**
  3. * 近 7 天老师组卷 + 学情分析套数(exam_analysis_results 按 paper_id 去重,一套卷计 1)。
  4. * 按老师:学案/分析/学生数为「本 / 上」并列(仅本侧数字在本大于上时绿色);保留学案·环比、学情·环比。
  5. * 用法:
  6. * php scripts/report_teacher_weekly_stats.php
  7. * php scripts/report_teacher_weekly_stats.php > storage/app/reports/teacher-weekly-stats-$(date +%Y-%m-%d)_$(date +%H%M%S).md
  8. * (shell 里 %H%M%S 会展开为当前时分秒;PDF 见 scripts/report_teacher_weekly_stats_pdf.php)
  9. */
  10. require __DIR__ . '/../vendor/autoload.php';
  11. $app = require_once __DIR__ . '/../bootstrap/app.php';
  12. $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
  13. $endCurrent = now();
  14. $startCurrent = now()->subDays(7);
  15. $startPrev = now()->subDays(14);
  16. $endPrev = $startCurrent;
  17. $db = \Illuminate\Support\Facades\DB::class;
  18. $sumPapers = static function ($from, $toExclusive) use ($db) {
  19. $q = $db::table('papers')
  20. ->whereNotNull('teacher_id')
  21. ->where('teacher_id', '!=', '')
  22. ->where('created_at', '>=', $from);
  23. if ($toExclusive !== null) {
  24. $q->where('created_at', '<', $toExclusive);
  25. }
  26. return (int) $q->count();
  27. };
  28. /** 学情分析:以卷子为单位,同一 paper_id 在区间内多条记录只计 1 */
  29. $countDistinctAnalysisPapers = static function ($from, $toExclusive) use ($db) {
  30. $q = $db::table('exam_analysis_results as ear')
  31. ->join('papers as p', 'p.paper_id', '=', 'ear.paper_id')
  32. ->whereNotNull('p.teacher_id')
  33. ->where('p.teacher_id', '!=', '')
  34. ->where('ear.created_at', '>=', $from);
  35. if ($toExclusive !== null) {
  36. $q->where('ear.created_at', '<', $toExclusive);
  37. }
  38. return (int) $q->distinct()->count('ear.paper_id');
  39. };
  40. $countActiveTeachers = static function ($from, $toExclusive) use ($db) {
  41. $q = $db::table('papers')
  42. ->whereNotNull('teacher_id')
  43. ->where('teacher_id', '!=', '')
  44. ->where('created_at', '>=', $from);
  45. if ($toExclusive !== null) {
  46. $q->where('created_at', '<', $toExclusive);
  47. }
  48. return (int) $q->distinct()->count('teacher_id');
  49. };
  50. $totalPapersCur = $sumPapers($startCurrent, $endCurrent);
  51. $totalPapersPrev = $sumPapers($startPrev, $endPrev);
  52. $totalAnalysisCur = $countDistinctAnalysisPapers($startCurrent, $endCurrent);
  53. $totalAnalysisPrev = $countDistinctAnalysisPapers($startPrev, $endPrev);
  54. $teachersCur = $countActiveTeachers($startCurrent, $endCurrent);
  55. $teachersPrev = $countActiveTeachers($startPrev, $endPrev);
  56. $wowLine = static function (int $cur, int $prev): string {
  57. $delta = $cur - $prev;
  58. if ($prev === 0) {
  59. if ($cur === 0) {
  60. return '0';
  61. }
  62. return sprintf('+%d(上周期0)', $delta);
  63. }
  64. $pct = round(($delta / $prev) * 100, 2);
  65. $sign = $delta >= 0 ? '+' : '';
  66. $dir = match (true) {
  67. $delta > 0 => '↑',
  68. $delta < 0 => '↓',
  69. default => '→',
  70. };
  71. return sprintf('%s%d(%s%.2f%%)%s', $sign, $delta, $sign, $pct, $dir);
  72. };
  73. /** 总量表环比列:正增长着色(学案蓝 / 学情橙 / 其余绿) */
  74. $wowLineHtml = static function (int $cur, int $prev, string $posColor = '#16a34a') use ($wowLine): string {
  75. $plain = $wowLine($cur, $prev);
  76. if ($cur > $prev) {
  77. return '<span style="color:'.$posColor.';font-weight:600;">'.$plain.'</span>';
  78. }
  79. return $plain;
  80. };
  81. /** 环比列:正增长用 $posColor(学案蓝 / 学情橙 / 默认绿) */
  82. $compareCellHtml = static function (int $cur, int $prev, string $posColor = '#16a34a'): string {
  83. $d = $cur - $prev;
  84. if ($d === 0) {
  85. return '0';
  86. }
  87. if ($prev === 0) {
  88. if ($cur === 0) {
  89. return '0';
  90. }
  91. return '<span style="color:'.$posColor.';font-weight:600;">+'.$d.'(上0)</span>';
  92. }
  93. $pct = round(($d / $prev) * 100, 1);
  94. $sign = $d > 0 ? '+' : '';
  95. $text = sprintf('%s%d(%s%.1f%%)', $sign, $d, $sign, $pct);
  96. if ($d > 0) {
  97. return '<span style="color:'.$posColor.';font-weight:600;">'.$text.'</span>';
  98. }
  99. return $text;
  100. };
  101. /** 「本 / 上」并列:本>上时本侧用强调色(与上方左/右图折线一致);「 / 」与上周期数字固定深灰黑 */
  102. $slashPairAccentHtml = static function (int $cur, int $prev, string $accent): string {
  103. $rest = '<span style="color:#111827;font-weight:normal;"> / '.$prev.'</span>';
  104. if ($cur > $prev) {
  105. return '<span style="color:'.$accent.';font-weight:700;">'.$cur.'</span>'.$rest;
  106. }
  107. return '<span style="color:#111827;font-weight:normal;">'.$cur.'</span>'.$rest;
  108. };
  109. /** 与每日对比图一致:学案蓝、学情橙、学生绿 */
  110. $colorPapers = '#2563eb';
  111. $colorAnalysis = '#ea580c';
  112. $colorStudents = '#16a34a';
  113. $byTeacher = \Illuminate\Support\Facades\DB::table('papers')
  114. ->whereNotNull('teacher_id')
  115. ->where('teacher_id', '!=', '')
  116. ->where('created_at', '>=', $startCurrent)
  117. ->selectRaw('teacher_id, COUNT(*) as paper_count')
  118. ->groupBy('teacher_id')
  119. ->get();
  120. // 近 7 天产生学情分析的试卷套数:按 ear.paper_id 去重后归到 papers.teacher_id
  121. $analysisByTeacher = \Illuminate\Support\Facades\DB::table('exam_analysis_results as ear')
  122. ->join('papers as p', 'p.paper_id', '=', 'ear.paper_id')
  123. ->whereNotNull('p.teacher_id')
  124. ->where('p.teacher_id', '!=', '')
  125. ->where('ear.created_at', '>=', $startCurrent)
  126. ->selectRaw('p.teacher_id, COUNT(DISTINCT ear.paper_id) AS paper_set_count')
  127. ->groupBy('p.teacher_id')
  128. ->get();
  129. $analysisMap = [];
  130. foreach ($analysisByTeacher as $r) {
  131. $analysisMap[(string) $r->teacher_id] = (int) $r->paper_set_count;
  132. }
  133. $paperMap = [];
  134. foreach ($byTeacher as $r) {
  135. $paperMap[(string) $r->teacher_id] = (int) $r->paper_count;
  136. }
  137. $byTeacherPrev = $db::table('papers')
  138. ->whereNotNull('teacher_id')
  139. ->where('teacher_id', '!=', '')
  140. ->where('created_at', '>=', $startPrev)
  141. ->where('created_at', '<', $endPrev)
  142. ->selectRaw('teacher_id, COUNT(*) as paper_count')
  143. ->groupBy('teacher_id')
  144. ->get();
  145. $analysisByTeacherPrev = $db::table('exam_analysis_results as ear')
  146. ->join('papers as p', 'p.paper_id', '=', 'ear.paper_id')
  147. ->whereNotNull('p.teacher_id')
  148. ->where('p.teacher_id', '!=', '')
  149. ->where('ear.created_at', '>=', $startPrev)
  150. ->where('ear.created_at', '<', $endPrev)
  151. ->selectRaw('p.teacher_id, COUNT(DISTINCT ear.paper_id) AS paper_set_count')
  152. ->groupBy('p.teacher_id')
  153. ->get();
  154. $paperMapPrev = [];
  155. foreach ($byTeacherPrev as $r) {
  156. $paperMapPrev[(string) $r->teacher_id] = (int) $r->paper_count;
  157. }
  158. $analysisMapPrev = [];
  159. foreach ($analysisByTeacherPrev as $r) {
  160. $analysisMapPrev[(string) $r->teacher_id] = (int) $r->paper_set_count;
  161. }
  162. /** 学生数:组卷 ∪ 学情,student_id 合并去重(按老师、时间窗) */
  163. $studentUnionSql = <<<'SQL'
  164. SELECT u.teacher_id, COUNT(DISTINCT u.student_id) AS c
  165. FROM (
  166. SELECT teacher_id, student_id FROM papers
  167. WHERE teacher_id IS NOT NULL AND teacher_id != ''
  168. AND student_id IS NOT NULL AND student_id != ''
  169. AND created_at >= ? AND created_at < ?
  170. UNION
  171. SELECT p.teacher_id, ear.student_id
  172. FROM exam_analysis_results ear
  173. INNER JOIN papers p ON p.paper_id = ear.paper_id
  174. WHERE p.teacher_id IS NOT NULL AND p.teacher_id != ''
  175. AND ear.student_id IS NOT NULL AND ear.student_id != ''
  176. AND ear.created_at >= ? AND ear.created_at < ?
  177. ) u
  178. GROUP BY u.teacher_id
  179. SQL;
  180. $studentUnionCurRows = $db::select($studentUnionSql, [$startCurrent, $endCurrent, $startCurrent, $endCurrent]);
  181. $studentUnionPrevRows = $db::select($studentUnionSql, [$startPrev, $endPrev, $startPrev, $endPrev]);
  182. $studentUnionCurMap = [];
  183. foreach ($studentUnionCurRows as $row) {
  184. $studentUnionCurMap[(string) $row->teacher_id] = (int) $row->c;
  185. }
  186. $studentUnionPrevMap = [];
  187. foreach ($studentUnionPrevRows as $row) {
  188. $studentUnionPrevMap[(string) $row->teacher_id] = (int) $row->c;
  189. }
  190. $names = \Illuminate\Support\Facades\DB::table('teachers')->pluck('name', 'teacher_id');
  191. $nameStrMap = [];
  192. foreach ($names as $tid => $nm) {
  193. $nameStrMap[(string) $tid] = $nm;
  194. }
  195. $rows = [];
  196. foreach ($paperMap as $tid => $paperCount) {
  197. $rows[] = [
  198. 'teacher_id' => $tid,
  199. 'name' => (string) ($nameStrMap[$tid] ?? $tid),
  200. 'papers' => $paperCount,
  201. 'analysis_sets' => (int) ($analysisMap[$tid] ?? 0),
  202. 'papers_prev' => (int) ($paperMapPrev[$tid] ?? 0),
  203. 'analysis_sets_prev' => (int) ($analysisMapPrev[$tid] ?? 0),
  204. ];
  205. }
  206. usort($rows, static fn ($a, $b) => $b['papers'] <=> $a['papers']);
  207. $windowCur = sprintf(
  208. '%s ~ %s',
  209. $startCurrent->format('Y-m-d H:i:s'),
  210. $endCurrent->format('Y-m-d H:i:s')
  211. );
  212. $windowPrev = sprintf(
  213. '%s ~ %s',
  214. $startPrev->format('Y-m-d H:i:s'),
  215. $endPrev->format('Y-m-d H:i:s')
  216. );
  217. $generatedAt = $endCurrent->format('Y-m-d H:i:s');
  218. $tz = (string) config('app.timezone', 'UTC');
  219. /**
  220. * 将 [from, to) 均分为 7 段,返回每段组卷数、学情套数(与上方「总量」同一窗口可对齐)。
  221. *
  222. * 学案:每段 COUNT(papers),七段之和 = 窗口内组卷总数。
  223. * 学情:每卷在窗口内取 MIN(ear.created_at) 所在段计 1 套,七段之和 = 窗口内卷去重套数(与总量表一致)。
  224. *
  225. * @return array{labels: list<string>, papers: list<int>, analysis: list<int>}
  226. */
  227. $dailySlices = static function (\Carbon\Carbon $from, \Carbon\Carbon $toExclusive) use ($db): array {
  228. $totalSec = max(1, $toExclusive->getTimestamp() - $from->getTimestamp());
  229. $sliceSec = $totalSec / 7;
  230. $labels = [];
  231. $papers = [];
  232. $analysis = array_fill(0, 7, 0);
  233. for ($i = 0; $i < 7; $i++) {
  234. $sliceFrom = $from->copy()->addSeconds((int) floor($sliceSec * $i));
  235. $sliceTo = $i < 6
  236. ? $from->copy()->addSeconds((int) floor($sliceSec * ($i + 1)))
  237. : $toExclusive;
  238. // 横轴日期:取每段结束瞬间的前一秒,避免「最后一段落在 4/19 却仍标成 4/18」(原先用段起点做标签)
  239. $labels[] = $sliceTo->copy()->subSecond()->format('n/j');
  240. $papers[] = (int) $db::table('papers')
  241. ->whereNotNull('teacher_id')
  242. ->where('teacher_id', '!=', '')
  243. ->where('created_at', '>=', $sliceFrom)
  244. ->where('created_at', '<', $sliceTo)
  245. ->count();
  246. }
  247. $firstAnalysisRows = $db::table('exam_analysis_results as ear')
  248. ->join('papers as p', 'p.paper_id', '=', 'ear.paper_id')
  249. ->whereNotNull('p.teacher_id')
  250. ->where('p.teacher_id', '!=', '')
  251. ->where('ear.created_at', '>=', $from)
  252. ->where('ear.created_at', '<', $toExclusive)
  253. ->selectRaw('ear.paper_id, MIN(ear.created_at) AS first_at')
  254. ->groupBy('ear.paper_id')
  255. ->get();
  256. $fromTs = $from->getTimestamp();
  257. foreach ($firstAnalysisRows as $row) {
  258. $t = \Carbon\Carbon::parse($row->first_at)->getTimestamp();
  259. $idx = (int) floor(($t - $fromTs) / $sliceSec);
  260. if ($idx < 0) {
  261. $idx = 0;
  262. }
  263. if ($idx > 6) {
  264. $idx = 6;
  265. }
  266. $analysis[$idx]++;
  267. }
  268. return ['labels' => $labels, 'papers' => $papers, 'analysis' => $analysis];
  269. };
  270. $curDaily = $dailySlices($startCurrent, $endCurrent);
  271. $prevDaily = $dailySlices($startPrev, $endPrev);
  272. /** 左侧:学案(组卷)套数;右侧:学情分析套数(卷去重)。实线本周期,虚线上周期。 */
  273. $buildDualChartsHtml = static function (array $curDaily, array $prevDaily): string {
  274. $labels = $curDaily['labels'];
  275. $pc = $curDaily['papers'];
  276. $ac = $curDaily['analysis'];
  277. $pp = $prevDaily['papers'];
  278. $ap = $prevDaily['analysis'];
  279. $halfSvg = static function (
  280. array $labels,
  281. array $cur,
  282. array $prev,
  283. string $strokeCur,
  284. string $strokePrev,
  285. string $legCur,
  286. string $legPrev
  287. ): string {
  288. $maxY = max(1, ...$cur, ...$prev);
  289. $W = 248;
  290. $H = 200;
  291. $padL = 38;
  292. $padR = 8;
  293. $padT = 14;
  294. $padB = 34;
  295. $gw = $W - $padL - $padR;
  296. $gh = $H - $padT - $padB;
  297. $n = 7;
  298. $xAt = static function (int $i) use ($padL, $gw, $n): float {
  299. return $padL + ($n <= 1 ? $gw / 2 : $gw * $i / ($n - 1));
  300. };
  301. $yAt = static function (int $v) use ($padT, $gh, $maxY): float {
  302. return $padT + $gh - ($v / $maxY) * $gh;
  303. };
  304. $lineWithDots = static function (array $vals, string $stroke, string $dash, float $sw = 1.6) use ($xAt, $yAt, $n): string {
  305. $pts = [];
  306. $circles = '';
  307. for ($i = 0; $i < $n; $i++) {
  308. $x = $xAt($i);
  309. $y = $yAt((int) $vals[$i]);
  310. $pts[] = round($x, 1).','.round($y, 1);
  311. $circles .= sprintf(
  312. '<circle cx="%.1f" cy="%.1f" r="3.2" fill="%s" stroke="#fff" stroke-width="1"/>',
  313. $x,
  314. $y,
  315. $stroke
  316. );
  317. }
  318. $poly = implode(' ', $pts);
  319. $dashAttr = $dash !== '' ? ' stroke-dasharray="'.$dash.'"' : '';
  320. return '<polyline fill="none" stroke="'.$stroke.'" stroke-width="'.$sw.'"'.$dashAttr.' points="'.$poly.'" />'.$circles;
  321. };
  322. $xAxisY = $padT + $gh;
  323. $tickTxt = '';
  324. for ($i = 0; $i < $n; $i++) {
  325. $x = $xAt($i);
  326. $lab = htmlspecialchars((string) ($labels[$i] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
  327. $tickTxt .= sprintf(
  328. '<text x="%.1f" y="%d" text-anchor="middle" font-size="8.5" fill="#374151" font-family="sun-exta,sans-serif">%s</text>',
  329. $x,
  330. $H - 8,
  331. $lab
  332. );
  333. }
  334. $yTick = '';
  335. for ($k = 0; $k <= 4; $k++) {
  336. $v = (int) round($maxY * $k / 4);
  337. $y = $yAt($v);
  338. $yTick .= sprintf(
  339. '<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="#e5e7eb" stroke-width="1"/>',
  340. $padL,
  341. $y,
  342. $padL + $gw,
  343. $y
  344. );
  345. $yTick .= sprintf(
  346. '<text x="%d" y="%.1f" font-size="7.5" fill="#6b7280" font-family="sun-exta,sans-serif">%d</text>',
  347. 2,
  348. $y + 3,
  349. $v
  350. );
  351. }
  352. $legend = '<g font-family="sun-exta,sans-serif" font-size="8.5">';
  353. $lx = max($padL + 4, $padL + $gw - 118);
  354. $ly = $padT + 2;
  355. $items = [
  356. [$strokeCur, $legCur, ''],
  357. [$strokePrev, $legPrev, '6,4'],
  358. ];
  359. foreach ($items as $idx => $it) {
  360. $yy = $ly + $idx * 12;
  361. $legend .= sprintf(
  362. '<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2" %s/>',
  363. $lx,
  364. $yy,
  365. $lx + 16,
  366. $yy,
  367. $it[0],
  368. $it[2] !== '' ? 'stroke-dasharray="'.$it[2].'"' : ''
  369. );
  370. $legend .= sprintf(
  371. '<text x="%d" y="%d" fill="#111">%s</text>',
  372. $lx + 20,
  373. $yy + 4,
  374. htmlspecialchars($it[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
  375. );
  376. }
  377. $legend .= '</g>';
  378. $svg = sprintf(
  379. '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="100%%" height="auto" style="max-width:100%%;">',
  380. $W,
  381. $H
  382. );
  383. $svg .= sprintf('<rect x="0" y="0" width="%d" height="%d" fill="#fafafa"/>', $W, $H);
  384. $svg .= $yTick;
  385. $svg .= sprintf('<line x1="%d" y1="%.1f" x2="%.1f" y2="%.1f" stroke="#9ca3af" stroke-width="1"/>', $padL, $xAxisY, $padL + $gw, $xAxisY);
  386. $svg .= $lineWithDots($prev, $strokePrev, '6,4', 1.4);
  387. $svg .= $lineWithDots($cur, $strokeCur, '', 1.8);
  388. $svg .= $tickTxt;
  389. $svg .= $legend;
  390. $svg .= '</svg>';
  391. return $svg;
  392. };
  393. $left = $halfSvg($labels, $pc, $pp, '#2563eb', '#93c5fd', '学案·本周期', '学案·上周期');
  394. $right = $halfSvg($labels, $ac, $ap, '#ea580c', '#fdba74', '学情·本周期', '学情·上周期');
  395. return '<table class="weekly-chart-pair" style="width:100%;border-collapse:collapse;margin:0;"><tr>'
  396. .'<td style="width:50%;vertical-align:top;padding:2px 5px 2px 0;">'.$left.'</td>'
  397. .'<td style="width:50%;vertical-align:top;padding:2px 0 2px 5px;">'.$right.'</td>'
  398. .'</tr></table>';
  399. };
  400. $chartSvg = $buildDualChartsHtml($curDaily, $prevDaily);
  401. echo "## 老师组卷与学情分析(近7天)\n\n";
  402. echo "> 生成 {$generatedAt} · {$tz} · 本 {$windowCur} · 上 {$windowPrev}\n\n";
  403. echo "### 总量\n\n";
  404. echo "| 指标 | 本周期 | 上周期 | 环比 |\n";
  405. echo "| :---: | :---: | :---: | :---: |\n";
  406. echo sprintf("| 组卷总套数 | %d | %d | %s |\n", $totalPapersCur, $totalPapersPrev, $wowLineHtml($totalPapersCur, $totalPapersPrev, $colorPapers));
  407. echo sprintf("| 学情分析套数(卷去重) | %d | %d | %s |\n", $totalAnalysisCur, $totalAnalysisPrev, $wowLineHtml($totalAnalysisCur, $totalAnalysisPrev, $colorAnalysis));
  408. echo sprintf("| 有组卷老师数 | %d | %d | %s |\n", $teachersCur, $teachersPrev, $wowLineHtml($teachersCur, $teachersPrev, $colorStudents));
  409. echo "\n### 近7段每日对比(时间轴对齐;左:学案 · 右:学情;七段合计与本表总量一致)\n\n";
  410. echo '<div class="weekly-chart">';
  411. echo $chartSvg;
  412. echo "</div>\n\n";
  413. echo sprintf("### 按老师 本周期有组卷 **%d** 人。\n\n", count($rows));
  414. echo '<table class="weekly-teacher-table">';
  415. echo '<colgroup>';
  416. echo '<col style="width:3%" /><col class="col-name" style="width:18mm;max-width:18mm;" />';
  417. echo '<col class="col-slash" style="width:11%" /><col style="width:14%" />';
  418. echo '<col class="col-slash" style="width:11%" /><col style="width:14%" />';
  419. echo '<col class="col-slash" style="width:11%" />';
  420. echo '</colgroup>';
  421. echo '<thead><tr>';
  422. echo '<th>排名</th><th>老师</th><th>学案数量</th><th>学案·环比</th><th>分析数量</th><th>学情·环比</th><th>学生数</th>';
  423. echo "</tr></thead>\n<tbody>\n";
  424. $i = 1;
  425. foreach ($rows as $r) {
  426. $tidKey = (string) $r['teacher_id'];
  427. $nmRaw = (string) $r['name'];
  428. $nmEsc = htmlspecialchars($nmRaw, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
  429. $tidEsc = htmlspecialchars($tidKey, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
  430. $nameWithId = $nmEsc.' <span class="teacher-id">('.$tidEsc.')</span>';
  431. $pc = $r['papers'];
  432. $pp = $r['papers_prev'];
  433. $ac = $r['analysis_sets'];
  434. $ap = $r['analysis_sets_prev'];
  435. $stuC = (int) ($studentUnionCurMap[$tidKey] ?? 0);
  436. $stuP = (int) ($studentUnionPrevMap[$tidKey] ?? 0);
  437. echo '<tr>';
  438. echo '<td>'.((string) $i++).'</td>';
  439. echo '<td class="td-name">'.$nameWithId.'</td>';
  440. echo '<td class="td-slash td-slash-papers" title="本周期 / 上周期:组卷套数">'.$slashPairAccentHtml($pc, $pp, $colorPapers).'</td>';
  441. echo '<td class="td-wow-papers">'.$compareCellHtml($pc, $pp, $colorPapers).'</td>';
  442. echo '<td class="td-slash td-slash-analysis" title="本周期 / 上周期:学情分析套数(卷去重)">'.$slashPairAccentHtml($ac, $ap, $colorAnalysis).'</td>';
  443. echo '<td class="td-wow-analysis">'.$compareCellHtml($ac, $ap, $colorAnalysis).'</td>';
  444. echo '<td class="td-slash td-slash-stu td-stu" title="本周期 / 上周期:组卷∪学情学生合并去重">'.$slashPairAccentHtml($stuC, $stuP, $colorStudents).'</td>';
  445. echo "</tr>\n";
  446. }
  447. echo "</tbody></table>\n";