@c2-markdown-ui/react 0.0.0-stage → 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 +93 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9453 -0
- 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 +44 -4
- package/src/index.ts +1 -0
- package/src/lib/MarkdownUI.tsx +73 -0
- package/src/lib/index.ts +3 -0
- package/src/lib/widgets.css +1373 -0
- package/src/widgets/Button.tsx +39 -0
- package/src/widgets/ButtonGroup.tsx +47 -0
- package/src/widgets/Cascader.tsx +399 -0
- package/src/widgets/Chart.tsx +238 -0
- package/src/widgets/DatePicker.tsx +434 -0
- package/src/widgets/Form.tsx +372 -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 +146 -0
- package/src/widgets/Select.tsx +142 -0
- package/src/widgets/SelectMulti.tsx +72 -0
- package/src/widgets/ShortAnswerQuestion.tsx +101 -0
- package/src/widgets/Slider.tsx +57 -0
- package/src/widgets/Switch.tsx +56 -0
- package/src/widgets/TextInput.tsx +38 -0
- package/src/widgets/Widget.tsx +83 -0
- package/src/widgets/chartRemoteData.ts +84 -0
- package/src/widgets/choiceUtils.ts +45 -0
- package/src/widgets/index.ts +17 -0
|
@@ -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,146 @@
|
|
|
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
|
+
/** 是否禁用 */
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
onchange: (value: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 远端响应归一化为选项数组:兼容裸数组与 {data|options|choices|items} 包裹 */
|
|
23
|
+
function normalizeRemoteChoices(json: any): Array<{ value?: string; key?: string; label?: string }> {
|
|
24
|
+
let list = json;
|
|
25
|
+
if (!Array.isArray(json)) {
|
|
26
|
+
list = Array.isArray(json?.data) ? json.data
|
|
27
|
+
: Array.isArray(json?.options) ? json.options
|
|
28
|
+
: Array.isArray(json?.choices) ? json.choices
|
|
29
|
+
: Array.isArray(json?.items) ? json.items
|
|
30
|
+
: [];
|
|
31
|
+
}
|
|
32
|
+
return (list || []).filter((it: any) => it !== null && it !== undefined);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const RadioGroup: React.FC<RadioGroupProps> = ({
|
|
36
|
+
choices,
|
|
37
|
+
label = '',
|
|
38
|
+
default: initial,
|
|
39
|
+
button = false,
|
|
40
|
+
url,
|
|
41
|
+
direction,
|
|
42
|
+
columns,
|
|
43
|
+
disabled = false,
|
|
44
|
+
onchange
|
|
45
|
+
}) => {
|
|
46
|
+
const localItems = parseChoices(choices);
|
|
47
|
+
// url 远端模式:拉取成功前用本地 choices 兜底;加载失败静默回落,均无数据时显示灰色"暂无数据"
|
|
48
|
+
const [remoteItems, setRemoteItems] = useState<ChoiceItem[] | null>(null);
|
|
49
|
+
const items = remoteItems ?? localItems;
|
|
50
|
+
|
|
51
|
+
// 初始选中:default 可能是 key、label 或 key:label 原文;无 default 时选中第一项(与 button-group 一致)
|
|
52
|
+
const mapInitial = (v: string): string => {
|
|
53
|
+
const hit = items.find(it => it.value === v || it.label === v);
|
|
54
|
+
return hit ? hit.value : parseChoice(v).value;
|
|
55
|
+
};
|
|
56
|
+
const [selected, setSelected] = useState<string>(() => {
|
|
57
|
+
if (initial !== undefined && initial !== null && initial !== '') return mapInitial(String(initial));
|
|
58
|
+
return items[0]?.value ?? '';
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!url) { setRemoteItems(null); return; }
|
|
63
|
+
let cancelled = false;
|
|
64
|
+
(async () => {
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(url);
|
|
67
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
68
|
+
const json: any = await res.json();
|
|
69
|
+
if (cancelled) return;
|
|
70
|
+
const list = parseChoices(normalizeRemoteChoices(json));
|
|
71
|
+
if (list.length === 0) throw new Error('响应中没有可用选项');
|
|
72
|
+
setRemoteItems(list);
|
|
73
|
+
} catch {
|
|
74
|
+
// 加载失败:静默回落本地 choices
|
|
75
|
+
}
|
|
76
|
+
})();
|
|
77
|
+
return () => { cancelled = true; };
|
|
78
|
+
}, [url]);
|
|
79
|
+
|
|
80
|
+
const handleChange = (value: string) => {
|
|
81
|
+
if (disabled) return;
|
|
82
|
+
setSelected(value);
|
|
83
|
+
onchange(value);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// ---------- 按钮样式(antd RadioButton):复用 widget-button-group 视觉 ----------
|
|
87
|
+
if (button) {
|
|
88
|
+
return (
|
|
89
|
+
<div className="widget-button-group">
|
|
90
|
+
{label && <label>{label}</label>}
|
|
91
|
+
<div
|
|
92
|
+
role="group"
|
|
93
|
+
aria-label={label}
|
|
94
|
+
className="radio-button-group"
|
|
95
|
+
data-direction={columns ? 'grid' : (direction || 'horizontal')}
|
|
96
|
+
style={columns && columns >= 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined}
|
|
97
|
+
>
|
|
98
|
+
{items.length === 0 && (
|
|
99
|
+
<div style={{ color: '#9ca3af', fontSize: 13 }}>暂无数据</div>
|
|
100
|
+
)}
|
|
101
|
+
{items.map((item) => (
|
|
102
|
+
<button
|
|
103
|
+
key={item.value}
|
|
104
|
+
type="button"
|
|
105
|
+
aria-pressed={selected === item.value}
|
|
106
|
+
onClick={() => handleChange(item.value)}
|
|
107
|
+
disabled={disabled}
|
|
108
|
+
>
|
|
109
|
+
{item.label}
|
|
110
|
+
</button>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------- 默认圆点单选样式(antd Radio) ----------
|
|
118
|
+
return (
|
|
119
|
+
<div className="selector">
|
|
120
|
+
{label && <div className="selector-multi-label">{label}</div>}
|
|
121
|
+
<div
|
|
122
|
+
className="radio-group"
|
|
123
|
+
role="radiogroup"
|
|
124
|
+
aria-label={label}
|
|
125
|
+
data-direction={columns ? 'grid' : (direction || 'vertical')}
|
|
126
|
+
style={columns && columns >= 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined}
|
|
127
|
+
>
|
|
128
|
+
{items.length === 0 && (
|
|
129
|
+
<div style={{ color: '#9ca3af', fontSize: 13 }}>暂无数据</div>
|
|
130
|
+
)}
|
|
131
|
+
{items.map((item) => (
|
|
132
|
+
<label key={item.value} className="radio-item">
|
|
133
|
+
<input
|
|
134
|
+
type="radio"
|
|
135
|
+
value={item.value}
|
|
136
|
+
checked={selected === item.value}
|
|
137
|
+
onChange={() => handleChange(item.value)}
|
|
138
|
+
disabled={disabled}
|
|
139
|
+
/>
|
|
140
|
+
<span>{item.label}</span>
|
|
141
|
+
</label>
|
|
142
|
+
))}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { parseChoices } from './choiceUtils';
|
|
3
|
+
|
|
4
|
+
export interface SelectProps {
|
|
5
|
+
choices: string[];
|
|
6
|
+
label?: string;
|
|
7
|
+
default?: string;
|
|
8
|
+
/** 是否可搜索:开启后下拉面板内置搜索框,按 key/value 与 label 过滤选项 */
|
|
9
|
+
searchable?: boolean;
|
|
10
|
+
/** 搜索框占位文案 */
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
/** 是否禁用 */
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
onchange: (value: string | string[]) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Select: React.FC<SelectProps> = ({
|
|
18
|
+
choices,
|
|
19
|
+
label = '',
|
|
20
|
+
default: initial,
|
|
21
|
+
searchable = false,
|
|
22
|
+
placeholder = '',
|
|
23
|
+
disabled = false,
|
|
24
|
+
onchange
|
|
25
|
+
}) => {
|
|
26
|
+
const items = parseChoices(choices);
|
|
27
|
+
const [value, setValue] = useState(initial ?? items[0]?.value ?? '');
|
|
28
|
+
|
|
29
|
+
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
30
|
+
const newValue = e.target.value;
|
|
31
|
+
setValue(newValue);
|
|
32
|
+
onchange(newValue);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// ---------- searchable 模式:自定义下拉(触发按钮 + 搜索框 + 滚动选项列表) ----------
|
|
36
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
37
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
38
|
+
const [open, setOpen] = useState(false);
|
|
39
|
+
/** 搜索关键字(按 value/key 与 label 过滤,大小写不敏感) */
|
|
40
|
+
const [query, setQuery] = useState('');
|
|
41
|
+
/** 当前选中项展示文案(value 与 label 不同时优先 label) */
|
|
42
|
+
const display = useMemo(() => {
|
|
43
|
+
const hit = items.find(it => it.value === value);
|
|
44
|
+
return hit ? hit.label : '';
|
|
45
|
+
}, [items, value]);
|
|
46
|
+
|
|
47
|
+
const filtered = useMemo(() => {
|
|
48
|
+
const q = query.trim().toLowerCase();
|
|
49
|
+
if (!q) return items;
|
|
50
|
+
return items.filter(it => it.value.toLowerCase().includes(q) || it.label.toLowerCase().includes(q));
|
|
51
|
+
}, [items, query]);
|
|
52
|
+
|
|
53
|
+
// 外部点击关闭面板
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
function onDocClick(e: MouseEvent) {
|
|
56
|
+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
|
57
|
+
}
|
|
58
|
+
document.addEventListener('mousedown', onDocClick);
|
|
59
|
+
return () => document.removeEventListener('mousedown', onDocClick);
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
// 打开面板时聚焦搜索框并清空关键字
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (open && searchInputRef.current) {
|
|
65
|
+
setQuery('');
|
|
66
|
+
searchInputRef.current.focus();
|
|
67
|
+
}
|
|
68
|
+
}, [open]);
|
|
69
|
+
|
|
70
|
+
function toggleOpen() {
|
|
71
|
+
if (disabled) return;
|
|
72
|
+
setOpen(prev => !prev);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 选中某选项:回填 value 并关闭面板 */
|
|
76
|
+
function pick(v: string) {
|
|
77
|
+
if (disabled) return;
|
|
78
|
+
setValue(v);
|
|
79
|
+
setOpen(false);
|
|
80
|
+
onchange(v);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (searchable) {
|
|
84
|
+
return (
|
|
85
|
+
<div className="selector" ref={rootRef}>
|
|
86
|
+
{label && <label>{label}</label>}
|
|
87
|
+
<div className="widget-field-content widget-select-content">
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
className="cas-input"
|
|
91
|
+
onClick={toggleOpen}
|
|
92
|
+
aria-expanded={open}
|
|
93
|
+
disabled={disabled}
|
|
94
|
+
>
|
|
95
|
+
<span className={`cas-input-text${display ? '' : ' cas-placeholder'}`}>{display || placeholder || '请选择'}</span>
|
|
96
|
+
<span className={`cas-arrow${open ? ' cas-arrow-open' : ''}`} aria-hidden="true">▾</span>
|
|
97
|
+
</button>
|
|
98
|
+
{open && (
|
|
99
|
+
<div className="cas-panel cas-panel-single">
|
|
100
|
+
<div className="cas-search">
|
|
101
|
+
<input
|
|
102
|
+
ref={searchInputRef}
|
|
103
|
+
type="text"
|
|
104
|
+
className="cas-search-input"
|
|
105
|
+
value={query}
|
|
106
|
+
placeholder="搜索选项…"
|
|
107
|
+
onChange={e => setQuery(e.target.value)}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
<div className="cas-menu cas-menu-full" role="menu">
|
|
111
|
+
{filtered.map(it => (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
key={it.value}
|
|
115
|
+
className={`cas-option${value === it.value ? ' cas-option-active' : ''}`}
|
|
116
|
+
onClick={() => pick(it.value)}
|
|
117
|
+
>
|
|
118
|
+
<span className="cas-option-label">{it.label}</span>
|
|
119
|
+
</button>
|
|
120
|
+
))}
|
|
121
|
+
{filtered.length === 0 && <div className="cas-empty">无匹配选项</div>}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className="selector">
|
|
132
|
+
{label && <label>{label}</label>}
|
|
133
|
+
<select value={value} onChange={handleChange} disabled={disabled}>
|
|
134
|
+
{items.map((item) => (
|
|
135
|
+
<option key={item.value} value={item.value}>
|
|
136
|
+
{item.label}
|
|
137
|
+
</option>
|
|
138
|
+
))}
|
|
139
|
+
</select>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { parseChoice, parseChoices } from './choiceUtils';
|
|
3
|
+
|
|
4
|
+
export interface SelectMultiProps {
|
|
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 | string[];
|
|
9
|
+
/** 选项排列方向:vertical 纵向(默认)/ horizontal 横向 */
|
|
10
|
+
direction?: 'vertical' | 'horizontal';
|
|
11
|
+
/** 自定义布局:每行列数(设置后按 N 列网格排列,优先于 direction) */
|
|
12
|
+
columns?: number;
|
|
13
|
+
/** 是否禁用 */
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
onchange: (value: string | string[]) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const SelectMulti: React.FC<SelectMultiProps> = ({
|
|
19
|
+
choices,
|
|
20
|
+
label = '',
|
|
21
|
+
default: initial,
|
|
22
|
+
direction,
|
|
23
|
+
columns,
|
|
24
|
+
disabled = false,
|
|
25
|
+
onchange
|
|
26
|
+
}) => {
|
|
27
|
+
const items = parseChoices(choices);
|
|
28
|
+
// 初始勾选值:default 可能是 key、label 或 key:label 原文,统一解析为 key 体系
|
|
29
|
+
const [value, setValue] = useState<string[]>(
|
|
30
|
+
Array.isArray(initial)
|
|
31
|
+
? initial.map(v => parseChoice(String(v)).value)
|
|
32
|
+
: (initial ? [parseChoice(String(initial)).value] : [])
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
36
|
+
const choice = e.target.value;
|
|
37
|
+
let newValue: string[];
|
|
38
|
+
|
|
39
|
+
if (e.target.checked) {
|
|
40
|
+
newValue = [...value, choice];
|
|
41
|
+
} else {
|
|
42
|
+
newValue = value.filter(v => v !== choice);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setValue(newValue);
|
|
46
|
+
onchange(newValue);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="selector-multi">
|
|
51
|
+
{label && <div className="selector-multi-label">{label}</div>}
|
|
52
|
+
<div
|
|
53
|
+
className="checkbox-group"
|
|
54
|
+
data-direction={columns ? 'grid' : (direction || 'vertical')}
|
|
55
|
+
style={columns && columns >= 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined}
|
|
56
|
+
>
|
|
57
|
+
{items.map((item) => (
|
|
58
|
+
<label key={item.value} className="checkbox-item">
|
|
59
|
+
<input
|
|
60
|
+
type="checkbox"
|
|
61
|
+
value={item.value}
|
|
62
|
+
checked={value.includes(item.value)}
|
|
63
|
+
onChange={handleChange}
|
|
64
|
+
disabled={disabled}
|
|
65
|
+
/>
|
|
66
|
+
<span>{item.label}</span>
|
|
67
|
+
</label>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
};
|