@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,202 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback } from 'react'
|
|
4
|
+
import { Plus, RefreshCw, FileText, Eye } 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
|
+
|
|
19
|
+
import { paperApi } from '../services/paper-service'
|
|
20
|
+
import type { QuizPaper } from '../types/quiz'
|
|
21
|
+
|
|
22
|
+
const statusVariant: Record<string, 'default' | 'secondary' | 'outline'> = {
|
|
23
|
+
DRAFT: 'secondary',
|
|
24
|
+
PUBLISHED: 'default',
|
|
25
|
+
ARCHIVED: 'outline',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function PapersPage() {
|
|
29
|
+
const t = useTranslations('quiz.paper')
|
|
30
|
+
const tc = useTranslations('common')
|
|
31
|
+
|
|
32
|
+
const [items, setItems] = useState<QuizPaper[]>([])
|
|
33
|
+
const [total, setTotal] = useState(0)
|
|
34
|
+
const [page, setPage] = useState(1)
|
|
35
|
+
const [loading, setLoading] = useState(true)
|
|
36
|
+
const [createOpen, setCreateOpen] = useState(false)
|
|
37
|
+
const [saving, setSaving] = useState(false)
|
|
38
|
+
const [form, setForm] = useState({ title: '', description: '', passingScore: 60, timeLimit: 0 })
|
|
39
|
+
|
|
40
|
+
const pageSize = 20
|
|
41
|
+
|
|
42
|
+
const loadData = useCallback(async () => {
|
|
43
|
+
setLoading(true)
|
|
44
|
+
try {
|
|
45
|
+
const res = await paperApi.list({ page: String(page), pageSize: String(pageSize) })
|
|
46
|
+
setItems(res.items)
|
|
47
|
+
setTotal(res.total)
|
|
48
|
+
} catch {
|
|
49
|
+
toast.error(tc('loadDataFailed'))
|
|
50
|
+
} finally {
|
|
51
|
+
setLoading(false)
|
|
52
|
+
}
|
|
53
|
+
}, [page, tc])
|
|
54
|
+
|
|
55
|
+
useEffect(() => { loadData() }, [loadData])
|
|
56
|
+
|
|
57
|
+
const handleCreate = async () => {
|
|
58
|
+
if (!form.title.trim()) { toast.error(t('titleRequired')); return }
|
|
59
|
+
setSaving(true)
|
|
60
|
+
try {
|
|
61
|
+
await paperApi.create(form)
|
|
62
|
+
toast.success(t('createSuccess'))
|
|
63
|
+
setCreateOpen(false)
|
|
64
|
+
setForm({ title: '', description: '', passingScore: 60, timeLimit: 0 })
|
|
65
|
+
await loadData()
|
|
66
|
+
} catch {
|
|
67
|
+
toast.error(t('createFailed'))
|
|
68
|
+
} finally {
|
|
69
|
+
setSaving(false)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const handlePublish = async (id: string) => {
|
|
74
|
+
try {
|
|
75
|
+
await paperApi.update(id, { status: 'PUBLISHED' })
|
|
76
|
+
toast.success(t('publishSuccess'))
|
|
77
|
+
await loadData()
|
|
78
|
+
} catch {
|
|
79
|
+
toast.error(t('publishFailed'))
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const handleDelete = async (id: string) => {
|
|
84
|
+
try {
|
|
85
|
+
await paperApi.delete(id)
|
|
86
|
+
toast.success(t('deleteSuccess'))
|
|
87
|
+
await loadData()
|
|
88
|
+
} catch {
|
|
89
|
+
toast.error(t('deleteFailed'))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const totalPages = Math.ceil(total / pageSize)
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="space-y-6">
|
|
97
|
+
<PageHeader title={t('title')} description={t('description')}>
|
|
98
|
+
<Button variant="outline" size="sm" onClick={loadData} disabled={loading}>
|
|
99
|
+
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
100
|
+
{tc('refresh')}
|
|
101
|
+
</Button>
|
|
102
|
+
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
103
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
104
|
+
{t('create')}
|
|
105
|
+
</Button>
|
|
106
|
+
</PageHeader>
|
|
107
|
+
|
|
108
|
+
{loading ? (
|
|
109
|
+
<div className="text-center py-12 text-muted-foreground">{tc('loading')}</div>
|
|
110
|
+
) : items.length === 0 ? (
|
|
111
|
+
<div className="text-center py-12 text-muted-foreground">{t('noData')}</div>
|
|
112
|
+
) : (
|
|
113
|
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
114
|
+
{items.map((paper) => (
|
|
115
|
+
<Card key={paper.id} className="hover:shadow-md transition-shadow dark:bg-zinc-900 dark:border-zinc-800">
|
|
116
|
+
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
|
|
117
|
+
<div className="flex items-center gap-2">
|
|
118
|
+
<FileText className="h-5 w-5 text-primary" />
|
|
119
|
+
<CardTitle className="text-base">{paper.title}</CardTitle>
|
|
120
|
+
</div>
|
|
121
|
+
<Badge variant={statusVariant[paper.status] ?? 'outline'}>
|
|
122
|
+
{t(`statuses.${paper.status.toLowerCase()}`)}
|
|
123
|
+
</Badge>
|
|
124
|
+
</CardHeader>
|
|
125
|
+
<CardContent>
|
|
126
|
+
{paper.description && (
|
|
127
|
+
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">{paper.description}</p>
|
|
128
|
+
)}
|
|
129
|
+
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground mb-3">
|
|
130
|
+
<span>{t('totalScore')}: <strong>{paper.totalScore}</strong></span>
|
|
131
|
+
<span>{t('passingScore')}: <strong>{paper.passingScore}</strong></span>
|
|
132
|
+
<span>{t('timeLimit')}: <strong>{paper.timeLimit > 0 ? `${paper.timeLimit}min` : tc('unlimited')}</strong></span>
|
|
133
|
+
</div>
|
|
134
|
+
<div className="flex gap-2">
|
|
135
|
+
{paper.status === 'DRAFT' && (
|
|
136
|
+
<Button size="sm" variant="outline" onClick={() => handlePublish(paper.id)}>
|
|
137
|
+
{t('publish')}
|
|
138
|
+
</Button>
|
|
139
|
+
)}
|
|
140
|
+
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive"
|
|
141
|
+
onClick={() => handleDelete(paper.id)}>
|
|
142
|
+
{tc('delete')}
|
|
143
|
+
</Button>
|
|
144
|
+
</div>
|
|
145
|
+
</CardContent>
|
|
146
|
+
</Card>
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{totalPages > 1 && (
|
|
152
|
+
<div className="flex items-center justify-center gap-2">
|
|
153
|
+
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
|
154
|
+
{tc('pagination.previous')}
|
|
155
|
+
</Button>
|
|
156
|
+
<span className="text-sm text-muted-foreground">
|
|
157
|
+
{tc('pagination.page', { current: page, total: totalPages })}
|
|
158
|
+
</span>
|
|
159
|
+
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
|
160
|
+
{tc('pagination.next')}
|
|
161
|
+
</Button>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
|
|
165
|
+
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
166
|
+
<DialogContent>
|
|
167
|
+
<DialogHeader><DialogTitle>{t('createTitle')}</DialogTitle></DialogHeader>
|
|
168
|
+
<div className="space-y-4">
|
|
169
|
+
<div className="space-y-2">
|
|
170
|
+
<Label>{t('col.title')}</Label>
|
|
171
|
+
<Input placeholder={t('titlePlaceholder')} value={form.title}
|
|
172
|
+
onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
|
173
|
+
</div>
|
|
174
|
+
<div className="space-y-2">
|
|
175
|
+
<Label>{t('col.description')}</Label>
|
|
176
|
+
<Textarea rows={3} value={form.description}
|
|
177
|
+
onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
|
178
|
+
</div>
|
|
179
|
+
<div className="grid grid-cols-2 gap-4">
|
|
180
|
+
<div className="space-y-2">
|
|
181
|
+
<Label>{t('passingScore')}</Label>
|
|
182
|
+
<Input type="number" min={0} value={form.passingScore}
|
|
183
|
+
onChange={(e) => setForm({ ...form, passingScore: Number(e.target.value) })} />
|
|
184
|
+
</div>
|
|
185
|
+
<div className="space-y-2">
|
|
186
|
+
<Label>{t('timeLimit')} (min, 0={tc('unlimited')})</Label>
|
|
187
|
+
<Input type="number" min={0} value={form.timeLimit}
|
|
188
|
+
onChange={(e) => setForm({ ...form, timeLimit: Number(e.target.value) })} />
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
<DialogFooter>
|
|
193
|
+
<Button variant="outline" onClick={() => setCreateOpen(false)}>{tc('cancel')}</Button>
|
|
194
|
+
<Button onClick={handleCreate} disabled={saving}>
|
|
195
|
+
{saving ? tc('saving') : tc('save')}
|
|
196
|
+
</Button>
|
|
197
|
+
</DialogFooter>
|
|
198
|
+
</DialogContent>
|
|
199
|
+
</Dialog>
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback } from 'react'
|
|
4
|
+
import { Plus, RefreshCw, Search, BookOpen, Filter } 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 { Input } from '@octo-cyber/ui/components/ui/input'
|
|
11
|
+
import { Card, CardContent } from '@octo-cyber/ui/components/ui/card'
|
|
12
|
+
import { Badge } from '@octo-cyber/ui/components/ui/badge'
|
|
13
|
+
import {
|
|
14
|
+
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
15
|
+
} from '@octo-cyber/ui/components/ui/select'
|
|
16
|
+
import {
|
|
17
|
+
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
18
|
+
} from '@octo-cyber/ui/components/ui/dialog'
|
|
19
|
+
import {
|
|
20
|
+
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
21
|
+
} from '@octo-cyber/ui/components/ui/table'
|
|
22
|
+
import { Label } from '@octo-cyber/ui/components/ui/label'
|
|
23
|
+
import { Textarea } from '@octo-cyber/ui/components/ui/textarea'
|
|
24
|
+
|
|
25
|
+
import { questionApi } from '../services/question-service'
|
|
26
|
+
import { categoryApi } from '../services/category-service'
|
|
27
|
+
import type { QuizQuestion, QuizCategory, QuestionType } from '../types/quiz'
|
|
28
|
+
|
|
29
|
+
const QUESTION_TYPES: QuestionType[] = ['SINGLE_CHOICE', 'MULTIPLE_CHOICE', 'TRUE_FALSE', 'FILL_BLANK', 'ESSAY']
|
|
30
|
+
const DIFFICULTIES = [1, 2, 3, 4, 5]
|
|
31
|
+
|
|
32
|
+
const difficultyLabel: Record<number, string> = {
|
|
33
|
+
1: '★', 2: '★★', 3: '★★★', 4: '★★★★', 5: '★★★★★',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function QuestionBankPage() {
|
|
37
|
+
const t = useTranslations('quiz.question')
|
|
38
|
+
const tc = useTranslations('common')
|
|
39
|
+
|
|
40
|
+
const [items, setItems] = useState<QuizQuestion[]>([])
|
|
41
|
+
const [total, setTotal] = useState(0)
|
|
42
|
+
const [page, setPage] = useState(1)
|
|
43
|
+
const [search, setSearch] = useState('')
|
|
44
|
+
const [typeFilter, setTypeFilter] = useState('all')
|
|
45
|
+
const [categoryFilter, setCategoryFilter] = useState('all')
|
|
46
|
+
const [difficultyFilter, setDifficultyFilter] = useState('all')
|
|
47
|
+
const [categories, setCategories] = useState<QuizCategory[]>([])
|
|
48
|
+
const [loading, setLoading] = useState(true)
|
|
49
|
+
const [createOpen, setCreateOpen] = useState(false)
|
|
50
|
+
const [saving, setSaving] = useState(false)
|
|
51
|
+
const [form, setForm] = useState<Partial<QuizQuestion>>({ type: 'SINGLE_CHOICE', difficulty: 3, defaultScore: 1 })
|
|
52
|
+
|
|
53
|
+
const pageSize = 20
|
|
54
|
+
|
|
55
|
+
const loadData = useCallback(async () => {
|
|
56
|
+
setLoading(true)
|
|
57
|
+
try {
|
|
58
|
+
const params: Record<string, string> = { page: String(page), pageSize: String(pageSize) }
|
|
59
|
+
if (search) params.search = search
|
|
60
|
+
if (typeFilter !== 'all') params.type = typeFilter
|
|
61
|
+
if (categoryFilter !== 'all') params.categoryId = categoryFilter
|
|
62
|
+
if (difficultyFilter !== 'all') params.difficulty = difficultyFilter
|
|
63
|
+
const res = await questionApi.list(params)
|
|
64
|
+
setItems(res.items)
|
|
65
|
+
setTotal(res.total)
|
|
66
|
+
} catch {
|
|
67
|
+
toast.error(tc('loadDataFailed'))
|
|
68
|
+
} finally {
|
|
69
|
+
setLoading(false)
|
|
70
|
+
}
|
|
71
|
+
}, [page, search, typeFilter, categoryFilter, difficultyFilter, tc])
|
|
72
|
+
|
|
73
|
+
useEffect(() => { loadData() }, [loadData])
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
categoryApi.list().then(setCategories).catch(() => {})
|
|
77
|
+
}, [])
|
|
78
|
+
|
|
79
|
+
const handleCreate = async () => {
|
|
80
|
+
setSaving(true)
|
|
81
|
+
try {
|
|
82
|
+
await questionApi.create(form)
|
|
83
|
+
toast.success(t('createSuccess'))
|
|
84
|
+
setCreateOpen(false)
|
|
85
|
+
setForm({ type: 'SINGLE_CHOICE', difficulty: 3, defaultScore: 1 })
|
|
86
|
+
await loadData()
|
|
87
|
+
} catch {
|
|
88
|
+
toast.error(t('createFailed'))
|
|
89
|
+
} finally {
|
|
90
|
+
setSaving(false)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const handleDelete = async (id: string) => {
|
|
95
|
+
try {
|
|
96
|
+
await questionApi.delete(id)
|
|
97
|
+
toast.success(t('deleteSuccess'))
|
|
98
|
+
await loadData()
|
|
99
|
+
} catch {
|
|
100
|
+
toast.error(t('deleteFailed'))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const totalPages = Math.ceil(total / pageSize)
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className="space-y-6">
|
|
108
|
+
<PageHeader title={t('title')} description={t('description')}>
|
|
109
|
+
<Button variant="outline" size="sm" onClick={loadData} disabled={loading}>
|
|
110
|
+
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
111
|
+
{tc('refresh')}
|
|
112
|
+
</Button>
|
|
113
|
+
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
114
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
115
|
+
{t('create')}
|
|
116
|
+
</Button>
|
|
117
|
+
</PageHeader>
|
|
118
|
+
|
|
119
|
+
<Card>
|
|
120
|
+
<CardContent className="pt-6">
|
|
121
|
+
<div className="mb-4 flex flex-wrap gap-3">
|
|
122
|
+
<div className="relative flex-1 min-w-48">
|
|
123
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
124
|
+
<Input className="pl-9" placeholder={t('searchPlaceholder')} value={search}
|
|
125
|
+
onChange={(e) => { setSearch(e.target.value); setPage(1) }} />
|
|
126
|
+
</div>
|
|
127
|
+
<Select value={typeFilter} onValueChange={(v) => { setTypeFilter(v); setPage(1) }}>
|
|
128
|
+
<SelectTrigger className="w-40">
|
|
129
|
+
<SelectValue placeholder={t('filterType')} />
|
|
130
|
+
</SelectTrigger>
|
|
131
|
+
<SelectContent>
|
|
132
|
+
<SelectItem value="all">{tc('all')}</SelectItem>
|
|
133
|
+
{QUESTION_TYPES.map((qt) => (
|
|
134
|
+
<SelectItem key={qt} value={qt}>{t(`types.${qt.toLowerCase()}`)}</SelectItem>
|
|
135
|
+
))}
|
|
136
|
+
</SelectContent>
|
|
137
|
+
</Select>
|
|
138
|
+
<Select value={categoryFilter} onValueChange={(v) => { setCategoryFilter(v); setPage(1) }}>
|
|
139
|
+
<SelectTrigger className="w-40">
|
|
140
|
+
<SelectValue placeholder={t('filterCategory')} />
|
|
141
|
+
</SelectTrigger>
|
|
142
|
+
<SelectContent>
|
|
143
|
+
<SelectItem value="all">{tc('all')}</SelectItem>
|
|
144
|
+
{categories.map((c) => (
|
|
145
|
+
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
|
146
|
+
))}
|
|
147
|
+
</SelectContent>
|
|
148
|
+
</Select>
|
|
149
|
+
<Select value={difficultyFilter} onValueChange={(v) => { setDifficultyFilter(v); setPage(1) }}>
|
|
150
|
+
<SelectTrigger className="w-36">
|
|
151
|
+
<SelectValue placeholder={t('filterDifficulty')} />
|
|
152
|
+
</SelectTrigger>
|
|
153
|
+
<SelectContent>
|
|
154
|
+
<SelectItem value="all">{tc('all')}</SelectItem>
|
|
155
|
+
{DIFFICULTIES.map((d) => (
|
|
156
|
+
<SelectItem key={d} value={String(d)}>{difficultyLabel[d]}</SelectItem>
|
|
157
|
+
))}
|
|
158
|
+
</SelectContent>
|
|
159
|
+
</Select>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<Table>
|
|
163
|
+
<TableHeader>
|
|
164
|
+
<TableRow>
|
|
165
|
+
<TableHead className="w-16">{t('col.type')}</TableHead>
|
|
166
|
+
<TableHead>{t('col.content')}</TableHead>
|
|
167
|
+
<TableHead className="w-24">{t('col.difficulty')}</TableHead>
|
|
168
|
+
<TableHead className="w-20">{t('col.score')}</TableHead>
|
|
169
|
+
<TableHead className="w-20">{t('col.useCount')}</TableHead>
|
|
170
|
+
<TableHead className="w-32">{tc('actions')}</TableHead>
|
|
171
|
+
</TableRow>
|
|
172
|
+
</TableHeader>
|
|
173
|
+
<TableBody>
|
|
174
|
+
{loading ? (
|
|
175
|
+
<TableRow><TableCell colSpan={6} className="text-center py-8 text-muted-foreground">{tc('loading')}</TableCell></TableRow>
|
|
176
|
+
) : items.length === 0 ? (
|
|
177
|
+
<TableRow><TableCell colSpan={6} className="text-center py-8 text-muted-foreground">{t('noData')}</TableCell></TableRow>
|
|
178
|
+
) : items.map((q) => (
|
|
179
|
+
<TableRow key={q.id}>
|
|
180
|
+
<TableCell>
|
|
181
|
+
<Badge variant="outline">{t(`types.${q.type.toLowerCase()}`)}</Badge>
|
|
182
|
+
</TableCell>
|
|
183
|
+
<TableCell className="max-w-xs truncate">{q.content}</TableCell>
|
|
184
|
+
<TableCell>{difficultyLabel[q.difficulty]}</TableCell>
|
|
185
|
+
<TableCell>{q.defaultScore}</TableCell>
|
|
186
|
+
<TableCell>{q.useCount}</TableCell>
|
|
187
|
+
<TableCell>
|
|
188
|
+
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive"
|
|
189
|
+
onClick={() => handleDelete(q.id)}>
|
|
190
|
+
{tc('delete')}
|
|
191
|
+
</Button>
|
|
192
|
+
</TableCell>
|
|
193
|
+
</TableRow>
|
|
194
|
+
))}
|
|
195
|
+
</TableBody>
|
|
196
|
+
</Table>
|
|
197
|
+
|
|
198
|
+
{totalPages > 1 && (
|
|
199
|
+
<div className="mt-4 flex items-center justify-center gap-2">
|
|
200
|
+
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
|
201
|
+
{tc('pagination.previous')}
|
|
202
|
+
</Button>
|
|
203
|
+
<span className="text-sm text-muted-foreground">
|
|
204
|
+
{tc('pagination.page', { current: page, total: totalPages })}
|
|
205
|
+
</span>
|
|
206
|
+
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
|
207
|
+
{tc('pagination.next')}
|
|
208
|
+
</Button>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</CardContent>
|
|
212
|
+
</Card>
|
|
213
|
+
|
|
214
|
+
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
215
|
+
<DialogContent className="max-w-2xl">
|
|
216
|
+
<DialogHeader><DialogTitle>{t('createTitle')}</DialogTitle></DialogHeader>
|
|
217
|
+
<div className="space-y-4">
|
|
218
|
+
<div className="grid grid-cols-2 gap-4">
|
|
219
|
+
<div className="space-y-2">
|
|
220
|
+
<Label>{t('col.type')}</Label>
|
|
221
|
+
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v as QuestionType })}>
|
|
222
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
223
|
+
<SelectContent>
|
|
224
|
+
{QUESTION_TYPES.map((qt) => (
|
|
225
|
+
<SelectItem key={qt} value={qt}>{t(`types.${qt.toLowerCase()}`)}</SelectItem>
|
|
226
|
+
))}
|
|
227
|
+
</SelectContent>
|
|
228
|
+
</Select>
|
|
229
|
+
</div>
|
|
230
|
+
<div className="space-y-2">
|
|
231
|
+
<Label>{t('col.difficulty')}</Label>
|
|
232
|
+
<Select value={String(form.difficulty ?? 3)} onValueChange={(v) => setForm({ ...form, difficulty: Number(v) })}>
|
|
233
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
234
|
+
<SelectContent>
|
|
235
|
+
{DIFFICULTIES.map((d) => <SelectItem key={d} value={String(d)}>{difficultyLabel[d]}</SelectItem>)}
|
|
236
|
+
</SelectContent>
|
|
237
|
+
</Select>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
<div className="space-y-2">
|
|
241
|
+
<Label>{t('col.content')}</Label>
|
|
242
|
+
<Textarea rows={4} placeholder={t('contentPlaceholder')}
|
|
243
|
+
value={form.content ?? ''}
|
|
244
|
+
onChange={(e) => setForm({ ...form, content: e.target.value })} />
|
|
245
|
+
</div>
|
|
246
|
+
<div className="space-y-2">
|
|
247
|
+
<Label>{t('col.score')}</Label>
|
|
248
|
+
<Input type="number" min={0} step={0.5}
|
|
249
|
+
value={form.defaultScore ?? 1}
|
|
250
|
+
onChange={(e) => setForm({ ...form, defaultScore: Number(e.target.value) })} />
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
<DialogFooter>
|
|
254
|
+
<Button variant="outline" onClick={() => setCreateOpen(false)}>{tc('cancel')}</Button>
|
|
255
|
+
<Button onClick={handleCreate} disabled={saving}>
|
|
256
|
+
{saving ? tc('saving') : tc('save')}
|
|
257
|
+
</Button>
|
|
258
|
+
</DialogFooter>
|
|
259
|
+
</DialogContent>
|
|
260
|
+
</Dialog>
|
|
261
|
+
</div>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { BarChart2, Trophy, Users, TrendingUp } 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 { Card, CardContent, CardHeader, CardTitle } from '@octo-cyber/ui/components/ui/card'
|
|
10
|
+
import { Badge } from '@octo-cyber/ui/components/ui/badge'
|
|
11
|
+
import {
|
|
12
|
+
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
13
|
+
} from '@octo-cyber/ui/components/ui/select'
|
|
14
|
+
import {
|
|
15
|
+
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
16
|
+
} from '@octo-cyber/ui/components/ui/table'
|
|
17
|
+
|
|
18
|
+
import { statisticsApi } from '../services/statistics-service'
|
|
19
|
+
import { examApi } from '../services/exam-service'
|
|
20
|
+
import type { ExamStats, RankingEntry, QuizExam } from '../types/quiz'
|
|
21
|
+
|
|
22
|
+
export default function StatisticsPage() {
|
|
23
|
+
const t = useTranslations('quiz.statistics')
|
|
24
|
+
const tc = useTranslations('common')
|
|
25
|
+
|
|
26
|
+
const [exams, setExams] = useState<QuizExam[]>([])
|
|
27
|
+
const [selectedExamId, setSelectedExamId] = useState('')
|
|
28
|
+
const [stats, setStats] = useState<ExamStats | null>(null)
|
|
29
|
+
const [ranking, setRanking] = useState<RankingEntry[]>([])
|
|
30
|
+
const [loading, setLoading] = useState(false)
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
examApi.list({ pageSize: '100' }).then((r) => {
|
|
34
|
+
setExams(r.items)
|
|
35
|
+
if (r.items.length > 0) setSelectedExamId(r.items[0].id)
|
|
36
|
+
}).catch(() => {})
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!selectedExamId) return
|
|
41
|
+
setLoading(true)
|
|
42
|
+
Promise.all([
|
|
43
|
+
statisticsApi.getExamStats(selectedExamId),
|
|
44
|
+
statisticsApi.getRanking(selectedExamId),
|
|
45
|
+
]).then(([s, r]) => {
|
|
46
|
+
setStats(s)
|
|
47
|
+
setRanking(r)
|
|
48
|
+
}).catch(() => {
|
|
49
|
+
toast.error(tc('loadDataFailed'))
|
|
50
|
+
}).finally(() => setLoading(false))
|
|
51
|
+
}, [selectedExamId, tc])
|
|
52
|
+
|
|
53
|
+
const kpis = stats ? [
|
|
54
|
+
{ label: t('totalAttempts'), value: stats.totalAttempts, icon: Users, color: 'text-blue-500' },
|
|
55
|
+
{ label: t('gradedAttempts'), value: stats.gradedAttempts, icon: BarChart2, color: 'text-green-500' },
|
|
56
|
+
{ label: t('averageScore'), value: stats.averageScore.toFixed(1), icon: TrendingUp, color: 'text-yellow-500' },
|
|
57
|
+
{ label: t('passRate'), value: `${(stats.passRate * 100).toFixed(0)}%`, icon: Trophy, color: 'text-purple-500' },
|
|
58
|
+
] : []
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="space-y-6">
|
|
62
|
+
<PageHeader title={t('title')} description={t('description')}>
|
|
63
|
+
<Select value={selectedExamId} onValueChange={setSelectedExamId}>
|
|
64
|
+
<SelectTrigger className="w-64">
|
|
65
|
+
<SelectValue placeholder={t('selectExam')} />
|
|
66
|
+
</SelectTrigger>
|
|
67
|
+
<SelectContent>
|
|
68
|
+
{exams.map((e) => <SelectItem key={e.id} value={e.id}>{e.title}</SelectItem>)}
|
|
69
|
+
</SelectContent>
|
|
70
|
+
</Select>
|
|
71
|
+
</PageHeader>
|
|
72
|
+
|
|
73
|
+
{loading ? (
|
|
74
|
+
<div className="text-center py-12 text-muted-foreground">{tc('loading')}</div>
|
|
75
|
+
) : !stats ? (
|
|
76
|
+
<div className="text-center py-12 text-muted-foreground">{t('selectExamHint')}</div>
|
|
77
|
+
) : (
|
|
78
|
+
<>
|
|
79
|
+
<div className="grid gap-4 grid-cols-2 md:grid-cols-4">
|
|
80
|
+
{kpis.map((kpi) => (
|
|
81
|
+
<Card key={kpi.label} className="dark:bg-zinc-900 dark:border-zinc-800">
|
|
82
|
+
<CardContent className="pt-6">
|
|
83
|
+
<div className="flex items-center gap-3">
|
|
84
|
+
<kpi.icon className={`h-8 w-8 ${kpi.color}`} />
|
|
85
|
+
<div>
|
|
86
|
+
<p className="text-sm text-muted-foreground">{kpi.label}</p>
|
|
87
|
+
<p className="text-2xl font-bold">{kpi.value}</p>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</CardContent>
|
|
91
|
+
</Card>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
96
|
+
<Card className="dark:bg-zinc-900 dark:border-zinc-800">
|
|
97
|
+
<CardHeader><CardTitle className="text-base">{t('scoreDistribution')}</CardTitle></CardHeader>
|
|
98
|
+
<CardContent>
|
|
99
|
+
<div className="space-y-3">
|
|
100
|
+
{stats.scoreDistribution.map((d) => {
|
|
101
|
+
const maxCount = Math.max(...stats.scoreDistribution.map((x) => x.count), 1)
|
|
102
|
+
const pct = (d.count / maxCount) * 100
|
|
103
|
+
return (
|
|
104
|
+
<div key={d.range} className="flex items-center gap-3">
|
|
105
|
+
<span className="text-sm w-16 shrink-0">{d.range}</span>
|
|
106
|
+
<div className="flex-1 bg-muted rounded-full h-4 overflow-hidden">
|
|
107
|
+
<div
|
|
108
|
+
className="h-full bg-primary transition-all duration-500"
|
|
109
|
+
style={{ width: `${pct}%` }}
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
<span className="text-sm w-8 text-right">{d.count}</span>
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
})}
|
|
116
|
+
</div>
|
|
117
|
+
</CardContent>
|
|
118
|
+
</Card>
|
|
119
|
+
|
|
120
|
+
<Card className="dark:bg-zinc-900 dark:border-zinc-800">
|
|
121
|
+
<CardHeader>
|
|
122
|
+
<CardTitle className="text-base flex items-center gap-2">
|
|
123
|
+
<Trophy className="h-4 w-4 text-yellow-500" />
|
|
124
|
+
{t('ranking')}
|
|
125
|
+
</CardTitle>
|
|
126
|
+
</CardHeader>
|
|
127
|
+
<CardContent>
|
|
128
|
+
{ranking.length === 0 ? (
|
|
129
|
+
<p className="text-sm text-muted-foreground text-center py-4">{t('noRanking')}</p>
|
|
130
|
+
) : (
|
|
131
|
+
<Table>
|
|
132
|
+
<TableHeader>
|
|
133
|
+
<TableRow>
|
|
134
|
+
<TableHead className="w-16">{t('rank')}</TableHead>
|
|
135
|
+
<TableHead>{t('userId')}</TableHead>
|
|
136
|
+
<TableHead className="w-20 text-right">{t('score')}</TableHead>
|
|
137
|
+
</TableRow>
|
|
138
|
+
</TableHeader>
|
|
139
|
+
<TableBody>
|
|
140
|
+
{ranking.map((entry) => (
|
|
141
|
+
<TableRow key={entry.userId}>
|
|
142
|
+
<TableCell>
|
|
143
|
+
{entry.rank <= 3 ? (
|
|
144
|
+
<Badge className={entry.rank === 1 ? 'bg-yellow-500' : entry.rank === 2 ? 'bg-gray-400' : 'bg-amber-600'}>
|
|
145
|
+
#{entry.rank}
|
|
146
|
+
</Badge>
|
|
147
|
+
) : `#${entry.rank}`}
|
|
148
|
+
</TableCell>
|
|
149
|
+
<TableCell className="font-mono text-sm">{entry.userId.slice(0, 8)}...</TableCell>
|
|
150
|
+
<TableCell className="text-right font-bold">{entry.score}</TableCell>
|
|
151
|
+
</TableRow>
|
|
152
|
+
))}
|
|
153
|
+
</TableBody>
|
|
154
|
+
</Table>
|
|
155
|
+
)}
|
|
156
|
+
</CardContent>
|
|
157
|
+
</Card>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<Card className="dark:bg-zinc-900 dark:border-zinc-800">
|
|
161
|
+
<CardContent className="pt-6">
|
|
162
|
+
<div className="flex flex-wrap gap-6 text-sm">
|
|
163
|
+
<div>
|
|
164
|
+
<span className="text-muted-foreground">{t('highestScore')}: </span>
|
|
165
|
+
<strong className="text-lg">{stats.highestScore}</strong>
|
|
166
|
+
</div>
|
|
167
|
+
<div>
|
|
168
|
+
<span className="text-muted-foreground">{t('lowestScore')}: </span>
|
|
169
|
+
<strong className="text-lg">{stats.lowestScore}</strong>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</CardContent>
|
|
173
|
+
</Card>
|
|
174
|
+
</>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|