这节我们来写下试卷编辑器。
和问卷星的类似:


可以选择不同的题型,然后设置题目的内容,答案、分值、答案解析等。
我们先来设计下 json 的数据结构:
这只是一个列表的 json,比较简单。
大概是这样的结构:
[
{
"type": "radio",
"question": "最长的河?",
"options": ["选项1", "选项2"],
"score": 5,
"answer": "选项1",
"answerAnalyse": "答案解析"
}
]
type 是题型
options 是单选的选项
score 是题目分数
answer 是答案
answerAnalyse 是答案解析
我们加一个 /edit/:id 的路由:

写下内容:
pages/Edit/index.tsx
import { useParams } from "react-router-dom";
export function Edit() {
let { id } = useParams();
return <div>Edit: {id}</div>;
}
我们按照低代码编辑器这种布局来写,比如 amis 编辑器:

左边是物料区、中间是画布区、右边是属性编辑区。
写下布局:
import { useParams } from "react-router-dom";
import "./index.scss";
import { Button } from "antd";
export function Edit() {
let { id } = useParams();
return (
<div id="edit-container">
<div className="header">
<div>试卷编辑器</div>
<Button type="primary">预览</Button>
</div>
<div className="body">
<div className="materials">
<div className="meterial-item">单选题</div>
<div className="meterial-item">多选题</div>
<div className="meterial-item">填空题</div>
</div>
<div className="edit-area"></div>
<div className="setting"></div>
</div>
</div>
);
}
index.scss
* {
margin: 0;
padding: 0;
}
#edit-container {
.header {
display: flex;
justify-content: space-between;
align-items: center;
height: 80px;
font-size: 30px;
line-height: 80px;
border-bottom: 1px solid #000;
padding: 0 20px;
}
.body {
height: calc(100vh - 80px);
display: flex;
.materials {
height: 100%;
width: 300px;
border-right: 1px solid #000;
.meterial-item {
padding: 20px;
border: 1px solid #000;
display: inline-block;
margin: 10px;
cursor: move;
}
}
.edit-area {
height: 100%;
flex: 1;
}
.setting {
height: 100%;
width: 400px;
border-left: 1px solid #000;
}
}
}
就是 flex、width、height、padding 这些布局。
看下效果:

中间部分通过就是递归渲染 json 为组件:
import { useParams } from "react-router-dom";
import "./index.scss";
import { Button, Radio, Checkbox, Input } from "antd";
export type Question = {
id: number;
question: string;
type: "radio" | "checkbox" | "input";
options?: string[];
score: number;
answer: string;
answerAnalyse: string;
};
const json: Array<Question> = [
{
id: 1,
type: "radio",
question: "最长的河?",
options: ["选项1", "选项2"],
score: 5,
answer: "选项1",
answerAnalyse: "答案解析",
},
{
id: 2,
type: "checkbox",
question: "最高的山?",
options: ["选项1", "选项2"],
score: 5,
answer: "选项1",
answerAnalyse: "答案解析",
},
{
id: 2,
type: "input",
question: "测试问题",
score: 5,
answer: "选项1",
answerAnalyse: "答案解析",
},
];
export function Edit() {
let { id } = useParams();
function renderComponents(arr: Array<Question>) {
return arr.map((item) => {
let formComponent;
if (item.type === "radio") {
formComponent = (
<Radio.Group>
{item.options?.map((option) => (
<Radio value={option}>{option}</Radio>
))}
</Radio.Group>
);
} else if (item.type === "checkbox") {
formComponent = <Checkbox.Group options={item.options} />;
} else if (item.type === "input") {
formComponent = <Input />;
}
return (
<div className="component-item" key={item.id}>
<p className="question">{item.question}</p>
<div className="options">{formComponent}</div>
<p className="score">分值:{item.score}</p>
<p className="answer">答案:{item.answer}</p>
<p className="answerAnalyse">
答案解析:{item.answerAnalyse}
</p>
</div>
);
});
}
return (
<div id="edit-container">
<div className="header">
<div>试卷编辑器</div>
<Button type="primary">预览</Button>
</div>
<div className="body">
<div className="materials">
<div className="meterial-item">单选题</div>
<div className="meterial-item">多选题</div>
<div className="meterial-item">填空题</div>
</div>
<div className="edit-area">{renderComponents(json)}</div>
<div className="setting"></div>
</div>
</div>
);
}
我们写死了一个 json:

然后写了一个 renderComponents 方法来渲染它:

css 如下:

.component-item {
margin: 20px;
line-height: 40px;
font-size: 20px;
border-bottom: 1px solid #000;
}
渲染出来是这样的:

然后我们拖拽左边的物料到画布的时候,在 json 数组加一个元素。
我们用 react-dnd 实现拖拽,安装用到的包:
npm install react-dnd react-dnd-html5-backend
在最外层加一下 DndProvider,这是 react-dnd 用来跨组件通信的:

在物料上加上 useDrag:
封装一个 pages/Edit/Material.tsx 组件
import { useDrag } from "react-dnd";
export function MaterialItem(props: { name: string; type: string }) {
const [_, drag] = useDrag({
type: props.type,
item: {
type: props.type,
},
});
return (
<div className="meterial-item" ref={drag}>
{props.name}
</div>
);
}
用 useDrag 给它加上拖拽。
item 是传递的数据
用一下:

<MaterialItem name="单选题" type="单选题"/>
<MaterialItem name="多选题" type="多选题"/>
<MaterialItem name="填空题" type="填空题"/>
这样,就可以拖拽了:

然后处理 drop:

accept 是可以接收的 drag 的类型,也就是这个:

drop 的时候显示个消息提示。
over 的时候加个蓝色边框
测试下:

没啥问题。
然后我们 drop 的时候把它加到 json 里就好了。
把写死的 json 清空,然后 drop 的时候往里 push 元素

const [{ isOver }, drop] = useDrop(() => ({
accept: ["单选题", "多选题", "填空题"],
drop: (item: { type: string }) => {
const type = {
单选题: "radio",
多选题: "checkbox",
填空题: "input",
}[item.type] as Question["type"];
json.push({
id: new Date().getTime(),
type,
question: "最高的山?",
options: ["选项1", "选项2"],
score: 5,
answer: "选项1",
answerAnalyse: "答案解析",
});
},
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
}));
在右边展示下 json:

<pre>{JSON.stringify(json, null, 4)}</pre>

然后点击问题的时候加一个高亮框:

const [curQuestionId, setCurQuestionId] = useState<number>();
<div className="component-item" key={item.id} onClick={() => {
setCurQuestionId(item.id)
}} style={ item.id === curQuestionId ? { border : '2px solid blue' } : {}}>

然后选中的时候在右边展示对应的编辑表单:
首先把 json 拿进来作为一个 state:

const [json, setJson] = useState < Array < Question >> [];
setJson((json) => [
...json,
{
id: new Date().getTime(),
type,
question: "最高的山?",
options: ["选项1", "选项2"],
score: 5,
answer: "选项1",
answerAnalyse: "答案解析",
},
]);
然后写下选中时的表单:

{
curQuestionId &&
json
.filter((item) => item.id === curQuestionId)
.map((item, index) => {
return (
<div key={index}>
<Form
style={{ padding: "20px" }}
initialValues={item}
onValuesChange={(changed, values) => {
setJson((json) => {
return json.map((cur) => {
return cur.id === item.id
? {
id: item.id,
...values,
options:
typeof values.options ===
"string"
? values.options?.split(
","
)
: values.options,
}
: cur;
});
});
}}>
<Form.Item
label="问题"
name="question"
rules={[
{ required: true, message: "请输入问题!" },
]}>
<Input />
</Form.Item>
<Form.Item
label="类型"
name="type"
rules={[
{ required: true, message: "请选择类型!" },
]}>
<Radio.Group>
<Radio value="radio">单选题</Radio>
<Radio value="checkbox">多选题</Radio>
<Radio value="input">填空题</Radio>
</Radio.Group>
</Form.Item>
{item.type !== "input" && (
<Form.Item
label="选项(逗号分割)"
name="options"
rules={[
{
required: true,
message: "请输入选项!",
},
]}>
<Input />
</Form.Item>
)}
<Form.Item
label="分数"
name="score"
rules={[
{ required: true, message: "请输入分数!" },
]}>
<InputNumber />
</Form.Item>
<Form.Item
label="答案"
name="answer"
rules={[
{ required: true, message: "请输入答案!" },
]}>
<Input />
</Form.Item>
<Form.Item
label="答案分析"
name="answerAnalyse"
rules={[
{
required: true,
message: "请输入答案分析!",
},
]}>
<TextArea />
</Form.Item>
</Form>
</div>
);
});
}
就是根据 curQuesitonId 从 json 中找到对应的数据,用 Form 来回显
当 onValuesChange 的时候,设置回 json

切换选中的问题的时候,有的表单值没变。
因为我们设置的是 initialValues,它只影响初始值。

const [form] = useForm();
useEffect(() => {
form.setFieldsValue(json.filter((item) => item.id === curQuestionId)[0]);
}, [curQuestionId]);
做下同步就好了。
试一下:

没啥问题。
然后再试下编辑: 
可以看到,选中的问题,会回显在表单,编辑后会同步修改对应 json。
我们再加一个 antd 的 Segmented 组件来做 Tab

const [key, setKey] = useState < string > "json";
<Segmented value={key} onChange={setKey} block options={["json", "属性"]} />

有了 tab 之后好看多了。
这样,试卷编辑功能就完成了。
案例代码在小册仓库:
总结
这节我们实现了试卷编辑器的功能。
左边是物料区,中间是画布区,右边是属性编辑区。
中间画布区就是渲染 json。
用 react-dnd 实现了拖拽,拖拽物料到中间的画布区,会在 json 中增加一条。
然后点击问题的时候会高亮,并且在右边展示编辑的表单。
编辑的时候会同步修改 json,中间画布区也会重新渲染。
当然,现在的 json 还没有保存,下节我们把它保存到数据库。
