@@ -7,13 +7,23 @@ import {
submitAnswer ,
submitPracticeSession ,
toggleQuestionFavorite ,
type AnswerResult ,
type PracticeReport ,
type PracticeSession ,
type QuestionItem ,
} from '@/services/learning' ;
import { submitFeedback } from '@/services/profile' ;
import { getStorage , setStorage } from '@/services/storage' ;
import '../student.css' ;
type AnswerState = {
selectedOptions : string [ ] ;
answerText : string ;
isCorrect : boolean | null ;
answeredAt? : string ;
selfJudged? : boolean ;
} ;
function plainOption ( option : unknown , index : number ) {
if ( typeof option === 'string' ) return option ;
if ( option && typeof option === 'object' ) {
@@ -23,6 +33,39 @@ function plainOption(option: unknown, index: number) {
return ` 选项 ${ index + 1 } ` ;
}
function isObjectiveQuestion ( question? : QuestionItem | null ) {
if ( ! question ) return false ;
return Array . isArray ( question . options ) && question . options . length > 0 ;
}
function isMultiQuestion ( question? : QuestionItem | null ) {
if ( ! question ) return false ;
const type = String ( question . type || '' ) . toLowerCase ( ) ;
return type . includes ( 'multi' ) || ( question . correctOptionIndices ? . length || 0 ) > 1 ;
}
function answerStorageKey ( sessionId : string ) {
return ` tiku:practice: ${ sessionId } :answers ` ;
}
function indexStorageKey ( sessionId : string ) {
return ` tiku:practice: ${ sessionId } :index ` ;
}
function formatDuration ( seconds : number ) {
const safeSeconds = Math . max ( 0 , Math . trunc ( seconds ) ) ;
const minutes = Math . floor ( safeSeconds / 60 ) ;
const rest = safeSeconds % 60 ;
return ` ${ String ( minutes ) . padStart ( 2 , '0' ) } : ${ String ( rest ) . padStart ( 2 , '0' ) } ` ;
}
function answerLabel ( state? : AnswerState ) {
if ( ! state ) return '未答' ;
if ( state . isCorrect === true ) return '正确' ;
if ( state . isCorrect === false ) return '错误' ;
return state . selfJudged ? '已自评' : '已答' ;
}
export default function StudentPracticePage() {
const router = useRouter ( ) ;
const params = router . params || { } ;
@@ -32,7 +75,12 @@ export default function StudentPracticePage() {
const [ selected , setSelected ] = useState < string [ ] > ( [ ] ) ;
const [ answerText , setAnswerText ] = useState ( '' ) ;
const [ feedbackText , setFeedbackText ] = useState ( '' ) ;
const [ result ByQuestion, setResult ByQuestion ] = useState < Record < string , boolean | null > > ( { } ) ;
const [ answer ByQuestion, setAnswer ByQuestion ] = useState < Record < string , AnswerState > > ( { } ) ;
const [ favoriteByQuestion , setFavoriteByQuestion ] = useState < Record < string , boolean > > ( { } ) ;
const [ showExplanation , setShowExplanation ] = useState ( false ) ;
const [ completed , setCompleted ] = useState ( false ) ;
const [ timeLeft , setTimeLeft ] = useState < number | null > ( null ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ report , setReport ] = useState < PracticeReport | null > ( null ) ;
const [ error , setError ] = useState ( '' ) ;
@@ -50,6 +98,11 @@ export default function StudentPracticePage() {
. then ( async payload = > {
const nextSession = payload . item ;
setSession ( nextSession ) ;
if ( nextSession . durationMinutes ) setTimeLeft ( nextSession . durationMinutes * 60 ) ;
const savedIndex = getStorage < number > ( indexStorageKey ( nextSession . id ) ) ;
const savedAnswers = getStorage < Record < string , AnswerState > > ( answerStorageKey ( nextSession . id ) ) ;
if ( savedAnswers ) setAnswerByQuestion ( savedAnswers ) ;
if ( typeof savedIndex === 'number' ) setIndex ( savedIndex ) ;
const questionPayload = collectionId
? await loadCollectionQuestions ( collectionId , 300 )
: await loadQuestions ( {
@@ -62,43 +115,133 @@ export default function StudentPracticePage() {
const ordered = ( nextSession . questionIds || [ ] ) . map ( id = > byId . get ( id ) ) . filter ( Boolean ) as QuestionItem [ ] ;
setQuestions ( ordered . length ? ordered : questionPayload.items || [ ] ) ;
} )
. catch ( nextError = > setError ( nextError instanceof Error ? nextError . message : '练习创建失败' ) ) ;
. catch ( nextError = > setError ( nextError instanceof Error ? nextError . message : '练习创建失败' ) )
. finally ( ( ) = > setLoading ( false ) ) ;
} , [ ] ) ;
const current = questions [ index ] ;
const options = useMemo ( ( ) = > Array . isArray ( current ? . options ) ? current . options : [ ] , [ current ] ) ;
const isSubmit ted = current ? current . id in resultByQuestion : false ;
const answerSta te = current ? answerByQuestion [ current . id ] : undefined ;
const isSubmitted = ! ! answerState ;
const answeredCount = Object . keys ( answerByQuestion ) . length ;
const correctCount = Object . values ( answerByQuestion ) . filter ( item = > item . isCorrect === true ) . length ;
const wrongCount = Object . values ( answerByQuestion ) . filter ( item = > item . isCorrect === false ) . length ;
const progressPercent = questions . length ? Math . round ( ( answeredCount / questions . length ) * 100 ) : 0 ;
function toggleOption ( optionIndex : number ) {
const value = String ( optionIndex ) ;
setSelected ( prev = > prev . includes ( value ) ? prev . filter ( item = > item !== value ) : [ . . . prev , value ] ) ;
useEffect ( ( ) = > {
if ( ! session ) return ;
setStorage ( indexStorageKey ( session . id ) , index ) ;
} , [ index , session ? . id ] ) ;
useEffect ( ( ) = > {
if ( ! session ) return ;
setStorage ( answerStorageKey ( session . id ) , answerByQuestion ) ;
} , [ answerByQuestion , session ? . id ] ) ;
useEffect ( ( ) = > {
if ( ! current ) return ;
const state = answerByQuestion [ current . id ] ;
setSelected ( state ? . selectedOptions || [ ] ) ;
setAnswerText ( state ? . answerText || '' ) ;
setFeedbackText ( '' ) ;
setShowExplanation ( ! ! state ) ;
} , [ current ? . id ] ) ;
useEffect ( ( ) = > {
if ( timeLeft === null || report || completed ) return ;
if ( timeLeft <= 0 ) {
void handleSubmitSession ( true ) ;
return ;
}
const timer = setTimeout ( ( ) = > setTimeLeft ( prev = > ( prev === null ? null : Math . max ( 0 , prev - 1 ) ) ) , 1000 ) ;
return ( ) = > clearTimeout ( timer ) ;
} , [ timeLeft , report , completed ] ) ;
function rememberAnswer (
questionId : string ,
result : AnswerResult | { isCorrect : boolean | null ; answeredAt? : string ; selfJudged? : boolean } ,
nextSelected = selected ,
nextAnswerText = answerText ,
) {
const nextState : AnswerState = {
selectedOptions : [ . . . nextSelected ] ,
answerText : nextAnswerText ,
isCorrect : result.isCorrect ,
answeredAt : 'answeredAt' in result ? result.answeredAt : undefined ,
selfJudged : 'selfJudged' in result ? result.selfJudged : undefined ,
} ;
setAnswerByQuestion ( prev = > ( { . . . prev , [ questionId ] : nextState } ) ) ;
setShowExplanation ( true ) ;
}
async function handleSubmit() {
function toggleOption ( optionIndex : number ) {
if ( isSubmitted ) return ;
const value = String ( optionIndex ) ;
const nextSelected = selected . includes ( value ) ? selected . filter ( item = > item !== value ) : [ . . . selected , value ] ;
setSelected ( nextSelected ) ;
if ( current && ! isMultiQuestion ( current ) && nextSelected . length === 1 ) {
void handleSubmit ( nextSelected ) ;
}
}
async function handleSubmit ( nextSelected = selected , selfCorrect? : boolean ) {
if ( ! session || ! current ) return ;
if ( ! isObjectiveQuestion ( current ) && selfCorrect !== undefined ) {
try {
const payload = await submitAnswer ( {
practiceSessionId : session.id ,
questionId : current.id ,
selectedOptions : [ ] ,
answerText ,
selfJudgedCorrect : selfCorrect ,
} ) ;
rememberAnswer ( current . id , payload . item ) ;
} catch ( nextError ) {
setError ( nextError instanceof Error ? nextError . message : '提交失败' ) ;
}
return ;
}
try {
const payload = await submitAnswer ( {
practiceSessionId : session.id ,
questionId : current.id ,
selectedOptions : s elected,
selectedOptions : nextS elected,
answerText ,
} ) ;
setResultByQuestion ( prev = > ( { . . . prev , [ current . id ] : payload . item . isCorrect } ) ) ;
rememberAnswer ( current . id , payload . item , nextSelected ) ;
} catch ( nextError ) {
setError ( nextError instanceof Error ? nextError . message : '提交失败' ) ;
}
}
function previousQuestion() {
setIndex ( prev = > Math . max ( 0 , prev - 1 ) ) ;
setCompleted ( false ) ;
}
function nextQuestion() {
setSelected ( [ ] ) ;
setAnswerText ( '' ) ;
setFeedbackText ( '' ) ;
if ( index >= questions . length - 1 ) {
setCompleted ( true ) ;
return ;
}
setIndex ( prev = > Math . min ( questions . length - 1 , prev + 1 ) ) ;
setCompleted ( false ) ;
}
function jumpQuestion ( nextIndex : number ) {
setIndex ( Math . max ( 0 , Math . min ( questions . length - 1 , nextIndex ) ) ) ;
setCompleted ( false ) ;
}
async function handleFavorite() {
if ( ! current ) return ;
await toggleQuestionFavorite ( current . id , true ) . catch ( nextError = > setError ( nextError instanceof Error ? nextError . message : '收藏失败' ) ) ;
const nextFavorite = ! favoriteByQuestion [ current . id ] ;
await toggleQuestionFavorite ( current . id , nextFavorite )
. then ( payload = > {
setFavoriteByQuestion ( prev = > ( { . . . prev , [ current . id ] : payload . favorite } ) ) ;
Taro . showToast ( { title : payload.favorite ? '已收藏' : '已取消' , icon : 'success' } ) ;
} )
. catch ( nextError = > setError ( nextError instanceof Error ? nextError . message : '收藏失败' ) ) ;
}
async function handleFeedback() {
@@ -121,18 +264,21 @@ export default function StudentPracticePage() {
}
}
async function handleSubmitSession() {
async function handleSubmitSession( auto = false ) {
if ( ! session ) return ;
const ok = await Taro . showModal ( {
title : '交卷' ,
content : '确认交卷并生成练习报告?报告会按后端 session 快照评分。 ',
confirmText : '交卷' ,
cancel Text: '取消 ' ,
} ) ;
if ( ! ok . confirm ) return ;
if ( ! auto ) {
const ok = await Taro . showModal ( {
title : '交卷 ' ,
content : ` 已答 ${ answeredCount } / ${ questions . length } ,确认交卷并生成练习报告? ` ,
confirm Text : '交卷 ' ,
cancelText : '取消' ,
} ) ;
if ( ! ok . confirm ) return ;
}
try {
const payload = await submitPracticeSession ( session . id ) ;
setReport ( payload . item || null ) ;
setCompleted ( true ) ;
Taro . showToast ( { title : '报告已生成' , icon : 'success' } ) ;
} catch ( nextError ) {
setError ( nextError instanceof Error ? nextError . message : '交卷失败' ) ;
@@ -157,8 +303,20 @@ export default function StudentPracticePage() {
< Text className = 'student-title' > { session ? . mode || '练习' } < / Text >
< Text className = 'student-subtitle' > 题 目 来 自 后 端 session 快 照 , 答 题 、 交 卷 、 错 题 和 报 告 都 以 后 端 校 验 为 准 。 < / Text >
< / View >
{ timeLeft !== null ? < Text className = 'status-badge' > { formatDuration ( timeLeft ) } < / Text > : null }
< / View >
{ session ? (
< View className = 'quiet-panel section-block' >
< View className = 'amount-row' >
< Text className = 'row-main' > 进 度 { answeredCount } / { questions . length } < / Text >
< Text className = 'status-badge' > { progressPercent } % < / Text >
< / View >
< Text className = 'row-meta' > 正 确 { correctCount } · 错 误 { wrongCount } · 未 答 { Math . max ( 0 , questions . length - answeredCount ) } · { session . accessMode || 'access' } < / Text >
{ session . consumedFreeQuota ? < Text className = 'row-meta' > 本 次 消 耗 免 费 题 量 { session . consumedFreeQuota } < / Text > : null }
< / View >
) : null }
{ report ? (
< View className = 'report-panel section-block' >
< Text className = 'metric-value' > { String ( report . score ) } / { String ( report . totalScore ) } < / Text >
@@ -169,31 +327,65 @@ export default function StudentPracticePage() {
< / View >
) : null }
{ curren t ? (
{ completed && ! repor t ? (
< View className = 'report-panel section-block' >
< Text className = 'metric-value' > 已 完 成 本 组 练 习 < / Text >
< Text className = 'metric-label' > 已 答 { answeredCount } / { questions . length } · 正 确 { correctCount } · 错 误 { wrongCount } < / Text >
< View className = 'toolbar wrap' >
< Button className = 'primary-button' onClick = { ( ) = > handleSubmitSession ( ) } > 生 成 报 告 < / Button >
< Button className = 'secondary-button' onClick = { ( ) = > jumpQuestion ( 0 ) } > 重 新 查 看 < / Button >
< Button className = 'secondary-button' onClick = { ( ) = > Taro . navigateTo ( { url : '/pages/student/review/index?type=wrong' } ) } > 查 看 错 题 < / Button >
< / View >
< / View >
) : null }
{ loading ? < View className = 'empty-state' > 练 习 加 载 中 . . . < / View > : null }
{ current && ! completed ? (
< View className = 'list-stack' >
< View className = 'quiet-panel' >
< Text className = 'row-meta' > { current . typeLabel || current . type || '题目' } < / Text >
< View className = 'amount-row' >
< Text className = 'row-meta' > { current . typeLabel || current . type || '题目' } < / Text >
< Text className = 'status-badge' > { answerLabel ( answerState ) } < / Text >
< / View >
< Text className = 'row-main' > { current . content || '未提供题干' } < / Text >
{ current . mediaUrl ? < Text className = 'row-meta break-text' > { current . mediaUrl } < / Text > : null }
{ current . subQuestions ? . length ? < Text className = 'row-meta' > 包 含 { current . subQuestions . length } 个 子 题 , 请 结 合 题 干 作 答 。 < / Text > : null }
< / View >
{ options . length ? options . map ( ( option , optionIndex ) = > (
< View className = 'list-row' key = { String ( optionIndex ) } onClick = { ( ) = > toggleOption ( optionIndex ) } >
< Text className = 'row-main' > {selected . includes ( String ( optionIndex ) ) ? '已选 · ' : '' }{ String . fromCharCode ( 65 + optionIndex ) } . { plainOption ( option , optionIndex ) } < / Text >
< View
className = { ` list-row $ {selected . includes ( String ( optionIndex ) ) ? 'active ' : '' } ` }
key = { String ( optionIndex ) }
onClick = { ( ) = > toggleOption ( optionIndex ) }
>
< Text className = 'row-main' > { String . fromCharCode ( 65 + optionIndex ) } . { plainOption ( option , optionIndex ) } < / Text >
< / View >
) ) : (
< Textarea className = 'textarea' placeholder = '请输入答案' value = { answerText } onInput = { event = > setAnswerText ( String ( event . detail . value || '' ) ) } / >
) }
{ isSubmitted ? (
< View className = 'quiet-panel' >
< Text className = { resultByQuestion [ current . id ] ? 'success-text' : 'error-t ext' } > { resultByQuestion [ curr ent. id ] ? '回答正确' : '回答错误 ' } < / Text >
< Textarea className = 'textarea' placeholder = '请输入答案或先思考后查看参考答案' value = { answerT ext} onInput = { event = > setAnswerText ( String ( ev ent. detail . value || '') ) } / >
< View className = 'toolbar wrap' >
< Button className = 'secondary-button' onClick = { ( ) = > setShowExplanation ( true ) } > 查 看 答 案 < / Button >
< Button className = 'primary-button' onClick = { ( ) = > handleSubmit ( selected , true ) } > 答 对 了 < / Button >
< Button className = 'danger-button' onClick = { ( ) = > handleSubmit ( selected , false ) } > 答 错 了 < / Button >
< / View >
< / View >
) }
{ showExplanation || isSubmitted ? (
< View className = 'quiet-panel' >
{ isSubmitted ? < Text className = { answerState ? . isCorrect ? 'success-text' : answerState ? . isCorrect === false ? 'error-text' : 'row-meta' } > { answerLabel ( answerState ) } < / Text > : null }
{ current . answerText ? < Text className = 'row-main' > 参 考 答 案 : { current . answerText } < / Text > : null }
{ current . correctOptionIndices ? . length ? < Text className = 'row-meta' > 正 确 选 项 : { current . correctOptionIndices . map ( item = > String . fromCharCode ( 65 + item ) ) . join ( '、' ) } < / Text > : null }
{ current . correctOptionIndex !== null && current . correctOptionIndex !== undefined ? < Text className = 'row-meta' > 正 确 选 项 : { String . fromCharCode ( 65 + current . correctOptionIndex ) } < / Text > : null }
{ current . explanation ? < Text className = 'row-meta' > { current . explanation } < / Text > : null }
< / View >
) : null }
< View className = 'toolbar' >
< Button className = 'primary-button' onClick = { handleSubmit } > 提 交 < / Button >
< Button className = 'secondary-button' onClick = { handleFavorite } > 收 藏 < / Button >
< Button className = 'secondary-button' onClick = { openVideo } > 视 频 < / Button >
< View className = 'toolbar wrap ' >
{ isObjectiveQuestion ( current ) ? <Button className = 'primary-button' onClick = { ( ) = > handleSubmit ( ) }> 提 交 < / Button > : null }
< Button className = 'secondary-button' onClick = { previousQuestion } > 上 一 题 < / Button >
< Button className = 'secondary-button' onClick = { handleFavorite } > { favoriteByQuestion [ current . id ] ? '取消收藏' : '收藏' } < / Button >
{ current . hasVideoExplanation ? < Button className = 'secondary-button' onClick = { openVideo } > 视 频 < / Button > : null }
< Button className = 'secondary-button' onClick = { nextQuestion } > 下 一 题 < / Button >
< Button className = 'secondary-button' onClick = { handleSubmitSession } > 交 卷 < / Button >
< Button className = 'secondary-button' onClick = { ( ) = > handleSubmitSession ( ) }> 交 卷 < / Button >
< / View >
< View className = 'quiet-panel' >
< Text className = 'row-main' > 题 目 反 馈 < / Text >
@@ -203,7 +395,24 @@ export default function StudentPracticePage() {
< / View >
< / View >
< / View >
) : < View className = 'empty-state' > 暂 无 题 目 。 请 确 认 后 台 已 发 布 题 目 并 配 置 集 合 或 练 习 蓝 图 。 < / View > }
) : ! loading && ! completed ? < View className = 'empty-state' > 暂 无 题 目 。 请 确 认 后 台 已 发 布 题 目 并 配 置 集 合 或 练 习 蓝 图 。 < / View > : null }
{ questions . length ? (
< View className = 'section-block' >
< Text className = 'section-heading' > 答 题 卡 < / Text >
< View className = 'answer-sheet' >
{ questions . map ( ( item , cardIndex ) = > (
< Button
key = { item . id }
className = { ` answer-card ${ cardIndex === index ? 'current' : '' } ${ answerByQuestion [ item . id ] ? 'answered' : '' } ${ answerByQuestion [ item . id ] ? . isCorrect === false ? 'wrong' : '' } ` }
onClick = { ( ) = > jumpQuestion ( cardIndex ) }
>
{ cardIndex + 1 }
< / Button >
) ) }
< / View >
< / View >
) : null }
{ error ? < Text className = 'error-text' > { error } < / Text > : null }
< / View >
) ;