22 * ClosedBookTest — N2 合书测试模式
33 *
44 * @ai -context: 编辑器内容被遮罩隐藏后,AI 基于笔记文本生成 3-5 个回忆问题
5- * (复用 useAIFeynmanQuestion),用户作答后"合书对照"解除遮罩核对原文。
6- * 提取练习(retrieval practice):主动回忆比被动重读更能巩固记忆。
5+ * (复用 useAIFeynmanQuestion),用户作答后"合书对照"提交答案,逐条 diff
6+ * 高亮(正确=绿 / 错误=红 / 遗漏=琥珀,纯本地 bigram 相似度,见 diffRecall)。
7+ * 提取练习(retrieval practice):主动回忆 + 即时反馈比被动重读更能巩固记忆。
78 * AI 不可用时降级为空白自由回忆区,保证本地可用。
89 */
910import { useEffect , useState } from 'react' ;
1011import { Button } from '@/components/ui' ;
1112import { AIThinkingIndicator } from '@/components/ui/AIThinkingIndicator' ;
12- import { EyeOff , Eye , X } from 'lucide-react' ;
13+ import { EyeOff , Check , X , RotateCcw } from 'lucide-react' ;
1314import { useAIFeynmanQuestion } from '@/lib/ai/useAI' ;
1415import type { FeynmanQuestionItem } from '@/lib/ai/types' ;
16+ import { diffRecallAgainstNote , type DiffSentence } from '../lib/diffRecall' ;
17+ import { cn } from '@/lib/utils' ;
1518
1619/** 送入 AI 的笔记文本上限(控制 token 消耗) */
1720const MAX_TEXT_LEN = 3000 ;
@@ -22,18 +25,25 @@ interface ClosedBookTestProps {
2225 noteTitle : string ;
2326 /** 编辑器实时纯文本 */
2427 noteText : string ;
25- /** 合书对照:解除遮罩显示原文 */
26- onReveal : ( ) => void ;
2728 /** 结束合书测试 */
2829 onClose : ( ) => void ;
2930}
3031
31- export function ClosedBookTest ( { noteTitle, noteText, onReveal, onClose } : ClosedBookTestProps ) {
32+ /** 三态配色映射 */
33+ const DIFF_STYLE : Record < DiffSentence [ 'kind' ] , { icon : React . ReactNode ; row : string ; label : string } > = {
34+ correct : { icon : < Check className = "w-3.5 h-3.5" strokeWidth = { 2 } /> , row : 'border-emerald-500/30 bg-emerald-500/5' , label : '回答到位' } ,
35+ wrong : { icon : < X className = "w-3.5 h-3.5" strokeWidth = { 2 } /> , row : 'border-red-500/30 bg-red-500/5' , label : '回忆偏差' } ,
36+ missing : { icon : < span className = "w-1.5 h-1.5 rounded-full bg-amber-400" /> , row : 'border-amber-500/30 bg-amber-500/5' , label : '原文遗漏' } ,
37+ } ;
38+
39+ export function ClosedBookTest ( { noteTitle, noteText, onClose } : ClosedBookTestProps ) {
3240 const { loading, generateQuestions } = useAIFeynmanQuestion ( ) ;
3341 const [ questions , setQuestions ] = useState < FeynmanQuestionItem [ ] > ( [ ] ) ;
3442 const [ fallback , setFallback ] = useState ( false ) ;
3543 const [ answers , setAnswers ] = useState < string [ ] > ( [ ] ) ;
3644 const [ freeRecall , setFreeRecall ] = useState ( '' ) ;
45+ // N2 提交对照结果:非 null 时展示逐条 diff 高亮
46+ const [ diff , setDiff ] = useState < DiffSentence [ ] | null > ( null ) ;
3747
3848 // 挂载时尝试 AI 出题;失败或内容不足则降级为自由回忆
3949 useEffect ( ( ) => {
@@ -69,6 +79,62 @@ export function ClosedBookTest({ noteTitle, noteText, onReveal, onClose }: Close
6979 ? freeRecall . trim ( ) . length > 0
7080 : answers . some ( ( a ) => a . trim ( ) . length > 0 ) ;
7181
82+ /** N2 提交对照:合并全部答案为一段回忆文本,逐条 diff 高亮 */
83+ const handleSubmit = ( ) => {
84+ const recall = fallback
85+ ? freeRecall
86+ : answers . map ( ( a ) => a . trim ( ) ) . filter ( Boolean ) . join ( '。' ) ;
87+ setDiff ( diffRecallAgainstNote ( recall , noteText ) ) ;
88+ } ;
89+
90+ const handleRetry = ( ) => {
91+ setDiff ( null ) ;
92+ if ( ! fallback ) setAnswers ( new Array ( questions . length ) . fill ( '' ) ) ;
93+ else setFreeRecall ( '' ) ;
94+ } ;
95+
96+ // N2 对照结果视图:三态逐条高亮 + 原文对应
97+ const renderDiff = ( ) => (
98+ < div className = "flex flex-col gap-2" >
99+ < p className = "text-b2 text-text-primary font-medium" > 对照结果——逐条核对,记住偏差处</ p >
100+ < div className = "flex flex-col gap-1.5" >
101+ { diff ?. map ( ( d , i ) => {
102+ const style = DIFF_STYLE [ d . kind ] ;
103+ return (
104+ < div
105+ key = { i }
106+ className = { cn ( 'flex items-start gap-2 px-3 py-2 rounded-kb-md border text-b2' , style . row ) }
107+ >
108+ < span className = { cn ( 'mt-0.5 flex-shrink-0' , d . kind === 'correct' && 'text-emerald-500' , d . kind === 'wrong' && 'text-red-500' ) } >
109+ { style . icon }
110+ </ span >
111+ < div className = "flex flex-col gap-0.5 min-w-0" >
112+ < span className = "text-text-primary leading-relaxed" > { d . text } </ span >
113+ { d . kind === 'correct' && d . matched && d . matched !== d . text && (
114+ < span className = "text-c1 text-text-tertiary truncate" > ↳ 原文:{ d . matched } </ span >
115+ ) }
116+ { d . kind === 'missing' && (
117+ < span className = "text-c1 text-amber-600/80" > 这条要点没想起来——回看笔记重点补一下</ span >
118+ ) }
119+ { d . kind === 'wrong' && (
120+ < span className = "text-c1 text-red-500/80" > 原文中没有对应内容,可能是记串了</ span >
121+ ) }
122+ </ div >
123+ </ div >
124+ ) ;
125+ } ) }
126+ </ div >
127+ < div className = "flex items-center justify-between gap-2 mt-2" >
128+ < p className = "text-c1 text-text-tertiary" > 绿=回答到位 · 红=回忆偏差 · 琥珀=原文遗漏</ p >
129+ < div className = "flex gap-2 flex-shrink-0" >
130+ < Button variant = "secondary" icon = { < RotateCcw className = "w-4 h-4" strokeWidth = { 1.5 } /> } onClick = { handleRetry } >
131+ 重新作答
132+ </ Button >
133+ < Button onClick = { onClose } > 完成测试</ Button >
134+ </ div >
135+ </ div >
136+ </ div >
137+ ) ;
72138 return (
73139 < div className = "max-w-[640px] mx-auto my-kb-lg p-kb-lg rounded-kb-lg bg-bg-primary/95 backdrop-blur-xl border border-border/40 shadow-[0_8px_32px_rgba(0,0,0,0.08)]" >
74140 { /* 头部 */ }
@@ -97,6 +163,11 @@ export function ClosedBookTest({ noteTitle, noteText, onReveal, onClose }: Close
97163 </ div >
98164 ) }
99165
166+ { /* N2 对照结果:提交后逐条 diff 高亮 */ }
167+ { diff && renderDiff ( ) }
168+
169+ { ! diff && (
170+ < >
100171 { /* 降级:自由回忆区 */ }
101172 { fallback && ! loading && (
102173 < div className = "flex flex-col gap-3" >
@@ -138,15 +209,15 @@ export function ClosedBookTest({ noteTitle, noteText, onReveal, onClose }: Close
138209 { /* 操作区 */ }
139210 < div className = "flex items-center justify-between gap-2 mt-5" >
140211 < p className = "text-c1 text-text-tertiary" >
141- { answered ? '写得不错,对照原文看看 ?' : '先试着写点什么,哪怕只言片语' }
212+ { answered ? '写得不错,提交对照看看 ?' : '先试着写点什么,哪怕只言片语' }
142213 </ p >
143214 < div className = "flex gap-2 flex-shrink-0" >
144215 < Button variant = "secondary" onClick = { onClose } > 结束测试</ Button >
145- < Button icon = { < Eye className = "w-4 h-4" strokeWidth = { 1.5 } /> } onClick = { onReveal } >
146- 合书对照
147- </ Button >
216+ < Button onClick = { handleSubmit } > 合书对照</ Button >
148217 </ div >
149218 </ div >
219+ </ >
220+ ) }
150221 </ div >
151222 ) ;
152223}
0 commit comments