761 lines
28 KiB
JavaScript
761 lines
28 KiB
JavaScript
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);
|