diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvActivityController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvActivityController.java index dccf7d6..2978122 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvActivityController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvActivityController.java @@ -17,6 +17,7 @@ import org.nutz.dao.Dao; import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.util.NutMap; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; @@ -62,8 +63,13 @@ public class QsvActivityController { @Aop(TransAop.READ_COMMITTED) @ApiOperation("保存问卷基础信息") @SLog(type = "qsv.activity", tag = "保存问卷基础信息", msg = "保存问卷基础信息") - public Result save(QsvActivity qsvActivity) { - QsvActivity activity = qsvActivityService.saveActivity(qsvActivity); + public Result save(QsvActivity qsvActivity, @Param("forceSave") Boolean forceSave) { + NutMap confirmResult = qsvActivityService.checkAnswerModeChangeConfirm(qsvActivity); + // Mode changes with existing answer records must be returned as a normal response, so the page can show a confirm dialog. + if (!Boolean.TRUE.equals(forceSave) && Boolean.TRUE.equals(confirmResult.get("needConfirm"))) { + return Result.success(confirmResult.getString("message"), confirmResult); + } + QsvActivity activity = qsvActivityService.saveActivity(qsvActivity, forceSave); return Result.success(activity); } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvOnlineController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvOnlineController.java index d1fffe5..ad1a91f 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvOnlineController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvOnlineController.java @@ -239,6 +239,11 @@ public class QsvOnlineController { if (extJson == null) { extJson = new JSONObject(); } + // 提交写入前统一校验多选题最大选择数,避免绕过页面限制提交超量选项。 + String maxMultiLimitMessage = qsvUserAnswerRecordService.checkMaxMultiLimit(qsvAnswerParam); + if (maxMultiLimitMessage != null) { + return Result.error(maxMultiLimitMessage); + } float totalScore = 0; boolean isQuiz = "QUIZ".equals(activity.getCategory()); for (QsvAnswerParam.Subject subject : qsvAnswerParam.getSubjects()) { diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvSurveyController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvSurveyController.java index 5ba96df..7d61762 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvSurveyController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvSurveyController.java @@ -189,15 +189,9 @@ public class QsvSurveyController { } private PDType0Font loadChineseFont(PDDocument document, boolean bold) throws IOException { - String[] paths = bold ? new String[]{ - "C:/Windows/Fonts/simhei.ttf", - "C:/Windows/Fonts/simsunb.ttf", - "C:/Windows/Fonts/msyhbd.ttf" - } : new String[]{ - "C:/Windows/Fonts/simfang.ttf", - "C:/Windows/Fonts/simsun.ttf", - "C:/Windows/Fonts/msyh.ttf" - }; + String[] paths = bold + ? new String[]{"/usr/share/fonts/chinese/simhei.ttf", "/usr/share/fonts/chinese/msyhbd.ttc", "/usr/share/fonts/chinese/simsun.ttc"} + : new String[]{"/usr/share/fonts/chinese/simfang.ttf", "/usr/share/fonts/chinese/simsun.ttc", "/usr/share/fonts/chinese/msyh.ttc"}; for (String path : paths) { File file = new File(path); if (file.exists()) { @@ -215,6 +209,9 @@ public class QsvSurveyController { private final float pageWidth = PDRectangle.A4.getWidth(); private final float pageHeight = PDRectangle.A4.getHeight(); private final float contentWidth = pageWidth - margin * 2; + private final float sectionTopGap = 14F; + private final float sectionMinHeight = 88F; + private final float tableBottomGap = 16F; private PDPageContentStream content; private float y; @@ -260,7 +257,12 @@ public class QsvSurveyController { } void section(String text) throws IOException { - ensure(30); + // Reserve enough space for the subject title and summary line before drawing the next question. + ensure(sectionMinHeight); + if (y < pageHeight - margin) { + y -= sectionTopGap; + ensure(sectionMinHeight - sectionTopGap); + } drawText(text, margin, y, 13, boldFont); y -= 24; } @@ -294,10 +296,15 @@ public class QsvSurveyController { void table(JSONArray rows) throws IOException { if (rows == null || rows.isEmpty()) { + // Empty text-question summaries also need bottom spacing before the next question starts. + ensure(42); text("暂无统计数据", 10); + gap(tableBottomGap); return; } float[] widths = new float[]{contentWidth * 0.46F, contentWidth * 0.15F, contentWidth * 0.17F, contentWidth * 0.22F}; + // Keep the table header with at least the first data row when a table starts near the page bottom. + ensure(48); drawTableRow(new String[]{"选项/内容", "数量", "占比", "备注"}, widths, true); for (int i = 0; i < rows.size(); i++) { JSONObject row = JSONUtil.parseObj(rows.get(i)); @@ -308,6 +315,7 @@ public class QsvSurveyController { StrUtil.blankToDefault(row.getStr("remark"), "") }, widths, false); } + gap(tableBottomGap); } void drawTableRow(String[] values, float[] widths, boolean header) throws IOException { diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvQuizController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvQuizController.java index c465775..838d0ee 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvQuizController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvQuizController.java @@ -338,6 +338,11 @@ public class H5QsvQuizController { @Aop(TransAop.READ_COMMITTED) public Result submitAnswer(@Valid @Param("answer") QsvAnswerParam qsvAnswerParam) { QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId()); + // 提交写入前统一校验多选题最大选择数,避免移动端绕过页面限制提交超量选项。 + String maxMultiLimitMessage = qsvUserAnswerRecordService.checkMaxMultiLimit(qsvAnswerParam); + if (maxMultiLimitMessage != null) { + return Result.error(maxMultiLimitMessage); + } JSONObject extJson = answerRecord.getExtJson(); float totalScore = 0; diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvSurveyController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvSurveyController.java index 264af86..4d1ca20 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvSurveyController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvSurveyController.java @@ -194,6 +194,11 @@ public class H5QsvSurveyController { } QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId()); + // 提交写入前统一校验多选题最大选择数,避免移动端绕过页面限制提交超量选项。 + String maxMultiLimitMessage = qsvUserAnswerRecordService.checkMaxMultiLimit(qsvAnswerParam); + if (maxMultiLimitMessage != null) { + return Result.error(maxMultiLimitMessage); + } JSONObject extJson = answerRecord.getExtJson(); for (QsvAnswerParam.Subject subject : qsvAnswerParam.getSubjects()) { diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvVoteController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvVoteController.java index aea49c0..f8c7b4e 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvVoteController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvVoteController.java @@ -152,6 +152,11 @@ public class H5QsvVoteController { if (extJson == null) { extJson = new JSONObject(); } + // 提交写入前统一校验多选题最大选择数,避免移动端投票绕过页面限制提交超量选项。 + String maxMultiLimitMessage = qsvUserAnswerRecordService.checkMaxMultiLimit(qsvAnswerParam); + if (maxMultiLimitMessage != null) { + return Result.error(maxMultiLimitMessage); + } for (QsvAnswerParam.Subject subject : qsvAnswerParam.getSubjects()) { // 逐题保存用户选择和补充内容,便于统计票数与查看详情。 diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvSubject.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvSubject.java index 47e6d63..c132fbe 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvSubject.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvSubject.java @@ -34,10 +34,15 @@ public class QsvSubject extends BaseModel implements Serializable { private String title; @Column - @Comment("题目类型(single, multi, judge, fill)") + @Comment("题目类型(radio单选,checkbox多选,text填空)") @ColDefine(type = ColType.VARCHAR, width = 50) private String type; + @Column + @Comment("题目组件展示类型(RADIO普通单选,DROPDOWN下拉选择)") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String componentType; + @Column @Comment("正确答案") @ColDefine(type = ColType.MYSQL_JSON) diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvActivityService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvActivityService.java index f57b880..9a1ffe9 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvActivityService.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvActivityService.java @@ -3,11 +3,25 @@ package com.budwk.app.zhgh.dayofficework.qsv.service; import com.budwk.app.base.service.BaseService; import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity; +import org.nutz.lang.util.NutMap; public interface QsvActivityService extends BaseService { + /** + * Save activity with normal mode-change protection. + */ QsvActivity saveActivity(QsvActivity qsvActivity); + /** + * Save activity and optionally bypass the answer-mode change confirmation after the user confirms. + */ + QsvActivity saveActivity(QsvActivity qsvActivity, Boolean forceSave); + + /** + * Check whether changing answer mode needs user confirmation because answer records already exist. + */ + NutMap checkAnswerModeChangeConfirm(QsvActivity qsvActivity); + void deleteActivity(String id); void updateEnabled(String id, Boolean enabled); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvUserAnswerRecordService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvUserAnswerRecordService.java index bb2ae45..269900c 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvUserAnswerRecordService.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvUserAnswerRecordService.java @@ -17,6 +17,14 @@ public interface QsvUserAnswerRecordService extends BaseService impleme @Override @Aop(TransAop.READ_COMMITTED) public QsvActivity saveActivity(QsvActivity qsvActivity) { + return saveActivity(qsvActivity, false); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public QsvActivity saveActivity(QsvActivity qsvActivity, Boolean forceSave) { + NutMap confirmResult = checkAnswerModeChangeConfirm(qsvActivity); + if (!Boolean.TRUE.equals(forceSave) && Boolean.TRUE.equals(confirmResult.get("needConfirm"))) { + throw new IllegalStateException(confirmResult.getString("message")); + } fillQuizRepeatMode(qsvActivity); dao().insertOrUpdate(qsvActivity); syncHomeActivity(qsvActivity); return qsvActivity; } + @Override + public NutMap checkAnswerModeChangeConfirm(QsvActivity qsvActivity) { + NutMap result = NutMap.NEW(); + result.put("needConfirm", false); + result.put("message", ""); + if (qsvActivity == null || Strings.isBlank(qsvActivity.getId())) { + return result; + } + + QsvActivity oldActivity = dao().fetch(QsvActivity.class, qsvActivity.getId()); + if (oldActivity == null || !isAnswerModeChanged(oldActivity, qsvActivity)) { + return result; + } + + int answerRecordCount = dao().count(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", qsvActivity.getId())); + if (answerRecordCount < 1) { + return result; + } + + // Answer-mode changes affect unfinished answers, submitted answers and statistics, so the editor must confirm first. + result.put("needConfirm", true); + result.put("message", "\u5f53\u524d\u95ee\u5377\u5df2\u6709\u7528\u6237\u7b54\u9898\u4e2d\u6216\u5df2\u7b54\u9898\u5b8c\u6210\uff0c\u4fee\u6539\u7b54\u9898\u6a21\u5f0f\u53ef\u80fd\u5f71\u54cd\u73b0\u6709\u7b54\u9898\u8bb0\u5f55\u548c\u7edf\u8ba1\u7ed3\u679c\uff0c\u662f\u5426\u786e\u8ba4\u4fdd\u5b58\uff1f"); + return result; + } + @Override @Aop(TransAop.READ_COMMITTED) public void deleteActivity(String id) { @@ -72,6 +110,18 @@ public class QsvActivityServiceImpl extends BaseServiceImpl impleme } } + /** + * 判断编辑前后的答题模式是否变化;问卷、答题等活动只要 mode 发生变化都需要参与风险确认。 + */ + private boolean isAnswerModeChanged(QsvActivity oldActivity, QsvActivity newActivity) { + String oldMode = Strings.sNull(oldActivity.getMode()).trim(); + String newMode = Strings.sNull(newActivity.getMode()).trim(); + if (Strings.isBlank(oldMode) && Strings.isBlank(newMode)) { + return false; + } + return !Objects.equals(oldMode, newMode); + } + /** * 同步移动端首页活动。问卷活动保存或启停后,都以活动自身 enabled、时间、人员范围生成首页记录。 */ diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvSurveyServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvSurveyServiceImpl.java index 231be8a..43b537c 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvSurveyServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvSurveyServiceImpl.java @@ -667,6 +667,7 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl i id, title, type, + componentType, sortNum FROM qsv_subject diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvUserAnswerRecordServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvUserAnswerRecordServiceImpl.java index 93aa6ea..5ef14a7 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvUserAnswerRecordServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvUserAnswerRecordServiceImpl.java @@ -26,6 +26,44 @@ public class QsvUserAnswerRecordServiceImpl extends BaseServiceImpl subjectIds = qsvAnswerParam.getSubjects().stream() + .filter(Objects::nonNull) + .map(QsvAnswerParam.Subject::getId) + .filter(Objects::nonNull) + .toList(); + if (subjectIds.isEmpty()) { + return null; + } + Map subjectMap = new HashMap<>(); + dao().query(QsvSubject.class, Cnd.where("id", "in", subjectIds)).forEach(subject -> subjectMap.put(subject.getId(), subject)); + for (QsvAnswerParam.Subject answerSubject : qsvAnswerParam.getSubjects()) { + if (answerSubject == null) { + continue; + } + QsvSubject configSubject = subjectMap.get(answerSubject.getId()); + if (configSubject == null || !"checkbox".equals(configSubject.getType()) || configSubject.getMaxMulti() == null || configSubject.getMaxMulti() <= 0) { + continue; + } + List selectedOptionIds = answerSubject.getUserSelectOptionIds(); + int selectedCount = selectedOptionIds == null ? 0 : new LinkedHashSet<>(selectedOptionIds).size(); + if (selectedCount > configSubject.getMaxMulti()) { + return "题目“" + configSubject.getTitle() + "”最多选择" + configSubject.getMaxMulti() + "项"; + } + } + return null; + } + @Override public QsvUserAnswerRecord insertRecord(String activityId, List subjectIds) { QsvActivity activity = dao().fetch(QsvActivity.class, activityId); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMySignupController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMySignupController.java index aaf800d..8984df2 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMySignupController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMySignupController.java @@ -1708,14 +1708,15 @@ public class TourMySignupController { private PDType0Font loadChineseFont(PDDocument document, boolean bold) throws IOException { String[] paths = bold - ? new String[]{"C:/Windows/Fonts/simhei.ttf", "C:/Windows/Fonts/msyhbd.ttf", "C:/Windows/Fonts/simsun.ttf"} - : new String[]{"C:/Windows/Fonts/simfang.ttf", "C:/Windows/Fonts/simsun.ttf", "C:/Windows/Fonts/msyh.ttf"}; + ? new String[]{"/usr/share/fonts/chinese/simhei.ttf", "/usr/share/fonts/chinese/msyhbd.ttc", "/usr/share/fonts/chinese/simsun.ttc"} + : new String[]{"/usr/share/fonts/chinese/simfang.ttf", "/usr/share/fonts/chinese/simsun.ttc", "/usr/share/fonts/chinese/msyh.ttc"}; for (String path : paths) { File file = new File(path); if (file.exists()) { return PDType0Font.load(document, file); } } + throw new IOException("未找到中文字体文件"); } diff --git a/src/main/resources/db/qsv_subject_add_component_type.sql b/src/main/resources/db/qsv_subject_add_component_type.sql new file mode 100644 index 0000000..c57d5fa --- /dev/null +++ b/src/main/resources/db/qsv_subject_add_component_type.sql @@ -0,0 +1,2 @@ +ALTER TABLE `qsv_subject` + ADD COLUMN `componentType` VARCHAR(30) NULL COMMENT '题目组件展示类型(RADIO普通单选,DROPDOWN下拉选择)' AFTER `type`; diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/basicForm.js b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/basicForm.js index 6f5f936..5e9bb73 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/basicForm.js +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/basicForm.js @@ -42,15 +42,52 @@ const basicForm = { this.$refs.formRef.validate((valid) => { if (valid) { $.post("/platform/qsv/activity/save", this.formData).then((res) => { + if (res.code === 0 && res.data && res.data.needConfirm) { + this.confirmModeChangeSave(res.data.message || res.msg) + return + } + if ((res.code === 2 || res.code === 409) && res.data && res.data.needConfirm) { + this.confirmModeChangeSave(res.msg) + return + } if (res.code === 0) { this.$message.success(res.msg) this.dialogVisible = false this.$emit("refresh", null) } + }).fail((xhr) => { + this.handleModeChangeConfirmResponse(xhr && xhr.responseJSON) }) } }) }, + handleModeChangeConfirmResponse(res) { + if (res && res.code === 0 && res.data && res.data.needConfirm) { + this.confirmModeChangeSave(res.data.message || res.msg) + return true + } + if (res && (res.code === 2 || res.code === 409) && res.data && res.data.needConfirm) { + this.confirmModeChangeSave(res.msg) + return true + } + return false + }, + confirmModeChangeSave(message) { + this.$confirm(message || "当前问卷已有用户答题记录,是否确认保存?", "提示", { + confirmButtonText: "确定", + cancelButtonText: "取消", + type: "warning" + }).then(() => { + const formData = Object.assign({}, this.formData, {forceSave: true}) + $.post("/platform/qsv/activity/save", formData).then((res) => { + if (res.code === 0) { + this.$message.success(res.msg) + this.dialogVisible = false + this.$emit("refresh", null) + } + }) + }) + }, getActivityGroup() { $.post("/platform/activity/basic/scope/getActivityUserScopeGroup").then((res) => { if (res.code === 0) { diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/logicForm.js b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/logicForm.js index bccc893..20ea67a 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/logicForm.js +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/logicForm.js @@ -73,7 +73,7 @@ const logicForm = { components: { "condition-group-editor": conditionGroupEditor }, - template: /*language=HTML*/ "\n \n
\n
\n
\n 题目列表\n \n 保存问卷\n \n
\n
\n \n
\n {{index + 1}}\n \n {{getSubjectTypeText(subject)}}\n \n \n {{subject.title || '未填写题目'}}\n \n
\n
0\">\n \n \n \n \n \n \n \n \n \n \n
\n
\n \n
\n \n\n
\n
\n 实时预览\n
\n 显示序号\n 配置即所得\n
\n
\n
\n \n {{index + 1}}.\n
\n
{{subject.title || '未填写题目'}}
\n\n \n\n \n\n \n \n
\n
\n \n \n
\n \n\n \n \n \n \n \n \n \n \n
\n 取 消\n 保存逻辑\n
\n \n \n ", + template: /*language=HTML*/ "\n \n
\n
\n
\n 题目列表\n \n 保存问卷\n \n
\n
\n \n
\n {{index + 1}}\n \n {{getSubjectTypeText(subject)}}\n \n \n {{subject.title || '未填写题目'}}\n \n
\n
0\">\n \n \n \n \n \n \n \n \n \n \n
\n
\n \n
\n \n\n
\n
\n 实时预览\n
\n 显示序号\n 配置即所得\n
\n
\n
\n \n {{index + 1}}.\n
\n
{{subject.title || '未填写题目'}}
\n\n \n\n \n\n \n\n \n \n
\n
\n \n \n
\n \n\n \n \n \n \n \n \n \n \n
\n 取 消\n 保存逻辑\n
\n \n \n ", data() { return { visible: false, @@ -244,6 +244,9 @@ const logicForm = { }) }, getSubjectTypeText(subject) { + if (this.isDropdownSubject(subject)) { + return "下拉选择" + } const typeMap = { radio: "单选题", checkbox: "多选题", @@ -252,6 +255,9 @@ const logicForm = { return typeMap[subject.type] || "题目" }, getSubjectTypeClass(subject) { + if (this.isDropdownSubject(subject)) { + return "select" + } if (subject.type === "checkbox") { return "multiple" } @@ -260,6 +266,10 @@ const logicForm = { } return "single" }, + // 下拉选择底层仍是单选题,逻辑条件继续按选项ID判断。 + isDropdownSubject(subject) { + return subject && subject.type === "radio" && subject.componentType === "DROPDOWN" + }, getPreviewRadioValue(subject) { const value = this.previewAnswers[subject.id] return value && value.length > 0 ? value[0] : "" diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/new.html b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/new.html index 6116232..e98c287 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/new.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/new.html @@ -391,8 +391,8 @@ layout("/layouts/platform.html"){ buildSubmitData(enabled) { const formData = Object.assign({}, this.formData, { enabled }) formData.category = this.normalizeCategoryValue(formData.category) + formData.mode = this.normalizeModeValue(formData.mode) if (formData.category !== "QUIZ") { - delete formData.mode delete formData.repeatable delete formData.repeatMode delete formData.maxAttempts @@ -403,7 +403,6 @@ layout("/layouts/platform.html"){ delete formData.timeLimit delete formData.scoreMode } else { - formData.mode = this.normalizeModeValue(formData.mode) formData.displayMode = this.normalizeDisplayModeValue(formData.displayMode) formData.timeLimit = formData.timeLimit || 0 if (formData.mode !== "REGULAR") { @@ -442,18 +441,60 @@ layout("/layouts/platform.html"){ this.saveLoading = true const formData = this.buildSubmitData(enabled) $.post("/platform/qsv/activity/save", formData).then((res) => { + if (res.code === 0 && res.data && res.data.needConfirm) { + this.saveLoading = false + this.confirmModeChangeSave(formData, actionText, res.data.message || res.msg) + return + } + if ((res.code === 2 || res.code === 409) && res.data && res.data.needConfirm) { + this.saveLoading = false + this.confirmModeChangeSave(formData, actionText, res.msg) + return + } if (res.code !== 0) { this.saveLoading = false return } this.$message.success(actionText + "成功") location.href = "/platform/qsv/activity" - }).fail(() => { + }).fail((xhr) => { this.saveLoading = false + this.handleModeChangeConfirmResponse(xhr && xhr.responseJSON, formData, actionText) }) }) }) }, + handleModeChangeConfirmResponse(res, formData, actionText) { + if (res && res.code === 0 && res.data && res.data.needConfirm) { + this.confirmModeChangeSave(formData, actionText, res.data.message || res.msg) + return true + } + if (res && (res.code === 2 || res.code === 409) && res.data && res.data.needConfirm) { + this.confirmModeChangeSave(formData, actionText, res.msg) + return true + } + return false + }, + confirmModeChangeSave(formData, actionText, message) { + this.$confirm(message || "当前问卷已有用户答题记录,是否确认保存?", "提示", { + confirmButtonText: "确定", + cancelButtonText: "取消", + type: "warning" + }).then(() => { + this.saveLoading = true + const submitData = Object.assign({}, formData, {forceSave: true}) + $.post("/platform/qsv/activity/save", submitData).then((res) => { + if (res.code !== 0) { + this.saveLoading = false + return + } + this.$message.success(actionText + "成功") + location.href = "/platform/qsv/activity" + }).fail(() => { + this.saveLoading = false + }) + }) + }, goBack() { location.href = "/platform/qsv/activity" } diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/subjectForm.js b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/subjectForm.js index 64bc329..8c20b76 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/subjectForm.js +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/subjectForm.js @@ -4,7 +4,7 @@ const subjectForm = { - template: /*language=HTML*/ "\n
\n \n
\n \n \n
\n
\n
\n \n 第 {{subjectIndex + 1}} 题\n \n \n
\n\n
\n
\n 题目类型:\n \n \n 单选题\n \n \n 多选题\n \n \n 填空题\n \n \n \n \n \n
\n
\n 最大选择数:\n \n
\n
\n 排列方式:\n \n \n \n \n
\n
\n 横向列数:\n \n \n \n \n
\n \n \n \n \n \n \n\n
\n 题目分数:\n \n \n \n
\n\n
\n 显示日期:\n \n \n
\n
\n
\n\n \n\n
\n \n \n \n \n \n \n \n \n \n
\n
\n
\n
\n \n
\n
\n\n
\n \n \n \n \n \n 导入题目\n \n \n 添加题目\n \n \n 存到题库\n \n \n 保存题目\n \n
\n\n \n \n \n \n \n \n \n \n \n \n
\n 取消\n 确定\n
\n \n
\n ", + template: /*language=HTML*/ "\n
\n \n
\n \n \n
\n
\n
\n \n 第 {{subjectIndex + 1}} 题\n \n \n
\n\n
\n
\n 题目类型:\n \n \n 单选题\n \n \n 多选题\n \n \n 填空题\n \n \n \n \n \n
\n
\n 是否下拉框显示:\n \n \n
\n
\n 批量导入选项\n
\n
\n 最大选择数:\n \n
\n
\n 批量导入选项\n
\n
\n 排列方式:\n \n \n \n \n
\n
\n 横向列数:\n \n \n \n \n
\n \n \n \n \n \n \n\n
\n 题目分数:\n \n \n \n
\n\n
\n 显示日期:\n \n \n
\n
\n
\n\n \n\n
\n \n \n \n \n \n \n \n \n \n
\n
\n
\n
\n \n
\n
\n\n
\n \n \n \n \n \n 导入题目\n \n \n 添加题目\n \n \n 存到题库\n \n \n 保存题目\n \n
\n\n \n
每行输入一个选项,例如:
\n
苍南疗休养
江西望仙谷景德镇疗休养
舟山岱山疗休养
象山石浦疗休养
\n \n \n
\n 取消\n 确定\n
\n \n \n \n \n \n \n \n \n \n \n \n
\n 取消\n 确定\n
\n \n
\n ", components: { 'option-img': optionImg, 'setting': setting, @@ -26,6 +26,11 @@ const subjectForm = { previewDialogVisible: false, activity: {}, formRules: {}, + bulkOptionDialog: { + visible: false, + subject: null, + content: "" + }, saveBankDialog: { visible: false, loading: false, @@ -55,6 +60,7 @@ const subjectForm = { } if (subjectsRes.code === 0) { this.subjects = subjectsRes.data || [] + this.normalizeSubjectComponentTypes() this.normalizeVoteSubjectLayouts() } if (this.activity.category === "VOTE" && this.subjects.length === 0) { @@ -76,6 +82,7 @@ const subjectForm = { title: "", hint: "", type: "radio", + componentType: "RADIO", score: 0, displayDate: "", optionLayout: "VERTICAL", @@ -99,6 +106,101 @@ const subjectForm = { }) }, + // 规整题目组件展示类型,历史单选题没有该字段时按普通单选处理。 + normalizeSubjectComponentTypes() { + this.subjects.forEach((subject) => { + if (subject.type === "radio" && !subject.componentType) { + this.$set(subject, "componentType", "RADIO") + } + if (subject.type !== "radio" && subject.componentType) { + this.$set(subject, "componentType", null) + } + }) + }, + + // 题目类型只保存真实业务类型;下拉框显示作为单选题的组件配置单独保存。 + subjectTypeChange(subject) { + if (subject.type === "radio") { + this.$set(subject, "componentType", subject.componentType === "DROPDOWN" ? "DROPDOWN" : "RADIO") + } else { + this.$set(subject, "componentType", null) + } + + if (subject.type === "text") { + subject.options = [] + return + } + this.ensureChoiceOptions(subject) + }, + + // 单选题可切换下拉框展示,答案仍按单选题选项ID保存,避免影响统计和导出。 + dropdownDisplayChange(subject, enabled) { + this.$set(subject, "componentType", enabled ? "DROPDOWN" : "RADIO") + }, + + // 组装当前下拉选项文本,每行一个选项,便于弹窗中重新导入或复制修改。 + buildBulkOptionText(subject) { + if (!subject.options || subject.options.length === 0) { + return "" + } + return subject.options.map((option) => option.text || "").join("\n") + }, + + // 打开批量导入弹窗,记录当前题目,确定时将文本框内容写回该题目的选项列表。 + openBulkOptionDialog(subject) { + this.bulkOptionDialog.subject = subject + this.bulkOptionDialog.content = this.buildBulkOptionText(subject) + this.bulkOptionDialog.visible = true + }, + + // 按行解析选项数据,空行自动忽略,每行生成一个选项并保留原有题型设置。 + confirmBulkOptionImport() { + const subject = this.bulkOptionDialog.subject + if (!subject) { + return + } + const lines = (this.bulkOptionDialog.content || "").split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line) + if (lines.length === 0) { + this.$message.warning("请至少输入一个选项") + return + } + const options = lines.map((line) => ({ + id: this.generateId(), + text: line, + hint: "", + imgUrl: null, + isCorrect: false + })) + this.$set(subject, "options", options) + this.bulkOptionDialog.visible = false + this.$message.success("已导入" + options.length + "条选项") + }, + + // 题目从填空切回选择题时补齐默认选项,避免保存时没有可选项。 + ensureChoiceOptions(subject) { + if (subject.options && subject.options.length > 0) { + return + } + this.$set(subject, "options", [ + { + id: this.generateId(), + text: "选项1", + hint: "", + imgUrl: null, + isCorrect: false + }, + { + id: this.generateId(), + text: "选项2", + hint: "", + imgUrl: null, + isCorrect: false + } + ]) + }, + // 投票选项排列默认值 normalizeVoteSubjectLayout(subject) { if (!subject) { @@ -133,12 +235,6 @@ const subjectForm = { } }, - subjectTypeChange(subject, subjectIndex) { - if (subject.type === 'text') { - subject.options = [] - } - }, - //删除题目 removeSubject(index) { this.$confirm("确认删除该题目?", "提示", { @@ -364,6 +460,7 @@ const subjectForm = { //保存 saveQuestionnaire() { + this.normalizeSubjectComponentTypes() this.normalizeVoteSubjectLayouts() $ .post("/platform/qsv/activity/saveSubjects", { @@ -379,5 +476,5 @@ const subjectForm = { }) } }, - style: /*language=CSS*/ "\n /deep/ .qsv-subject-editor {\n padding-bottom: 72px;\n }\n\n /deep/ .qsv-subject-footer {\n position: fixed;\n left: 0;\n width: 100%;\n right: 0;\n bottom: 0;\n z-index: 900;\n padding: 12px 24px;\n border-top: 1px solid #ebeef5;\n background: #ffffff;\n box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);\n text-align: right;\n box-sizing: border-box;\n }\n\n @media (max-width: 768px) {\n /deep/ .qsv-subject-footer {\n left: 0;\n width: 100%;\n }\n }\n\n /deep/ .save-bank-dialog {\n top: 50%;\n margin-top: 0 !important;\n transform: translateY(calc(-50% + 250px));\n }\n\n /*.subject-form-dialog,.subject-form-dialog .el-dialog__body {*/\n /* background: rgb(248, 249, 250);*/\n /*}*/\n\n /deep/ .questionnaire-editor {\n max-width: 1200px;\n margin: 0 auto;\n padding: 20px;\n }\n\n /deep/ .editor-header {\n background: white;\n padding: 20px;\n border-radius: 8px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);\n margin-bottom: 20px;\n }\n\n /deep/ .subject-item {\n background: white;\n border-radius: 8px;\n padding: 25px;\n margin-bottom: 20px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);\n transition: all 0.3s ease;\n border: 1px dashed #d9d9d9;\n }\n\n /deep/ .subject-item:hover {\n box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.1);\n /*transform: translateY(-1px);*/\n }\n\n /deep/ .subject-header {\n border-bottom: 1px solid #eee;\n padding-bottom: 15px;\n margin-bottom: 15px;\n }\n\n /deep/ .subject-title-row {\n display: flex;\n align-items: center;\n }\n\n /deep/ .subject-title-input {\n flex: 1;\n }\n\n /deep/ .subject-toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-top: 15px;\n padding-top: 15px;\n border-top: 1px solid #eee;\n }\n\n /deep/ .toolbar {\n background: white;\n padding: 15px;\n border-radius: 8px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);\n z-index: 1000;\n }\n\n /deep/ .toolbar-btn {\n margin: 5px 0;\n width: 100%;\n }\n\n /deep/ .option-item {\n background: #f8f9fa;\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 10px;\n display: flex;\n align-items: center;\n }\n\n /deep/ .option-drag-handle {\n cursor: move;\n margin-right: 10px;\n color: #909399;\n }\n\n /deep/ .option-content {\n flex-grow: 1;\n margin-right: 10px;\n }\n\n /deep/ .option-tools {\n display: flex;\n align-items: center;\n gap: 10px;\n }\n\n /deep/ .preview-dialog {\n max-width: 800px;\n margin: 0 auto;\n }\n\n /deep/ .date-visible {\n margin-left: 20px;\n }\n\n /deep/ .subject-meta {\n display: flex;\n flex-wrap: wrap;\n gap: 15px;\n margin-top: 15px;\n background: #f8f9fa;\n padding: 10px;\n border-radius: 4px;\n }\n\n /deep/ .image-preview {\n max-width: 200px;\n max-height: 200px;\n margin-top: 10px;\n border-radius: 4px;\n border: 1px solid #dcdfe6;\n }\n\n /deep/ .drag-handler {\n cursor: move;\n color: #909399;\n margin-right: 10px;\n }\n\n /deep/ .meta-info {\n color: #909399;\n font-size: 12px;\n margin-top: 5px;\n }\n\n /deep/ .subject-type-icon {\n margin-right: 5px;\n }\n\n\n /deep/ .option-img-box {\n position: relative;\n width: 40px;\n height: 40px;\n cursor: pointer;\n flex-shrink: 0\n }\n\n /deep/ .option-img-box:hover .el-icon-remove {\n display: block;\n }\n\n /deep/ .option-img-box img {\n width: 100%;\n height: 100%;\n }\n\n /deep/ .option-img-box .el-icon-remove {\n position: absolute;\n top: -7px;\n right: -7px;\n color: red;\n display: none;\n }\n\n /deep/ .option-img-box .el-icon-remove:hover {\n transform: scale(1.1);\n }\n\n " + style: /*language=CSS*/ "\n /deep/ .qsv-subject-editor {\n padding-bottom: 72px;\n }\n\n /deep/ .qsv-subject-footer {\n position: fixed;\n left: 0;\n width: 100%;\n right: 0;\n bottom: 0;\n z-index: 900;\n padding: 12px 24px;\n border-top: 1px solid #ebeef5;\n background: #ffffff;\n box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);\n text-align: right;\n box-sizing: border-box;\n }\n\n @media (max-width: 768px) {\n /deep/ .qsv-subject-footer {\n left: 0;\n width: 100%;\n }\n }\n\n /deep/ .save-bank-dialog {\n top: 50%;\n margin-top: 0 !important;\n transform: translateY(calc(-50% + 250px));\n }\n\n /*.subject-form-dialog,.subject-form-dialog .el-dialog__body {*/\n /* background: rgb(248, 249, 250);*/\n /*}*/\n\n /deep/ .questionnaire-editor {\n max-width: 1200px;\n margin: 0 auto;\n padding: 20px;\n }\n\n /deep/ .editor-header {\n background: white;\n padding: 20px;\n border-radius: 8px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);\n margin-bottom: 20px;\n }\n\n /deep/ .subject-item {\n background: white;\n border-radius: 8px;\n padding: 25px;\n margin-bottom: 20px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);\n transition: all 0.3s ease;\n border: 1px dashed #d9d9d9;\n }\n\n /deep/ .subject-item:hover {\n box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.1);\n /*transform: translateY(-1px);*/\n }\n\n /deep/ .subject-header {\n border-bottom: 1px solid #eee;\n padding-bottom: 15px;\n margin-bottom: 15px;\n }\n\n /deep/ .subject-title-row {\n display: flex;\n align-items: center;\n }\n\n /deep/ .subject-title-input {\n flex: 1;\n }\n\n /deep/ .subject-toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-top: 15px;\n padding-top: 15px;\n border-top: 1px solid #eee;\n }\n\n /deep/ .toolbar {\n background: white;\n padding: 15px;\n border-radius: 8px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);\n z-index: 1000;\n }\n\n /deep/ .toolbar-btn {\n margin: 5px 0;\n width: 100%;\n }\n\n /deep/ .option-item {\n background: #f8f9fa;\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 10px;\n display: flex;\n align-items: center;\n }\n\n /deep/ .option-drag-handle {\n cursor: move;\n margin-right: 10px;\n color: #909399;\n }\n\n /deep/ .option-content {\n flex-grow: 1;\n margin-right: 10px;\n }\n\n /deep/ .option-tools {\n display: flex;\n align-items: center;\n gap: 10px;\n }\n\n /deep/ .preview-dialog {\n max-width: 800px;\n margin: 0 auto;\n }\n\n /deep/ .date-visible {\n margin-left: 20px;\n }\n\n /deep/ .subject-meta {\n display: flex;\n flex-wrap: wrap;\n gap: 15px;\n margin-top: 15px;\n background: #f8f9fa;\n padding: 10px;\n border-radius: 4px;\n }\n\n\n /deep/ .subject-meta-item {\n display: flex;\n align-items: center;\n white-space: nowrap;\n }\n\n /deep/ .subject-dropdown-meta {\n margin-left: 18px;\n }\n\n /deep/ .subject-dropdown-meta .el-switch {\n margin-left: 10px;\n }\n\n /deep/ .subject-bulk-option-meta {\n margin-left: 18px;\n }\n\n /deep/ .bulk-option-dialog .bulk-option-help {\n margin-bottom: 8px;\n color: #303133;\n }\n\n /deep/ .bulk-option-dialog .bulk-option-example {\n margin-bottom: 12px;\n padding: 10px 12px;\n border: 1px dashed #dcdfe6;\n color: #606266;\n line-height: 1.7;\n background: #fafafa;\n }\n\n /deep/ .image-preview {\n max-width: 200px;\n max-height: 200px;\n margin-top: 10px;\n border-radius: 4px;\n border: 1px solid #dcdfe6;\n }\n\n /deep/ .drag-handler {\n cursor: move;\n color: #909399;\n margin-right: 10px;\n }\n\n /deep/ .meta-info {\n color: #909399;\n font-size: 12px;\n margin-top: 5px;\n }\n\n /deep/ .subject-type-icon {\n margin-right: 5px;\n }\n\n\n /deep/ .option-img-box {\n position: relative;\n width: 40px;\n height: 40px;\n cursor: pointer;\n flex-shrink: 0\n }\n\n /deep/ .option-img-box:hover .el-icon-remove {\n display: block;\n }\n\n /deep/ .option-img-box img {\n width: 100%;\n height: 100%;\n }\n\n /deep/ .option-img-box .el-icon-remove {\n position: absolute;\n top: -7px;\n right: -7px;\n color: red;\n display: none;\n }\n\n /deep/ .option-img-box .el-icon-remove:hover {\n transform: scale(1.1);\n }\n\n " } diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/txtImport.js b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/txtImport.js index 3aab873..35f9815 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/txtImport.js +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/txtImport.js @@ -1,5 +1,5 @@ const txtImport = { - template: /*language=HTML*/ "\n \n
\n
\n
\n
\n \n 文本解析\n 调用题库\n \n
题目输入区
\n \n
\n
\n

支持的格式:

\n

单选题:

\n

1.题目标题?(A)

\n

A.选项A

\n

B.选项B

\n

多选题:

\n

2.题目标题?(ABC)

\n

A.选项A

\n

B.选项B

\n

正确答案数量大于1时自动判断为多选题

\n
\n
\n

支持的格式:

\n

单选题:

\n

1.题目标题?

\n

A.选项A

\n

B.选项B

\n

多选题:

\n

2.题目标题?(多选)

\n

A.选项A

\n

B.选项B

\n

填空题(仅问卷):

\n

3.请填写建议(填空)

\n
\n
\n 格式说明\n
\n
\n
\n \n \n
\n
\n 解析题目\n 清空\n
\n
\n\n
\n
\n \n 文本解析\n 调用题库\n \n
题库列表
\n 刷新\n
\n
\n \n \n
{{bank.title || '未命名题库'}}
\n {{getCategoryText(bank.category)}}\n {{getBankSubjectCount(bank)}} 题\n
\n
\n
\n\n
\n
\n
{{activeTab === 'text' ? '解析结果' : '题库题目'}}
\n
0\">\n \n 全选\n \n 已选 {{currentSelectedIds.length}} / {{currentQuestions.length}} 题\n
\n
\n
\n \n \n \n
\n \n \n {{index + 1}}\n {{question.title}}\n 多选题\n 填空题\n 单选题\n
\n
\n \n
无选项
\n
\n
0\">\n 正确答案: {{getCorrectAnswerText(question) || '未设置'}}\n
\n
\n
\n
\n \n \n
\n 取 消\n 确 定\n
\n
\n ", + template: /*language=HTML*/ "\n \n
\n
\n
\n
\n \n 文本解析\n 调用题库\n \n
题目输入区
\n \n
\n
\n

支持的格式:

\n

单选题:

\n

1.题目标题?(A)

\n

A.选项A

\n

B.选项B

\n

多选题:

\n

2.题目标题?(ABC)

\n

A.选项A

\n

B.选项B

\n

正确答案数量大于1时自动判断为多选题

\n
\n
\n

支持的格式:

\n

单选题:

\n

1.题目标题?

\n

A.选项A

\n

B.选项B

\n

多选题:

\n

2.题目标题?(多选)

\n

A.选项A

\n

B.选项B

\n

填空题(仅问卷):

\n

3.请填写建议(填空)

\n
\n
\n 格式说明\n
\n
\n
\n \n \n
\n
\n 解析题目\n 清空\n
\n
\n\n
\n
\n \n 文本解析\n 调用题库\n \n
题库列表
\n 刷新\n
\n
\n \n \n
{{bank.title || '未命名题库'}}
\n {{getCategoryText(bank.category)}}\n {{getBankSubjectCount(bank)}} 题\n
\n
\n
\n\n
\n
\n
{{activeTab === 'text' ? '解析结果' : '题库题目'}}
\n
0\">\n \n 全选\n \n 已选 {{currentSelectedIds.length}} / {{currentQuestions.length}} 题\n
\n
\n
\n \n \n \n
\n \n \n {{index + 1}}\n {{question.title}}\n 多选题\n 填空题\n 下拉选择\n 单选题\n
\n
\n \n
无选项
\n
\n
0\">\n 正确答案: {{getCorrectAnswerText(question) || '未设置'}}\n
\n
\n
\n
\n \n \n
\n 取 消\n 确 定\n
\n
\n ", data() { return { visible: false, @@ -288,6 +288,7 @@ const txtImport = { activityId: null, title: data.title, type: data.type, + componentType: data.type === "radio" ? "RADIO" : null, correctAnswer: correctOptionIds, score: data.score || 0, displayDate: null, diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/bank/subjectForm.js b/src/main/resources/views/platform/zhgh/dayofficework/qsv/bank/subjectForm.js index 46818cf..52ed0cf 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/bank/subjectForm.js +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/bank/subjectForm.js @@ -3,7 +3,7 @@ const subjectForm = { - template: /*language=HTML*/ "\n
\n \n
\n \n \n
\n
\n
\n \n 第 {{subjectIndex + 1}} 题\n \n \n
\n\n
\n
\n 题目类型:\n \n \n 单选题\n \n \n 多选题\n \n \n 填空题\n \n \n 判断题\n \n \n
\n
\n 最大选择数:\n \n
\n \n \n \n \n \n \n\n
\n 题目分数:\n \n \n \n
\n\n
\n 显示日期:\n \n \n
\n
\n
\n\n \n\n
\n \n \n \n \n \n \n \n \n \n
\n
\n
\n
\n \n
\n
\n\n
\n \n \n 导入题目\n \n \n 添加题目\n \n \n 保存题目\n \n
\n\n \n \n \n
\n ", + template: /*language=HTML*/ "\n
\n \n
\n \n \n
\n
\n
\n \n 第 {{subjectIndex + 1}} 题\n \n \n
\n\n
\n
\n 题目类型:\n \n \n 单选题\n \n \n 多选题\n \n \n 填空题\n \n \n 判断题\n \n \n
\n
\n 是否下拉框显示:\n \n \n
\n
\n 批量导入选项\n
\n
\n 最大选择数:\n \n
\n
\n 批量导入选项\n
\n \n \n \n \n \n \n\n
\n 题目分数:\n \n \n \n
\n\n
\n 显示日期:\n \n \n
\n
\n
\n\n \n\n
\n \n \n \n \n \n \n \n \n \n
\n
\n
\n
\n \n
\n
\n\n
\n \n \n 导入题目\n \n \n 添加题目\n \n \n 保存题目\n \n
\n\n \n
每行输入一个选项,例如:
\n
苍南疗休养
江西望仙谷景德镇疗休养
舟山岱山疗休养
象山石浦疗休养
\n \n \n
\n 取消\n 确定\n
\n \n \n \n \n
\n ", components: { 'option-img': optionImg, 'setting': setting, @@ -17,6 +17,11 @@ const subjectForm = { subjects: [], previewDialogVisible: false, activity: {}, + bulkOptionDialog: { + visible: false, + subject: null, + content: "" + }, formRules: {} } }, @@ -29,6 +34,7 @@ const subjectForm = { if (res.code === 0) { this.activity = res.data this.subjects = res.data.subjects || [] + this.normalizeSubjectComponentTypes() } }) } @@ -46,6 +52,7 @@ const subjectForm = { title: "", hint: "", type: "radio", + componentType: "RADIO", score: 0, displayDate: "", options: [ @@ -67,11 +74,99 @@ const subjectForm = { }) }, - //题目类型切换 - subjectTypeChange(subject, subjectIndex) { - if (subject.type === 'text') { - subject.options = [] + // 规整题目组件展示类型,历史单选题没有该字段时按普通单选处理。 + normalizeSubjectComponentTypes() { + this.subjects.forEach((subject) => { + if (subject.type === "radio" && !subject.componentType) { + this.$set(subject, "componentType", "RADIO") + } + if (subject.type !== "radio" && subject.componentType) { + this.$set(subject, "componentType", null) + } + }) + }, + + // 题目类型只保存真实业务类型;下拉框显示作为单选题的组件配置单独保存。 + subjectTypeChange(subject) { + if (subject.type === "radio") { + this.$set(subject, "componentType", subject.componentType === "DROPDOWN" ? "DROPDOWN" : "RADIO") + } else { + this.$set(subject, "componentType", null) } + + if (subject.type === "text") { + subject.options = [] + return + } + this.ensureChoiceOptions(subject) + }, + + // 单选题可切换下拉框展示,答案仍按单选题选项ID保存,避免影响题库复用。 + dropdownDisplayChange(subject, enabled) { + this.$set(subject, "componentType", enabled ? "DROPDOWN" : "RADIO") + }, + + // 组装当前下拉选项文本,每行一个选项,便于弹窗中重新导入或复制修改。 + buildBulkOptionText(subject) { + if (!subject.options || subject.options.length === 0) { + return "" + } + return subject.options.map((option) => option.text || "").join("\n") + }, + + // 打开批量导入弹窗,记录当前题目,确定时将文本框内容写回该题目的选项列表。 + openBulkOptionDialog(subject) { + this.bulkOptionDialog.subject = subject + this.bulkOptionDialog.content = this.buildBulkOptionText(subject) + this.bulkOptionDialog.visible = true + }, + + // 按行解析选项数据,空行自动忽略,每行生成一个选项并保留原有题型设置。 + confirmBulkOptionImport() { + const subject = this.bulkOptionDialog.subject + if (!subject) { + return + } + const lines = (this.bulkOptionDialog.content || "").split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line) + if (lines.length === 0) { + this.$message.warning("请至少输入一个选项") + return + } + const options = lines.map((line) => ({ + id: this.generateId(), + text: line, + hint: "", + imgUrl: null, + isCorrect: false + })) + this.$set(subject, "options", options) + this.bulkOptionDialog.visible = false + this.$message.success("已导入" + options.length + "条选项") + }, + + // 题目从填空切回选择题时补齐默认选项,避免保存时没有可选项。 + ensureChoiceOptions(subject) { + if (subject.options && subject.options.length > 0) { + return + } + this.$set(subject, "options", [ + { + id: this.generateId(), + text: "选项1", + hint: "", + imgUrl: null, + isCorrect: false + }, + { + id: this.generateId(), + text: "选项2", + hint: "", + imgUrl: null, + isCorrect: false + } + ]) }, //删除题目 @@ -170,6 +265,7 @@ const subjectForm = { //保存 saveQuestionnaire() { + this.normalizeSubjectComponentTypes() this.$axios.post("/platform/qsv/bank/saveSubjects", { id: this.activity.id, subjects: JSON.stringify(this.subjects) @@ -182,5 +278,5 @@ const subjectForm = { }) } }, - style: /*language=CSS*/ "\n /deep/ .qsv-subject-editor {\n padding-bottom: 72px;\n }\n\n /deep/ .qsv-subject-footer {\n position: fixed;\n left: 0;\n width: 100%;\n right: 0;\n bottom: 0;\n z-index: 900;\n padding: 12px 24px;\n border-top: 1px solid #ebeef5;\n background: #ffffff;\n box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);\n text-align: right;\n box-sizing: border-box;\n }\n\n @media (max-width: 768px) {\n /deep/ .qsv-subject-footer {\n left: 0;\n width: 100%;\n }\n }\n\n /*.subject-form-dialog,.subject-form-dialog .el-dialog__body {*/\n /* background: rgb(248, 249, 250);*/\n /*}*/\n\n /deep/ .questionnaire-editor {\n max-width: 1200px;\n margin: 0 auto;\n padding: 20px;\n }\n\n /deep/ .editor-header {\n background: white;\n padding: 20px;\n border-radius: 8px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);\n margin-bottom: 20px;\n }\n\n /deep/ .subject-item {\n background: white;\n border-radius: 8px;\n padding: 25px;\n margin-bottom: 20px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);\n transition: all 0.3s ease;\n border: 1px dashed #d9d9d9;\n }\n\n /deep/ .subject-item:hover {\n box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.1);\n /*transform: translateY(-1px);*/\n }\n\n /deep/ .subject-header {\n border-bottom: 1px solid #eee;\n padding-bottom: 15px;\n margin-bottom: 15px;\n }\n\n /deep/ .subject-toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-top: 15px;\n padding-top: 15px;\n border-top: 1px solid #eee;\n }\n\n /deep/ .toolbar {\n background: white;\n padding: 15px;\n border-radius: 8px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);\n z-index: 1000;\n }\n\n /deep/ .toolbar-btn {\n margin: 5px 0;\n width: 100%;\n }\n\n /deep/ .option-item {\n background: #f8f9fa;\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 10px;\n display: flex;\n align-items: center;\n }\n\n /deep/ .option-drag-handle {\n cursor: move;\n margin-right: 10px;\n color: #909399;\n }\n\n /deep/ .option-content {\n flex-grow: 1;\n margin-right: 10px;\n }\n\n /deep/ .option-tools {\n display: flex;\n align-items: center;\n gap: 10px;\n }\n\n /deep/ .preview-dialog {\n max-width: 800px;\n margin: 0 auto;\n }\n\n /deep/ .date-visible {\n margin-left: 20px;\n }\n\n /deep/ .subject-meta {\n display: flex;\n flex-wrap: wrap;\n gap: 15px;\n margin-top: 15px;\n background: #f8f9fa;\n padding: 10px;\n border-radius: 4px;\n }\n\n /deep/ .image-preview {\n max-width: 200px;\n max-height: 200px;\n margin-top: 10px;\n border-radius: 4px;\n border: 1px solid #dcdfe6;\n }\n\n /deep/ .drag-handler {\n cursor: move;\n color: #909399;\n margin-right: 10px;\n }\n\n /deep/ .meta-info {\n color: #909399;\n font-size: 12px;\n margin-top: 5px;\n }\n\n /deep/ .subject-type-icon {\n margin-right: 5px;\n }\n\n\n /deep/ .option-img-box {\n position: relative;\n width: 40px;\n height: 40px;\n cursor: pointer;\n flex-shrink: 0\n }\n\n /deep/ .option-img-box:hover .el-icon-remove {\n display: block;\n }\n\n /deep/ .option-img-box img {\n width: 100%;\n height: 100%;\n }\n\n /deep/ .option-img-box .el-icon-remove {\n position: absolute;\n top: -7px;\n right: -7px;\n color: red;\n display: none;\n }\n\n /deep/ .option-img-box .el-icon-remove:hover {\n transform: scale(1.1);\n }\n\n " + style: /*language=CSS*/ "\n /deep/ .qsv-subject-editor {\n padding-bottom: 72px;\n }\n\n /deep/ .qsv-subject-footer {\n position: fixed;\n left: 0;\n width: 100%;\n right: 0;\n bottom: 0;\n z-index: 900;\n padding: 12px 24px;\n border-top: 1px solid #ebeef5;\n background: #ffffff;\n box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);\n text-align: right;\n box-sizing: border-box;\n }\n\n @media (max-width: 768px) {\n /deep/ .qsv-subject-footer {\n left: 0;\n width: 100%;\n }\n }\n\n /*.subject-form-dialog,.subject-form-dialog .el-dialog__body {*/\n /* background: rgb(248, 249, 250);*/\n /*}*/\n\n /deep/ .questionnaire-editor {\n max-width: 1200px;\n margin: 0 auto;\n padding: 20px;\n }\n\n /deep/ .editor-header {\n background: white;\n padding: 20px;\n border-radius: 8px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);\n margin-bottom: 20px;\n }\n\n /deep/ .subject-item {\n background: white;\n border-radius: 8px;\n padding: 25px;\n margin-bottom: 20px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);\n transition: all 0.3s ease;\n border: 1px dashed #d9d9d9;\n }\n\n /deep/ .subject-item:hover {\n box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.1);\n /*transform: translateY(-1px);*/\n }\n\n /deep/ .subject-header {\n border-bottom: 1px solid #eee;\n padding-bottom: 15px;\n margin-bottom: 15px;\n }\n\n /deep/ .subject-toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-top: 15px;\n padding-top: 15px;\n border-top: 1px solid #eee;\n }\n\n /deep/ .toolbar {\n background: white;\n padding: 15px;\n border-radius: 8px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);\n z-index: 1000;\n }\n\n /deep/ .toolbar-btn {\n margin: 5px 0;\n width: 100%;\n }\n\n /deep/ .option-item {\n background: #f8f9fa;\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 10px;\n display: flex;\n align-items: center;\n }\n\n /deep/ .option-drag-handle {\n cursor: move;\n margin-right: 10px;\n color: #909399;\n }\n\n /deep/ .option-content {\n flex-grow: 1;\n margin-right: 10px;\n }\n\n /deep/ .option-tools {\n display: flex;\n align-items: center;\n gap: 10px;\n }\n\n /deep/ .preview-dialog {\n max-width: 800px;\n margin: 0 auto;\n }\n\n /deep/ .date-visible {\n margin-left: 20px;\n }\n\n /deep/ .subject-meta {\n display: flex;\n flex-wrap: wrap;\n gap: 15px;\n margin-top: 15px;\n background: #f8f9fa;\n padding: 10px;\n border-radius: 4px;\n }\n\n\n /deep/ .subject-meta-item {\n display: flex;\n align-items: center;\n white-space: nowrap;\n }\n\n /deep/ .subject-dropdown-meta {\n margin-left: 18px;\n }\n\n /deep/ .subject-dropdown-meta .el-switch {\n margin-left: 10px;\n }\n\n /deep/ .subject-bulk-option-meta {\n margin-left: 18px;\n }\n\n /deep/ .bulk-option-dialog .bulk-option-help {\n margin-bottom: 8px;\n color: #303133;\n }\n\n /deep/ .bulk-option-dialog .bulk-option-example {\n margin-bottom: 12px;\n padding: 10px 12px;\n border: 1px dashed #dcdfe6;\n color: #606266;\n line-height: 1.7;\n background: #fafafa;\n }\n\n /deep/ .image-preview {\n max-width: 200px;\n max-height: 200px;\n margin-top: 10px;\n border-radius: 4px;\n border: 1px solid #dcdfe6;\n }\n\n /deep/ .drag-handler {\n cursor: move;\n color: #909399;\n margin-right: 10px;\n }\n\n /deep/ .meta-info {\n color: #909399;\n font-size: 12px;\n margin-top: 5px;\n }\n\n /deep/ .subject-type-icon {\n margin-right: 5px;\n }\n\n\n /deep/ .option-img-box {\n position: relative;\n width: 40px;\n height: 40px;\n cursor: pointer;\n flex-shrink: 0\n }\n\n /deep/ .option-img-box:hover .el-icon-remove {\n display: block;\n }\n\n /deep/ .option-img-box img {\n width: 100%;\n height: 100%;\n }\n\n /deep/ .option-img-box .el-icon-remove {\n position: absolute;\n top: -7px;\n right: -7px;\n color: red;\n display: none;\n }\n\n /deep/ .option-img-box .el-icon-remove:hover {\n transform: scale(1.1);\n }\n\n " } diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/online/index.html b/src/main/resources/views/platform/zhgh/dayofficework/qsv/online/index.html index e9bf34e..7f2984e 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/online/index.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/online/index.html @@ -123,6 +123,16 @@ layout("/layouts/platform.html"){ min-width: 72px; } + .qsv-online-select .el-select { + width: 360px; + max-width: 100%; + } + + .qsv-online-select .qsv-online-select-fill { + display: block; + margin: 12px 0 0 0; + } + .qsv-online-option-fill { width: 520px; max-width: 100%; @@ -625,7 +635,34 @@ layout("/layouts/platform.html"){ {{subject.score}}分 -
+
+ + + + + +
+ +
{ this.clearUnselectedOptionFillContents(subject) this.clearHiddenSubjectAnswers() }) }, onCheckboxChange(subject) { + const maxMulti = Number(subject.maxMulti || 0) + if (maxMulti > 0 && subject.userSelectOptionIds && subject.userSelectOptionIds.length > maxMulti) { + // 多选题超过最大选择数时立即截断,保证页面选择状态与题目配置一致。 + this.$message.warning("最多选择" + maxMulti + "项") + this.$set(subject, "userSelectOptionIds", subject.userSelectOptionIds.slice(0, maxMulti)) + } this.$nextTick(() => { this.clearUnselectedOptionFillContents(subject) this.clearHiddenSubjectAnswers() @@ -1341,6 +1388,11 @@ layout("/layouts/platform.html"){ this.$message.warning(this.isVoteActivity ? "请选择投票选项" : "第" + (i + 1) + "题未作答") return false } + const maxMulti = Number(subject.maxMulti || 0) + if (subject.type === "checkbox" && maxMulti > 0 && subject.userSelectOptionIds.length > maxMulti) { + this.$message.warning("第" + (i + 1) + "题最多选择" + maxMulti + "项") + return false + } for (let j = 0; j < (subject.options || []).length; j++) { const option = subject.options[j] if (this.isOptionFillVisible(subject, option) && !subject.optionFillContents[option.id]) { diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/survey/report.js b/src/main/resources/views/platform/zhgh/dayofficework/qsv/survey/report.js index ee3dce5..d93412e 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/survey/report.js +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/survey/report.js @@ -1,6 +1,6 @@ const report = { /*language=HTML*/ - template: "\n \n
\n \n 导出xlsx\n \n
\n
\n
\n
\n
\n 第{{subjectIndex+1}}题: {{subject.title}}\n [填空题]\n [单选题]\n [多选题]\n
\n\n
\n 查看填写内容\n
\n\n
\n \n \n \n \n \n \n \n \n \n \n
\n
\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n
\n ", + template: "\n \n
\n \n 导出xlsx\n \n
\n
\n
\n
\n
\n 第{{subjectIndex+1}}题: {{subject.title}}\n [填空题]\n [下拉选择]\n [单选题]\n [多选题]\n
\n\n
\n 查看填写内容\n
\n\n
\n \n \n \n \n \n \n \n \n \n \n
\n
\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n
\n ", dicts: ["ACTIVITY_QSV_CATEGORY"], data() { return { diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/survey/reportPage.html b/src/main/resources/views/platform/zhgh/dayofficework/qsv/survey/reportPage.html index 20a9415..9b3697a 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/survey/reportPage.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/survey/reportPage.html @@ -547,7 +547,7 @@ layout("/layouts/platform.html"){ - + @@ -705,6 +705,9 @@ layout("/layouts/platform.html"){ if (subject.type === "text") { return "[填空题]" } + if (subject.type === "radio" && subject.componentType === "DROPDOWN") { + return "[下拉选择]" + } if (subject.type === "radio") { return "[单选题]" } @@ -1145,6 +1148,13 @@ layout("/layouts/platform.html"){ } }) }, + formatAttemptDate(row) { + // attemptDate is a statistics date, so only show yyyy-MM-dd to avoid misleading users with 00:00:00. + if (!row || !row.attemptDate) { + return "" + } + return String(row.attemptDate).substring(0, 10) + }, openTextDetail(subject) { this.textSubject = subject this.textAnswers = subject.textAnswers || (subject.texts || []).map((text) => { diff --git a/src/main/resources/views/platform/zhghh5/dayofficework/qsv/survey/index.html b/src/main/resources/views/platform/zhghh5/dayofficework/qsv/survey/index.html index 98ac259..8213a51 100644 --- a/src/main/resources/views/platform/zhghh5/dayofficework/qsv/survey/index.html +++ b/src/main/resources/views/platform/zhghh5/dayofficework/qsv/survey/index.html @@ -151,6 +151,14 @@ layout("/layouts/platform_h5.html"){ width: 100%; } + .survey-select-box { + margin-top: 10px; + } + + .survey-select-fill { + margin: 10px 0 0 0; + } + .required-star { color: #ee0a24; margin-left: 4px; @@ -171,14 +179,38 @@ layout("/layouts/platform_h5.html"){

{{index+1}}、{{ subject.title }}*

多选 - 单选 + 下拉 + 单选 {{subject.score}}分
+ +
+ + +
+ +
+
+