This commit is contained in:
2026-07-13 08:49:10 +08:00
parent 075448ab2b
commit 878b0de75f
24 changed files with 625 additions and 41 deletions
@@ -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);
}
@@ -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()) {
@@ -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 {
@@ -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;
@@ -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()) {
@@ -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()) {
// 逐题保存用户选择和补充内容,便于统计票数与查看详情。
@@ -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)
@@ -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<QsvActivity> {
/**
* 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);
@@ -17,6 +17,14 @@ public interface QsvUserAnswerRecordService extends BaseService<QsvUserAnswerRec
*/
float calcScore(QsvAnswerParam qsvAnswerParam);
/**
* 校验多选题提交答案是否超过题目配置的最大选择数,返回空表示校验通过。
*
* @param qsvAnswerParam 用户提交的答题参数
* @return 超限提示文案,空表示不超限
*/
String checkMaxMultiLimit(QsvAnswerParam qsvAnswerParam);
void calcByScoreMode(String activityId, String userId);
}
@@ -12,8 +12,11 @@ import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@IocBean(args = {"refer:dao"})
@@ -26,12 +29,47 @@ public class QsvActivityServiceImpl extends BaseServiceImpl<QsvActivity> 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<QsvActivity> 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、时间、人员范围生成首页记录。
*/
@@ -667,6 +667,7 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
id,
title,
type,
componentType,
sortNum
FROM
qsv_subject
@@ -26,6 +26,44 @@ public class QsvUserAnswerRecordServiceImpl extends BaseServiceImpl<QsvUserAnswe
super(dao);
}
/**
* 按题目配置校验多选题最大选择数,防止前端限制被绕过后写入超量选项。
*
* @param qsvAnswerParam 用户提交的答题参数
* @return 超限提示文案,空表示校验通过
*/
@Override
public String checkMaxMultiLimit(QsvAnswerParam qsvAnswerParam) {
if (qsvAnswerParam == null || qsvAnswerParam.getSubjects() == null || qsvAnswerParam.getSubjects().isEmpty()) {
return null;
}
List<String> subjectIds = qsvAnswerParam.getSubjects().stream()
.filter(Objects::nonNull)
.map(QsvAnswerParam.Subject::getId)
.filter(Objects::nonNull)
.toList();
if (subjectIds.isEmpty()) {
return null;
}
Map<String, QsvSubject> 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<String> 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<String> subjectIds) {
QsvActivity activity = dao().fetch(QsvActivity.class, activityId);
@@ -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("未找到中文字体文件");
}
@@ -0,0 +1,2 @@
ALTER TABLE `qsv_subject`
ADD COLUMN `componentType` VARCHAR(30) NULL COMMENT '题目组件展示类型(RADIO普通单选,DROPDOWN下拉选择)' AFTER `type`;
@@ -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) {
File diff suppressed because one or more lines are too long
@@ -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"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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"){
<span class="qsv-online-score-tag" v-if="isQuizActivity && subject.score">{{subject.score}}分</span>
</div>
<div v-if="subject.type === 'radio'" class="qsv-online-options">
<div v-if="isDropdownSubject(subject)" class="qsv-online-options qsv-online-select">
<el-select
:disabled="answerDialog.readonly"
:value="getRadioValue(subject)"
placeholder="请选择"
clearable
filterable
@input="setRadioValue(subject, $event)">
<el-option
v-for="option in subject.options"
:key="option.id"
:label="option.text"
:value="option.id">
</el-option>
</el-select>
<template v-for="option in subject.options">
<el-input
v-if="isOptionFillVisible(subject, option)"
:key="option.id + '-fill'"
class="qsv-online-option-fill qsv-online-select-fill"
size="small"
:readonly="answerDialog.readonly"
v-model="subject.optionFillContents[option.id]"
:placeholder="option.fillPlaceholder || '请输入补充内容'"></el-input>
</template>
</div>
<div v-else-if="subject.type === 'radio'" class="qsv-online-options">
<el-radio-group
:disabled="answerDialog.readonly"
:value="getRadioValue(subject)"
@@ -1010,14 +1047,24 @@ layout("/layouts/platform.html"){
? subject.userSelectOptionIds[0]
: ""
},
// 下拉选择是单选题的展示变体,答案仍写入 userSelectOptionIds 以复用原统计和导出。
isDropdownSubject(subject) {
return subject && subject.type === "radio" && subject.componentType === "DROPDOWN"
},
setRadioValue(subject, optionId) {
this.$set(subject, "userSelectOptionIds", [String(optionId)])
this.$set(subject, "userSelectOptionIds", optionId ? [String(optionId)] : [])
this.$nextTick(() => {
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]) {
@@ -1,6 +1,6 @@
const report = {
/*language=HTML*/
template: "\n <el-dialog title=\"分析报告\" :visible.sync=\"dialogVisible\" width=\"50%\" custom-class=\"survey-report-dialog\">\n <div class=\"survey-report-toolbar\">\n <el-row type=\"flex\" justify=\"end\" class=\"mb10\">\n <el-button type=\"primary\" size=\"small\" icon=\"el-icon-download\" @click=\"exportReportXlsx\">导出xlsx</el-button>\n </el-row>\n </div>\n <div class=\"survey-report-body\">\n <div v-for=\"(subject,subjectIndex) in subjects\" class=\"subject\">\n <div>\n <div class=\"title\">\n 第{{subjectIndex+1}}题: {{subject.title}}\n <span class=\"type\" v-if=\"subject.type==='text'\">[填空题]</span>\n <span class=\"type\" v-else-if=\"subject.type==='radio'\">[单选题]</span>\n <span class=\"type\" v-else-if=\"subject.type==='checkbox'\">[多选题]</span>\n </div>\n\n <div v-if=\"subject.type==='text'\">\n <el-button type=\"text\" @click=\"openTextDetail(subject)\">查看填写内容</el-button>\n </div>\n\n <div v-else-if=\"subject.type==='radio' || subject.type==='checkbox'\">\n <el-table :data=\"subject.options\" size=\"small\">\n <el-table-column label=\"选项\" prop=\"text\"></el-table-column>\n <el-table-column label=\"小计\" prop=\"selectCount\" width=\"100\"></el-table-column>\n <el-table-column label=\"比例\" width=\"500\">\n <template slot-scope=\"scope\">\n <el-progress\n :percentage=\"Math.round(scope.row.selectCount / subject.selectTotal * 100)\"></el-progress>\n </template>\n </el-table-column>\n <el-table-column label=\"详情\" width=\"100px\">\n <template slot-scope=\"{row}\">\n <el-button type=\"text\" @click=\"openDetail(subject.id,row.id)\">详情</el-button>\n </template>\n </el-table-column>\n </el-table>\n </div>\n </div>\n </div>\n </div>\n \n <el-dialog title=\"选择详情\" :visible.sync=\"optionVisible\" width=\"50%\" append-to-body>\n <el-table :data=\"optionUsers\" size=\"small\">\n <el-table-column label=\"姓名\" prop=\"userName\"></el-table-column>\n <el-table-column label=\"工号\" prop=\"loginName\"></el-table-column>\n <el-table-column label=\"所属工会\" prop=\"unionName\"></el-table-column>\n <el-table-column label=\"所属单位\" prop=\"unitName\"></el-table-column>\n <el-table-column label=\"补充理由\" prop=\"fillContent\" show-overflow-tooltip></el-table-column>\n <el-table-column label=\"选择时间\" prop=\"attemptDate\"></el-table-column>\n </el-table>\n </el-dialog>\n\n <el-dialog :title=\"textSubject.title || '填写内容'\" :visible.sync=\"textVisible\" width=\"50%\" append-to-body>\n <el-table :data=\"textAnswers\" size=\"small\">\n <el-table-column label=\"序号\" type=\"index\" width=\"70\"></el-table-column>\n <el-table-column label=\"姓名\" prop=\"userName\" width=\"100\"></el-table-column>\n <el-table-column label=\"工号\" prop=\"loginName\" width=\"120\"></el-table-column>\n <el-table-column label=\"所属工会\" prop=\"unionName\" width=\"140\"></el-table-column>\n <el-table-column label=\"所属单位\" prop=\"unitName\" width=\"180\" show-overflow-tooltip></el-table-column>\n <el-table-column label=\"填写内容\" prop=\"text\" show-overflow-tooltip></el-table-column>\n <el-table-column label=\"提交时间\" prop=\"submitTime\" width=\"160\"></el-table-column>\n </el-table>\n </el-dialog>\n \n </el-dialog>\n ",
template: "\n <el-dialog title=\"分析报告\" :visible.sync=\"dialogVisible\" width=\"50%\" custom-class=\"survey-report-dialog\">\n <div class=\"survey-report-toolbar\">\n <el-row type=\"flex\" justify=\"end\" class=\"mb10\">\n <el-button type=\"primary\" size=\"small\" icon=\"el-icon-download\" @click=\"exportReportXlsx\">导出xlsx</el-button>\n </el-row>\n </div>\n <div class=\"survey-report-body\">\n <div v-for=\"(subject,subjectIndex) in subjects\" class=\"subject\">\n <div>\n <div class=\"title\">\n 第{{subjectIndex+1}}题: {{subject.title}}\n <span class=\"type\" v-if=\"subject.type==='text'\">[填空题]</span>\n <span class=\"type\" v-else-if=\"subject.type==='radio' && subject.componentType==='DROPDOWN'\">[下拉选择]</span>\n <span class=\"type\" v-else-if=\"subject.type==='radio'\">[单选题]</span>\n <span class=\"type\" v-else-if=\"subject.type==='checkbox'\">[多选题]</span>\n </div>\n\n <div v-if=\"subject.type==='text'\">\n <el-button type=\"text\" @click=\"openTextDetail(subject)\">查看填写内容</el-button>\n </div>\n\n <div v-else-if=\"subject.type==='radio' || subject.type==='checkbox'\">\n <el-table :data=\"subject.options\" size=\"small\">\n <el-table-column label=\"选项\" prop=\"text\"></el-table-column>\n <el-table-column label=\"小计\" prop=\"selectCount\" width=\"100\"></el-table-column>\n <el-table-column label=\"比例\" width=\"500\">\n <template slot-scope=\"scope\">\n <el-progress\n :percentage=\"Math.round(scope.row.selectCount / subject.selectTotal * 100)\"></el-progress>\n </template>\n </el-table-column>\n <el-table-column label=\"详情\" width=\"100px\">\n <template slot-scope=\"{row}\">\n <el-button type=\"text\" @click=\"openDetail(subject.id,row.id)\">详情</el-button>\n </template>\n </el-table-column>\n </el-table>\n </div>\n </div>\n </div>\n </div>\n \n <el-dialog title=\"选择详情\" :visible.sync=\"optionVisible\" width=\"50%\" append-to-body>\n <el-table :data=\"optionUsers\" size=\"small\">\n <el-table-column label=\"姓名\" prop=\"userName\"></el-table-column>\n <el-table-column label=\"工号\" prop=\"loginName\"></el-table-column>\n <el-table-column label=\"所属工会\" prop=\"unionName\"></el-table-column>\n <el-table-column label=\"所属单位\" prop=\"unitName\"></el-table-column>\n <el-table-column label=\"补充理由\" prop=\"fillContent\" show-overflow-tooltip></el-table-column>\n <el-table-column label=\"选择时间\" prop=\"attemptDate\"></el-table-column>\n </el-table>\n </el-dialog>\n\n <el-dialog :title=\"textSubject.title || '填写内容'\" :visible.sync=\"textVisible\" width=\"50%\" append-to-body>\n <el-table :data=\"textAnswers\" size=\"small\">\n <el-table-column label=\"序号\" type=\"index\" width=\"70\"></el-table-column>\n <el-table-column label=\"姓名\" prop=\"userName\" width=\"100\"></el-table-column>\n <el-table-column label=\"工号\" prop=\"loginName\" width=\"120\"></el-table-column>\n <el-table-column label=\"所属工会\" prop=\"unionName\" width=\"140\"></el-table-column>\n <el-table-column label=\"所属单位\" prop=\"unitName\" width=\"180\" show-overflow-tooltip></el-table-column>\n <el-table-column label=\"填写内容\" prop=\"text\" show-overflow-tooltip></el-table-column>\n <el-table-column label=\"提交时间\" prop=\"submitTime\" width=\"160\"></el-table-column>\n </el-table>\n </el-dialog>\n \n </el-dialog>\n ",
dicts: ["ACTIVITY_QSV_CATEGORY"],
data() {
return {
@@ -547,7 +547,7 @@ layout("/layouts/platform.html"){
<el-table-column label="所属工会" prop="unionName"></el-table-column>
<el-table-column label="所属单位" prop="unitName"></el-table-column>
<el-table-column label="补充理由" prop="fillContent" show-overflow-tooltip></el-table-column>
<el-table-column label="选择时间" prop="attemptDate"></el-table-column>
<el-table-column label="选择时间" prop="attemptDate" :formatter="formatAttemptDate"></el-table-column>
</el-table>
</el-dialog>
@@ -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) => {
@@ -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"){
<p>{{index+1}}、{{ subject.title }}<span class="required-star">*</span></p>
<div class="tag">
<van-tag v-if="subject.type === 'checkbox'" type="primary" size="large">多选</van-tag>
<van-tag v-if="subject.type === 'radio'" type="primary" size="large">单选</van-tag>
<van-tag v-if="isDropdownSubject(subject)" type="primary" size="large">下拉</van-tag>
<van-tag v-else-if="subject.type === 'radio'" type="primary" size="large">单选</van-tag>
<van-tag v-if="subject.score" type="primary" size="large">{{subject.score}}分</van-tag>
</div>
<!--下拉选择-->
<div class="survey-select-box" v-if="isDropdownSubject(subject)">
<van-field
readonly
clickable
:disabled="answerRecord.isFinish"
:value="getDropdownText(subject)"
placeholder="请选择"
right-icon="arrow"
@click="openDropdown(subject)">
</van-field>
<div
class="option-fill-box survey-select-fill"
v-for="option in subject.options"
v-if="isOptionFillVisible(subject, option)"
:key="option.id + '-dropdown-fill'">
<input
v-model="subject.optionFillContents[option.id]"
:placeholder="option.fillPlaceholder || '请输入补充内容'"
:readonly="answerRecord.isFinish">
</div>
</div>
<!--单选-->
<van-radio-group
:value="getRadioValue(subject)"
v-if="subject.type === 'radio'"
v-if="subject.type === 'radio' && !isDropdownSubject(subject)"
>
<van-cell-group>
<template v-for="(option,index) in subject.options">
@@ -271,6 +303,14 @@ layout("/layouts/platform_h5.html"){
</div>
</div>
</div>
<van-popup v-model="dropdownPicker.visible" position="bottom">
<van-picker
show-toolbar
:columns="dropdownPicker.columns"
@confirm="onDropdownConfirm"
@cancel="dropdownPicker.visible = false">
</van-picker>
</van-popup>
</div>
<script nonce="${cspNonce!}">
@@ -285,7 +325,13 @@ layout("/layouts/platform_h5.html"){
remainingTime: 0,
isFinished: false,
answerRecord: {},
answerRecordId: null
answerRecordId: null,
dropdownPicker: {
visible: false,
subjectId: "",
columns: [],
options: []
}
}
},
@@ -546,6 +592,47 @@ layout("/layouts/platform_h5.html"){
: ""
},
// 下拉选择是单选题的展示变体,答案仍写入 userSelectOptionIds 以复用原统计和导出。
isDropdownSubject(subject) {
return subject && subject.type === "radio" && subject.componentType === "DROPDOWN"
},
getDropdownText(subject) {
const optionId = this.getRadioValue(subject)
const option = (subject.options || []).find((item) => {
return String(item.id) === String(optionId)
})
return option ? option.text : ""
},
// 打开当前下拉题的选项面板,面板只负责选择展示,最终答案仍落到原单选数组。
openDropdown(subject) {
if (this.answerRecord.isFinish) {
return
}
this.dropdownPicker.subjectId = subject.id
this.dropdownPicker.options = subject.options || []
this.dropdownPicker.columns = this.dropdownPicker.options.map((option) => {
return option.text || "未填写选项"
})
this.dropdownPicker.visible = true
},
onDropdownConfirm(value, index) {
const option = this.dropdownPicker.options[index]
const subject = this.subjects.find((item) => {
return item.id === this.dropdownPicker.subjectId
})
if (subject && option) {
this.$set(subject, "userSelectOptionIds", [String(option.id)])
this.$nextTick(() => {
this.clearUnselectedOptionFillContents(subject)
this.clearHiddenSubjectAnswers()
})
}
this.dropdownPicker.visible = false
},
//选中需要补充填写的选项后显示输入框,例如选择“否”后填写原因
isOptionFillVisible(subject, option) {
return option.fillRequired && this.isOptionSelected(subject, option.id)