safety-eval-service-frontend/src/pages/Container/EduTraining/ClassManage/Add/PaperConfig.js

761 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useEffect, useState } from "react";
import {
Button,
Descriptions,
Empty,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Space,
Table,
message,
} from "antd";
import { DownloadOutlined, PlusOutlined } from "@ant-design/icons";
import TableAction from "@cqsjjb/jjb-react-admin-component/TableAction";
import { Connect } from "@cqsjjb/jjb-dva-runtime";
import { NS_COURSEWARE, NS_PAPER } from "~/enumerate/namespace";
import {
CLASS_PAPER_TYPE_MAP,
CLASS_PAPER_TYPE_OPTIONS,
QUESTION_TYPE_MAP,
QUESTION_TYPE_OPTIONS,
} from "~/enumerate/constant";
const API_HOST = window.process?.env?.app?.API_HOST || "";
/** 下载试卷 PDF文件流文件名取自响应头 */
const downloadPaperPdf = async (paperId) => {
const res = await fetch(
`${API_HOST}/safetyEval/paper/paper/exam/download?paperId=${paperId}`,
{ headers: { token: sessionStorage.getItem("token") || "" } },
);
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const json = await res.json();
throw new Error(json.errMessage || json.message || "下载失败");
}
if (!res.ok) throw new Error(`下载失败(${res.status})`);
const blob = await res.blob();
let fileName = "试卷.pdf";
const disposition = res.headers.get("content-disposition") || "";
const matched = disposition.match(/filename\*?=(?:UTF-8''|")?([^";]+)/i);
if (matched?.[1]) {
try {
fileName = decodeURIComponent(matched[1].replace(/"/g, ""));
} catch {
fileName = matched[1].replace(/"/g, "");
}
}
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = fileName;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
};
/** 班级课程 Tab — 配置试卷 / 试卷详情入口 */
function ClassPaperConfig(props) {
const { classId } = props;
/** 班级已绑定的试卷 id来自班级详情 */
const [paperId, setPaperId] = useState("");
const [configOpen, setConfigOpen] = useState(false);
/** 试卷详情/预览弹窗展示的试卷 id */
const [previewPaperId, setPreviewPaperId] = useState("");
/** 查询班级详情,取绑定试卷 */
const loadClassPaper = async () => {
const res = await props.classDetail({ id: classId });
if (res?.success !== false) setPaperId(res?.data?.paperId || "");
};
useEffect(() => {
if (classId) loadClassPaper();
}, [classId]);
return (
<>
<Space>
<Button onClick={() => setConfigOpen(true)}>配置试卷</Button>
<Button
disabled={!paperId}
onClick={() => setPreviewPaperId(paperId)}
>
试卷详情
</Button>
</Space>
<PaperConfigModal
open={configOpen}
classId={classId}
paperId={paperId}
paper={props.paper}
courseware={props.courseware}
basicLoading={props.paper?.paperBasicLoading}
fetchBasicInfo={props.paperBasicInfo}
paperPage={props.paperPage}
paperRuleGenerate={props.paperRuleGenerate}
coursewarePage={props.coursewarePage}
classBindPaper={props.classBindPaper}
onPreview={(id) => setPreviewPaperId(id)}
onBound={loadClassPaper}
resetModelState={props.resetModelState}
onClose={() => setConfigOpen(false)}
/>
<PaperExamPreviewModal
open={!!previewPaperId}
paperId={previewPaperId}
title="试卷详情"
loading={props.paper?.paperExamLoading}
fetchExamInfo={props.paperExamInfo}
resetModelState={props.resetModelState}
onClose={() => setPreviewPaperId("")}
/>
</>
);
}
/** 配置试卷弹窗:已绑定时展示试卷信息(查看/编辑/下载),未绑定时提供新增 */
function PaperConfigModal({
open,
classId,
paperId,
paper,
courseware,
basicLoading,
fetchBasicInfo,
paperPage,
paperRuleGenerate,
coursewarePage,
classBindPaper,
onPreview,
onBound,
resetModelState,
onClose,
}) {
const [basicInfo, setBasicInfo] = useState(null);
const [addOpen, setAddOpen] = useState(false);
// 打开且有绑定试卷时才查询基本信息paperId 变化(换绑)后重新查询
useEffect(() => {
if (!open) {
setAddOpen(false);
resetModelState?.(NS_PAPER, { paperBasicLoading: false });
return;
}
setBasicInfo(null);
if (paperId) {
fetchBasicInfo({ paperId }).then((res) => {
if (res?.success !== false) setBasicInfo(res?.data || null);
});
}
}, [open, paperId]);
const columns = [
{
title: "试卷类型",
dataIndex: "paperType",
width: 140,
render: (v) => CLASS_PAPER_TYPE_MAP[v] ?? (v || "-"),
},
{
title: "考试时间(分钟)",
dataIndex: "examTime",
width: 140,
render: (v) => (v == null ? "-" : v),
},
{
title: "操作",
render: () => (
<TableAction>
<Button
type="link"
size="small"
onClick={() => onPreview(paperId)}
>
查看
</Button>
<Button
type="link"
size="small"
onClick={() => setAddOpen(true)}
>
编辑
</Button>
<Button
type="link"
size="small"
icon={<DownloadOutlined />}
onClick={async () => {
try {
await downloadPaperPdf(paperId);
} catch (e) {
message.error(e?.message || "下载失败");
}
}}
>
下载
</Button>
</TableAction>
),
},
];
return (
<Modal
open={open}
title="配置试卷"
width={640}
footer={null}
destroyOnHidden
onCancel={onClose}
>
{!paperId && (
<Button
type="primary"
icon={<PlusOutlined />}
style={{ marginBottom: 12 }}
onClick={() => setAddOpen(true)}
>
新增
</Button>
)}
<Table
rowKey="id"
size="small"
columns={columns}
dataSource={paperId && basicInfo ? [basicInfo] : []}
loading={basicLoading}
pagination={false}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂未配置试卷"
/>
),
}}
/>
<PaperAddModal
open={addOpen}
mode={paperId ? "edit" : "add"}
classId={classId}
paper={paper}
courseware={courseware}
paperPage={paperPage}
paperRuleGenerate={paperRuleGenerate}
coursewarePage={coursewarePage}
classBindPaper={classBindPaper}
onPreview={onPreview}
resetModelState={resetModelState}
onCancel={() => setAddOpen(false)}
onSuccess={() => {
setAddOpen(false);
onBound();
}}
/>
</Modal>
);
}
/** 新增/编辑试卷弹窗:平台试卷列表选用 或 自动生成试卷(规则表单) */
function PaperAddModal({
open,
mode,
classId,
paper,
courseware,
paperPage,
paperRuleGenerate,
coursewarePage,
classBindPaper,
onPreview,
resetModelState,
onCancel,
onSuccess,
}) {
const [form] = Form.useForm();
const [paperType, setPaperType] = useState("fixed");
const [query, setQuery] = useState({ current: 1, size: 10 });
const [keyword, setKeyword] = useState("");
const isFixed = paperType === "fixed";
const coursewareOptions = (courseware?.coursewareList || []).map((c) => ({
label: c.coursewareName,
value: c.id,
}));
// 切换类型/打开时加载数据:平台试卷查分页,自动生成查课件下拉
useEffect(() => {
if (!open) {
resetModelState?.(NS_PAPER, {
paperLoading: false,
paperRuleLoading: false,
});
resetModelState?.(NS_COURSEWARE, { classBindPaperLoading: false });
return;
}
// 规则表单挂载后才可重置,避免 useForm 未关联告警
if (!isFixed) form.resetFields();
setKeyword("");
if (isFixed) {
setQuery({ current: 1, size: 10 });
paperPage({ current: 1, size: 10, paperType: "fixed" });
} else {
coursewarePage({ current: 1, size: 200 });
}
}, [open, paperType]);
const handleSearch = (params) => {
setQuery(params);
paperPage({ paperType: "fixed", ...params });
};
/** 使用平台试卷:绑定到班级 */
const handleUse = async (record) => {
const res = await classBindPaper({
id: classId,
paperId: record.id,
paperType: "fixed",
examMinutes: record.examTime,
});
if (res?.success === false) return;
message.success("绑定成功");
onSuccess();
};
/** 自动生成试卷并绑定 */
const handleRuleOk = async () => {
try {
const values = await form.validateFields();
const res = await paperRuleGenerate(values);
if (res?.success === false) return;
const basic = res?.data || {};
const bindRes = await classBindPaper({
id: classId,
paperId: basic.id,
paperType: "config_rule",
examMinutes: basic.examTime,
});
if (bindRes?.success === false) return;
message.success("自动生成试卷并绑定成功");
onSuccess();
} catch {
// 表单校验失败,由 antd 提示
}
};
const columns = [
{ title: "试卷名称", dataIndex: "paperName", ellipsis: true },
{ title: "试卷分数", dataIndex: "paperTotalScore", width: 90 },
{ title: "合格分数", dataIndex: "paperPassScore", width: 90 },
{
title: "考试时长(分)",
dataIndex: "examTime",
width: 110,
render: (v) => (v == null ? "-" : v),
},
{
title: "操作",
width: 130,
render: (_, record) => (
<TableAction>
<Button
type="link"
size="small"
onClick={() => onPreview(record.id)}
>
预览
</Button>
<Button
type="link"
size="small"
onClick={() => handleUse(record)}
>
使用
</Button>
</TableAction>
),
},
];
return (
<Modal
open={open}
title={mode === "edit" ? "编辑试卷" : "新增试卷"}
width={860}
destroyOnHidden
okText="生成并绑定"
confirmLoading={
!isFixed &&
(paper?.paperRuleLoading || courseware?.classBindPaperLoading)
}
footer={isFixed ? null : undefined}
onOk={handleRuleOk}
onCancel={onCancel}
>
<Form.Item label="试卷类型" style={{ marginBottom: 16 }}>
<Radio.Group
value={paperType}
onChange={(e) => setPaperType(e.target.value)}
options={CLASS_PAPER_TYPE_OPTIONS}
optionType="button"
/>
</Form.Item>
{isFixed ? (
<>
<Input.Search
placeholder="请输入试卷名称"
allowClear
style={{ width: 280, marginBottom: 12 }}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={(value) =>
handleSearch({
current: 1,
size: query.size,
paperName: value,
})
}
/>
<Table
rowKey="id"
size="small"
columns={columns}
dataSource={
Array.isArray(paper?.paperList)
? paper.paperList
: []
}
loading={paper?.paperLoading}
pagination={{
total: paper?.paperTotal,
size: "small",
showTotal: (t) => `${t}`,
current: Number(query.current) || 1,
pageSize: Number(query.size) || 10,
onChange: (page, pageSize) =>
handleSearch({
current: page,
size: pageSize,
paperName: query.paperName,
}),
}}
/>
</>
) : (
<Form form={form} layout="vertical" scrollToFirstError>
<Form.Item
name="paperName"
label="试卷名称"
rules={[{ required: true, message: "请输入试卷名称" }]}
>
<Input
placeholder="请输入试卷名称"
maxLength={128}
allowClear
/>
</Form.Item>
<Space style={{ display: "flex" }} align="start">
<Form.Item
name="paperTotalScore"
label="试卷总分"
rules={[{ required: true, message: "请输入试卷总分" }]}
style={{ width: 180 }}
>
<InputNumber
style={{ width: "100%" }}
min={0.1}
max={9999.9}
placeholder="0.1-9999.9"
/>
</Form.Item>
<Form.Item
name="paperPassScore"
label="合格分数"
rules={[{ required: true, message: "请输入合格分数" }]}
style={{ width: 180 }}
>
<InputNumber
style={{ width: "100%" }}
min={0}
max={9999.9}
placeholder="0-9999.9"
/>
</Form.Item>
<Form.Item
name="examTime"
label="考试时长(分钟)"
rules={[
{ required: true, message: "请输入考试时长" },
]}
style={{ width: 180 }}
>
<InputNumber
style={{ width: "100%" }}
min={1}
max={3600}
precision={0}
placeholder="1-3600"
/>
</Form.Item>
</Space>
<Form.Item
name="coursewareManagementIdList"
label="课程课件"
rules={[
{
required: true,
message: "请选择课程课件",
},
]}
>
<Select
mode="multiple"
placeholder="请选择课程课件(按抽题范围)"
showSearch
optionFilterProp="label"
options={coursewareOptions}
/>
</Form.Item>
<Form.Item
label="题型规则(试题数量与每题分数)"
required
style={{ marginBottom: 0 }}
>
<Form.List
name="questionTypeRuleList"
rules={[
{
validator: (_, value) =>
value?.length >= 1
? Promise.resolve()
: Promise.reject(
new Error(
"请至少配置一条题型规则",
),
),
},
]}
>
{(fields, { add, remove }, { errors }) => (
<>
{fields.map((field) => (
<Form.Item
key={field.key}
style={{ marginBottom: 8 }}
>
<Space align="center">
<Form.Item
noStyle
name={[
field.name,
"questionType",
]}
rules={[
{
required: true,
message: "请选择题型",
},
]}
>
<Select
placeholder="题型"
options={
QUESTION_TYPE_OPTIONS
}
style={{ width: 110 }}
/>
</Form.Item>
<Form.Item
noStyle
name={[field.name, "num"]}
rules={[
{
required: true,
message: "请输入试题数量",
},
]}
>
<InputNumber
placeholder="试题数量"
min={1}
max={1024}
precision={0}
style={{ width: 130 }}
/>
</Form.Item>
<Form.Item
noStyle
name={[
field.name,
"score",
]}
rules={[
{
required: true,
message: "请输入每题分数",
},
]}
>
<InputNumber
placeholder="每题分数"
min={0.1}
max={999.9}
style={{ width: 130 }}
/>
</Form.Item>
<Button
type="link"
size="small"
danger
disabled={
fields.length <= 1
}
onClick={() =>
remove(field.name)
}
>
删除
</Button>
</Space>
</Form.Item>
))}
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="dashed"
block
icon={<PlusOutlined />}
onClick={() =>
add({
questionType: "single",
num: 1,
score: 1,
})
}
>
添加规则
</Button>
<Form.ErrorList errors={errors} />
</Form.Item>
</>
)}
</Form.List>
</Form.Item>
</Form>
)}
</Modal>
);
}
/** 试卷考试信息预览弹窗:基本信息 + 试题列表(试卷详情/查看/预览共用) */
function PaperExamPreviewModal({
open,
paperId,
title,
loading,
fetchExamInfo,
resetModelState,
onClose,
}) {
const [info, setInfo] = useState(null);
useEffect(() => {
if (!open) {
resetModelState?.(NS_PAPER, { paperExamLoading: false });
return;
}
if (!paperId) return;
setInfo(null);
fetchExamInfo({ paperId }).then((res) => {
if (res?.success !== false) setInfo(res?.data || null);
});
}, [open, paperId]);
const basic = info?.paperBasicInfoVO || {};
const columns = [
{
title: "序号",
width: 60,
render: (_, __, index) => index + 1,
},
{
title: "题型",
dataIndex: "questionType",
width: 70,
render: (v) => QUESTION_TYPE_MAP[v] ?? (v || "-"),
},
{
title: "题干",
dataIndex: "title",
render: (v, record) => (
<>
<div>{v || "-"}</div>
{(record.questionOptionVOList || []).map((o) => (
<div key={o.id}>
{o.optionKey}. {o.optionText}
</div>
))}
</>
),
},
{ title: "分值", dataIndex: "score", width: 70 },
{ title: "答案", dataIndex: "answer", width: 90 },
];
return (
<Modal
open={open}
title={title}
width={760}
footer={null}
destroyOnHidden
loading={loading}
onCancel={onClose}
>
<Descriptions
size="small"
bordered
column={2}
style={{ marginBottom: 16 }}
>
<Descriptions.Item label="试卷名称">
{basic.paperName || "-"}
</Descriptions.Item>
<Descriptions.Item label="试卷类型">
{CLASS_PAPER_TYPE_MAP[basic.paperType] ??
(basic.paperType || "-")}
</Descriptions.Item>
<Descriptions.Item label="试卷总分">
{basic.paperTotalScore ?? "-"}
</Descriptions.Item>
<Descriptions.Item label="合格分数">
{basic.paperPassScore ?? "-"}
</Descriptions.Item>
<Descriptions.Item label="考试时长(分钟)">
{basic.examTime ?? "-"}
</Descriptions.Item>
</Descriptions>
<Table
rowKey="id"
size="small"
columns={columns}
dataSource={Array.isArray(info?.questionVOList) ? info.questionVOList : []}
pagination={false}
scroll={{ y: 360 }}
/>
</Modal>
);
}
export default Connect([NS_COURSEWARE, NS_PAPER], true)(ClassPaperConfig);