hydro-ai-helper 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +1 -0
  2. package/dist/handlers/adminConfigHandler.js +30 -0
  3. package/dist/handlers/adminConfigHandler.js.map +1 -1
  4. package/dist/handlers/batchSummaryHandler.js +15 -8
  5. package/dist/handlers/batchSummaryHandler.js.map +1 -1
  6. package/dist/handlers/studentHandler.js +1 -1
  7. package/dist/handlers/studentHandler.js.map +1 -1
  8. package/dist/handlers/teachingSummaryHandler.js +38 -9
  9. package/dist/handlers/teachingSummaryHandler.js.map +1 -1
  10. package/dist/models/aiConfig.js +15 -2
  11. package/dist/models/aiConfig.js.map +1 -1
  12. package/dist/models/studentSummary.js +13 -0
  13. package/dist/models/studentSummary.js.map +1 -1
  14. package/dist/models/teachingSummary.js +2 -0
  15. package/dist/models/teachingSummary.js.map +1 -1
  16. package/dist/services/analyzers/errorClusterAnalyzer.js +1 -0
  17. package/dist/services/analyzers/errorClusterAnalyzer.js.map +1 -1
  18. package/dist/services/analyzers/findingConsolidator.js +172 -0
  19. package/dist/services/analyzers/findingConsolidator.js.map +1 -0
  20. package/dist/services/batchSummaryService.js +79 -38
  21. package/dist/services/batchSummaryService.js.map +1 -1
  22. package/dist/services/openaiClient.js +59 -28
  23. package/dist/services/openaiClient.js.map +1 -1
  24. package/dist/services/promptService.js +49 -24
  25. package/dist/services/promptService.js.map +1 -1
  26. package/dist/services/teachingAnalysisService.js +13 -2
  27. package/dist/services/teachingAnalysisService.js.map +1 -1
  28. package/dist/services/teachingSuggestionService.js +43 -44
  29. package/dist/services/teachingSuggestionService.js.map +1 -1
  30. package/frontend/admin/BudgetConfigForm.tsx +1 -1
  31. package/frontend/admin/ConfigPanel.tsx +53 -3
  32. package/frontend/admin/EndpointManager.tsx +1 -1
  33. package/frontend/admin/FeedbackForm.tsx +1 -1
  34. package/frontend/admin/JailbreakLogsViewer.tsx +1 -1
  35. package/frontend/admin/ScenarioModelSelector.tsx +238 -0
  36. package/frontend/admin/TelemetrySettings.tsx +1 -1
  37. package/frontend/admin/VersionBadge.tsx +1 -1
  38. package/frontend/admin/configTypes.ts +9 -0
  39. package/frontend/batchSummary/BatchSummaryPanel.tsx +29 -1
  40. package/frontend/batchSummary/StudentSummaryView.tsx +47 -14
  41. package/frontend/batchSummary/SummaryCard.tsx +1 -1
  42. package/frontend/batchSummary/useBatchSummary.ts +26 -8
  43. package/frontend/components/AIHelperDashboard.tsx +1 -1
  44. package/frontend/components/ErrorBoundary.tsx +1 -1
  45. package/frontend/components/ScoreboardTabContainer.tsx +1 -1
  46. package/frontend/generated/localeFallback.ts +1502 -0
  47. package/frontend/problem_detail.page.tsx +1 -1
  48. package/frontend/student/AIAssistantPanel.tsx +1 -1
  49. package/frontend/student/ChatInput.tsx +1 -1
  50. package/frontend/student/ChatMessageList.tsx +1 -1
  51. package/frontend/student/ThinkingBlock.tsx +1 -1
  52. package/frontend/student/hooks/useChatSession.ts +1 -1
  53. package/frontend/teacher/AnalyticsPage.tsx +1 -1
  54. package/frontend/teacher/ClassAnalyticsTable.tsx +1 -1
  55. package/frontend/teacher/ConversationDetail.tsx +1 -1
  56. package/frontend/teacher/ConversationDetailModal.tsx +1 -1
  57. package/frontend/teacher/ConversationList.tsx +1 -1
  58. package/frontend/teacher/CostDashboard.tsx +1 -1
  59. package/frontend/teacher/ExportDialog.tsx +1 -1
  60. package/frontend/teacher/MetricsPanel.tsx +1 -1
  61. package/frontend/teacher/ProblemAnalyticsTable.tsx +1 -1
  62. package/frontend/teacher/StudentAnalyticsTable.tsx +1 -1
  63. package/frontend/teacher/analyticsTypes.ts +1 -1
  64. package/frontend/teachingSummary/TeachingReviewPanel.tsx +1 -1
  65. package/frontend/teachingSummary/TeachingSummaryPanel.tsx +525 -175
  66. package/frontend/teachingSummary/useTeachingSummary.ts +10 -0
  67. package/frontend/utils/i18n.ts +32 -0
  68. package/frontend/utils/i18nFallbackCore.ts +30 -0
  69. package/locales/en.yaml +30 -6
  70. package/locales/zh.yaml +30 -6
  71. package/package.json +3 -1
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * TeachingSummaryPanel — teacher-facing UI for AI teaching summary generation.
3
3
  * Injects into homework/contest scoreboard pages for whole-class analysis.
4
+ *
5
+ * 完成态按教师备课工作流组织为四段:
6
+ * ① 一句话诊断 → ② 下节课回顾清单(含个体干预)→ ③ 学生问题(重点卡片 + 其他观察)
7
+ * → ④ 课后强化训练(可一键复制下发)
4
8
  */
5
9
 
6
10
  import React, { useState, useEffect, useCallback } from 'react';
7
- import { i18n } from '@hydrooj/ui-default';
11
+ import { i18n } from '../utils/i18n';
8
12
  import {
9
13
  COLORS, SPACING, RADIUS, SHADOWS, TRANSITIONS,
10
14
  getButtonStyle, cardStyle, markdownTheme, LAYOUT,
@@ -23,17 +27,26 @@ const I18N_FALLBACK: Record<string, string> = {
23
27
  ai_helper_teaching_summary_focus_placeholder: '可选:输入教学重点(如"递归理解"或"复杂度优化")',
24
28
  ai_helper_teaching_summary_snapshot_notice: '数据快照时间:',
25
29
  ai_helper_teaching_summary_participated: '参与学生',
26
- ai_helper_teaching_summary_findings: '发现项',
30
+ ai_helper_teaching_summary_primary_findings: '重点问题',
27
31
  ai_helper_teaching_summary_high_priority: '高优先级',
28
32
  ai_helper_teaching_summary_ai_users: 'AI 使用者',
33
+ ai_helper_teaching_summary_completed_all: '完成全部题目',
29
34
  ai_helper_teaching_summary_low_data_warning: '参与学生不足 10 人,分析结果仅供参考。',
30
- ai_helper_teaching_summary_findings_title: '教学发现',
35
+ ai_helper_teaching_summary_section_diagnosis: '一句话诊断',
36
+ ai_helper_teaching_summary_section_review: '下节课回顾清单',
37
+ ai_helper_teaching_summary_section_problems: '学生问题',
38
+ ai_helper_teaching_summary_section_homework: '课后强化训练',
39
+ ai_helper_teaching_summary_section_full_report: 'AI 完整报告',
40
+ ai_helper_teaching_summary_other_observations: '其他观察',
31
41
  ai_helper_teaching_summary_no_findings: '未发现明显问题',
32
42
  ai_helper_teaching_summary_overall_suggestion: 'AI 综合建议',
33
- ai_helper_teaching_summary_expand: '展开',
34
- ai_helper_teaching_summary_collapse: '收起',
35
43
  ai_helper_teaching_summary_affected: '涉及',
36
44
  ai_helper_teaching_summary_students: '名学生',
45
+ ai_helper_teaching_summary_student_list: '涉及学生',
46
+ ai_helper_teaching_summary_copy_homework: '复制作业',
47
+ ai_helper_teaching_summary_copied: '已复制',
48
+ ai_helper_teaching_summary_copy_failed: '复制失败,请手动选择文本复制',
49
+ ai_helper_teaching_summary_homework_hint: '可直接复制下发("建议挖空点说明"为教师参考答案,请勿下发)',
37
50
  ai_helper_teaching_summary_feedback_helpful: '有帮助',
38
51
  ai_helper_teaching_summary_feedback_not_helpful: '没帮助',
39
52
  ai_helper_teaching_summary_feedback_thanks: '感谢反馈!',
@@ -73,11 +86,6 @@ const METRIC_LABELS: Record<string, string> = {
73
86
  passRate: '通过率',
74
87
  attempted: '尝试人数',
75
88
  accepted: '通过人数',
76
- affectedCount: '受影响人数',
77
- totalStudents: '总学生数',
78
- percentage: '占比',
79
- atRiskCount: '高危人数',
80
- completedCount: '完成人数',
81
89
  comprehensionPct: '理解类提问占比',
82
90
  aiUserCount: 'AI 使用人数',
83
91
  nonAiUserCount: '未使用 AI 人数',
@@ -90,10 +98,7 @@ const METRIC_LABELS: Record<string, string> = {
90
98
  aiPassRate: 'AI 用户通过率',
91
99
  nonAiPassRate: '非 AI 通过率',
92
100
  diff: '差异',
93
- burst_then_quit: '受挫放弃',
94
- stuck_silent: '沉默挣扎',
95
- persistent_learner: '持续努力',
96
- disengaged: '未参与',
101
+ sameSignatureCount: '同一错误位学生数',
97
102
  aiGroupSize: 'AI组人数',
98
103
  nonAiGroupSize: '非AI组人数',
99
104
  aiACRate: 'AI组通过率',
@@ -102,12 +107,84 @@ const METRIC_LABELS: Record<string, string> = {
102
107
  dominantClusterPct: '主要错误集群占比',
103
108
  };
104
109
 
110
+ /** 这些指标与卡片标题/"涉及 N 名学生"徽标重复,展开时不再显示 */
111
+ const REDUNDANT_METRICS = new Set([
112
+ 'affectedCount', 'totalStudents', 'percentage',
113
+ 'atRiskCount', 'completedCount',
114
+ ]);
115
+
116
+ /** 百分比类指标,展示时补 % 号 */
117
+ const PERCENT_METRIC = /rate|pct|percentage/i;
118
+
105
119
  const SEVERITY_COLORS = {
106
120
  high: { bg: '#fef2f2', text: '#b91c1c', border: '#fecaca' },
107
121
  medium: { bg: '#fffbeb', text: '#92400e', border: '#fde68a' },
108
122
  low: { bg: '#f0fdf4', text: '#166534', border: '#bbf7d0' },
109
123
  };
110
124
 
125
+ /** 学生名单默认展示上限,超出折叠为 +N */
126
+ const MAX_VISIBLE_NAMES = 24;
127
+
128
+ // ─── overallSuggestion 分段解析 ───────────────────────────────────────────────
129
+ //
130
+ // 主报告是一段 markdown,按 "### " 标题切块归类;识别失败的块进入 rest,
131
+ // 由"AI 完整报告"折叠区兜底(兼容旧版含 P0 报告/作业的长文档)。
132
+
133
+ export interface ParsedSuggestion {
134
+ diagnosis?: string;
135
+ reviewList?: string;
136
+ p1?: string;
137
+ homework?: string;
138
+ rest: string[];
139
+ }
140
+
141
+ export function parseSuggestionSections(md: string): ParsedSuggestion {
142
+ const result: ParsedSuggestion = { rest: [] };
143
+ if (!md || !md.trim()) return result;
144
+
145
+ const chunks = md.split(/(?=^###\s)/m).filter(c => c.trim());
146
+ for (const chunk of chunks) {
147
+ const firstLine = chunk.split('\n', 1)[0];
148
+ const body = chunk.replace(/^###[^\n]*\n?/, '').trim();
149
+ if (/一句话诊断/.test(firstLine) && !result.diagnosis) {
150
+ result.diagnosis = body;
151
+ } else if (/下节课回顾清单/.test(firstLine) && !result.reviewList) {
152
+ result.reviewList = body;
153
+ } else if (/个体干预/.test(firstLine) && !result.p1) {
154
+ result.p1 = body;
155
+ } else if (/课后巩固|挖空练习/.test(firstLine) && !result.homework) {
156
+ result.homework = chunk.trim();
157
+ } else {
158
+ result.rest.push(chunk.trim());
159
+ }
160
+ }
161
+ return result;
162
+ }
163
+
164
+ // ─── 剪贴板 ───────────────────────────────────────────────────────────────────
165
+
166
+ async function copyText(text: string): Promise<boolean> {
167
+ try {
168
+ if (navigator.clipboard?.writeText) {
169
+ await navigator.clipboard.writeText(text);
170
+ return true;
171
+ }
172
+ } catch { /* fall through to legacy path */ }
173
+ try {
174
+ const ta = document.createElement('textarea');
175
+ ta.value = text;
176
+ ta.style.position = 'fixed';
177
+ ta.style.opacity = '0';
178
+ document.body.appendChild(ta);
179
+ ta.select();
180
+ const ok = document.execCommand('copy');
181
+ document.body.removeChild(ta);
182
+ return ok;
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
111
188
  // ─── SkeletonBlock subcomponent ───────────────────────────────────────────────
112
189
 
113
190
  const SkeletonBlock: React.FC<{ lines?: number }> = ({ lines = 8 }) => (
@@ -131,19 +208,86 @@ const SkeletonBlock: React.FC<{ lines?: number }> = ({ lines = 8 }) => (
131
208
  </div>
132
209
  );
133
210
 
211
+ // ─── Section shell ────────────────────────────────────────────────────────────
212
+
213
+ interface SectionCardProps {
214
+ icon: string;
215
+ title: string;
216
+ accentColor?: string;
217
+ headerRight?: React.ReactNode;
218
+ children: React.ReactNode;
219
+ }
220
+
221
+ const SectionCard: React.FC<SectionCardProps> = ({
222
+ icon, title, accentColor = COLORS.border, headerRight, children,
223
+ }) => (
224
+ <div style={{
225
+ border: `1px solid ${COLORS.border}`,
226
+ borderLeft: `4px solid ${accentColor}`,
227
+ borderRadius: RADIUS.md,
228
+ backgroundColor: COLORS.bgCard,
229
+ boxShadow: SHADOWS.sm,
230
+ marginBottom: SPACING.lg,
231
+ overflow: 'hidden',
232
+ }}>
233
+ <div style={{
234
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
235
+ gap: SPACING.sm,
236
+ padding: `${SPACING.md} ${SPACING.base}`,
237
+ borderBottom: `1px solid ${COLORS.border}`,
238
+ backgroundColor: COLORS.bgPage,
239
+ }}>
240
+ <span style={{
241
+ fontWeight: 600, fontSize: '13px', color: COLORS.textSecondary,
242
+ letterSpacing: '0.05em',
243
+ }}>
244
+ {icon} {title}
245
+ </span>
246
+ {headerRight}
247
+ </div>
248
+ <div style={{ padding: `${SPACING.md} ${SPACING.base}` }}>
249
+ {children}
250
+ </div>
251
+ </div>
252
+ );
253
+
134
254
  // ─── FindingCard subcomponent ─────────────────────────────────────────────────
135
255
 
136
256
  interface FindingCardProps {
137
257
  finding: TeachingFinding;
138
258
  deepDiveText?: string;
259
+ studentNames?: Record<string, string>;
139
260
  }
140
261
 
141
- const FindingCard: React.FC<FindingCardProps> = ({ finding, deepDiveText }) => {
262
+ const FindingCard: React.FC<FindingCardProps> = ({ finding, deepDiveText, studentNames }) => {
142
263
  const [expanded, setExpanded] = useState(false);
264
+ const [showAllNames, setShowAllNames] = useState(false);
143
265
  const colors = SEVERITY_COLORS[finding.severity] || SEVERITY_COLORS.low;
144
266
  const dimensionLabel = DIMENSION_LABELS[finding.dimension] || finding.dimension;
145
267
  const affectedCount = finding.evidence.affectedStudents.length;
146
268
 
269
+ const names = (finding.evidence.affectedStudents || [])
270
+ .map(uid => studentNames?.[String(uid)])
271
+ .filter(Boolean) as string[];
272
+
273
+ const extraMetrics = Object.entries(finding.evidence.metrics || {})
274
+ .filter(([key]) => !REDUNDANT_METRICS.has(key));
275
+
276
+ const codeSample = (finding.dimension === 'commonError' || finding.dimension === 'errorCluster')
277
+ ? finding.evidence.samples?.code?.[0]
278
+ : undefined;
279
+
280
+ // 展开必须有实际内容;否则整行不可点,避免"展开什么也没有"
281
+ const hasDetails = Boolean(
282
+ (finding.supplements && finding.supplements.length > 0)
283
+ || deepDiveText
284
+ || codeSample
285
+ || names.length > 0
286
+ || extraMetrics.length > 0,
287
+ );
288
+
289
+ const visibleNames = showAllNames ? names : names.slice(0, MAX_VISIBLE_NAMES);
290
+
147
291
  return (
148
292
  <div style={{
149
293
  backgroundColor: COLORS.bgCard,
@@ -155,11 +299,11 @@ const FindingCard: React.FC<FindingCardProps> = ({ finding, deepDiveText }) => {
155
299
  transition: 'box-shadow 200ms ease',
156
300
  }}>
157
301
  <div
158
- onClick={() => setExpanded(!expanded)}
302
+ onClick={hasDetails ? () => setExpanded(!expanded) : undefined}
159
303
  style={{
160
304
  display: 'flex', alignItems: 'center', gap: SPACING.sm,
161
305
  padding: `${SPACING.md} ${SPACING.base}`,
162
- cursor: 'pointer', userSelect: 'none',
306
+ cursor: hasDetails ? 'pointer' : 'default', userSelect: 'none',
163
307
  }}
164
308
  >
165
309
  <span style={{
@@ -204,47 +348,43 @@ const FindingCard: React.FC<FindingCardProps> = ({ finding, deepDiveText }) => {
204
348
  {t('ai_helper_teaching_summary_affected')} {affectedCount} {t('ai_helper_teaching_summary_students')}
205
349
  </span>
206
350
 
207
- <span style={{
208
- display: 'inline-block', width: '16px', height: '16px', flexShrink: 0,
209
- textAlign: 'center', lineHeight: '16px', fontSize: '12px',
210
- color: COLORS.textMuted,
211
- transition: 'transform 200ms ease',
212
- transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
213
- }}>▶</span>
351
+ {hasDetails && (
352
+ <span style={{
353
+ display: 'inline-block', width: '16px', height: '16px', flexShrink: 0,
354
+ textAlign: 'center', lineHeight: '16px', fontSize: '12px',
355
+ color: COLORS.textMuted,
356
+ transition: 'transform 200ms ease',
357
+ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
358
+ }}>▶</span>
359
+ )}
214
360
  </div>
215
361
 
216
- {expanded && (
362
+ {expanded && hasDetails && (
217
363
  <div style={{
218
364
  padding: `0 ${SPACING.base} ${SPACING.base}`,
219
365
  borderTop: `1px solid ${COLORS.border}`,
220
366
  paddingTop: SPACING.md,
221
367
  }}>
222
- {Object.keys(finding.evidence.metrics).length > 0 && (
368
+ {/* 关联洞察(合并进来的交叉关联/错误聚类补充) */}
369
+ {finding.supplements && finding.supplements.length > 0 && (
223
370
  <div style={{
224
- display: 'flex', flexWrap: 'wrap', gap: SPACING.sm,
225
371
  marginBottom: SPACING.md,
226
372
  padding: `${SPACING.sm} ${SPACING.md}`,
227
373
  backgroundColor: COLORS.bgPage,
374
+ borderLeft: `3px solid ${COLORS.textMuted}`,
228
375
  borderRadius: RADIUS.sm,
229
376
  }}>
230
- {Object.entries(finding.evidence.metrics).map(([key, val]) => (
231
- <span key={key} style={{
232
- fontSize: '12px', color: COLORS.textSecondary,
377
+ {finding.supplements.map((s, i) => (
378
+ <div key={i} style={{
379
+ fontSize: '13px', color: COLORS.textSecondary, lineHeight: 1.6,
233
380
  }}>
234
- <span style={{ color: COLORS.textMuted }}>{METRIC_LABELS[key] || key}:</span>{' '}
235
- <strong>{typeof val === 'number' ? (val % 1 === 0 ? val : val.toFixed(2)) : val}</strong>
236
- </span>
381
+ {s}
382
+ </div>
237
383
  ))}
238
384
  </div>
239
385
  )}
240
386
 
241
- {finding.aiSuggestion && (
242
- <div style={{ fontSize: '13px', color: COLORS.textSecondary, marginBottom: SPACING.md, lineHeight: 1.6 }}>
243
- <strong>{DIMENSION_LABELS[finding.dimension] || finding.dimension}:</strong>
244
- {finding.aiSuggestion}
245
- </div>
246
- )}
247
-
387
+ {/* 深度诊断(根因 + 反例 + 课堂提问) */}
248
388
  {deepDiveText && (
249
389
  <div
250
390
  className="markdown-body"
@@ -253,8 +393,8 @@ const FindingCard: React.FC<FindingCardProps> = ({ finding, deepDiveText }) => {
253
393
  />
254
394
  )}
255
395
 
256
- {(finding.dimension === 'commonError' || finding.dimension === 'errorCluster')
257
- && finding.evidence.samples?.code && finding.evidence.samples.code.length > 0 && (
396
+ {/* 典型错误代码 */}
397
+ {codeSample && (
258
398
  <div style={{ marginTop: SPACING.md }}>
259
399
  <div style={{
260
400
  fontSize: '12px', fontWeight: 600, color: COLORS.textSecondary,
@@ -269,10 +409,66 @@ const FindingCard: React.FC<FindingCardProps> = ({ finding, deepDiveText }) => {
269
409
  color: '#e2e8f0', lineHeight: 1.6,
270
410
  fontFamily: "'SFMono-Regular', 'Menlo', 'Consolas', monospace",
271
411
  }}>
272
- {finding.evidence.samples.code[0]}
412
+ {codeSample}
273
413
  </pre>
274
414
  </div>
275
415
  )}
416
+
417
+ {/* 涉及学生名单 — 教师可据此点名辅导 */}
418
+ {names.length > 0 && (
419
+ <div style={{ marginTop: SPACING.md }}>
420
+ <div style={{
421
+ fontSize: '12px', fontWeight: 600, color: COLORS.textSecondary,
422
+ marginBottom: SPACING.sm, letterSpacing: '0.02em',
423
+ }}>
424
+ {t('ai_helper_teaching_summary_student_list')}({names.length}):
425
+ </div>
426
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
427
+ {visibleNames.map((name, i) => (
428
+ <span key={i} style={{
429
+ fontSize: '12px', padding: '2px 8px',
430
+ backgroundColor: COLORS.bgPage,
431
+ border: `1px solid ${COLORS.border}`,
432
+ borderRadius: RADIUS.full,
433
+ color: COLORS.textSecondary,
434
+ }}>
435
+ {name}
436
+ </span>
437
+ ))}
438
+ {names.length > MAX_VISIBLE_NAMES && !showAllNames && (
439
+ <span
440
+ onClick={() => setShowAllNames(true)}
441
+ style={{
442
+ fontSize: '12px', padding: '2px 8px',
443
+ borderRadius: RADIUS.full,
444
+ color: COLORS.primary, cursor: 'pointer',
445
+ border: `1px dashed ${COLORS.primary}`,
446
+ }}
447
+ >
448
+ +{names.length - MAX_VISIBLE_NAMES}
449
+ </span>
450
+ )}
451
+ </div>
452
+ </div>
453
+ )}
454
+
455
+ {/* 其余非冗余指标 */}
456
+ {extraMetrics.length > 0 && (
457
+ <div style={{
458
+ display: 'flex', flexWrap: 'wrap', gap: SPACING.sm,
459
+ marginTop: SPACING.md,
460
+ }}>
461
+ {extraMetrics.map(([key, val]) => (
462
+ <span key={key} style={{ fontSize: '12px', color: COLORS.textMuted }}>
463
+ {METRIC_LABELS[key] || key}:{' '}
464
+ <strong style={{ color: COLORS.textSecondary }}>
465
+ {typeof val === 'number' ? (val % 1 === 0 ? val : val.toFixed(2)) : val}
466
+ {typeof val === 'number' && PERCENT_METRIC.test(key) ? '%' : ''}
467
+ </strong>
468
+ </span>
469
+ ))}
470
+ </div>
471
+ )}
276
472
  </div>
277
473
  )}
278
474
  </div>
@@ -281,29 +477,25 @@ const FindingCard: React.FC<FindingCardProps> = ({ finding, deepDiveText }) => {
281
477
 
282
478
  // ─── Overview bar ─────────────────────────────────────────────────────────────
283
479
 
284
- interface OverviewBarProps {
285
- stats: TeachingSummary['stats'];
286
- findingsCount: number;
287
- highCount: number;
480
+ interface OverviewItem {
481
+ label: string;
482
+ value: React.ReactNode;
483
+ highlight?: boolean;
484
+ positive?: boolean;
288
485
  }
289
486
 
290
- const OverviewBar: React.FC<OverviewBarProps> = ({ stats, findingsCount, highCount }) => (
487
+ const OverviewBar: React.FC<{ items: OverviewItem[] }> = ({ items }) => (
291
488
  <div style={{
292
489
  display: 'flex', flexWrap: 'wrap', gap: SPACING.lg,
293
490
  marginBottom: SPACING.base,
294
491
  padding: `${SPACING.sm} 0`,
295
492
  }}>
296
- {[
297
- { label: t('ai_helper_teaching_summary_participated'), value: stats.participatedStudents },
298
- { label: t('ai_helper_teaching_summary_findings'), value: findingsCount },
299
- { label: t('ai_helper_teaching_summary_high_priority'), value: highCount, highlight: highCount > 0 },
300
- { label: t('ai_helper_teaching_summary_ai_users'), value: stats.aiUserCount },
301
- ].map(({ label, value, highlight }) => (
493
+ {items.map(({ label, value, highlight, positive }) => (
302
494
  <span key={label} style={{ display: 'inline-flex', alignItems: 'baseline', gap: '4px' }}>
303
495
  <span style={{ fontSize: '12px', color: COLORS.textMuted }}>{label}</span>
304
496
  <span style={{
305
497
  fontSize: '18px', fontWeight: 700,
306
- color: highlight ? COLORS.error : COLORS.textPrimary,
498
+ color: highlight ? COLORS.error : positive ? COLORS.hydroGreenDark : COLORS.textPrimary,
307
499
  }}>
308
500
  {value}
309
501
  </span>
@@ -326,16 +518,20 @@ export const TeachingSummaryPanel: React.FC<TeachingSummaryPanelProps> = ({ doma
326
518
 
327
519
  const [teachingFocus, setTeachingFocus] = useState('');
328
520
  const [feedbackSubmitted, setFeedbackSubmitted] = useState(false);
329
- const [suggestionCollapsed, setSuggestionCollapsed] = useState(false);
521
+ const [fullReportCollapsed, setFullReportCollapsed] = useState(true);
522
+ const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
330
523
 
331
524
  useEffect(() => {
332
525
  fetchSummary();
333
526
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
334
527
 
335
- // Report findings count to parent for tab badge
528
+ // Report primary problem count to parent for tab badge
336
529
  useEffect(() => {
337
530
  if (onStatsUpdate && summary?.findings) {
338
- onStatsUpdate(summary.findings.length);
531
+ const primaryCount = summary.findings.filter(
532
+ f => !f.isSecondary && f.dimension !== 'progress',
533
+ ).length;
534
+ onStatsUpdate(primaryCount);
339
535
  }
340
536
  }, [summary?.findings?.length]); // eslint-disable-line react-hooks/exhaustive-deps
341
537
 
@@ -468,14 +664,98 @@ export const TeachingSummaryPanel: React.FC<TeachingSummaryPanelProps> = ({ doma
468
664
  // ── Completed state ───────────────────────────────────────────────────────
469
665
 
470
666
  const findings = summary.findings || [];
471
- const highFindings = findings.filter(f => f.severity === 'high');
472
- const mediumFindings = findings.filter(f => f.severity === 'medium');
473
- const lowFindings = findings.filter(f => f.severity === 'low');
667
+ const progressFinding = findings.find(f => f.dimension === 'progress');
668
+ const problemFindings = findings.filter(f => f.dimension !== 'progress');
669
+
670
+ // 后端已按严重度+影响面排序并标记次要发现;对旧文档做同样的客户端兜底限量
671
+ const flaggedPrimary = problemFindings.filter(f => !f.isSecondary);
672
+ const primaryFindings = flaggedPrimary.slice(0, 5);
673
+ const secondaryFindings = [
674
+ ...flaggedPrimary.slice(5),
675
+ ...problemFindings.filter(f => f.isSecondary),
676
+ ];
677
+
678
+ const highCount = primaryFindings.filter(f => f.severity === 'high').length;
679
+
680
+ const parsed = parseSuggestionSections(summary.overallSuggestion || '');
681
+ const parsedOk = Boolean(parsed.diagnosis || parsed.reviewList);
682
+ const homeworkMd = (summary.homeworkText && summary.homeworkText.trim())
683
+ ? summary.homeworkText
684
+ : parsed.homework;
474
685
 
475
686
  const snapshotDate = summary.dataSnapshotAt
476
687
  ? new Date(summary.dataSnapshotAt).toLocaleString('zh-CN', { hour12: false })
477
688
  : '';
478
689
 
690
+ const handleCopyHomework = async () => {
691
+ if (!homeworkMd) return;
692
+ const ok = await copyText(homeworkMd);
693
+ setCopyState(ok ? 'copied' : 'failed');
694
+ setTimeout(() => setCopyState('idle'), 2000);
695
+ };
696
+
697
+ const overviewItems: OverviewItem[] = [
698
+ {
699
+ label: t('ai_helper_teaching_summary_participated'),
700
+ value: `${summary.stats.participatedStudents}/${summary.stats.totalStudents}`,
701
+ },
702
+ {
703
+ label: t('ai_helper_teaching_summary_primary_findings'),
704
+ value: primaryFindings.length,
705
+ },
706
+ {
707
+ label: t('ai_helper_teaching_summary_high_priority'),
708
+ value: highCount,
709
+ highlight: highCount > 0,
710
+ },
711
+ ...(progressFinding ? [{
712
+ label: t('ai_helper_teaching_summary_completed_all'),
713
+ value: `${progressFinding.evidence.affectedStudents.length}${
714
+ typeof progressFinding.evidence.metrics?.percentage === 'number'
715
+ ? ` (${progressFinding.evidence.metrics.percentage}%)` : ''
716
+ }`,
717
+ positive: true,
718
+ }] : []),
719
+ {
720
+ label: t('ai_helper_teaching_summary_ai_users'),
721
+ value: summary.stats.aiUserCount,
722
+ },
723
+ ];
724
+
725
+ const feedbackRow = (
726
+ <div style={{
727
+ display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
728
+ gap: SPACING.sm, padding: `${SPACING.sm} 0`,
729
+ }}>
730
+ {feedbackSubmitted ? (
731
+ <span style={{ fontSize: '12px', color: COLORS.successText }}>
732
+ {t('ai_helper_teaching_summary_feedback_thanks')}
733
+ </span>
734
+ ) : (
735
+ <>
736
+ {(['up', 'down'] as const).map(rating => (
737
+ <button
738
+ key={rating}
739
+ onClick={() => handleFeedback(rating)}
740
+ style={{
741
+ fontSize: '12px', padding: '3px 8px',
742
+ border: `1px solid ${COLORS.border}`,
743
+ borderRadius: RADIUS.sm,
744
+ backgroundColor: 'transparent',
745
+ color: summary.feedback?.rating === rating
746
+ ? (rating === 'up' ? COLORS.successText : COLORS.errorText)
747
+ : COLORS.textMuted,
748
+ cursor: 'pointer',
749
+ }}
750
+ >
751
+ {rating === 'up' ? t('ai_helper_teaching_summary_feedback_helpful') : t('ai_helper_teaching_summary_feedback_not_helpful')}
752
+ </button>
753
+ ))}
754
+ </>
755
+ )}
756
+ </div>
757
+ );
758
+
479
759
  return (
480
760
  <div style={{ fontFamily: 'inherit', color: COLORS.textPrimary, maxWidth: LAYOUT.contentMaxWidth, margin: '0 auto', width: '100%' }}>
481
761
  <style>{markdownTheme}</style>
@@ -519,11 +799,7 @@ export const TeachingSummaryPanel: React.FC<TeachingSummaryPanelProps> = ({ doma
519
799
  )}
520
800
 
521
801
  {/* Overview bar */}
522
- <OverviewBar
523
- stats={summary.stats}
524
- findingsCount={findings.length}
525
- highCount={highFindings.length}
526
- />
802
+ <OverviewBar items={overviewItems} />
527
803
 
528
804
  {/* Low-data warning */}
529
805
  {summary.stats.participatedStudents < 10 && (
@@ -539,128 +815,202 @@ export const TeachingSummaryPanel: React.FC<TeachingSummaryPanelProps> = ({ doma
539
815
  </div>
540
816
  )}
541
817
 
542
- {/* Single-column layout: Findings AI Suggestion */}
543
- <div>
544
- {/* Findings */}
545
- <div>
818
+ {/* 一句话诊断 30 秒抓住重点 */}
819
+ {parsed.diagnosis && (
820
+ <div style={{
821
+ padding: `${SPACING.base} ${SPACING.lg}`,
822
+ marginBottom: SPACING.lg,
823
+ backgroundColor: COLORS.bgCard,
824
+ border: `1px solid ${COLORS.border}`,
825
+ borderLeft: `4px solid ${COLORS.primary}`,
826
+ borderRadius: RADIUS.md,
827
+ boxShadow: SHADOWS.sm,
828
+ }}>
546
829
  <div style={{
547
- fontWeight: 600, fontSize: '13px', marginBottom: SPACING.md,
548
- color: COLORS.textMuted, textTransform: 'uppercase' as const,
549
- letterSpacing: '0.05em',
830
+ fontSize: '12px', fontWeight: 600, color: COLORS.textMuted,
831
+ letterSpacing: '0.05em', marginBottom: SPACING.sm,
550
832
  }}>
551
- {t('ai_helper_teaching_summary_findings_title')}
833
+ 📊 {t('ai_helper_teaching_summary_section_diagnosis')}
552
834
  </div>
835
+ <div
836
+ className="markdown-body"
837
+ style={{ fontSize: '15px', lineHeight: 1.7 }}
838
+ dangerouslySetInnerHTML={{ __html: renderMarkdown(parsed.diagnosis) }}
839
+ />
840
+ </div>
841
+ )}
553
842
 
554
- {findings.length === 0 ? (
555
- <div style={{ color: COLORS.textMuted, fontSize: '13px', fontStyle: 'italic' }}>
556
- {t('ai_helper_teaching_summary_no_findings')}
843
+ {/* 下节课回顾清单 拿着就能上课,常驻展示 */}
844
+ {parsed.reviewList && (
845
+ <SectionCard
846
+ icon="📋"
847
+ title={t('ai_helper_teaching_summary_section_review')}
848
+ accentColor={COLORS.hydroGreen}
849
+ >
850
+ <div
851
+ className="markdown-body"
852
+ dangerouslySetInnerHTML={{ __html: renderMarkdown(parsed.reviewList) }}
853
+ />
854
+ {parsed.p1 && (
855
+ <div style={{ marginTop: SPACING.md, paddingTop: SPACING.md, borderTop: `1px dashed ${COLORS.border}` }}>
856
+ <div style={{
857
+ fontSize: '12px', fontWeight: 600, color: COLORS.textSecondary,
858
+ marginBottom: SPACING.sm, letterSpacing: '0.02em',
859
+ }}>
860
+ 👥 个体干预建议
861
+ </div>
862
+ <div
863
+ className="markdown-body"
864
+ dangerouslySetInnerHTML={{ __html: renderMarkdown(parsed.p1) }}
865
+ />
557
866
  </div>
558
- ) : (
559
- <>
560
- {highFindings.map(f => (
561
- <FindingCard key={f.id} finding={f} deepDiveText={summary.deepDiveResults?.[f.id]} />
562
- ))}
563
- {mediumFindings.map(f => (
564
- <FindingCard key={f.id} finding={f} deepDiveText={summary.deepDiveResults?.[f.id]} />
565
- ))}
566
- {lowFindings.map(f => (
567
- <FindingCard key={f.id} finding={f} deepDiveText={summary.deepDiveResults?.[f.id]} />
568
- ))}
569
- </>
570
867
  )}
571
- </div>
868
+ </SectionCard>
869
+ )}
572
870
 
573
- {/* AI Suggestioncollapsible conclusion card */}
574
- {(summary.overallSuggestion || summary.status === 'generating') && (
575
- <div style={{ marginTop: LAYOUT.sectionGap }}>
576
- <div style={{
577
- border: `1px solid ${COLORS.border}`,
578
- borderLeft: `4px solid ${COLORS.hydroGreen}`,
579
- borderRadius: RADIUS.md,
580
- backgroundColor: COLORS.bgCard,
581
- boxShadow: SHADOWS.sm,
582
- }}>
583
- {/* Clickable header */}
584
- <div
585
- onClick={() => setSuggestionCollapsed(!suggestionCollapsed)}
871
+ {/* 学生问题去重后的重点卡片 + 其他观察一行带过 */}
872
+ <SectionCard
873
+ icon="🔍"
874
+ title={`${t('ai_helper_teaching_summary_section_problems')}${primaryFindings.length > 0 ? `(${primaryFindings.length})` : ''}`}
875
+ accentColor={highCount > 0 ? SEVERITY_COLORS.high.text : COLORS.border}
876
+ >
877
+ {primaryFindings.length === 0 ? (
878
+ <div style={{ color: COLORS.textMuted, fontSize: '13px', fontStyle: 'italic' }}>
879
+ {t('ai_helper_teaching_summary_no_findings')}
880
+ </div>
881
+ ) : (
882
+ primaryFindings.map(f => (
883
+ <FindingCard
884
+ key={f.id}
885
+ finding={f}
886
+ deepDiveText={summary.deepDiveResults?.[f.id]}
887
+ studentNames={summary.studentNames}
888
+ />
889
+ ))
890
+ )}
891
+
892
+ {secondaryFindings.length > 0 && (
893
+ <div style={{
894
+ marginTop: SPACING.sm,
895
+ padding: `${SPACING.sm} ${SPACING.md}`,
896
+ backgroundColor: COLORS.bgPage,
897
+ borderRadius: RADIUS.sm,
898
+ fontSize: '12px', color: COLORS.textMuted, lineHeight: 1.8,
899
+ }}>
900
+ <strong>{t('ai_helper_teaching_summary_other_observations')}:</strong>
901
+ {secondaryFindings.map(f => f.title).join(' · ')}
902
+ </div>
903
+ )}
904
+ </SectionCard>
905
+
906
+ {/* ④ 课后强化训练 — 一键复制下发 */}
907
+ {homeworkMd && (
908
+ <SectionCard
909
+ icon="📝"
910
+ title={t('ai_helper_teaching_summary_section_homework')}
911
+ accentColor={COLORS.primary}
912
+ headerRight={(
913
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: SPACING.sm }}>
914
+ {copyState !== 'idle' && (
915
+ <span style={{
916
+ fontSize: '12px',
917
+ color: copyState === 'copied' ? COLORS.successText : COLORS.errorText,
918
+ }}>
919
+ {copyState === 'copied'
920
+ ? t('ai_helper_teaching_summary_copied')
921
+ : t('ai_helper_teaching_summary_copy_failed')}
922
+ </span>
923
+ )}
924
+ <button
925
+ onClick={handleCopyHomework}
586
926
  style={{
587
- padding: `${SPACING.md} ${SPACING.base}`,
588
- borderBottom: suggestionCollapsed ? 'none' : `1px solid ${COLORS.border}`,
589
- backgroundColor: COLORS.hydroGreenLight,
927
+ fontSize: '12px', padding: '3px 10px',
928
+ border: `1px solid ${COLORS.primary}`,
929
+ borderRadius: RADIUS.sm,
930
+ backgroundColor: 'transparent',
931
+ color: COLORS.primary,
590
932
  cursor: 'pointer',
591
- display: 'flex',
592
- alignItems: 'center',
593
- justifyContent: 'space-between',
594
- userSelect: 'none' as const,
595
933
  }}
596
934
  >
597
- <span style={{
598
- fontWeight: 600, fontSize: '13px', color: COLORS.hydroGreenDark,
599
- textTransform: 'uppercase' as const, letterSpacing: '0.05em',
600
- }}>
601
- {t('ai_helper_teaching_summary_overall_suggestion')}
602
- </span>
603
- <span style={{
604
- display: 'inline-block', width: '16px', height: '16px',
605
- textAlign: 'center' as const, lineHeight: '16px', fontSize: '12px',
606
- color: COLORS.textMuted,
607
- transition: `transform ${TRANSITIONS.fast}`,
608
- transform: suggestionCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
609
- }}>▶</span>
610
- </div>
935
+ 📄 {t('ai_helper_teaching_summary_copy_homework')}
936
+ </button>
937
+ </span>
938
+ )}
939
+ >
940
+ <div style={{
941
+ fontSize: '12px', color: COLORS.textMuted, marginBottom: SPACING.md,
942
+ }}>
943
+ {t('ai_helper_teaching_summary_homework_hint')}
944
+ </div>
945
+ <div
946
+ className="markdown-body"
947
+ dangerouslySetInnerHTML={{ __html: renderMarkdown(homeworkMd) }}
948
+ />
949
+ </SectionCard>
950
+ )}
611
951
 
612
- {/* Collapsible content */}
613
- {!suggestionCollapsed && (
614
- <>
615
- {summary.overallSuggestion ? (
616
- <div
617
- className="markdown-body"
618
- style={{ padding: `${SPACING.base} ${SPACING.lg}` }}
619
- dangerouslySetInnerHTML={{ __html: renderMarkdown(summary.overallSuggestion) }}
620
- />
621
- ) : (
622
- <SkeletonBlock lines={10} />
623
- )}
624
-
625
- {/* Feedback */}
626
- <div style={{
627
- display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
628
- gap: SPACING.sm, padding: `${SPACING.sm} ${SPACING.base}`,
629
- borderTop: `1px solid ${COLORS.border}`,
630
- }}>
631
- {feedbackSubmitted ? (
632
- <span style={{ fontSize: '12px', color: COLORS.successText }}>
633
- {t('ai_helper_teaching_summary_feedback_thanks')}
634
- </span>
635
- ) : (
636
- <>
637
- {(['up', 'down'] as const).map(rating => (
638
- <button
639
- key={rating}
640
- onClick={() => handleFeedback(rating)}
641
- style={{
642
- fontSize: '12px', padding: '3px 8px',
643
- border: `1px solid ${COLORS.border}`,
644
- borderRadius: RADIUS.sm,
645
- backgroundColor: 'transparent',
646
- color: summary.feedback?.rating === rating
647
- ? (rating === 'up' ? COLORS.successText : COLORS.errorText)
648
- : COLORS.textMuted,
649
- cursor: 'pointer',
650
- }}
651
- >
652
- {rating === 'up' ? t('ai_helper_teaching_summary_feedback_helpful') : t('ai_helper_teaching_summary_feedback_not_helpful')}
653
- </button>
654
- ))}
655
- </>
656
- )}
657
- </div>
658
- </>
659
- )}
660
- </div>
952
+ {/* 兜底:解析失败(或旧版文档)时完整展示 AI 报告 */}
953
+ {!parsedOk && summary.overallSuggestion && (
954
+ <SectionCard
955
+ icon="🤖"
956
+ title={t('ai_helper_teaching_summary_overall_suggestion')}
957
+ accentColor={COLORS.hydroGreen}
958
+ >
959
+ <div
960
+ className="markdown-body"
961
+ dangerouslySetInnerHTML={{ __html: renderMarkdown(summary.overallSuggestion) }}
962
+ />
963
+ </SectionCard>
964
+ )}
965
+
966
+ {/* 已解析主要板块后剩余的未识别章节(如旧版 P0 长报告),默认折叠 */}
967
+ {parsedOk && parsed.rest.length > 0 && (
968
+ <div style={{
969
+ border: `1px solid ${COLORS.border}`,
970
+ borderRadius: RADIUS.md,
971
+ backgroundColor: COLORS.bgCard,
972
+ marginBottom: SPACING.lg,
973
+ overflow: 'hidden',
974
+ }}>
975
+ <div
976
+ onClick={() => setFullReportCollapsed(!fullReportCollapsed)}
977
+ style={{
978
+ padding: `${SPACING.md} ${SPACING.base}`,
979
+ cursor: 'pointer', userSelect: 'none',
980
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
981
+ }}
982
+ >
983
+ <span style={{
984
+ fontWeight: 600, fontSize: '13px', color: COLORS.textMuted,
985
+ letterSpacing: '0.05em',
986
+ }}>
987
+ 🗂 {t('ai_helper_teaching_summary_section_full_report')}
988
+ </span>
989
+ <span style={{
990
+ display: 'inline-block', width: '16px', height: '16px',
991
+ textAlign: 'center', lineHeight: '16px', fontSize: '12px',
992
+ color: COLORS.textMuted,
993
+ transition: `transform ${TRANSITIONS.fast}`,
994
+ transform: fullReportCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
995
+ }}>▶</span>
661
996
  </div>
662
- )}
663
- </div>
997
+ {!fullReportCollapsed && (
998
+ <div
999
+ className="markdown-body"
1000
+ style={{ padding: `0 ${SPACING.base} ${SPACING.base}`, borderTop: `1px solid ${COLORS.border}`, paddingTop: SPACING.md }}
1001
+ dangerouslySetInnerHTML={{ __html: renderMarkdown(parsed.rest.join('\n\n')) }}
1002
+ />
1003
+ )}
1004
+ </div>
1005
+ )}
1006
+
1007
+ {/* 生成中兜底:completed 但建议为空 */}
1008
+ {!summary.overallSuggestion && !homeworkMd && (
1009
+ <SkeletonBlock lines={6} />
1010
+ )}
1011
+
1012
+ {/* Feedback */}
1013
+ {feedbackRow}
664
1014
  </div>
665
1015
  );
666
1016
  };