feat: 添加选择填空题和多选题组件,增强学习互动性

- 在 quiz 组件中引入 SelectFillQuizPanel 和 MultipleChoiceQuizPanel,丰富用户的学习体验。
- 更新 Maozedong 相关页面,增加新的选择填空题和多选题,提升互动性。
- 优化状态管理,确保题目完成后的反馈逻辑流畅。
- 更新 README 文档,详细说明新组件的功能和使用示例。
This commit is contained in:
zhongyidian committed 2026-02-27 17:50:47 +08:00
1 parent 2c8bc73e57
commit b91f2430c6
13 files changed
+1386 -238

No files matched your search

+83 -5
View File
@@ -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;
// 处理选项点击
+4
View File
@@ -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";
// 保留旧组件以保持向后兼容
+50
View File
@@ -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;