@c2-markdown-ui/react 0.4.3 → 0.4.4
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/README.md +6 -24
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3733 -4365
- package/dist/lib/MarkdownUI.d.ts +10 -0
- package/dist/lib/MarkdownUI.d.ts.map +1 -0
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/widgets/Button.d.ts +15 -0
- package/dist/widgets/Button.d.ts.map +1 -0
- package/dist/widgets/ButtonGroup.d.ts +11 -0
- package/dist/widgets/ButtonGroup.d.ts.map +1 -0
- package/dist/widgets/Cascader.d.ts +34 -0
- package/dist/widgets/Cascader.d.ts.map +1 -0
- package/dist/widgets/Chart.d.ts +20 -0
- package/dist/widgets/Chart.d.ts.map +1 -0
- package/dist/widgets/DatePicker.d.ts +28 -0
- package/dist/widgets/DatePicker.d.ts.map +1 -0
- package/dist/widgets/Form.d.ts +47 -0
- package/dist/widgets/Form.d.ts.map +1 -0
- package/dist/widgets/Incomplete.d.ts +3 -0
- package/dist/widgets/Incomplete.d.ts.map +1 -0
- package/dist/widgets/MultipleChoiceQuestion.d.ts +15 -0
- package/dist/widgets/MultipleChoiceQuestion.d.ts.map +1 -0
- package/dist/widgets/NumberInput.d.ts +26 -0
- package/dist/widgets/NumberInput.d.ts.map +1 -0
- package/dist/widgets/Quiz.d.ts +37 -0
- package/dist/widgets/Quiz.d.ts.map +1 -0
- package/dist/widgets/Radio.d.ts +24 -0
- package/dist/widgets/Radio.d.ts.map +1 -0
- package/dist/widgets/Select.d.ts +15 -0
- package/dist/widgets/Select.d.ts.map +1 -0
- package/dist/widgets/SelectMulti.d.ts +20 -0
- package/dist/widgets/SelectMulti.d.ts.map +1 -0
- package/dist/widgets/ShortAnswerQuestion.d.ts +16 -0
- package/dist/widgets/ShortAnswerQuestion.d.ts.map +1 -0
- package/dist/widgets/Slider.d.ts +13 -0
- package/dist/widgets/Slider.d.ts.map +1 -0
- package/dist/widgets/Switch.d.ts +17 -0
- package/dist/widgets/Switch.d.ts.map +1 -0
- package/dist/widgets/TextInput.d.ts +11 -0
- package/dist/widgets/TextInput.d.ts.map +1 -0
- package/dist/widgets/Widget.d.ts +7 -0
- package/dist/widgets/Widget.d.ts.map +1 -0
- package/dist/widgets/chartRemoteData.d.ts +18 -0
- package/dist/widgets/chartRemoteData.d.ts.map +1 -0
- package/dist/widgets/choiceUtils.d.ts +24 -0
- package/dist/widgets/choiceUtils.d.ts.map +1 -0
- package/dist/widgets/index.d.ts +18 -0
- package/dist/widgets/index.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/lib/MarkdownUI.tsx +9 -47
- package/src/lib/widgets.css +1372 -2188
- package/src/widgets/Button.tsx +39 -39
- package/src/widgets/ButtonGroup.tsx +47 -47
- package/src/widgets/Cascader.tsx +2 -3
- package/src/widgets/Chart.tsx +237 -237
- package/src/widgets/DatePicker.tsx +434 -451
- package/src/widgets/Form.tsx +372 -512
- package/src/widgets/Incomplete.tsx +3 -3
- package/src/widgets/MultipleChoiceQuestion.tsx +82 -82
- package/src/widgets/Quiz.tsx +320 -320
- package/src/widgets/Radio.tsx +26 -57
- package/src/widgets/Select.tsx +142 -178
- package/src/widgets/SelectMulti.tsx +72 -105
- package/src/widgets/ShortAnswerQuestion.tsx +101 -101
- package/src/widgets/Slider.tsx +56 -70
- package/src/widgets/Switch.tsx +2 -14
- package/src/widgets/TextInput.tsx +38 -76
- package/src/widgets/Widget.tsx +82 -130
- package/src/widgets/choiceUtils.ts +45 -90
- package/src/widgets/index.ts +16 -17
- package/src/widgets/TreeSelect.tsx +0 -649
package/src/widgets/Quiz.tsx
CHANGED
|
@@ -1,320 +1,320 @@
|
|
|
1
|
-
import React, { useState, useMemo } from 'react';
|
|
2
|
-
import { parseChoices } from './choiceUtils';
|
|
3
|
-
|
|
4
|
-
export interface QuizProps {
|
|
5
|
-
title: string;
|
|
6
|
-
questions: QuizQuestion[];
|
|
7
|
-
showScore?: boolean;
|
|
8
|
-
showProgress?: boolean;
|
|
9
|
-
passingScore?: number;
|
|
10
|
-
/** 是否禁用:级联禁用所有题目 */
|
|
11
|
-
disabled?: boolean;
|
|
12
|
-
onchange: (value: QuizState) => void;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface QuizQuestion {
|
|
16
|
-
id: string;
|
|
17
|
-
type: "mcq" | "short-answer";
|
|
18
|
-
question: string;
|
|
19
|
-
points: number;
|
|
20
|
-
// MCQ specific
|
|
21
|
-
choices?: string[];
|
|
22
|
-
correctAnswer?: string;
|
|
23
|
-
// Short answer specific
|
|
24
|
-
placeholder?: string;
|
|
25
|
-
correctAnswers?: string[];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface QuizAnswer {
|
|
29
|
-
answer: string;
|
|
30
|
-
isCorrect: boolean;
|
|
31
|
-
points: number;
|
|
32
|
-
submitted: boolean;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface QuizState {
|
|
36
|
-
answers: Record<string, QuizAnswer>;
|
|
37
|
-
totalScore: number;
|
|
38
|
-
maxScore: number;
|
|
39
|
-
questionsAnswered: number;
|
|
40
|
-
isCompleted: boolean;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export const Quiz: React.FC<QuizProps> = ({
|
|
44
|
-
title,
|
|
45
|
-
questions,
|
|
46
|
-
showScore = true,
|
|
47
|
-
showProgress = true,
|
|
48
|
-
passingScore,
|
|
49
|
-
disabled = false,
|
|
50
|
-
onchange
|
|
51
|
-
}) => {
|
|
52
|
-
const [answers, setAnswers] = useState<Record<string, QuizAnswer>>({});
|
|
53
|
-
const [draftInputs, setDraftInputs] = useState<Record<string, string>>({});
|
|
54
|
-
|
|
55
|
-
const quizState = useMemo<QuizState>(() => {
|
|
56
|
-
const maxScore = questions.reduce((sum, q) => sum + q.points, 0);
|
|
57
|
-
const totalScore = Object.values(answers).reduce((sum, a) => sum + a.points, 0);
|
|
58
|
-
const questionsAnswered = Object.keys(answers).length;
|
|
59
|
-
const isCompleted = questionsAnswered === questions.length;
|
|
60
|
-
|
|
61
|
-
return {
|
|
62
|
-
answers,
|
|
63
|
-
totalScore,
|
|
64
|
-
maxScore,
|
|
65
|
-
questionsAnswered,
|
|
66
|
-
isCompleted
|
|
67
|
-
};
|
|
68
|
-
}, [answers, questions]);
|
|
69
|
-
|
|
70
|
-
const handleQuestionAnswer = (questionId: string, userAnswer: string) => {
|
|
71
|
-
if (disabled) return;
|
|
72
|
-
const question = questions.find(q => q.id === questionId);
|
|
73
|
-
if (!question || answers[questionId]?.submitted) return;
|
|
74
|
-
|
|
75
|
-
let isCorrect = false;
|
|
76
|
-
|
|
77
|
-
if (question.type === "mcq") {
|
|
78
|
-
isCorrect = question.correctAnswer === userAnswer;
|
|
79
|
-
} else if (question.type === "short-answer") {
|
|
80
|
-
if (question.correctAnswers) {
|
|
81
|
-
isCorrect = question.correctAnswers.some(
|
|
82
|
-
correct => correct.toLowerCase() === userAnswer.toLowerCase()
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const newAnswer: QuizAnswer = {
|
|
88
|
-
answer: userAnswer,
|
|
89
|
-
isCorrect,
|
|
90
|
-
points: isCorrect ? question.points : 0,
|
|
91
|
-
submitted: true
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const newAnswers = { ...answers, [questionId]: newAnswer };
|
|
95
|
-
setAnswers(newAnswers);
|
|
96
|
-
|
|
97
|
-
// Trigger onChange with updated state
|
|
98
|
-
const updatedState = {
|
|
99
|
-
answers: newAnswers,
|
|
100
|
-
totalScore: Object.values(newAnswers).reduce((sum, a) => sum + a.points, 0),
|
|
101
|
-
maxScore: quizState.maxScore,
|
|
102
|
-
questionsAnswered: Object.keys(newAnswers).length,
|
|
103
|
-
isCompleted: Object.keys(newAnswers).length === questions.length
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
onchange(updatedState);
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
const getProgressPercentage = () => {
|
|
110
|
-
return Math.round((quizState.questionsAnswered / questions.length) * 100);
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const getScorePercentage = () => {
|
|
114
|
-
return quizState.maxScore > 0 ? Math.round((quizState.totalScore / quizState.maxScore) * 100) : 0;
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const isPassing = () => {
|
|
118
|
-
return passingScore ? getScorePercentage() >= passingScore : null;
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
const renderMCQQuestion = (question: QuizQuestion) => {
|
|
122
|
-
const answer = answers[question.id];
|
|
123
|
-
const isAnswered = answer?.submitted;
|
|
124
|
-
const hasCorrect = typeof question.correctAnswer !== 'undefined';
|
|
125
|
-
// mcq choices 支持 key:label 格式:value(key) 是取值,label 是展示文案
|
|
126
|
-
const items = parseChoices(question.choices ?? []);
|
|
127
|
-
// 反馈文案:correctAnswer 是 key,展示时映射回 label
|
|
128
|
-
const correctLabel = hasCorrect
|
|
129
|
-
? (items.find(i => i.value === question.correctAnswer)?.label ?? question.correctAnswer as string)
|
|
130
|
-
: '';
|
|
131
|
-
|
|
132
|
-
return (
|
|
133
|
-
<div key={question.id} className="quiz-question">
|
|
134
|
-
<div className="quiz-question-header">
|
|
135
|
-
<h3 className="quiz-question-text">{question.question}</h3>
|
|
136
|
-
<span className="quiz-question-points">{question.points} pts</span>
|
|
137
|
-
</div>
|
|
138
|
-
|
|
139
|
-
<div className="quiz-choices">
|
|
140
|
-
{items.map((item, index) => {
|
|
141
|
-
let choiceClass = 'quiz-choice';
|
|
142
|
-
|
|
143
|
-
if (isAnswered && hasCorrect) {
|
|
144
|
-
if (item.value === question.correctAnswer) {
|
|
145
|
-
choiceClass += ' quiz-choice-correct';
|
|
146
|
-
} else if (item.value === answer.answer && item.value !== question.correctAnswer) {
|
|
147
|
-
choiceClass += ' quiz-choice-incorrect';
|
|
148
|
-
}
|
|
149
|
-
} else if (item.value === answer?.answer) {
|
|
150
|
-
choiceClass += ' quiz-choice-selected';
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return (
|
|
154
|
-
<button
|
|
155
|
-
key={index}
|
|
156
|
-
className={choiceClass}
|
|
157
|
-
onClick={() => handleQuestionAnswer(question.id, item.value)}
|
|
158
|
-
disabled={disabled || isAnswered}
|
|
159
|
-
>
|
|
160
|
-
{item.label}
|
|
161
|
-
</button>
|
|
162
|
-
);
|
|
163
|
-
})}
|
|
164
|
-
</div>
|
|
165
|
-
|
|
166
|
-
{isAnswered && (
|
|
167
|
-
hasCorrect ? (
|
|
168
|
-
<div className={`quiz-feedback ${answer.isCorrect ? 'correct' : 'incorrect'}`}>
|
|
169
|
-
{answer.isCorrect
|
|
170
|
-
? `✓ Correct! (+${answer.points} pts)`
|
|
171
|
-
: `✗ Incorrect. The correct answer is: ${correctLabel}`}
|
|
172
|
-
</div>
|
|
173
|
-
) : (
|
|
174
|
-
<div className="quiz-feedback">
|
|
175
|
-
{`You selected: ${answer.answer}`}
|
|
176
|
-
</div>
|
|
177
|
-
)
|
|
178
|
-
)}
|
|
179
|
-
</div>
|
|
180
|
-
);
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
const renderShortAnswerQuestion = (question: QuizQuestion) => {
|
|
184
|
-
const answer = answers[question.id];
|
|
185
|
-
const isAnswered = answer?.submitted;
|
|
186
|
-
const hasAnswers = (question.correctAnswers && question.correctAnswers.length > 0) || false;
|
|
187
|
-
const inputValue = draftInputs[question.id] ?? '';
|
|
188
|
-
|
|
189
|
-
const handleSubmit = () => {
|
|
190
|
-
if (disabled) return;
|
|
191
|
-
if (!inputValue.trim() || isAnswered) return;
|
|
192
|
-
handleQuestionAnswer(question.id, inputValue.trim());
|
|
193
|
-
// Clear draft after submission
|
|
194
|
-
setDraftInputs(prev => {
|
|
195
|
-
const next = { ...prev };
|
|
196
|
-
delete next[question.id];
|
|
197
|
-
return next;
|
|
198
|
-
});
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
202
|
-
if (e.key === 'Enter' && !isAnswered && !disabled) {
|
|
203
|
-
handleSubmit();
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
return (
|
|
208
|
-
<div key={question.id} className="quiz-question">
|
|
209
|
-
<div className="quiz-question-header">
|
|
210
|
-
<h3 className="quiz-question-text">{question.question}</h3>
|
|
211
|
-
<span className="quiz-question-points">{question.points} pts</span>
|
|
212
|
-
</div>
|
|
213
|
-
|
|
214
|
-
<div className="quiz-input-container">
|
|
215
|
-
<input
|
|
216
|
-
type="text"
|
|
217
|
-
value={isAnswered ? answer.answer : inputValue}
|
|
218
|
-
onChange={(e) => {
|
|
219
|
-
if (!isAnswered && !disabled) {
|
|
220
|
-
const val = e.target.value;
|
|
221
|
-
setDraftInputs(prev => ({ ...prev, [question.id]: val }));
|
|
222
|
-
}
|
|
223
|
-
}}
|
|
224
|
-
onKeyDown={handleKeyDown}
|
|
225
|
-
placeholder={question.placeholder || "Type your answer here..."}
|
|
226
|
-
disabled={disabled || isAnswered}
|
|
227
|
-
className={`quiz-input ${
|
|
228
|
-
isAnswered
|
|
229
|
-
? hasAnswers
|
|
230
|
-
? (answer.isCorrect ? 'quiz-input-correct' : 'quiz-input-incorrect')
|
|
231
|
-
: ''
|
|
232
|
-
: ''
|
|
233
|
-
}`}
|
|
234
|
-
/>
|
|
235
|
-
{!isAnswered && (
|
|
236
|
-
<button
|
|
237
|
-
onClick={handleSubmit}
|
|
238
|
-
disabled={disabled || !inputValue.trim()}
|
|
239
|
-
className="quiz-submit"
|
|
240
|
-
>
|
|
241
|
-
Submit
|
|
242
|
-
</button>
|
|
243
|
-
)}
|
|
244
|
-
</div>
|
|
245
|
-
|
|
246
|
-
{isAnswered && (
|
|
247
|
-
hasAnswers ? (
|
|
248
|
-
<div className={`quiz-feedback ${answer.isCorrect ? 'correct' : 'incorrect'}`}>
|
|
249
|
-
{answer.isCorrect
|
|
250
|
-
? `✓ Correct! (+${answer.points} pts)`
|
|
251
|
-
: `✗ Incorrect. Accepted answers: ${question.correctAnswers?.join(', ') || 'N/A'}`}
|
|
252
|
-
</div>
|
|
253
|
-
) : (
|
|
254
|
-
<div className="quiz-feedback">
|
|
255
|
-
{`Your answer: ${answer.answer}`}
|
|
256
|
-
</div>
|
|
257
|
-
)
|
|
258
|
-
)}
|
|
259
|
-
</div>
|
|
260
|
-
);
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
return (
|
|
264
|
-
<div className="widget-quiz">
|
|
265
|
-
{/* Quiz Header */}
|
|
266
|
-
<div className="quiz-header">
|
|
267
|
-
<h2 className="quiz-title">{title}</h2>
|
|
268
|
-
|
|
269
|
-
<div className="quiz-stats">
|
|
270
|
-
{showProgress && (
|
|
271
|
-
<div className="quiz-progress">
|
|
272
|
-
<span className="quiz-progress-text">
|
|
273
|
-
Progress: {quizState.questionsAnswered}/{questions.length} questions
|
|
274
|
-
</span>
|
|
275
|
-
<div className="quiz-progress-bar">
|
|
276
|
-
<div
|
|
277
|
-
className="quiz-progress-fill"
|
|
278
|
-
style={{ width: `${getProgressPercentage()}%` }}
|
|
279
|
-
/>
|
|
280
|
-
</div>
|
|
281
|
-
</div>
|
|
282
|
-
)}
|
|
283
|
-
|
|
284
|
-
{showScore && (
|
|
285
|
-
<div className="quiz-score">
|
|
286
|
-
<span className="quiz-score-text">
|
|
287
|
-
Score: {quizState.totalScore}/{quizState.maxScore} ({getScorePercentage()}%)
|
|
288
|
-
</span>
|
|
289
|
-
</div>
|
|
290
|
-
)}
|
|
291
|
-
</div>
|
|
292
|
-
</div>
|
|
293
|
-
|
|
294
|
-
{/* Questions */}
|
|
295
|
-
<div className="quiz-questions">
|
|
296
|
-
{questions.map((question) =>
|
|
297
|
-
question.type === "mcq"
|
|
298
|
-
? renderMCQQuestion(question)
|
|
299
|
-
: renderShortAnswerQuestion(question)
|
|
300
|
-
)}
|
|
301
|
-
</div>
|
|
302
|
-
|
|
303
|
-
{/* Quiz Summary */}
|
|
304
|
-
{quizState.isCompleted && (
|
|
305
|
-
<div className="quiz-summary">
|
|
306
|
-
<h3>Quiz Complete!</h3>
|
|
307
|
-
<div className="quiz-final-score">
|
|
308
|
-
Final Score: {quizState.totalScore}/{quizState.maxScore} points ({getScorePercentage()}%)
|
|
309
|
-
</div>
|
|
310
|
-
{passingScore && (
|
|
311
|
-
<div className={`quiz-result ${isPassing() ? 'passed' : 'failed'}`}>
|
|
312
|
-
{isPassing() ? '🎉 Passed!' : '❌ Failed'}
|
|
313
|
-
(Passing: {passingScore}%)
|
|
314
|
-
</div>
|
|
315
|
-
)}
|
|
316
|
-
</div>
|
|
317
|
-
)}
|
|
318
|
-
</div>
|
|
319
|
-
);
|
|
320
|
-
};
|
|
1
|
+
import React, { useState, useMemo } from 'react';
|
|
2
|
+
import { parseChoices } from './choiceUtils';
|
|
3
|
+
|
|
4
|
+
export interface QuizProps {
|
|
5
|
+
title: string;
|
|
6
|
+
questions: QuizQuestion[];
|
|
7
|
+
showScore?: boolean;
|
|
8
|
+
showProgress?: boolean;
|
|
9
|
+
passingScore?: number;
|
|
10
|
+
/** 是否禁用:级联禁用所有题目 */
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
onchange: (value: QuizState) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface QuizQuestion {
|
|
16
|
+
id: string;
|
|
17
|
+
type: "mcq" | "short-answer";
|
|
18
|
+
question: string;
|
|
19
|
+
points: number;
|
|
20
|
+
// MCQ specific
|
|
21
|
+
choices?: string[];
|
|
22
|
+
correctAnswer?: string;
|
|
23
|
+
// Short answer specific
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
correctAnswers?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface QuizAnswer {
|
|
29
|
+
answer: string;
|
|
30
|
+
isCorrect: boolean;
|
|
31
|
+
points: number;
|
|
32
|
+
submitted: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface QuizState {
|
|
36
|
+
answers: Record<string, QuizAnswer>;
|
|
37
|
+
totalScore: number;
|
|
38
|
+
maxScore: number;
|
|
39
|
+
questionsAnswered: number;
|
|
40
|
+
isCompleted: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const Quiz: React.FC<QuizProps> = ({
|
|
44
|
+
title,
|
|
45
|
+
questions,
|
|
46
|
+
showScore = true,
|
|
47
|
+
showProgress = true,
|
|
48
|
+
passingScore,
|
|
49
|
+
disabled = false,
|
|
50
|
+
onchange
|
|
51
|
+
}) => {
|
|
52
|
+
const [answers, setAnswers] = useState<Record<string, QuizAnswer>>({});
|
|
53
|
+
const [draftInputs, setDraftInputs] = useState<Record<string, string>>({});
|
|
54
|
+
|
|
55
|
+
const quizState = useMemo<QuizState>(() => {
|
|
56
|
+
const maxScore = questions.reduce((sum, q) => sum + q.points, 0);
|
|
57
|
+
const totalScore = Object.values(answers).reduce((sum, a) => sum + a.points, 0);
|
|
58
|
+
const questionsAnswered = Object.keys(answers).length;
|
|
59
|
+
const isCompleted = questionsAnswered === questions.length;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
answers,
|
|
63
|
+
totalScore,
|
|
64
|
+
maxScore,
|
|
65
|
+
questionsAnswered,
|
|
66
|
+
isCompleted
|
|
67
|
+
};
|
|
68
|
+
}, [answers, questions]);
|
|
69
|
+
|
|
70
|
+
const handleQuestionAnswer = (questionId: string, userAnswer: string) => {
|
|
71
|
+
if (disabled) return;
|
|
72
|
+
const question = questions.find(q => q.id === questionId);
|
|
73
|
+
if (!question || answers[questionId]?.submitted) return;
|
|
74
|
+
|
|
75
|
+
let isCorrect = false;
|
|
76
|
+
|
|
77
|
+
if (question.type === "mcq") {
|
|
78
|
+
isCorrect = question.correctAnswer === userAnswer;
|
|
79
|
+
} else if (question.type === "short-answer") {
|
|
80
|
+
if (question.correctAnswers) {
|
|
81
|
+
isCorrect = question.correctAnswers.some(
|
|
82
|
+
correct => correct.toLowerCase() === userAnswer.toLowerCase()
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const newAnswer: QuizAnswer = {
|
|
88
|
+
answer: userAnswer,
|
|
89
|
+
isCorrect,
|
|
90
|
+
points: isCorrect ? question.points : 0,
|
|
91
|
+
submitted: true
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const newAnswers = { ...answers, [questionId]: newAnswer };
|
|
95
|
+
setAnswers(newAnswers);
|
|
96
|
+
|
|
97
|
+
// Trigger onChange with updated state
|
|
98
|
+
const updatedState = {
|
|
99
|
+
answers: newAnswers,
|
|
100
|
+
totalScore: Object.values(newAnswers).reduce((sum, a) => sum + a.points, 0),
|
|
101
|
+
maxScore: quizState.maxScore,
|
|
102
|
+
questionsAnswered: Object.keys(newAnswers).length,
|
|
103
|
+
isCompleted: Object.keys(newAnswers).length === questions.length
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
onchange(updatedState);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const getProgressPercentage = () => {
|
|
110
|
+
return Math.round((quizState.questionsAnswered / questions.length) * 100);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const getScorePercentage = () => {
|
|
114
|
+
return quizState.maxScore > 0 ? Math.round((quizState.totalScore / quizState.maxScore) * 100) : 0;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const isPassing = () => {
|
|
118
|
+
return passingScore ? getScorePercentage() >= passingScore : null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const renderMCQQuestion = (question: QuizQuestion) => {
|
|
122
|
+
const answer = answers[question.id];
|
|
123
|
+
const isAnswered = answer?.submitted;
|
|
124
|
+
const hasCorrect = typeof question.correctAnswer !== 'undefined';
|
|
125
|
+
// mcq choices 支持 key:label 格式:value(key) 是取值,label 是展示文案
|
|
126
|
+
const items = parseChoices(question.choices ?? []);
|
|
127
|
+
// 反馈文案:correctAnswer 是 key,展示时映射回 label
|
|
128
|
+
const correctLabel = hasCorrect
|
|
129
|
+
? (items.find(i => i.value === question.correctAnswer)?.label ?? question.correctAnswer as string)
|
|
130
|
+
: '';
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div key={question.id} className="quiz-question">
|
|
134
|
+
<div className="quiz-question-header">
|
|
135
|
+
<h3 className="quiz-question-text">{question.question}</h3>
|
|
136
|
+
<span className="quiz-question-points">{question.points} pts</span>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div className="quiz-choices">
|
|
140
|
+
{items.map((item, index) => {
|
|
141
|
+
let choiceClass = 'quiz-choice';
|
|
142
|
+
|
|
143
|
+
if (isAnswered && hasCorrect) {
|
|
144
|
+
if (item.value === question.correctAnswer) {
|
|
145
|
+
choiceClass += ' quiz-choice-correct';
|
|
146
|
+
} else if (item.value === answer.answer && item.value !== question.correctAnswer) {
|
|
147
|
+
choiceClass += ' quiz-choice-incorrect';
|
|
148
|
+
}
|
|
149
|
+
} else if (item.value === answer?.answer) {
|
|
150
|
+
choiceClass += ' quiz-choice-selected';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<button
|
|
155
|
+
key={index}
|
|
156
|
+
className={choiceClass}
|
|
157
|
+
onClick={() => handleQuestionAnswer(question.id, item.value)}
|
|
158
|
+
disabled={disabled || isAnswered}
|
|
159
|
+
>
|
|
160
|
+
{item.label}
|
|
161
|
+
</button>
|
|
162
|
+
);
|
|
163
|
+
})}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{isAnswered && (
|
|
167
|
+
hasCorrect ? (
|
|
168
|
+
<div className={`quiz-feedback ${answer.isCorrect ? 'correct' : 'incorrect'}`}>
|
|
169
|
+
{answer.isCorrect
|
|
170
|
+
? `✓ Correct! (+${answer.points} pts)`
|
|
171
|
+
: `✗ Incorrect. The correct answer is: ${correctLabel}`}
|
|
172
|
+
</div>
|
|
173
|
+
) : (
|
|
174
|
+
<div className="quiz-feedback">
|
|
175
|
+
{`You selected: ${answer.answer}`}
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const renderShortAnswerQuestion = (question: QuizQuestion) => {
|
|
184
|
+
const answer = answers[question.id];
|
|
185
|
+
const isAnswered = answer?.submitted;
|
|
186
|
+
const hasAnswers = (question.correctAnswers && question.correctAnswers.length > 0) || false;
|
|
187
|
+
const inputValue = draftInputs[question.id] ?? '';
|
|
188
|
+
|
|
189
|
+
const handleSubmit = () => {
|
|
190
|
+
if (disabled) return;
|
|
191
|
+
if (!inputValue.trim() || isAnswered) return;
|
|
192
|
+
handleQuestionAnswer(question.id, inputValue.trim());
|
|
193
|
+
// Clear draft after submission
|
|
194
|
+
setDraftInputs(prev => {
|
|
195
|
+
const next = { ...prev };
|
|
196
|
+
delete next[question.id];
|
|
197
|
+
return next;
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
202
|
+
if (e.key === 'Enter' && !isAnswered && !disabled) {
|
|
203
|
+
handleSubmit();
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<div key={question.id} className="quiz-question">
|
|
209
|
+
<div className="quiz-question-header">
|
|
210
|
+
<h3 className="quiz-question-text">{question.question}</h3>
|
|
211
|
+
<span className="quiz-question-points">{question.points} pts</span>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div className="quiz-input-container">
|
|
215
|
+
<input
|
|
216
|
+
type="text"
|
|
217
|
+
value={isAnswered ? answer.answer : inputValue}
|
|
218
|
+
onChange={(e) => {
|
|
219
|
+
if (!isAnswered && !disabled) {
|
|
220
|
+
const val = e.target.value;
|
|
221
|
+
setDraftInputs(prev => ({ ...prev, [question.id]: val }));
|
|
222
|
+
}
|
|
223
|
+
}}
|
|
224
|
+
onKeyDown={handleKeyDown}
|
|
225
|
+
placeholder={question.placeholder || "Type your answer here..."}
|
|
226
|
+
disabled={disabled || isAnswered}
|
|
227
|
+
className={`quiz-input ${
|
|
228
|
+
isAnswered
|
|
229
|
+
? hasAnswers
|
|
230
|
+
? (answer.isCorrect ? 'quiz-input-correct' : 'quiz-input-incorrect')
|
|
231
|
+
: ''
|
|
232
|
+
: ''
|
|
233
|
+
}`}
|
|
234
|
+
/>
|
|
235
|
+
{!isAnswered && (
|
|
236
|
+
<button
|
|
237
|
+
onClick={handleSubmit}
|
|
238
|
+
disabled={disabled || !inputValue.trim()}
|
|
239
|
+
className="quiz-submit"
|
|
240
|
+
>
|
|
241
|
+
Submit
|
|
242
|
+
</button>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{isAnswered && (
|
|
247
|
+
hasAnswers ? (
|
|
248
|
+
<div className={`quiz-feedback ${answer.isCorrect ? 'correct' : 'incorrect'}`}>
|
|
249
|
+
{answer.isCorrect
|
|
250
|
+
? `✓ Correct! (+${answer.points} pts)`
|
|
251
|
+
: `✗ Incorrect. Accepted answers: ${question.correctAnswers?.join(', ') || 'N/A'}`}
|
|
252
|
+
</div>
|
|
253
|
+
) : (
|
|
254
|
+
<div className="quiz-feedback">
|
|
255
|
+
{`Your answer: ${answer.answer}`}
|
|
256
|
+
</div>
|
|
257
|
+
)
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div className="widget-quiz">
|
|
265
|
+
{/* Quiz Header */}
|
|
266
|
+
<div className="quiz-header">
|
|
267
|
+
<h2 className="quiz-title">{title}</h2>
|
|
268
|
+
|
|
269
|
+
<div className="quiz-stats">
|
|
270
|
+
{showProgress && (
|
|
271
|
+
<div className="quiz-progress">
|
|
272
|
+
<span className="quiz-progress-text">
|
|
273
|
+
Progress: {quizState.questionsAnswered}/{questions.length} questions
|
|
274
|
+
</span>
|
|
275
|
+
<div className="quiz-progress-bar">
|
|
276
|
+
<div
|
|
277
|
+
className="quiz-progress-fill"
|
|
278
|
+
style={{ width: `${getProgressPercentage()}%` }}
|
|
279
|
+
/>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
)}
|
|
283
|
+
|
|
284
|
+
{showScore && (
|
|
285
|
+
<div className="quiz-score">
|
|
286
|
+
<span className="quiz-score-text">
|
|
287
|
+
Score: {quizState.totalScore}/{quizState.maxScore} ({getScorePercentage()}%)
|
|
288
|
+
</span>
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* Questions */}
|
|
295
|
+
<div className="quiz-questions">
|
|
296
|
+
{questions.map((question) =>
|
|
297
|
+
question.type === "mcq"
|
|
298
|
+
? renderMCQQuestion(question)
|
|
299
|
+
: renderShortAnswerQuestion(question)
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
{/* Quiz Summary */}
|
|
304
|
+
{quizState.isCompleted && (
|
|
305
|
+
<div className="quiz-summary">
|
|
306
|
+
<h3>Quiz Complete!</h3>
|
|
307
|
+
<div className="quiz-final-score">
|
|
308
|
+
Final Score: {quizState.totalScore}/{quizState.maxScore} points ({getScorePercentage()}%)
|
|
309
|
+
</div>
|
|
310
|
+
{passingScore && (
|
|
311
|
+
<div className={`quiz-result ${isPassing() ? 'passed' : 'failed'}`}>
|
|
312
|
+
{isPassing() ? '🎉 Passed!' : '❌ Failed'}
|
|
313
|
+
(Passing: {passingScore}%)
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
};
|