feat: 添加选择填空题和多选题组件,增强学习互动性
- 在 quiz 组件中引入 SelectFillQuizPanel 和 MultipleChoiceQuizPanel,丰富用户的学习体验。 - 更新 Maozedong 相关页面,增加新的选择填空题和多选题,提升互动性。 - 优化状态管理,确保题目完成后的反馈逻辑流畅。 - 更新 README 文档,详细说明新组件的功能和使用示例。
This commit is contained in:
1 parent
2c8bc73e57
commit
b91f2430c6
13 files changed
+1386
-238
No files matched your search
@@ -6,8 +6,85 @@
|
||||
|
||||
- **SingleChoiceQuizPanel** - 单选题面板
|
||||
- **MultipleChoiceQuizPanel** - 多选题面板
|
||||
- **SelectFillQuizPanel** - 选择填空题面板
|
||||
- **QuizPanel** - 旧版组件(已废弃,保留以保持向后兼容)
|
||||
|
||||
## SelectFillQuizPanel - 选择填空题面板
|
||||
|
||||
### 功能特性
|
||||
|
||||
- ✅ 支持多个空依次填写
|
||||
- ✅ 下方提供一组可点击选项(如数字 0-9)
|
||||
- ✅ 内部管理答题状态与当前激活空
|
||||
- ✅ 支持正确答案校验与错误提示
|
||||
- ✅ 关闭面板时自动重置内部状态
|
||||
|
||||
### 使用示例
|
||||
|
||||
```tsx
|
||||
import { SelectFillQuizPanel } from "@/components";
|
||||
|
||||
function MyComponent() {
|
||||
const [showQuiz, setShowQuiz] = useState(false);
|
||||
|
||||
return (
|
||||
<SelectFillQuizPanel
|
||||
visible={showQuiz}
|
||||
titlePrefix="【填空】毛泽东主席认为,到了"
|
||||
titleSuffix="年夏天,在理论上,而且在某种程度的行动上,他已成为一个马克思主义者。"
|
||||
blanks={[
|
||||
{ id: "year", prefix: "____" },
|
||||
]}
|
||||
options={[
|
||||
{ value: "1919", label: "1919" },
|
||||
{ value: "1920", label: "1920" },
|
||||
{ value: "1921", label: "1921" },
|
||||
]}
|
||||
correctAnswers={{ year: "1920" }}
|
||||
errorMessage="回想一下 1920 年 5 月到 7 月在上海居住的这段经历~"
|
||||
onSubmit={(results, isCorrect) => {
|
||||
console.log("选择结果:", results, "是否正确:", isCorrect);
|
||||
if (isCorrect) {
|
||||
setShowQuiz(false);
|
||||
}
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.log("错误信息:", error);
|
||||
}}
|
||||
submitText="确定"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| visible | boolean | - | 是否可见(必填) |
|
||||
| zIndex | number | 10000 | z-index 层级 |
|
||||
| titlePrefix | string | - | 整体题干前缀文本(可选) |
|
||||
| titleSuffix | string | - | 整体题干后缀文本(可选) |
|
||||
| blanks | SelectFillBlank[] | - | 每个填空位的配置(必填,包含 id / prefix / suffix) |
|
||||
| options | { value: string; label: string }[] | - | 候选项列表(必填) |
|
||||
| correctAnswers | Record<string, string> | - | 正确答案映射(可选,不传则不做校验,直接判定为正确) |
|
||||
| errorMessage | string | `"还不太对,再仔细对照题干和提示想一想吧~"` | 自定义错误提示信息(答案错误时显示) |
|
||||
| onSubmit | (results: SelectFillResult[], isCorrect: boolean) => void | - | 提交回调 |
|
||||
| onError | (error: string) => void | - | 错误回调 |
|
||||
| disabled | boolean | false | 是否禁用 |
|
||||
| submitText | string | "提交" | 提交按钮文本 |
|
||||
|
||||
### 行为说明
|
||||
|
||||
1. **点击空格**:切换当前激活的填空位。
|
||||
2. **点击下方选项**:将选项填入当前激活的空中,并自动跳转到下一个未填写的空。
|
||||
3. **点击空中的 ×**:清除该空的内容,并把当前激活位置移动到该空。
|
||||
4. **点击提交**:如果有空未填,给出“请先把所有空都填完再提交哦~”提示;如果提供了 `correctAnswers`,会逐空校验是否全部正确。
|
||||
5. **答案正确**:调用 `onSubmit(results, true)`,同时清除错误提示。
|
||||
6. **答案错误**:显示错误提示,仅调用 `onError(error)`,不调用 `onSubmit(false)`。
|
||||
7. **未提供正确答案**:直接调用 `onSubmit(results, true)`,不进行校验。
|
||||
8. **关闭面板**(`visible` 从 `true` 变为 `false`):内部会自动重置所有填空和错误提示。
|
||||
|
||||
## SingleChoiceQuizPanel - 单选题面板
|
||||
|
||||
### 功能特性
|
||||
@@ -16,7 +93,7 @@
|
||||
- ✅ 点击选项后自动提交
|
||||
- ✅ 支持答案验证(可选)
|
||||
- ✅ 自动显示错误提示
|
||||
- ✅ 提交后禁用交互
|
||||
- ✅ 关闭面板时自动重置内部状态
|
||||
|
||||
### 使用方法
|
||||
|
||||
@@ -72,9 +149,10 @@ function MyComponent() {
|
||||
### 行为说明
|
||||
|
||||
1. **点击选项**:立即触发提交,如果提供了 `correctAnswer`,会自动验证
|
||||
2. **答案正确**:调用 `onSubmit(value, true)`,面板进入已提交状态
|
||||
3. **答案错误**:显示错误提示,调用 `onError(error)` 和 `onSubmit(value, false)`
|
||||
2. **答案正确**:调用 `onSubmit(value, true)`,并清空内部选择与错误状态
|
||||
3. **答案错误**:显示错误提示,仅调用 `onError(error)`,并清空内部选择状态
|
||||
4. **未提供正确答案**:直接调用 `onSubmit(value, true)`,不进行验证
|
||||
5. **关闭面板**(`visible` 从 `true` 变为 `false`):内部会自动重置已选择的值和错误提示
|
||||
|
||||
## MultipleChoiceQuizPanel - 多选题面板
|
||||
|
||||
@@ -205,7 +283,7 @@ function MyComponent() {
|
||||
## 注意事项
|
||||
|
||||
1. 两个组件都使用内部状态管理,不需要外部传入 `value`
|
||||
2. 提交后组件会进入禁用状态,防止重复提交
|
||||
3. 如果提供了 `correctAnswer`,组件会自动验证并显示错误信息
|
||||
2. 如果提供了 `correctAnswer`,组件会自动验证并显示错误信息
|
||||
3. 当 `visible` 从 `true` 变为 `false` 时,单选题和多选题面板都会自动重置内部选择与错误状态,便于下次重新答题
|
||||
4. 多选题的答案比较不考虑顺序,只要选择的选项和正确答案一致即可
|
||||
5. 旧版 `QuizPanel` 仍然可用,但建议迁移到新组件
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type {
|
||||
SelectFillQuizPanelProps,
|
||||
SelectFillResult,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* 选择填空题面板组件
|
||||
*
|
||||
* 示例场景:给出一句话中若干空格,在下方提供一排数字或短语,学生点击选中后依次填入空格,最后统一提交判断正误。
|
||||
*/
|
||||
const SelectFillQuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
titlePrefix,
|
||||
titleSuffix,
|
||||
blanks,
|
||||
options,
|
||||
correctAnswers,
|
||||
errorMessage,
|
||||
onSubmit,
|
||||
onError,
|
||||
disabled = false,
|
||||
submitText = "提交",
|
||||
}: SelectFillQuizPanelProps) => {
|
||||
const [results, setResults] = useState<SelectFillResult[]>([]);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
// 当前正在填的空的下标(从 0 开始),默认为第一个空
|
||||
const [activeBlankIndex, setActiveBlankIndex] = useState(0);
|
||||
|
||||
// 关闭时重置内部状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setResults([]);
|
||||
setError("");
|
||||
setActiveBlankIndex(0);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const isAllFilled = blanks.every((blank) =>
|
||||
results.some((r) => r.blankId === blank.id && r.value !== "")
|
||||
);
|
||||
|
||||
// 点击下方选项:填入当前激活的空
|
||||
const handleOptionClick = (value: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
const currentBlank = blanks[activeBlankIndex];
|
||||
if (!currentBlank) return;
|
||||
|
||||
const newResults: SelectFillResult[] = (() => {
|
||||
const others = results.filter((r) => r.blankId !== currentBlank.id);
|
||||
return [...others, { blankId: currentBlank.id, value }];
|
||||
})();
|
||||
|
||||
setResults(newResults);
|
||||
setError("");
|
||||
|
||||
// 自动跳到下一个空
|
||||
const nextIndex = (() => {
|
||||
// 优先跳到还未填写的下一个空
|
||||
for (let i = 0; i < blanks.length; i++) {
|
||||
const blankItem = blanks[i]!;
|
||||
const hasValue = newResults.some(
|
||||
(r) => r.blankId === blankItem.id && r.value !== ""
|
||||
);
|
||||
if (!hasValue) return i;
|
||||
}
|
||||
return activeBlankIndex;
|
||||
})();
|
||||
setActiveBlankIndex(nextIndex);
|
||||
};
|
||||
|
||||
// 清空某个空
|
||||
const handleClearBlank = (blankId: string) => {
|
||||
if (disabled) return;
|
||||
setResults(results.filter((r) => r.blankId !== blankId));
|
||||
setError("");
|
||||
const index = blanks.findIndex((b) => b.id === blankId);
|
||||
if (index >= 0) setActiveBlankIndex(index);
|
||||
};
|
||||
|
||||
// 提交答案
|
||||
const handleSubmit = () => {
|
||||
if (disabled) return;
|
||||
|
||||
// 有空未填
|
||||
if (!isAllFilled) {
|
||||
const msg = "请先把所有空都填完再提交哦~";
|
||||
setError(msg);
|
||||
onError?.(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const filledResults = blanks.map((blank) => {
|
||||
const r = results.find((res) => res.blankId === blank.id);
|
||||
return { blankId: blank.id, value: r?.value ?? "" };
|
||||
});
|
||||
|
||||
let isCorrect = true;
|
||||
if (correctAnswers) {
|
||||
for (const r of filledResults) {
|
||||
const expected = correctAnswers[r.blankId];
|
||||
if (expected !== undefined && expected !== r.value) {
|
||||
isCorrect = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!correctAnswers) {
|
||||
onSubmit?.(filledResults, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCorrect) {
|
||||
setError("");
|
||||
onSubmit?.(filledResults, true);
|
||||
} else {
|
||||
const msg =
|
||||
errorMessage || "还不太对,再仔细对照题干和提示想一想吧~";
|
||||
setError(msg);
|
||||
// 错误后清空已填写内容,重新作答
|
||||
setResults([]);
|
||||
setActiveBlankIndex(0);
|
||||
onError?.(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const getBlankValue = (blankId: string) =>
|
||||
results.find((r) => r.blankId === blankId)?.value ?? "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<div className="relative w-[1728px] h-[972px] flex flex-col items-center justify-center">
|
||||
{/* 题干 + 空格区域 */}
|
||||
<div className="w-[1700px] rounded-[32px] bg-black px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
<div className="text-[32px] leading-relaxed text-white">
|
||||
{titlePrefix && <span>{titlePrefix}</span>}
|
||||
{blanks.map((blank, index) => {
|
||||
const value = getBlankValue(blank.id);
|
||||
const isActive = index === activeBlankIndex;
|
||||
return (
|
||||
<span key={blank.id}>
|
||||
{blank.prefix && <span>{blank.prefix}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center justify-center min-w-[120px] h-[52px] px-4 mx-2 rounded-xl border-2 text-[28px] transition-all ${
|
||||
disabled
|
||||
? "opacity-60 cursor-not-allowed border-[#d4c5a9]/40"
|
||||
: "cursor-pointer"
|
||||
} ${
|
||||
value
|
||||
? "border-[#f0be75] bg-gradient-to-br from-[#3a3020] to-[#2a2520] text-[#f0be75]"
|
||||
: "border-[#d4c5a9]/60 bg-white/10 text-white/40"
|
||||
} ${
|
||||
isActive && !disabled
|
||||
? "ring-2 ring-[#f0be75] ring-offset-2 ring-offset-black"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setActiveBlankIndex(index);
|
||||
}}
|
||||
>
|
||||
{value || "____"}
|
||||
{value && !disabled && (
|
||||
<span
|
||||
className="ml-3 text-[22px] text-[#f0be75]/80"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearBlank(blank.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{blank.suffix && <span>{blank.suffix}</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{titleSuffix && <span>{titleSuffix}</span>}
|
||||
</div>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-6 text-center text-[26px] text-[#fcbe53]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 选项区域 */}
|
||||
<div className="mt-16 w-[1500px] flex flex-col items-center">
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={`min-w-[90px] h-[72px] px-6 rounded-2xl text-[28px] font-medium border-2 transition-all ${
|
||||
disabled
|
||||
? "opacity-60 cursor-not-allowed bg-[#d7b888]/60 border-[#d4c5a9]/60 text-[#5c3e2e]/60"
|
||||
: "cursor-pointer bg-[#d7b888] border-[#d4c5a9] text-[#5c3e2e] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]"
|
||||
}`}
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className="mt-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !isAllFilled}
|
||||
className="px-24 py-4 text-[28px] font-medium text-white bg-[#c2995f] rounded-[40px] transition-all duration-200 hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
|
||||
>
|
||||
{submitText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectFillQuizPanel;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SingleChoice } from "@/components";
|
||||
import type { SingleChoiceQuizPanelProps } from "./types";
|
||||
|
||||
@@ -42,6 +42,14 @@ const SingleChoiceQuizPanel = ({
|
||||
const [selectedValue, setSelectedValue] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
// 当窗口关闭时,重置内部状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSelectedValue("");
|
||||
setError("");
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
// 处理选项点击
|
||||
|
||||
@@ -3,6 +3,7 @@ export { default as SingleChoiceQuizPanel } from "./SingleChoiceQuizPanel";
|
||||
export { default as MultipleChoiceQuizPanel } from "./MultipleChoiceQuizPanel";
|
||||
export { default as DragQuizPanel } from "./DragQuizPanel";
|
||||
export { default as MatchQuizPanel } from "./MatchQuizPanel";
|
||||
export { default as SelectFillQuizPanel } from "./SelectFillQuizPanel";
|
||||
export type {
|
||||
SingleChoiceQuizPanelProps,
|
||||
MultipleChoiceQuizPanelProps,
|
||||
@@ -10,6 +11,9 @@ export type {
|
||||
MatchQuizPanelProps,
|
||||
MatchQuizItem,
|
||||
MatchQuizMatch,
|
||||
SelectFillQuizPanelProps,
|
||||
SelectFillBlank,
|
||||
SelectFillResult,
|
||||
} from "./types";
|
||||
|
||||
// 保留旧组件以保持向后兼容
|
||||
|
||||
@@ -125,6 +125,56 @@ export interface MatchQuizPanelProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** 选择填空题中,每一个空的配置 */
|
||||
export interface SelectFillBlank {
|
||||
/** 唯一标识,用于和答案、结果关联 */
|
||||
id: string;
|
||||
/** 空前面的文本(可选) */
|
||||
prefix?: string;
|
||||
/** 空后面的文本(可选) */
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
/** 选择填空题的答题结果 */
|
||||
export interface SelectFillResult {
|
||||
blankId: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** 选择填空题面板配置 */
|
||||
export interface SelectFillQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
/** 题目前缀整体文本(可选) */
|
||||
titlePrefix?: string;
|
||||
/** 题目后缀整体文本(可选) */
|
||||
titleSuffix?: string;
|
||||
/** 填空位配置(控制有几个空、空前后的说明文本) */
|
||||
blanks: SelectFillBlank[];
|
||||
/** 可选项列表(如 0-9 的数字、若干短语等) */
|
||||
options: { value: string; label: string }[];
|
||||
/**
|
||||
* 正确答案映射
|
||||
* key 为 blankId,value 为该空对应的正确值
|
||||
*/
|
||||
correctAnswers?: Record<string, string>;
|
||||
/** 自定义错误提示信息(答案错误时显示) */
|
||||
errorMessage?: string;
|
||||
/**
|
||||
* 提交回调,返回用户选择结果与是否全部正确
|
||||
* 注意:如果未提供 correctAnswers,则 isCorrect 恒为 true
|
||||
*/
|
||||
onSubmit?: (results: SelectFillResult[], isCorrect: boolean) => void;
|
||||
/** 错误回调(可选) */
|
||||
onError?: (error: string) => void;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
}
|
||||
|
||||
/** @deprecated 使用 SingleChoiceQuizPanelProps 替代 */
|
||||
export interface QuizPanelProps {
|
||||
visible: boolean;
|
||||
|
||||
Reference in new issue
Block a user