@octo-cyber/quiz-engine 0.5.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.
- package/dist/controllers/attempt.controller.d.ts +3 -0
- package/dist/controllers/attempt.controller.d.ts.map +1 -0
- package/dist/controllers/attempt.controller.js +83 -0
- package/dist/controllers/attempt.controller.js.map +1 -0
- package/dist/controllers/category.controller.d.ts +3 -0
- package/dist/controllers/category.controller.d.ts.map +1 -0
- package/dist/controllers/category.controller.js +38 -0
- package/dist/controllers/category.controller.js.map +1 -0
- package/dist/controllers/exam.controller.d.ts +3 -0
- package/dist/controllers/exam.controller.d.ts.map +1 -0
- package/dist/controllers/exam.controller.js +48 -0
- package/dist/controllers/exam.controller.js.map +1 -0
- package/dist/controllers/index.d.ts +7 -0
- package/dist/controllers/index.d.ts.map +1 -0
- package/dist/controllers/index.js +16 -0
- package/dist/controllers/index.js.map +1 -0
- package/dist/controllers/paper.controller.d.ts +3 -0
- package/dist/controllers/paper.controller.d.ts.map +1 -0
- package/dist/controllers/paper.controller.js +70 -0
- package/dist/controllers/paper.controller.js.map +1 -0
- package/dist/controllers/question.controller.d.ts +3 -0
- package/dist/controllers/question.controller.d.ts.map +1 -0
- package/dist/controllers/question.controller.js +59 -0
- package/dist/controllers/question.controller.js.map +1 -0
- package/dist/controllers/statistics.controller.d.ts +3 -0
- package/dist/controllers/statistics.controller.d.ts.map +1 -0
- package/dist/controllers/statistics.controller.js +37 -0
- package/dist/controllers/statistics.controller.js.map +1 -0
- package/dist/entities/index.d.ts +24 -0
- package/dist/entities/index.d.ts.map +1 -0
- package/dist/entities/index.js +43 -0
- package/dist/entities/index.js.map +1 -0
- package/dist/entities/quiz-answer.entity.d.ts +19 -0
- package/dist/entities/quiz-answer.entity.d.ts.map +1 -0
- package/dist/entities/quiz-answer.entity.js +81 -0
- package/dist/entities/quiz-answer.entity.js.map +1 -0
- package/dist/entities/quiz-attempt.entity.d.ts +17 -0
- package/dist/entities/quiz-attempt.entity.d.ts.map +1 -0
- package/dist/entities/quiz-attempt.entity.js +80 -0
- package/dist/entities/quiz-attempt.entity.js.map +1 -0
- package/dist/entities/quiz-category.entity.d.ts +10 -0
- package/dist/entities/quiz-category.entity.d.ts.map +1 -0
- package/dist/entities/quiz-category.entity.js +55 -0
- package/dist/entities/quiz-category.entity.js.map +1 -0
- package/dist/entities/quiz-exam.entity.d.ts +25 -0
- package/dist/entities/quiz-exam.entity.d.ts.map +1 -0
- package/dist/entities/quiz-exam.entity.js +99 -0
- package/dist/entities/quiz-exam.entity.js.map +1 -0
- package/dist/entities/quiz-paper-question.entity.d.ts +12 -0
- package/dist/entities/quiz-paper-question.entity.d.ts.map +1 -0
- package/dist/entities/quiz-paper-question.entity.js +58 -0
- package/dist/entities/quiz-paper-question.entity.js.map +1 -0
- package/dist/entities/quiz-paper.entity.d.ts +18 -0
- package/dist/entities/quiz-paper.entity.d.ts.map +1 -0
- package/dist/entities/quiz-paper.entity.js +75 -0
- package/dist/entities/quiz-paper.entity.js.map +1 -0
- package/dist/entities/quiz-question.entity.d.ts +28 -0
- package/dist/entities/quiz-question.entity.d.ts.map +1 -0
- package/dist/entities/quiz-question.entity.js +107 -0
- package/dist/entities/quiz-question.entity.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/quiz-engine.module.d.ts +8 -0
- package/dist/quiz-engine.module.d.ts.map +1 -0
- package/dist/quiz-engine.module.js +45 -0
- package/dist/quiz-engine.module.js.map +1 -0
- package/dist/schemas/attempt.schema.d.ts +47 -0
- package/dist/schemas/attempt.schema.d.ts.map +1 -0
- package/dist/schemas/attempt.schema.js +19 -0
- package/dist/schemas/attempt.schema.js.map +1 -0
- package/dist/schemas/category.schema.d.ts +36 -0
- package/dist/schemas/category.schema.d.ts.map +1 -0
- package/dist/schemas/category.schema.js +12 -0
- package/dist/schemas/category.schema.js.map +1 -0
- package/dist/schemas/exam.schema.d.ts +70 -0
- package/dist/schemas/exam.schema.d.ts.map +1 -0
- package/dist/schemas/exam.schema.js +20 -0
- package/dist/schemas/exam.schema.js.map +1 -0
- package/dist/schemas/paper.schema.d.ts +71 -0
- package/dist/schemas/paper.schema.d.ts.map +1 -0
- package/dist/schemas/paper.schema.js +26 -0
- package/dist/schemas/paper.schema.js.map +1 -0
- package/dist/schemas/question.schema.d.ts +147 -0
- package/dist/schemas/question.schema.d.ts.map +1 -0
- package/dist/schemas/question.schema.js +32 -0
- package/dist/schemas/question.schema.js.map +1 -0
- package/dist/services/attempt.service.d.ts +33 -0
- package/dist/services/attempt.service.d.ts.map +1 -0
- package/dist/services/attempt.service.js +197 -0
- package/dist/services/attempt.service.js.map +1 -0
- package/dist/services/category.service.d.ts +14 -0
- package/dist/services/category.service.d.ts.map +1 -0
- package/dist/services/category.service.js +74 -0
- package/dist/services/category.service.js.map +1 -0
- package/dist/services/exam.service.d.ts +17 -0
- package/dist/services/exam.service.d.ts.map +1 -0
- package/dist/services/exam.service.js +92 -0
- package/dist/services/exam.service.js.map +1 -0
- package/dist/services/grade.service.d.ts +16 -0
- package/dist/services/grade.service.d.ts.map +1 -0
- package/dist/services/grade.service.js +75 -0
- package/dist/services/grade.service.js.map +1 -0
- package/dist/services/index.d.ts +8 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +18 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/paper.service.d.ts +32 -0
- package/dist/services/paper.service.d.ts.map +1 -0
- package/dist/services/paper.service.js +157 -0
- package/dist/services/paper.service.js.map +1 -0
- package/dist/services/question.service.d.ts +30 -0
- package/dist/services/question.service.d.ts.map +1 -0
- package/dist/services/question.service.js +155 -0
- package/dist/services/question.service.js.map +1 -0
- package/dist/services/statistics.service.d.ts +43 -0
- package/dist/services/statistics.service.d.ts.map +1 -0
- package/dist/services/statistics.service.js +134 -0
- package/dist/services/statistics.service.js.map +1 -0
- package/package.json +85 -0
- package/web/index.ts +51 -0
- package/web/manifest.ts +36 -0
- package/web/messages/en-US.json +143 -0
- package/web/messages/zh-CN.json +143 -0
- package/web/pages/ExamRoomPage.tsx +289 -0
- package/web/pages/ExamsPage.tsx +248 -0
- package/web/pages/PapersPage.tsx +202 -0
- package/web/pages/QuestionBankPage.tsx +263 -0
- package/web/pages/StatisticsPage.tsx +178 -0
- package/web/services/attempt-service.ts +53 -0
- package/web/services/category-service.ts +26 -0
- package/web/services/exam-service.ts +31 -0
- package/web/services/paper-service.ts +50 -0
- package/web/services/question-service.ts +36 -0
- package/web/services/statistics-service.ts +28 -0
- package/web/stores/quiz-store.ts +31 -0
- package/web/types/quiz.ts +166 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback, useRef } from 'react'
|
|
4
|
+
import { ChevronLeft, ChevronRight, Send, Clock } from 'lucide-react'
|
|
5
|
+
import { useTranslations } from 'next-intl'
|
|
6
|
+
import { toast } from 'sonner'
|
|
7
|
+
|
|
8
|
+
import { PageHeader } from '@octo-cyber/ui/components/shared/page-header'
|
|
9
|
+
import { Button } from '@octo-cyber/ui/components/ui/button'
|
|
10
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@octo-cyber/ui/components/ui/card'
|
|
11
|
+
import { Badge } from '@octo-cyber/ui/components/ui/badge'
|
|
12
|
+
import { Progress } from '@octo-cyber/ui/components/ui/progress'
|
|
13
|
+
import { RadioGroup, RadioGroupItem } from '@octo-cyber/ui/components/ui/radio-group'
|
|
14
|
+
import { Checkbox } from '@octo-cyber/ui/components/ui/checkbox'
|
|
15
|
+
import { Label } from '@octo-cyber/ui/components/ui/label'
|
|
16
|
+
import { Textarea } from '@octo-cyber/ui/components/ui/textarea'
|
|
17
|
+
import {
|
|
18
|
+
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|
19
|
+
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
|
20
|
+
} from '@octo-cyber/ui/components/ui/alert-dialog'
|
|
21
|
+
|
|
22
|
+
import { attemptApi } from '../services/attempt-service'
|
|
23
|
+
import { examApi } from '../services/exam-service'
|
|
24
|
+
import { paperApi } from '../services/paper-service'
|
|
25
|
+
import { useQuizStore } from '../stores/quiz-store'
|
|
26
|
+
import type { QuizExam, PaperWithQuestions, QuizAttempt } from '../types/quiz'
|
|
27
|
+
|
|
28
|
+
interface ExamRoomPageProps {
|
|
29
|
+
examId: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function ExamRoomPage({ examId }: ExamRoomPageProps) {
|
|
33
|
+
const t = useTranslations('quiz.examRoom')
|
|
34
|
+
const tc = useTranslations('common')
|
|
35
|
+
|
|
36
|
+
const { draftAnswers, setDraftAnswer, clearDraftAnswers, setCurrentAttemptId } = useQuizStore()
|
|
37
|
+
|
|
38
|
+
const [exam, setExam] = useState<QuizExam | null>(null)
|
|
39
|
+
const [detail, setDetail] = useState<PaperWithQuestions | null>(null)
|
|
40
|
+
const [attempt, setAttempt] = useState<QuizAttempt | null>(null)
|
|
41
|
+
const [currentIdx, setCurrentIdx] = useState(0)
|
|
42
|
+
const [loading, setLoading] = useState(true)
|
|
43
|
+
const [submitting, setSubmitting] = useState(false)
|
|
44
|
+
const [confirmOpen, setConfirmOpen] = useState(false)
|
|
45
|
+
const [elapsed, setElapsed] = useState(0)
|
|
46
|
+
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const init = async () => {
|
|
50
|
+
setLoading(true)
|
|
51
|
+
try {
|
|
52
|
+
const e = await examApi.get(examId)
|
|
53
|
+
setExam(e)
|
|
54
|
+
const d = await paperApi.getWithQuestions(e.paperId)
|
|
55
|
+
setDetail(d)
|
|
56
|
+
const a = await attemptApi.start(examId)
|
|
57
|
+
setAttempt(a)
|
|
58
|
+
setCurrentAttemptId(a.id)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
toast.error(t('startFailed'))
|
|
61
|
+
} finally {
|
|
62
|
+
setLoading(false)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
init()
|
|
66
|
+
return () => { clearDraftAnswers() }
|
|
67
|
+
}, [examId])
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
timerRef.current = setInterval(() => setElapsed((prev) => prev + 1), 1000)
|
|
71
|
+
return () => { if (timerRef.current) clearInterval(timerRef.current) }
|
|
72
|
+
}, [])
|
|
73
|
+
|
|
74
|
+
const formatTime = (secs: number) => {
|
|
75
|
+
const m = Math.floor(secs / 60).toString().padStart(2, '0')
|
|
76
|
+
const s = (secs % 60).toString().padStart(2, '0')
|
|
77
|
+
return `${m}:${s}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const handleSubmit = async () => {
|
|
81
|
+
if (!attempt) return
|
|
82
|
+
setSubmitting(true)
|
|
83
|
+
try {
|
|
84
|
+
const answers = Object.entries(draftAnswers).map(([questionId, userAnswer]) => ({
|
|
85
|
+
questionId,
|
|
86
|
+
userAnswer,
|
|
87
|
+
}))
|
|
88
|
+
const result = await attemptApi.submit(attempt.id, answers, elapsed)
|
|
89
|
+
setAttempt(result)
|
|
90
|
+
clearDraftAnswers()
|
|
91
|
+
if (timerRef.current) clearInterval(timerRef.current)
|
|
92
|
+
toast.success(t('submitSuccess', { score: result.totalScore }))
|
|
93
|
+
} catch {
|
|
94
|
+
toast.error(t('submitFailed'))
|
|
95
|
+
} finally {
|
|
96
|
+
setSubmitting(false)
|
|
97
|
+
setConfirmOpen(false)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (loading) {
|
|
102
|
+
return <div className="flex items-center justify-center h-64 text-muted-foreground">{tc('loading')}</div>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!exam || !detail) {
|
|
106
|
+
return <div className="text-center py-12 text-muted-foreground">{t('loadFailed')}</div>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (attempt?.status === 'SUBMITTED' || attempt?.status === 'GRADED') {
|
|
110
|
+
return (
|
|
111
|
+
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
|
112
|
+
<div className="text-6xl">✅</div>
|
|
113
|
+
<h2 className="text-2xl font-bold">{t('submitted')}</h2>
|
|
114
|
+
<p className="text-muted-foreground">{t('yourScore')}: <strong className="text-2xl">{attempt.totalScore}</strong></p>
|
|
115
|
+
{attempt.isPassed ? (
|
|
116
|
+
<Badge className="text-lg px-4 py-1">{t('passed')}</Badge>
|
|
117
|
+
) : (
|
|
118
|
+
<Badge variant="destructive" className="text-lg px-4 py-1">{t('failed')}</Badge>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const questions = detail.questions
|
|
125
|
+
const current = questions[currentIdx]
|
|
126
|
+
const answeredCount = Object.keys(draftAnswers).length
|
|
127
|
+
const progress = (answeredCount / questions.length) * 100
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className="space-y-4 max-w-3xl mx-auto">
|
|
131
|
+
<div className="flex items-center justify-between">
|
|
132
|
+
<PageHeader title={exam.title} description={`${t('question')} ${currentIdx + 1} / ${questions.length}`} />
|
|
133
|
+
<div className="flex items-center gap-3">
|
|
134
|
+
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
|
135
|
+
<Clock className="h-4 w-4" />
|
|
136
|
+
<span>{formatTime(elapsed)}</span>
|
|
137
|
+
</div>
|
|
138
|
+
<Button size="sm" variant="default" onClick={() => setConfirmOpen(true)}>
|
|
139
|
+
<Send className="mr-1 h-4 w-4" />
|
|
140
|
+
{t('submit')}
|
|
141
|
+
</Button>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<Progress value={progress} className="h-2" />
|
|
146
|
+
<p className="text-xs text-muted-foreground">{t('answered', { count: answeredCount, total: questions.length })}</p>
|
|
147
|
+
|
|
148
|
+
{current && (
|
|
149
|
+
<Card className="dark:bg-zinc-900 dark:border-zinc-800">
|
|
150
|
+
<CardHeader>
|
|
151
|
+
<div className="flex items-start gap-3">
|
|
152
|
+
<Badge variant="outline" className="shrink-0">{currentIdx + 1}</Badge>
|
|
153
|
+
<div className="space-y-1">
|
|
154
|
+
<div className="flex gap-2">
|
|
155
|
+
<Badge variant="secondary">
|
|
156
|
+
{t(`types.${current.questionDetail.type.toLowerCase()}`)}
|
|
157
|
+
</Badge>
|
|
158
|
+
<span className="text-sm text-muted-foreground">{t('score')}: {current.score}</span>
|
|
159
|
+
</div>
|
|
160
|
+
<CardTitle className="text-base font-normal leading-relaxed">
|
|
161
|
+
{current.questionDetail.content}
|
|
162
|
+
</CardTitle>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</CardHeader>
|
|
166
|
+
<CardContent>
|
|
167
|
+
<QuestionInput
|
|
168
|
+
question={current.questionDetail}
|
|
169
|
+
value={draftAnswers[current.questionId]}
|
|
170
|
+
onChange={(v) => setDraftAnswer(current.questionId, v)}
|
|
171
|
+
t={t}
|
|
172
|
+
/>
|
|
173
|
+
</CardContent>
|
|
174
|
+
</Card>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
<div className="flex justify-between">
|
|
178
|
+
<Button variant="outline" disabled={currentIdx <= 0} onClick={() => setCurrentIdx(currentIdx - 1)}>
|
|
179
|
+
<ChevronLeft className="mr-1 h-4 w-4" />
|
|
180
|
+
{tc('previous')}
|
|
181
|
+
</Button>
|
|
182
|
+
<div className="flex flex-wrap gap-1">
|
|
183
|
+
{questions.map((_, i) => (
|
|
184
|
+
<button
|
|
185
|
+
key={i}
|
|
186
|
+
onClick={() => setCurrentIdx(i)}
|
|
187
|
+
className={`w-8 h-8 text-xs rounded border font-medium transition-colors ${
|
|
188
|
+
i === currentIdx ? 'bg-primary text-primary-foreground border-primary' :
|
|
189
|
+
draftAnswers[questions[i].questionId] !== undefined ? 'bg-green-100 text-green-700 border-green-300 dark:bg-green-900 dark:text-green-300' :
|
|
190
|
+
'bg-background dark:bg-zinc-900 hover:bg-muted'
|
|
191
|
+
}`}
|
|
192
|
+
>
|
|
193
|
+
{i + 1}
|
|
194
|
+
</button>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
<Button variant="outline" disabled={currentIdx >= questions.length - 1} onClick={() => setCurrentIdx(currentIdx + 1)}>
|
|
198
|
+
{tc('next')}
|
|
199
|
+
<ChevronRight className="ml-1 h-4 w-4" />
|
|
200
|
+
</Button>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
|
204
|
+
<AlertDialogContent>
|
|
205
|
+
<AlertDialogHeader>
|
|
206
|
+
<AlertDialogTitle>{t('confirmSubmitTitle')}</AlertDialogTitle>
|
|
207
|
+
<AlertDialogDescription>
|
|
208
|
+
{t('confirmSubmitDesc', { answered: answeredCount, total: questions.length })}
|
|
209
|
+
</AlertDialogDescription>
|
|
210
|
+
</AlertDialogHeader>
|
|
211
|
+
<AlertDialogFooter>
|
|
212
|
+
<AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
|
|
213
|
+
<AlertDialogAction onClick={handleSubmit} disabled={submitting}>
|
|
214
|
+
{submitting ? tc('saving') : t('submit')}
|
|
215
|
+
</AlertDialogAction>
|
|
216
|
+
</AlertDialogFooter>
|
|
217
|
+
</AlertDialogContent>
|
|
218
|
+
</AlertDialog>
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
import type { QuizQuestion } from '../types/quiz'
|
|
224
|
+
|
|
225
|
+
function QuestionInput({
|
|
226
|
+
question,
|
|
227
|
+
value,
|
|
228
|
+
onChange,
|
|
229
|
+
t,
|
|
230
|
+
}: {
|
|
231
|
+
question: QuizQuestion
|
|
232
|
+
value: string | string[] | undefined
|
|
233
|
+
onChange: (v: string | string[]) => void
|
|
234
|
+
t: (key: string, options?: Record<string, unknown>) => string
|
|
235
|
+
}) {
|
|
236
|
+
if (question.type === 'SINGLE_CHOICE' || question.type === 'TRUE_FALSE') {
|
|
237
|
+
const options = question.type === 'TRUE_FALSE'
|
|
238
|
+
? [{ key: 'true', text: t('trueLabel') }, { key: 'false', text: t('falseLabel') }]
|
|
239
|
+
: (question.options ?? [])
|
|
240
|
+
return (
|
|
241
|
+
<RadioGroup value={String(value ?? '')} onValueChange={onChange}>
|
|
242
|
+
{options.map((opt) => (
|
|
243
|
+
<div key={opt.key} className="flex items-center gap-3 py-2 px-3 rounded hover:bg-muted dark:hover:bg-zinc-800">
|
|
244
|
+
<RadioGroupItem value={opt.key} id={`opt-${opt.key}`} />
|
|
245
|
+
<Label htmlFor={`opt-${opt.key}`} className="cursor-pointer flex-1">
|
|
246
|
+
<span className="font-medium mr-2">{opt.key.toUpperCase()}.</span>{opt.text}
|
|
247
|
+
</Label>
|
|
248
|
+
</div>
|
|
249
|
+
))}
|
|
250
|
+
</RadioGroup>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (question.type === 'MULTIPLE_CHOICE') {
|
|
255
|
+
const selected = Array.isArray(value) ? value : (value ? [value] : [])
|
|
256
|
+
const options = question.options ?? []
|
|
257
|
+
return (
|
|
258
|
+
<div className="space-y-2">
|
|
259
|
+
{options.map((opt) => (
|
|
260
|
+
<div key={opt.key} className="flex items-center gap-3 py-2 px-3 rounded hover:bg-muted dark:hover:bg-zinc-800">
|
|
261
|
+
<Checkbox
|
|
262
|
+
id={`opt-${opt.key}`}
|
|
263
|
+
checked={selected.includes(opt.key)}
|
|
264
|
+
onCheckedChange={(checked) => {
|
|
265
|
+
const next = checked
|
|
266
|
+
? [...selected, opt.key]
|
|
267
|
+
: selected.filter((k) => k !== opt.key)
|
|
268
|
+
onChange(next)
|
|
269
|
+
}}
|
|
270
|
+
/>
|
|
271
|
+
<Label htmlFor={`opt-${opt.key}`} className="cursor-pointer flex-1">
|
|
272
|
+
<span className="font-medium mr-2">{opt.key.toUpperCase()}.</span>{opt.text}
|
|
273
|
+
</Label>
|
|
274
|
+
</div>
|
|
275
|
+
))}
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<Textarea
|
|
282
|
+
rows={6}
|
|
283
|
+
placeholder={question.type === 'FILL_BLANK' ? t('fillBlankPlaceholder') : t('essayPlaceholder')}
|
|
284
|
+
value={typeof value === 'string' ? value : ''}
|
|
285
|
+
onChange={(e) => onChange(e.target.value)}
|
|
286
|
+
className="resize-none"
|
|
287
|
+
/>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback } from 'react'
|
|
4
|
+
import { Plus, RefreshCw, PlayCircle, Clock } from 'lucide-react'
|
|
5
|
+
import { useTranslations } from 'next-intl'
|
|
6
|
+
import { toast } from 'sonner'
|
|
7
|
+
|
|
8
|
+
import { PageHeader } from '@octo-cyber/ui/components/shared/page-header'
|
|
9
|
+
import { Button } from '@octo-cyber/ui/components/ui/button'
|
|
10
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@octo-cyber/ui/components/ui/card'
|
|
11
|
+
import { Badge } from '@octo-cyber/ui/components/ui/badge'
|
|
12
|
+
import {
|
|
13
|
+
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
14
|
+
} from '@octo-cyber/ui/components/ui/dialog'
|
|
15
|
+
import { Input } from '@octo-cyber/ui/components/ui/input'
|
|
16
|
+
import { Label } from '@octo-cyber/ui/components/ui/label'
|
|
17
|
+
import { Textarea } from '@octo-cyber/ui/components/ui/textarea'
|
|
18
|
+
import {
|
|
19
|
+
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
20
|
+
} from '@octo-cyber/ui/components/ui/select'
|
|
21
|
+
import { Switch } from '@octo-cyber/ui/components/ui/switch'
|
|
22
|
+
|
|
23
|
+
import { examApi } from '../services/exam-service'
|
|
24
|
+
import { paperApi } from '../services/paper-service'
|
|
25
|
+
import type { QuizExam, QuizPaper } from '../types/quiz'
|
|
26
|
+
|
|
27
|
+
const statusColor: Record<string, string> = {
|
|
28
|
+
DRAFT: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
|
29
|
+
SCHEDULED: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
|
30
|
+
ONGOING: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
|
31
|
+
ENDED: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
|
|
32
|
+
CANCELLED: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function ExamsPage() {
|
|
36
|
+
const t = useTranslations('quiz.exam')
|
|
37
|
+
const tc = useTranslations('common')
|
|
38
|
+
|
|
39
|
+
const [items, setItems] = useState<QuizExam[]>([])
|
|
40
|
+
const [total, setTotal] = useState(0)
|
|
41
|
+
const [page, setPage] = useState(1)
|
|
42
|
+
const [loading, setLoading] = useState(true)
|
|
43
|
+
const [papers, setPapers] = useState<QuizPaper[]>([])
|
|
44
|
+
const [createOpen, setCreateOpen] = useState(false)
|
|
45
|
+
const [saving, setSaving] = useState(false)
|
|
46
|
+
const [form, setForm] = useState({
|
|
47
|
+
title: '', paperId: '', description: '',
|
|
48
|
+
maxAttempts: 1, shuffleQuestions: false, shuffleOptions: false, showAnswerAfterSubmit: false,
|
|
49
|
+
startTime: '', endTime: '',
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const pageSize = 20
|
|
53
|
+
|
|
54
|
+
const loadData = useCallback(async () => {
|
|
55
|
+
setLoading(true)
|
|
56
|
+
try {
|
|
57
|
+
const res = await examApi.list({ page: String(page), pageSize: String(pageSize) })
|
|
58
|
+
setItems(res.items)
|
|
59
|
+
setTotal(res.total)
|
|
60
|
+
} catch {
|
|
61
|
+
toast.error(tc('loadDataFailed'))
|
|
62
|
+
} finally {
|
|
63
|
+
setLoading(false)
|
|
64
|
+
}
|
|
65
|
+
}, [page, tc])
|
|
66
|
+
|
|
67
|
+
useEffect(() => { loadData() }, [loadData])
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
paperApi.list({ pageSize: '100' }).then((r) => setPapers(r.items)).catch(() => {})
|
|
71
|
+
}, [])
|
|
72
|
+
|
|
73
|
+
const handleCreate = async () => {
|
|
74
|
+
if (!form.title.trim() || !form.paperId) { toast.error(t('formRequired')); return }
|
|
75
|
+
setSaving(true)
|
|
76
|
+
try {
|
|
77
|
+
await examApi.create({
|
|
78
|
+
...form,
|
|
79
|
+
startTime: form.startTime || null as unknown as string,
|
|
80
|
+
endTime: form.endTime || null as unknown as string,
|
|
81
|
+
})
|
|
82
|
+
toast.success(t('createSuccess'))
|
|
83
|
+
setCreateOpen(false)
|
|
84
|
+
await loadData()
|
|
85
|
+
} catch {
|
|
86
|
+
toast.error(t('createFailed'))
|
|
87
|
+
} finally {
|
|
88
|
+
setSaving(false)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const handleActivate = async (id: string, status: string) => {
|
|
93
|
+
const next = status === 'DRAFT' ? 'SCHEDULED' : status === 'SCHEDULED' ? 'ONGOING' : 'ENDED'
|
|
94
|
+
try {
|
|
95
|
+
await examApi.update(id, { status: next as QuizExam['status'] })
|
|
96
|
+
toast.success(t('statusUpdated'))
|
|
97
|
+
await loadData()
|
|
98
|
+
} catch {
|
|
99
|
+
toast.error(t('updateFailed'))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const handleDelete = async (id: string) => {
|
|
104
|
+
try {
|
|
105
|
+
await examApi.delete(id)
|
|
106
|
+
toast.success(t('deleteSuccess'))
|
|
107
|
+
await loadData()
|
|
108
|
+
} catch {
|
|
109
|
+
toast.error(t('deleteFailed'))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const totalPages = Math.ceil(total / pageSize)
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="space-y-6">
|
|
117
|
+
<PageHeader title={t('title')} description={t('description')}>
|
|
118
|
+
<Button variant="outline" size="sm" onClick={loadData} disabled={loading}>
|
|
119
|
+
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
120
|
+
{tc('refresh')}
|
|
121
|
+
</Button>
|
|
122
|
+
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
123
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
124
|
+
{t('create')}
|
|
125
|
+
</Button>
|
|
126
|
+
</PageHeader>
|
|
127
|
+
|
|
128
|
+
{loading ? (
|
|
129
|
+
<div className="text-center py-12 text-muted-foreground">{tc('loading')}</div>
|
|
130
|
+
) : items.length === 0 ? (
|
|
131
|
+
<div className="text-center py-12 text-muted-foreground">{t('noData')}</div>
|
|
132
|
+
) : (
|
|
133
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
134
|
+
{items.map((exam) => (
|
|
135
|
+
<Card key={exam.id} className="dark:bg-zinc-900 dark:border-zinc-800">
|
|
136
|
+
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
|
|
137
|
+
<div className="flex items-center gap-2">
|
|
138
|
+
<PlayCircle className="h-5 w-5 text-primary" />
|
|
139
|
+
<CardTitle className="text-base">{exam.title}</CardTitle>
|
|
140
|
+
</div>
|
|
141
|
+
<span className={`text-xs px-2 py-1 rounded-full font-medium ${statusColor[exam.status] ?? ''}`}>
|
|
142
|
+
{t(`statuses.${exam.status.toLowerCase()}`)}
|
|
143
|
+
</span>
|
|
144
|
+
</CardHeader>
|
|
145
|
+
<CardContent>
|
|
146
|
+
{exam.description && (
|
|
147
|
+
<p className="text-sm text-muted-foreground mb-2 line-clamp-1">{exam.description}</p>
|
|
148
|
+
)}
|
|
149
|
+
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground mb-3">
|
|
150
|
+
<span className="flex items-center gap-1">
|
|
151
|
+
<Clock className="h-3 w-3" />
|
|
152
|
+
{exam.startTime ? new Date(exam.startTime).toLocaleString() : tc('unlimited')}
|
|
153
|
+
</span>
|
|
154
|
+
<span>{t('maxAttempts')}: {exam.maxAttempts === 0 ? tc('unlimited') : exam.maxAttempts}</span>
|
|
155
|
+
</div>
|
|
156
|
+
<div className="flex gap-2">
|
|
157
|
+
{exam.status !== 'ENDED' && exam.status !== 'CANCELLED' && (
|
|
158
|
+
<Button size="sm" variant="outline" onClick={() => handleActivate(exam.id, exam.status)}>
|
|
159
|
+
{exam.status === 'DRAFT' ? t('schedule')
|
|
160
|
+
: exam.status === 'SCHEDULED' ? t('start')
|
|
161
|
+
: t('end')}
|
|
162
|
+
</Button>
|
|
163
|
+
)}
|
|
164
|
+
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive"
|
|
165
|
+
onClick={() => handleDelete(exam.id)}>
|
|
166
|
+
{tc('delete')}
|
|
167
|
+
</Button>
|
|
168
|
+
</div>
|
|
169
|
+
</CardContent>
|
|
170
|
+
</Card>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{totalPages > 1 && (
|
|
176
|
+
<div className="flex items-center justify-center gap-2">
|
|
177
|
+
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
|
178
|
+
{tc('pagination.previous')}
|
|
179
|
+
</Button>
|
|
180
|
+
<span className="text-sm text-muted-foreground">
|
|
181
|
+
{tc('pagination.page', { current: page, total: totalPages })}
|
|
182
|
+
</span>
|
|
183
|
+
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
|
184
|
+
{tc('pagination.next')}
|
|
185
|
+
</Button>
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
190
|
+
<DialogContent className="max-w-lg">
|
|
191
|
+
<DialogHeader><DialogTitle>{t('createTitle')}</DialogTitle></DialogHeader>
|
|
192
|
+
<div className="space-y-4">
|
|
193
|
+
<div className="space-y-2">
|
|
194
|
+
<Label>{t('col.title')}</Label>
|
|
195
|
+
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
|
196
|
+
</div>
|
|
197
|
+
<div className="space-y-2">
|
|
198
|
+
<Label>{t('col.paper')}</Label>
|
|
199
|
+
<Select value={form.paperId} onValueChange={(v) => setForm({ ...form, paperId: v })}>
|
|
200
|
+
<SelectTrigger><SelectValue placeholder={t('selectPaper')} /></SelectTrigger>
|
|
201
|
+
<SelectContent>
|
|
202
|
+
{papers.map((p) => <SelectItem key={p.id} value={p.id}>{p.title}</SelectItem>)}
|
|
203
|
+
</SelectContent>
|
|
204
|
+
</Select>
|
|
205
|
+
</div>
|
|
206
|
+
<div className="space-y-2">
|
|
207
|
+
<Label>{t('col.description')}</Label>
|
|
208
|
+
<Textarea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
|
209
|
+
</div>
|
|
210
|
+
<div className="grid grid-cols-2 gap-4">
|
|
211
|
+
<div className="space-y-2">
|
|
212
|
+
<Label>{t('startTime')}</Label>
|
|
213
|
+
<Input type="datetime-local" value={form.startTime}
|
|
214
|
+
onChange={(e) => setForm({ ...form, startTime: e.target.value })} />
|
|
215
|
+
</div>
|
|
216
|
+
<div className="space-y-2">
|
|
217
|
+
<Label>{t('endTime')}</Label>
|
|
218
|
+
<Input type="datetime-local" value={form.endTime}
|
|
219
|
+
onChange={(e) => setForm({ ...form, endTime: e.target.value })} />
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
<div className="space-y-2">
|
|
223
|
+
<Label>{t('maxAttempts')} (0={tc('unlimited')})</Label>
|
|
224
|
+
<Input type="number" min={0} value={form.maxAttempts}
|
|
225
|
+
onChange={(e) => setForm({ ...form, maxAttempts: Number(e.target.value) })} />
|
|
226
|
+
</div>
|
|
227
|
+
<div className="flex items-center justify-between">
|
|
228
|
+
<Label>{t('shuffleQuestions')}</Label>
|
|
229
|
+
<Switch checked={form.shuffleQuestions}
|
|
230
|
+
onCheckedChange={(v) => setForm({ ...form, shuffleQuestions: v })} />
|
|
231
|
+
</div>
|
|
232
|
+
<div className="flex items-center justify-between">
|
|
233
|
+
<Label>{t('showAnswerAfterSubmit')}</Label>
|
|
234
|
+
<Switch checked={form.showAnswerAfterSubmit}
|
|
235
|
+
onCheckedChange={(v) => setForm({ ...form, showAnswerAfterSubmit: v })} />
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
<DialogFooter>
|
|
239
|
+
<Button variant="outline" onClick={() => setCreateOpen(false)}>{tc('cancel')}</Button>
|
|
240
|
+
<Button onClick={handleCreate} disabled={saving}>
|
|
241
|
+
{saving ? tc('saving') : tc('save')}
|
|
242
|
+
</Button>
|
|
243
|
+
</DialogFooter>
|
|
244
|
+
</DialogContent>
|
|
245
|
+
</Dialog>
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
248
|
+
}
|