tangjie 2026-08-21 18:52:25 +08:00
commit 9333a712d8
5 changed files with 803 additions and 8 deletions

View File

@ -89,6 +89,12 @@ export const classRemove = declareRequest(
"Post > @/safetyEval/classManagement/delete",
);
/** 绑定试卷(请求体:{ id, paperId, paperType, examMinutes } */
export const classBindPaper = declareRequest(
"classBindPaperLoading",
"Post > @/safetyEval/classManagement/bindPaper",
);
/** 分页查询班级学员 */
export const classStudentPage = declareRequest(
"classStudentLoading",

View File

@ -9,6 +9,24 @@ export const paperPage = declareRequest(
"paperList: [] | res.data || [] & paperTotal: 0 | res.total || 0",
);
/** 试卷基本信息查询(不含试题列表) */
export const paperBasicInfo = declareRequest(
"paperBasicLoading",
"Get > /safetyEval/paper/paper/basic/info",
);
/** 试卷考试信息查询(基本信息 + 试题列表) */
export const paperExamInfo = declareRequest(
"paperExamLoading",
"Get > /safetyEval/paper/paper/exam/info",
);
/** 自动生成试卷(按课件与题型规则抽题组卷) */
export const paperRuleGenerate = declareRequest(
"paperRuleLoading",
"Post > @/safetyEval/paper/courseware/question/rule",
);
/** 修改试卷基本信息 */
export const paperUpdateBasic = declareRequest(
"paperUpdateLoading",

View File

@ -738,6 +738,17 @@ export const PAPER_TYPE_MAP = PAPER_TYPE_OPTIONS.reduce(
{},
);
/** 班级配置试卷类型fixed平台试卷/config_rule自动生成试卷 */
export const CLASS_PAPER_TYPE_OPTIONS = [
{ label: "平台试卷", value: "fixed" },
{ label: "自动生成试卷", value: "config_rule" },
];
export const CLASS_PAPER_TYPE_MAP = CLASS_PAPER_TYPE_OPTIONS.reduce(
(acc, cur) => ({ ...acc, [cur.value]: cur.label }),
{},
);
/** 题型single单选/multiple多选/judge判断 */
export const QUESTION_TYPE_OPTIONS = [
{ label: "单选", value: "single" },

View File

@ -1,11 +1,12 @@
import { useEffect, useState } from "react";
import { Badge, Button, Form, Input, Modal, Table, message } from "antd";
import { Badge, Button, Form, Input, Modal, Space, Table, message } from "antd";
import ControlWrapper from "@cqsjjb/jjb-react-admin-component/ControlWrapper";
import SearchForm from "~/components/SearchForm";
import TableAction from "@cqsjjb/jjb-react-admin-component/TableAction";
import { Connect } from "@cqsjjb/jjb-dva-runtime";
import { NS_COURSEWARE } from "~/enumerate/namespace";
import { COURSEWARE_STATUS_MAP } from "~/enumerate/constant";
import ClassPaperConfig from "./PaperConfig";
/** 视频累计时长(小时)→ “X分钟Y秒” */
const formatVideoDuration = (hours) => {
@ -144,13 +145,12 @@ function ClassCourseTab(props) {
}}
/>
<Button
type="primary"
style={{ marginBottom: 12 }}
onClick={() => setSelectOpen(true)}
>
选择课程
</Button>
<Space style={{ marginBottom: 12 }}>
<Button type="primary" onClick={() => setSelectOpen(true)}>
选择课程
</Button>
<ClassPaperConfig classId={classId} />
</Space>
<Table
rowKey="id"

View File

@ -0,0 +1,760 @@
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);