@c2-markdown-ui/react 0.0.0-stage → 0.4.3
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 +111 -2
- package/dist/index.js +10085 -0
- package/package.json +44 -4
- package/src/index.ts +1 -0
- package/src/lib/MarkdownUI.tsx +111 -0
- package/src/lib/index.ts +3 -0
- package/src/lib/widgets.css +2189 -0
- package/src/widgets/Button.tsx +39 -0
- package/src/widgets/ButtonGroup.tsx +47 -0
- package/src/widgets/Cascader.tsx +400 -0
- package/src/widgets/Chart.tsx +238 -0
- package/src/widgets/DatePicker.tsx +451 -0
- package/src/widgets/Form.tsx +512 -0
- package/src/widgets/Incomplete.tsx +55 -0
- package/src/widgets/MultipleChoiceQuestion.tsx +82 -0
- package/src/widgets/NumberInput.tsx +169 -0
- package/src/widgets/Quiz.tsx +320 -0
- package/src/widgets/Radio.tsx +177 -0
- package/src/widgets/Select.tsx +178 -0
- package/src/widgets/SelectMulti.tsx +105 -0
- package/src/widgets/ShortAnswerQuestion.tsx +101 -0
- package/src/widgets/Slider.tsx +71 -0
- package/src/widgets/Switch.tsx +68 -0
- package/src/widgets/TextInput.tsx +76 -0
- package/src/widgets/TreeSelect.tsx +649 -0
- package/src/widgets/Widget.tsx +131 -0
- package/src/widgets/chartRemoteData.ts +84 -0
- package/src/widgets/choiceUtils.ts +90 -0
- package/src/widgets/index.ts +18 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface NumberInputProps {
|
|
4
|
+
label?: string;
|
|
5
|
+
/** 最小值 */
|
|
6
|
+
min?: number;
|
|
7
|
+
/** 最大值 */
|
|
8
|
+
max?: number;
|
|
9
|
+
/** 步长(点击上下按钮/方向键的增减量),默认 1 */
|
|
10
|
+
step?: number;
|
|
11
|
+
/** 数值精度(小数位数) */
|
|
12
|
+
precision?: number;
|
|
13
|
+
default?: number;
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
hint?: string;
|
|
16
|
+
/** 是否必填:空值提交校验不通过 */
|
|
17
|
+
required?: boolean;
|
|
18
|
+
/** 校验失败提示文案 */
|
|
19
|
+
message?: string;
|
|
20
|
+
/** 外部(form 提交时)传入的错误信息,优先于组件内部即时校验 */
|
|
21
|
+
error?: string;
|
|
22
|
+
/** 是否禁用 */
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
onchange: (value: number | null) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 文本 → 数字:空串或输入中途态返回 null,非法文本返回 null */
|
|
28
|
+
function parseNum(v: string): number | null {
|
|
29
|
+
const s = v.trim();
|
|
30
|
+
if (!s || s === '-' || s === '.' || s === '-.' || s === '+' || s === '+.') return null;
|
|
31
|
+
const n = Number(s);
|
|
32
|
+
return isNaN(n) ? null : n;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const NumberInput: React.FC<NumberInputProps> = ({
|
|
36
|
+
label = '',
|
|
37
|
+
min,
|
|
38
|
+
max,
|
|
39
|
+
step = 1,
|
|
40
|
+
precision,
|
|
41
|
+
default: initial,
|
|
42
|
+
placeholder = '',
|
|
43
|
+
hint = '',
|
|
44
|
+
required = false,
|
|
45
|
+
message = '',
|
|
46
|
+
error = '',
|
|
47
|
+
disabled = false,
|
|
48
|
+
onchange
|
|
49
|
+
}) => {
|
|
50
|
+
// 输入框显示的原始文本:允许输入中途的临时态(如 "-" / "1."),失焦/回车时规范化
|
|
51
|
+
const [text, setText] = useState(initial !== undefined && initial !== null ? formatNum(initial, precision) : '');
|
|
52
|
+
const [localError, setLocalError] = useState('');
|
|
53
|
+
|
|
54
|
+
/** 显示格式化:设置精度时固定位数小数(如 18 → "18.00",antd 行为) */
|
|
55
|
+
function formatNum(v: number, p?: number): string {
|
|
56
|
+
if (p !== undefined && p >= 0) return v.toFixed(p);
|
|
57
|
+
return String(v);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 应用精度四舍五入 + min/max 钳制 */
|
|
61
|
+
const normalize = (n: number): number => {
|
|
62
|
+
if (precision !== undefined && precision >= 0) n = Number(n.toFixed(precision));
|
|
63
|
+
if (min !== undefined && n < min) n = min;
|
|
64
|
+
if (max !== undefined && n > max) n = max;
|
|
65
|
+
return n;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** 校验当前值:返回错误文案或空串 */
|
|
69
|
+
const validate = (v: number | null): string => {
|
|
70
|
+
if (required && v === null) return message || (label ? `请填写${label}` : '该项为必填项');
|
|
71
|
+
return '';
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** 提交:规范化显示文本并对外发值(空/非法 → null) */
|
|
75
|
+
const commit = () => {
|
|
76
|
+
const n = parseNum(text);
|
|
77
|
+
if (n === null) {
|
|
78
|
+
setText('');
|
|
79
|
+
setLocalError(validate(null));
|
|
80
|
+
onchange(null);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const v = normalize(n);
|
|
84
|
+
setText(formatNum(v, precision));
|
|
85
|
+
setLocalError('');
|
|
86
|
+
onchange(v);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** 步进:direction 为 1(加)/ -1(减);空值从 0(或 min/max)起步 */
|
|
90
|
+
const stepBy = (direction: 1 | -1) => {
|
|
91
|
+
const base = parseNum(text);
|
|
92
|
+
const start = base ?? 0;
|
|
93
|
+
const v = normalize(start + step * direction);
|
|
94
|
+
setText(formatNum(v, precision));
|
|
95
|
+
setLocalError('');
|
|
96
|
+
onchange(v);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleKeydown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
100
|
+
if (e.key === 'ArrowUp') {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
stepBy(1);
|
|
103
|
+
} else if (e.key === 'ArrowDown') {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
stepBy(-1);
|
|
106
|
+
} else if (e.key === 'Enter') {
|
|
107
|
+
commit();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const current = parseNum(text);
|
|
112
|
+
const atMax = max !== undefined && current !== null && current >= max;
|
|
113
|
+
const atMin = min !== undefined && current !== null && current <= min;
|
|
114
|
+
const shownError = error || localError;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className={`widget-number-input${shownError ? ' widget-field-error' : ''}`}>
|
|
118
|
+
{label || hint || required ? (
|
|
119
|
+
<div className="widget-label-row">
|
|
120
|
+
{required && <span className="widget-required-mark" aria-hidden="true">*</span>}
|
|
121
|
+
{label && <label>{label}</label>}
|
|
122
|
+
{hint && <span className="widget-hint" data-hint={hint}>ⓘ</span>}
|
|
123
|
+
</div>
|
|
124
|
+
) : null}
|
|
125
|
+
<div className="widget-field-content">
|
|
126
|
+
<div className="ni-wrap">
|
|
127
|
+
<input
|
|
128
|
+
type="text"
|
|
129
|
+
inputMode="decimal"
|
|
130
|
+
autoComplete="off"
|
|
131
|
+
className={shownError ? 'widget-input-error' : undefined}
|
|
132
|
+
value={text}
|
|
133
|
+
placeholder={placeholder}
|
|
134
|
+
onKeyDown={handleKeydown}
|
|
135
|
+
onBlur={commit}
|
|
136
|
+
disabled={disabled}
|
|
137
|
+
onChange={(e) => {
|
|
138
|
+
setText(e.target.value);
|
|
139
|
+
if (localError) setLocalError(validate(parseNum(e.target.value)));
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
<span className="ni-steps" aria-hidden="true">
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
className="ni-step-up"
|
|
146
|
+
onClick={() => stepBy(1)}
|
|
147
|
+
disabled={disabled || atMax}
|
|
148
|
+
aria-label="增加"
|
|
149
|
+
tabIndex={-1}
|
|
150
|
+
>
|
|
151
|
+
<svg viewBox="0 0 10 6" width="10" height="6"><path d="M5 0 L10 6 H0 Z" fill="currentColor" /></svg>
|
|
152
|
+
</button>
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
className="ni-step-down"
|
|
156
|
+
onClick={() => stepBy(-1)}
|
|
157
|
+
disabled={disabled || atMin}
|
|
158
|
+
aria-label="减少"
|
|
159
|
+
tabIndex={-1}
|
|
160
|
+
>
|
|
161
|
+
<svg viewBox="0 0 10 6" width="10" height="6"><path d="M5 6 L0 0 H10 Z" fill="currentColor" /></svg>
|
|
162
|
+
</button>
|
|
163
|
+
</span>
|
|
164
|
+
</div>
|
|
165
|
+
{shownError ? <div className="widget-error-text" role="alert">{shownError}</div> : null}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
};
|
|
@@ -0,0 +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
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { parseChoice, parseChoices, type ChoiceItem } from './choiceUtils';
|
|
3
|
+
|
|
4
|
+
export interface RadioGroupProps {
|
|
5
|
+
/** 选项:字符串数组(每项支持 key:label 格式)或对象数组({key|value,label},取值为 key/value,展示 label) */
|
|
6
|
+
choices: string[] | Array<{ value?: string; key?: string; label?: string }>;
|
|
7
|
+
label?: string;
|
|
8
|
+
default?: string;
|
|
9
|
+
/** 按钮样式(参考 antd Radio optionType:button):渲染为按钮组而非圆点单选 */
|
|
10
|
+
button?: boolean;
|
|
11
|
+
/** 远端数据源 URL:设置后运行时 fetch 拉取选项,choices 作为后备/初始选项 */
|
|
12
|
+
url?: string;
|
|
13
|
+
/** 选项排列方向:vertical 纵向(默认)/ horizontal 横向 */
|
|
14
|
+
direction?: 'vertical' | 'horizontal';
|
|
15
|
+
/** 自定义布局:每行列数(设置后按 N 列网格排列,优先于 direction) */
|
|
16
|
+
columns?: number;
|
|
17
|
+
hint?: string;
|
|
18
|
+
/** 是否必填 */
|
|
19
|
+
required?: boolean;
|
|
20
|
+
/** 校验失败提示文案 */
|
|
21
|
+
message?: string;
|
|
22
|
+
/** 外部(form 提交时)传入的错误信息,优先于组件内部即时校验 */
|
|
23
|
+
error?: string;
|
|
24
|
+
/** 是否禁用 */
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
onchange: (value: string) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 远端响应归一化为选项数组:兼容裸数组与 {data|options|choices|items} 包裹 */
|
|
30
|
+
function normalizeRemoteChoices(json: any): Array<{ value?: string; key?: string; label?: string }> {
|
|
31
|
+
let list = json;
|
|
32
|
+
if (!Array.isArray(json)) {
|
|
33
|
+
list = Array.isArray(json?.data) ? json.data
|
|
34
|
+
: Array.isArray(json?.options) ? json.options
|
|
35
|
+
: Array.isArray(json?.choices) ? json.choices
|
|
36
|
+
: Array.isArray(json?.items) ? json.items
|
|
37
|
+
: [];
|
|
38
|
+
}
|
|
39
|
+
return (list || []).filter((it: any) => it !== null && it !== undefined);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const RadioGroup: React.FC<RadioGroupProps> = ({
|
|
43
|
+
choices,
|
|
44
|
+
label = '',
|
|
45
|
+
default: initial,
|
|
46
|
+
button = false,
|
|
47
|
+
url,
|
|
48
|
+
direction,
|
|
49
|
+
columns,
|
|
50
|
+
hint = '',
|
|
51
|
+
required = false,
|
|
52
|
+
message = '',
|
|
53
|
+
error = '',
|
|
54
|
+
disabled = false,
|
|
55
|
+
onchange
|
|
56
|
+
}) => {
|
|
57
|
+
const localItems = parseChoices(choices);
|
|
58
|
+
// url 远端模式:拉取成功前用本地 choices 兜底;加载失败静默回落,均无数据时显示灰色"暂无数据"
|
|
59
|
+
const [remoteItems, setRemoteItems] = useState<ChoiceItem[] | null>(null);
|
|
60
|
+
const items = remoteItems ?? localItems;
|
|
61
|
+
|
|
62
|
+
// 初始选中:default 可能是 key、label 或 key:label 原文;无 default 时选中第一项(与 button-group 一致)
|
|
63
|
+
const mapInitial = (v: string): string => {
|
|
64
|
+
const hit = items.find(it => it.value === v || it.label === v);
|
|
65
|
+
return hit ? hit.value : parseChoice(v).value;
|
|
66
|
+
};
|
|
67
|
+
const [selected, setSelected] = useState<string>(() => {
|
|
68
|
+
if (initial !== undefined && initial !== null && initial !== '') return mapInitial(String(initial));
|
|
69
|
+
return items[0]?.value ?? '';
|
|
70
|
+
});
|
|
71
|
+
const [localError, setLocalError] = useState('');
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!url) { setRemoteItems(null); return; }
|
|
75
|
+
let cancelled = false;
|
|
76
|
+
(async () => {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(url);
|
|
79
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
80
|
+
const json: any = await res.json();
|
|
81
|
+
if (cancelled) return;
|
|
82
|
+
const list = parseChoices(normalizeRemoteChoices(json));
|
|
83
|
+
if (list.length === 0) throw new Error('响应中没有可用选项');
|
|
84
|
+
setRemoteItems(list);
|
|
85
|
+
} catch {
|
|
86
|
+
// 加载失败:静默回落本地 choices
|
|
87
|
+
}
|
|
88
|
+
})();
|
|
89
|
+
return () => { cancelled = true; };
|
|
90
|
+
}, [url]);
|
|
91
|
+
|
|
92
|
+
const handleChange = (value: string) => {
|
|
93
|
+
if (disabled) return;
|
|
94
|
+
setSelected(value);
|
|
95
|
+
setLocalError(validate(value));
|
|
96
|
+
onchange(value);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** 校验当前值:返回错误文案或空串 */
|
|
100
|
+
const validate = (v: string): string => {
|
|
101
|
+
if (required && v === '') return message || (label ? `请填写${label}` : '请至少选择一项');
|
|
102
|
+
return '';
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const shownError = error || localError;
|
|
106
|
+
|
|
107
|
+
// ---------- 按钮样式(antd RadioButton):复用 widget-button-group 视觉 ----------
|
|
108
|
+
if (button) {
|
|
109
|
+
return (
|
|
110
|
+
<div className={`widget-button-group${shownError ? ' widget-field-error' : ''}`}>
|
|
111
|
+
{label && <label>{label}</label>}
|
|
112
|
+
<div
|
|
113
|
+
role="group"
|
|
114
|
+
aria-label={label}
|
|
115
|
+
className="radio-button-group"
|
|
116
|
+
data-direction={columns ? 'grid' : (direction || 'horizontal')}
|
|
117
|
+
style={columns && columns >= 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined}
|
|
118
|
+
>
|
|
119
|
+
{items.length === 0 && (
|
|
120
|
+
<div style={{ color: 'rgba(0, 0, 0, 0.45)', fontSize: 13 }}>暂无数据</div>
|
|
121
|
+
)}
|
|
122
|
+
{items.map((item) => (
|
|
123
|
+
<button
|
|
124
|
+
key={item.value}
|
|
125
|
+
type="button"
|
|
126
|
+
aria-pressed={selected === item.value}
|
|
127
|
+
onClick={() => handleChange(item.value)}
|
|
128
|
+
disabled={disabled}
|
|
129
|
+
>
|
|
130
|
+
{item.label}
|
|
131
|
+
</button>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
{shownError ? <div className="widget-error-text" role="alert">{shownError}</div> : null}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------- 默认圆点单选样式(antd Radio) ----------
|
|
140
|
+
return (
|
|
141
|
+
<div className={`selector-multi${shownError ? ' widget-field-error' : ''}`}>
|
|
142
|
+
{label || hint || required ? (
|
|
143
|
+
<div className="widget-label-row">
|
|
144
|
+
{required && <span className="widget-required-mark" aria-hidden="true">*</span>}
|
|
145
|
+
{label && <div className="selector-multi-label">{label}</div>}
|
|
146
|
+
{hint && <span className="widget-hint" data-hint={hint}>ⓘ</span>}
|
|
147
|
+
</div>
|
|
148
|
+
) : null}
|
|
149
|
+
<div className="widget-field-content">
|
|
150
|
+
<div
|
|
151
|
+
className="radio-group"
|
|
152
|
+
role="radiogroup"
|
|
153
|
+
aria-label={label}
|
|
154
|
+
data-direction={columns ? 'grid' : (direction || 'vertical')}
|
|
155
|
+
style={columns && columns >= 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined}
|
|
156
|
+
>
|
|
157
|
+
{items.length === 0 && (
|
|
158
|
+
<div style={{ color: 'rgba(0, 0, 0, 0.45)', fontSize: 13 }}>暂无数据</div>
|
|
159
|
+
)}
|
|
160
|
+
{items.map((item) => (
|
|
161
|
+
<label key={item.value} className="radio-item">
|
|
162
|
+
<input
|
|
163
|
+
type="radio"
|
|
164
|
+
value={item.value}
|
|
165
|
+
checked={selected === item.value}
|
|
166
|
+
onChange={() => handleChange(item.value)}
|
|
167
|
+
disabled={disabled}
|
|
168
|
+
/>
|
|
169
|
+
<span>{item.label}</span>
|
|
170
|
+
</label>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
{shownError ? <div className="widget-error-text" role="alert">{shownError}</div> : null}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
};
|