<script lang="ts" setup>
|
import type { PaperEntity, PaperQuestionItem, PaperSectionItem } from './types';
|
import type { QuestionEntity } from '#/views/x/tms/question/types';
|
|
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
|
import { useRoute, useRouter } from 'vue-router';
|
|
import { useMessage } from '@jnpf/hooks';
|
import { useModal } from '@jnpf/ui/modal';
|
|
import { Modal } from 'ant-design-vue';
|
import dayjs from 'dayjs';
|
|
import { createPaper, getPaperInfo, updatePaper } from '#/api/x/tms/paper';
|
import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
|
import { loadQuestionDics } from '#/views/x/tms/question/constants';
|
|
import QuestionPicker from './components/QuestionPicker.vue';
|
import {
|
QUESTION_TYPE_OPTIONS,
|
SORT_MODE_OPTIONS,
|
STATUS_OPTIONS,
|
labelOfType,
|
loadPaperDics,
|
nextTmpId,
|
} from './constants';
|
|
defineOptions({ name: 'TmsPaperForm' });
|
|
const DT_FMT = 'YYYY-MM-DD HH:mm:ss';
|
|
const route = useRoute();
|
const router = useRouter();
|
const { createMessage } = useMessage();
|
|
const loading = ref(false);
|
const submitting = ref(false);
|
const formRef = ref();
|
const activeSectionId = ref('');
|
|
const isEdit = computed(() => !!route.params.id && route.params.id !== 'create');
|
const pageTitle = computed(() => (isEdit.value ? '编辑试卷' : '新增试卷'));
|
|
const dataForm = reactive<PaperEntity>({
|
paperName: '',
|
bizStatus: 'open',
|
durationMin: 60,
|
examStart: undefined,
|
examEnd: undefined,
|
scorePublishTime: undefined,
|
passScore: undefined,
|
totalScore: 0,
|
sortMode: 'paper',
|
remark: '',
|
sections: [],
|
});
|
|
const statusTip = computed(
|
() => STATUS_OPTIONS.value.find((x) => (x.enCode || x.id) === dataForm.bizStatus)?.tip,
|
);
|
const statusTipColor = computed(
|
() => STATUS_OPTIONS.value.find((x) => (x.enCode || x.id) === dataForm.bizStatus)?.tipColor,
|
);
|
const editableStatusOptions = computed(() =>
|
STATUS_OPTIONS.value.filter((x) => (x.enCode || x.id) !== 'invalid'),
|
);
|
|
const computedTotal = computed(() => {
|
let sum = 0;
|
for (const sec of dataForm.sections || []) {
|
for (const q of sec.questions || []) {
|
sum += Number(q.score || 0);
|
}
|
}
|
return Math.round(sum * 10) / 10;
|
});
|
|
function toDayjs(v: any) {
|
if (v == null || v === '') return null;
|
if (typeof v === 'number') {
|
const d = dayjs(v);
|
return d.isValid() ? d : null;
|
}
|
const text = String(v).trim();
|
if (!text) return null;
|
if (/^\d+$/.test(text)) {
|
const n = Number(text);
|
const d = dayjs(text.length === 10 ? n * 1000 : n);
|
return d.isValid() ? d : null;
|
}
|
const d = dayjs(text);
|
return d.isValid() ? d : null;
|
}
|
|
function formatDateTime(v: any): string | undefined {
|
const d = toDayjs(v);
|
return d ? d.format(DT_FMT) : undefined;
|
}
|
|
const validateExamEnd = async (_rule: any, value: any) => {
|
const end = toDayjs(value);
|
const start = toDayjs(dataForm.examStart);
|
if (!end && !start) return;
|
if (start && !end) {
|
return Promise.reject('请选择结束时间');
|
}
|
if (!start && end) {
|
return Promise.reject('请先选择开考时间');
|
}
|
if (start && end && !end.isAfter(start)) {
|
return Promise.reject('结束时间必须晚于开考时间');
|
}
|
const duration = Number(dataForm.durationMin || 0);
|
if (start && end && duration > 0) {
|
const spanMin = end.diff(start, 'minute');
|
if (duration > spanMin) {
|
return Promise.reject(`考试时长不能超过开考至结束的间隔(${spanMin} 分钟)`);
|
}
|
}
|
};
|
|
const validateExamStart = async (_rule: any, value: any) => {
|
const start = toDayjs(value);
|
const end = toDayjs(dataForm.examEnd);
|
if (!start && end) {
|
return Promise.reject('请选择开考时间');
|
}
|
if (start && end && !end.isAfter(start)) {
|
return Promise.reject('开考时间必须早于结束时间');
|
}
|
};
|
|
const validatePublishTime = async (_rule: any, value: any) => {
|
const publish = toDayjs(value);
|
if (!publish) return;
|
const end = toDayjs(dataForm.examEnd);
|
const start = toDayjs(dataForm.examStart);
|
if (end && publish.isBefore(end)) {
|
return Promise.reject('成绩公布时间不能早于结束时间');
|
}
|
if (!end && start && publish.isBefore(start)) {
|
return Promise.reject('成绩公布时间不能早于开考时间');
|
}
|
};
|
|
const validateDuration = async (_rule: any, value: any) => {
|
if (value == null || value === '') return;
|
const n = Number(value);
|
if (Number.isNaN(n) || n < 0) {
|
return Promise.reject('考试时长不能为负数');
|
}
|
const start = toDayjs(dataForm.examStart);
|
const end = toDayjs(dataForm.examEnd);
|
if (start && end && n > 0) {
|
const spanMin = end.diff(start, 'minute');
|
if (n > spanMin) {
|
return Promise.reject(`考试时长不能超过开考至结束的间隔(${spanMin} 分钟)`);
|
}
|
}
|
};
|
|
const validatePassScore = async (_rule: any, value: any) => {
|
if (value == null || value === '') return;
|
const pass = Number(value);
|
if (Number.isNaN(pass) || pass < 0) {
|
return Promise.reject('合格分数不能为负数');
|
}
|
const total = computedTotal.value;
|
if (pass > total) {
|
return Promise.reject(`合格分数不能大于试卷总分(当前总分 ${total})`);
|
}
|
};
|
|
const rules = {
|
paperName: [{ required: true, message: '请填写试卷名称', trigger: 'blur' }],
|
bizStatus: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
sortMode: [{ required: true, message: '请选择试题排序', trigger: 'change' }],
|
durationMin: [{ validator: validateDuration, trigger: 'change' }],
|
examStart: [{ validator: validateExamStart, trigger: 'change' }],
|
examEnd: [{ validator: validateExamEnd, trigger: 'change' }],
|
scorePublishTime: [{ validator: validatePublishTime, trigger: 'change' }],
|
passScore: [{ validator: validatePassScore, trigger: 'change' }],
|
};
|
|
function revalidateTimes() {
|
formRef.value?.validateFields?.(['examStart', 'examEnd', 'scorePublishTime', 'durationMin']).catch(() => undefined);
|
}
|
|
function revalidatePassScore() {
|
formRef.value?.validateFields?.(['passScore']).catch(() => undefined);
|
}
|
|
function toTimestamp(v: any): number | undefined {
|
const d = toDayjs(v);
|
return d ? d.valueOf() : undefined;
|
}
|
|
const [registerPicker, { openModal: openPicker }] = useModal();
|
|
onMounted(async () => {
|
await Promise.all([loadPaperDics(), loadQuestionDics()]);
|
if (isEdit.value) {
|
await loadDetail(String(route.params.id));
|
}
|
});
|
|
async function loadDetail(id: string) {
|
loading.value = true;
|
try {
|
const info = await getPaperInfo(id);
|
Object.assign(dataForm, {
|
...info,
|
sections: (info.sections || []).map((s) => ({
|
...s,
|
id: s.id || nextTmpId('sec'),
|
questions: (s.questions || []).map((q) => ({ ...q })),
|
})),
|
});
|
if (dataForm.sections?.length) {
|
activeSectionId.value = dataForm.sections[0]!.id;
|
}
|
} catch (e: any) {
|
createMessage.error(e?.message || '加载失败');
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
function goList() {
|
router.push('/tms/paper');
|
}
|
|
function handleCancel() {
|
Modal.confirm({
|
title: '确认取消',
|
content: '确定取消编辑吗?未保存的内容将丢失。',
|
okText: '确定',
|
cancelText: '继续编辑',
|
onOk: () => goList(),
|
});
|
}
|
|
function addSection() {
|
const sec: PaperSectionItem = {
|
id: nextTmpId('sec'),
|
sectionName: '单选题',
|
questionType: 'single',
|
sortNo: (dataForm.sections?.length || 0) + 1,
|
questions: [],
|
};
|
if (!dataForm.sections) dataForm.sections = [];
|
dataForm.sections.push(sec);
|
activeSectionId.value = sec.id;
|
nextTick(() => {
|
document.getElementById(`paper-sec-${sec.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
});
|
}
|
|
function removeSection(secId: string) {
|
dataForm.sections = (dataForm.sections || []).filter((s) => s.id !== secId);
|
if (activeSectionId.value === secId) {
|
activeSectionId.value = dataForm.sections[0]?.id || '';
|
}
|
}
|
|
function onSectionTypeChange(sec: PaperSectionItem) {
|
if (sec.questionType === 'single') sec.sectionName = '单选题';
|
else if (sec.questionType === 'multi') sec.sectionName = '多选题';
|
else if (sec.questionType === 'judge') sec.sectionName = '判断题';
|
else if (sec.questionType === 'blank') sec.sectionName = '填空题';
|
else if (sec.questionType === 'essay') sec.sectionName = '问答题';
|
}
|
|
function openPick(sec: PaperSectionItem) {
|
if (!sec.questionType) {
|
createMessage.warning('请先选择章节题型');
|
return;
|
}
|
activeSectionId.value = sec.id;
|
const excludeIds = (dataForm.sections || [])
|
.flatMap((s) => s.questions || [])
|
.map((q) => q.questionId)
|
.filter(Boolean);
|
openPicker(true, { questionType: sec.questionType, excludeIds });
|
}
|
|
function onPickConfirm(rows: QuestionEntity[]) {
|
const sec = (dataForm.sections || []).find((s) => s.id === activeSectionId.value);
|
if (!sec) return;
|
const start = sec.questions.length;
|
for (let i = 0; i < rows.length; i++) {
|
const row = rows[i]!;
|
if (!row.id) continue;
|
if (sec.questionType && row.questionType && row.questionType !== sec.questionType) {
|
createMessage.warning(`试题 ${row.questionNo} 与章节题型不一致,已跳过`);
|
continue;
|
}
|
sec.questions.push({
|
questionId: row.id,
|
questionNo: row.questionNo,
|
stem: row.stem,
|
questionType: row.questionType,
|
bankName: row.bankName,
|
score: 1,
|
sortNo: start + i + 1,
|
} as PaperQuestionItem);
|
}
|
syncTotal();
|
}
|
|
function removeQuestion(sec: PaperSectionItem, idx: number) {
|
sec.questions.splice(idx, 1);
|
sec.questions.forEach((q, i) => {
|
q.sortNo = i + 1;
|
});
|
syncTotal();
|
}
|
|
function moveQuestion(sec: PaperSectionItem, idx: number, delta: number) {
|
const target = idx + delta;
|
if (target < 0 || target >= sec.questions.length) return;
|
const list = sec.questions;
|
const tmp = list[idx]!;
|
list[idx] = list[target]!;
|
list[target] = tmp;
|
list.forEach((q, i) => {
|
q.sortNo = i + 1;
|
});
|
}
|
|
function syncTotal() {
|
dataForm.totalScore = computedTotal.value;
|
revalidatePassScore();
|
}
|
|
function stripHtml(html?: string) {
|
if (!html) return '';
|
const text = html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim();
|
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
}
|
|
async function handleSubmit() {
|
try {
|
await formRef.value?.validate();
|
} catch {
|
return;
|
}
|
if (!(dataForm.sections || []).some((s) => (s.questions || []).length)) {
|
createMessage.warning('请至少添加一道试题');
|
return;
|
}
|
for (const sec of dataForm.sections || []) {
|
for (const q of sec.questions || []) {
|
if (q.score == null || Number(q.score) <= 0) {
|
createMessage.warning(`试题 ${q.questionNo || ''} 分值必须大于 0`);
|
return;
|
}
|
}
|
}
|
if (dataForm.passScore != null && Number(dataForm.passScore) > computedTotal.value) {
|
createMessage.warning(`合格分数不能大于试卷总分(当前总分 ${computedTotal.value})`);
|
return;
|
}
|
syncTotal();
|
submitting.value = true;
|
try {
|
const payload: PaperEntity = {
|
...dataForm,
|
examStart: formatDateTime(dataForm.examStart) ?? null,
|
examEnd: formatDateTime(dataForm.examEnd) ?? null,
|
scorePublishTime: formatDateTime(dataForm.scorePublishTime) ?? null,
|
sections: (dataForm.sections || []).map((s, si) => ({
|
...s,
|
sortNo: si + 1,
|
questions: (s.questions || []).map((q, qi) => ({
|
questionId: q.questionId,
|
score: q.score ?? 0,
|
sortNo: qi + 1,
|
})),
|
})),
|
};
|
if (isEdit.value) {
|
await updatePaper(payload);
|
createMessage.success('更新成功');
|
} else {
|
await createPaper(payload);
|
createMessage.success('创建成功');
|
}
|
goList();
|
} catch (e: any) {
|
createMessage.error(e?.message || '保存失败');
|
} finally {
|
submitting.value = false;
|
}
|
}
|
</script>
|
|
<template>
|
<div class="jnpf-content-wrapper tms-paper-form-page" v-loading="loading">
|
<div class="jnpf-content-wrapper-center">
|
<div class="jnpf-content-wrapper-content tms-paper-form-wrap">
|
<div class="page-toolbar">
|
<div class="page-title">{{ pageTitle }}</div>
|
</div>
|
|
<div class="tms-paper-form-body">
|
<a-card title="基本信息" :bordered="false" class="form-card">
|
<a-form ref="formRef" :model="dataForm" :rules="rules" :label-col="{ style: { width: '120px' } }">
|
<a-row :gutter="16">
|
<a-col v-if="dataForm.id" :span="12">
|
<a-form-item label="试卷编号">
|
<a-input :value="dataForm.paperNo || '-'" disabled />
|
</a-form-item>
|
</a-col>
|
<a-col :span="12">
|
<a-form-item label="试卷名称" name="paperName">
|
<a-input v-model:value="dataForm.paperName" placeholder="请输入试卷名称" :maxlength="200" />
|
</a-form-item>
|
</a-col>
|
<a-col :span="12">
|
<a-form-item label="试卷状态" name="bizStatus">
|
<a-select
|
v-model:value="dataForm.bizStatus"
|
:options="editableStatusOptions"
|
:field-names="TMS_DIC_FIELD_NAMES"
|
style="width: 160px"
|
/>
|
<span v-if="statusTip" class="status-tip" :style="{ color: statusTipColor }">{{ statusTip }}</span>
|
</a-form-item>
|
</a-col>
|
<a-col :span="12">
|
<a-form-item label="考试时长" name="durationMin">
|
<a-input-number
|
v-model:value="dataForm.durationMin"
|
:min="0"
|
:precision="0"
|
style="width: 160px"
|
@change="revalidateTimes"
|
/>
|
<span class="field-hint">分钟(不应超过设置的时间间隔)</span>
|
</a-form-item>
|
</a-col>
|
<a-col :span="12">
|
<a-form-item label="试题排序" name="sortMode">
|
<a-radio-group v-model:value="dataForm.sortMode">
|
<a-radio v-for="opt in SORT_MODE_OPTIONS" :key="opt.enCode || opt.id" :value="opt.enCode || opt.id">
|
{{ opt.fullName }}
|
</a-radio>
|
</a-radio-group>
|
</a-form-item>
|
</a-col>
|
<a-col :span="12">
|
<a-form-item label="开考时间" name="examStart">
|
<jnpf-date-picker
|
v-model:value="dataForm.examStart"
|
format="YYYY-MM-DD HH:mm:ss"
|
placeholder="请选择开考时间"
|
style="width: 100%"
|
:end-time="toTimestamp(dataForm.examEnd)"
|
@change="revalidateTimes"
|
/>
|
</a-form-item>
|
</a-col>
|
<a-col :span="12">
|
<a-form-item label="结束时间" name="examEnd">
|
<jnpf-date-picker
|
v-model:value="dataForm.examEnd"
|
format="YYYY-MM-DD HH:mm:ss"
|
placeholder="请选择结束时间"
|
style="width: 100%"
|
:start-time="toTimestamp(dataForm.examStart)"
|
@change="revalidateTimes"
|
/>
|
</a-form-item>
|
</a-col>
|
<a-col :span="12">
|
<a-form-item label="成绩公布时间" name="scorePublishTime">
|
<jnpf-date-picker
|
v-model:value="dataForm.scorePublishTime"
|
format="YYYY-MM-DD HH:mm:ss"
|
placeholder="立即公布请留空"
|
style="width: 100%"
|
:start-time="toTimestamp(dataForm.examEnd || dataForm.examStart)"
|
@change="revalidateTimes"
|
/>
|
<div class="field-hint block">设置成绩公布时间,立即公布请留空</div>
|
</a-form-item>
|
</a-col>
|
<a-col :span="12">
|
<a-form-item label="试卷总分">
|
<a-input-number :value="computedTotal" disabled style="width: 160px" />
|
<span class="field-hint">由组卷分值自动汇总</span>
|
</a-form-item>
|
</a-col>
|
<a-col :span="12">
|
<a-form-item label="合格分数" name="passScore">
|
<a-input-number
|
v-model:value="dataForm.passScore"
|
:min="0"
|
:precision="1"
|
style="width: 160px"
|
@change="revalidatePassScore"
|
/>
|
<span class="field-hint">不能大于试卷总分</span>
|
</a-form-item>
|
</a-col>
|
<a-col :span="24">
|
<a-form-item label="备注">
|
<a-textarea v-model:value="dataForm.remark" :rows="3" placeholder="请输入备注" />
|
</a-form-item>
|
</a-col>
|
</a-row>
|
</a-form>
|
</a-card>
|
|
<a-card title="组卷" :bordered="false" class="form-card">
|
<template #extra>
|
<a-button type="primary" @click="addSection">添加章节</a-button>
|
</template>
|
|
<a-empty v-if="!(dataForm.sections || []).length" description="请添加章节并从试题管理选题组卷" />
|
|
<div
|
v-for="sec in dataForm.sections"
|
:id="`paper-sec-${sec.id}`"
|
:key="sec.id"
|
class="section-block"
|
>
|
<div class="section-head">
|
<a-input v-model:value="sec.sectionName" placeholder="章节名称" style="width: 200px" />
|
<a-select
|
v-model:value="sec.questionType"
|
placeholder="题型"
|
style="width: 140px; margin-left: 8px"
|
:options="QUESTION_TYPE_OPTIONS"
|
:field-names="TMS_DIC_FIELD_NAMES"
|
@change="onSectionTypeChange(sec)"
|
/>
|
<a-space style="margin-left: auto">
|
<a-button type="link" @click="openPick(sec)">选题</a-button>
|
<a-button type="link" danger @click="removeSection(sec.id)">删除章节</a-button>
|
</a-space>
|
</div>
|
|
<a-table
|
size="small"
|
row-key="questionId"
|
:pagination="false"
|
:data-source="sec.questions"
|
:columns="[
|
{ title: '序号', width: 60, key: 'idx' },
|
{ title: '编号', dataIndex: 'questionNo', width: 90 },
|
{ title: '题型', width: 80, key: 'qtype' },
|
{ title: '题干', dataIndex: 'stem', ellipsis: true, key: 'stem' },
|
{ title: '题库', dataIndex: 'bankName', width: 140, ellipsis: true },
|
{ title: '分值', width: 110, key: 'score' },
|
{ title: '操作', width: 160, key: 'action' },
|
]"
|
>
|
<template #bodyCell="{ column, record, index }">
|
<template v-if="column.key === 'idx'">{{ index + 1 }}</template>
|
<template v-else-if="column.key === 'qtype'">{{ labelOfType(record.questionType) }}</template>
|
<template v-else-if="column.key === 'stem'">{{ stripHtml(record.stem) }}</template>
|
<template v-else-if="column.key === 'score'">
|
<a-input-number
|
v-model:value="record.score"
|
:min="0"
|
:precision="1"
|
size="small"
|
style="width: 90px"
|
@change="syncTotal"
|
/>
|
</template>
|
<template v-else-if="column.key === 'action'">
|
<a-space>
|
<a @click="moveQuestion(sec, index, -1)">上移</a>
|
<a @click="moveQuestion(sec, index, 1)">下移</a>
|
<a class="danger" @click="removeQuestion(sec, index)">移除</a>
|
</a-space>
|
</template>
|
</template>
|
</a-table>
|
</div>
|
</a-card>
|
|
<div class="form-footer">
|
<a-space>
|
<a-button @click="handleCancel">取消</a-button>
|
<a-button type="primary" :loading="submitting" @click="handleSubmit">保存</a-button>
|
</a-space>
|
</div>
|
</div>
|
</div>
|
</div>
|
|
<QuestionPicker @register="registerPicker" @confirm="onPickConfirm" />
|
</div>
|
</template>
|
|
<style scoped>
|
.tms-paper-form-page {
|
height: 100%;
|
min-height: 0;
|
}
|
|
.tms-paper-form-wrap {
|
display: flex;
|
flex-direction: column;
|
height: 100%;
|
min-height: 0;
|
overflow: hidden;
|
background: #fff;
|
padding: 16px;
|
}
|
|
.page-toolbar {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
flex-shrink: 0;
|
margin-bottom: 12px;
|
padding-bottom: 12px;
|
border-bottom: 1px solid #f0f0f0;
|
}
|
|
.page-title {
|
font-size: 16px;
|
font-weight: 600;
|
}
|
|
.tms-paper-form-body {
|
flex: 1;
|
min-height: 0;
|
overflow-x: hidden;
|
overflow-y: auto;
|
}
|
|
.form-card {
|
margin-bottom: 12px;
|
}
|
|
.status-tip,
|
.field-hint {
|
margin-left: 8px;
|
color: #999;
|
font-size: 12px;
|
}
|
|
.field-hint.block {
|
display: block;
|
margin: 4px 0 0;
|
}
|
|
.section-block {
|
margin-bottom: 16px;
|
padding: 12px;
|
background: #fafafa;
|
border: 1px solid #f0f0f0;
|
border-radius: 4px;
|
}
|
|
.section-head {
|
display: flex;
|
align-items: center;
|
margin-bottom: 8px;
|
}
|
|
.form-footer {
|
padding: 16px 0 24px;
|
text-align: center;
|
}
|
|
.danger {
|
color: #ff4d4f;
|
}
|
</style>
|