# Conflicts:
#	src/main/resources/application-dev.yaml
This commit is contained in:
2026-06-16 10:28:39 +08:00
46 changed files with 10089 additions and 772 deletions
@@ -310,7 +310,7 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
String hashedPassword = PwdUtil.getPassword(decodePwd, user.getSalt());
if (!Strings.sNull(hashedPassword).equalsIgnoreCase(user.getPassword())) {
// if (Globals.sso) {
throw new BaseException("用户名或者密码不正确");
// throw new BaseException("用户名或者密码不正确");
// }
}
user = this.fetchLinks(user, "unit");
@@ -59,18 +59,12 @@ public class QsvActivityController {
//保存问卷基础信息
@At
@SaCheckPermission("qsv.activity")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("保存问卷基础信息")
@SLog(type = "qsv.activity", tag = "保存问卷基础信息", msg = "保存问卷基础信息")
public Result save(QsvActivity qsvActivity) {
if (qsvActivity.getCategory().equals("QUIZ")) {
if (qsvActivity.getMode().equals("SCHEDULED")) {
qsvActivity.setRepeatMode("DAILY");
} else if (qsvActivity.getMode().equals("REGULAR")) {
qsvActivity.setRepeatMode("TOTAL");
}
}
dao.insertOrUpdate(qsvActivity);
return Result.success();
QsvActivity activity = qsvActivityService.saveActivity(qsvActivity);
return Result.success(activity);
}
@At
@@ -83,13 +77,25 @@ public class QsvActivityController {
// 删除问卷
@At
@SaCheckPermission("qsv.activity")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("删除问卷")
@SLog(type = "qsv.activity", tag = "删除问卷", msg = "删除问卷")
public Result delete(@Valid String id) {
dao.delete(QsvActivity.class, id);
qsvActivityService.deleteActivity(id);
return Result.success();
}
// 更新活动开启状态
@At
@SaCheckPermission("qsv.activity")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("更新活动开启状态")
@SLog(type = "qsv.activity", tag = "更新活动开启状态", msg = "更新活动开启状态")
public Result updateEnabled(@Valid String id, Boolean enabled) {
qsvActivityService.updateEnabled(id, enabled);
return Result.success(enabled ? "开启成功" : "关闭成功");
}
// 保存问卷题目
@At
@@ -0,0 +1,21 @@
package com.budwk.app.zhgh.dayofficework.qsv.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import io.swagger.annotations.ApiOperation;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
@IocBean
@At("/platform/qsv")
@Ok("json:full")
@ApiOperation("问卷调查新建")
public class QsvNewController {
@At("/new")
@Ok("beetl:/platform/zhgh/dayofficework/qsv/activity/new.html")
@SaCheckPermission("qsv.activity")
public void newPage() {
}
}
@@ -0,0 +1,562 @@
package com.budwk.app.zhgh.dayofficework.qsv.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.lang.Dict;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSONObject;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvOption;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.param.QsvAnswerParam;
import com.budwk.app.zhgh.dayofficework.qsv.dto.QsvCheckAnswerResult;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvQuizService;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvUserAnswerRecordService;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
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;
import javax.validation.Valid;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
@IocBean
@At("/platform/qsv/online")
@Ok("json:full")
@ApiOperation("在线答题")
public class QsvOnlineController {
@Inject
private Dao dao;
@Inject
private QsvUserAnswerRecordService qsvUserAnswerRecordService;
@Inject
private QsvQuizService qsvQuizService;
@At("")
@Ok("beetl:/platform/zhgh/dayofficework/qsv/online/index.html")
@SaCheckPermission("qsv.online")
public void index() {
}
@At
@SaCheckPermission("qsv.online")
@ApiOperation("在线答题任务分页")
public Result pageData(@Valid PageForm pageForm, Integer year, String title, Boolean isAnswered) {
List<Integer> groupIds = dao.query(ActivityUserScope.class, Cnd.where("userId", "=", SecurityUtil.getUserId()))
.stream().map(ActivityUserScope::getGroupId).collect(Collectors.toList());
if (groupIds.isEmpty()) {
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), 0, new ArrayList<>()));
}
Cnd cnd = Cnd.where("enabled", "=", true);
cnd.and("groupId", "in", groupIds);
cnd.andEX("YEAR(startTime)", "=", year);
cnd.and(Cnd.likeEX("title", title));
cnd.desc("startTime");
int totalCount = dao.count(QsvActivity.class, cnd);
if (totalCount == 0) {
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), 0, new ArrayList<>()));
}
List<QsvActivity> activities = dao.query(QsvActivity.class, cnd);
List<String> activityIds = activities.stream().map(QsvActivity::getId).collect(Collectors.toList());
Map<String, List<QsvUserAnswerRecord>> recordGroup = dao.query(QsvUserAnswerRecord.class, Cnd.where("userId", "=", SecurityUtil.getUserId())
.and("activityId", "in", activityIds))
.stream().collect(Collectors.groupingBy(QsvUserAnswerRecord::getActivityId));
List<NutMap> rows = activities.stream()
.map((activity) -> buildOnlineRow(activity, recordGroup.getOrDefault(activity.getId(), new ArrayList<>())))
.filter((row) -> {
if (Boolean.TRUE.equals(isAnswered)) {
return row.getBoolean("isAnswered");
}
return !row.getBoolean("isAnswered") || row.getBoolean("canAnswer");
})
.collect(Collectors.toList());
int filteredTotal = rows.size();
int fromIndex = Math.min((pageForm.getPageNumber() - 1) * pageForm.getPageSize(), filteredTotal);
int toIndex = Math.min(fromIndex + pageForm.getPageSize(), filteredTotal);
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), filteredTotal, rows.subList(fromIndex, toIndex)));
}
@At
@SaCheckPermission("qsv.online")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("在线答题题目")
public Result subjects(@Valid String activityId, Boolean viewOnly) {
QsvActivity activity = dao.fetch(QsvActivity.class, activityId);
if (activity == null) {
return Result.error("调查不存在");
}
QsvUserAnswerRecord answerRecord = findCurrentAnswerRecord(activityId, Boolean.TRUE.equals(viewOnly));
if (Boolean.TRUE.equals(viewOnly) && answerRecord == null) {
return Result.error("未找到已完成的答题记录");
}
boolean finishedView = Boolean.TRUE.equals(viewOnly) && answerRecord != null && Boolean.TRUE.equals(answerRecord.getIsFinish());
if (!finishedView) {
Result activityStatusResult = checkActivityStatus(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
}
Result scopeResult = checkUserScope(activity);
if (scopeResult != null) {
return scopeResult;
}
answerRecord = prepareAnswerRecord(activity, answerRecord);
List<QsvSubject> activitySubjects = queryActivitySubjectsForSync(activity);
syncAnswerRecordSubjects(answerRecord, activity, activitySubjects);
List<QsvSubject> subjects = querySubjectsByRecordOrder(answerRecord.getSubjectIds());
dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum"));
subjects.forEach((subject) -> {
if (subject.getUserSelectOptionIds() == null) {
subject.setUserSelectOptionIds(new ArrayList<>());
}
});
return Result.success(NutMap.NEW()
.addv("activity", activity)
.addv("subjects", subjects)
.addv("answerRecordId", answerRecord.getId()));
}
@At
@SaCheckPermission("qsv.online")
@ApiOperation("在线答题记录")
public Result answerRecord(@Valid String answerRecordId) {
QsvUserAnswerRecord record = dao.fetch(QsvUserAnswerRecord.class, answerRecordId);
return Result.success(record);
}
@At
@SaCheckPermission("qsv.online")
@ApiOperation("在线投票统计")
public Result voteStats(@Valid String activityId) {
QsvActivity activity = dao.fetch(QsvActivity.class, activityId);
if (activity == null || !"VOTE".equals(activity.getCategory())) {
return Result.error("投票不存在");
}
Result scopeResult = checkUserScope(activity);
if (scopeResult != null) {
return scopeResult;
}
return Result.success(buildVoteStats(activityId));
}
@At
@SaCheckPermission("qsv.online")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("提交在线答题")
public Result submitAnswer(@Valid @Param("answer") QsvAnswerParam qsvAnswerParam) {
QsvActivity activity = dao.fetch(QsvActivity.class, qsvAnswerParam.getActivityId());
Result activityStatusResult = checkActivityStatus(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
Result scopeResult = checkUserScope(activity);
if (scopeResult != null) {
return scopeResult;
}
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId());
if (answerRecord == null || !SecurityUtil.getUserId().equals(answerRecord.getUserId())) {
return Result.error("答题记录不存在");
}
if (Boolean.TRUE.equals(answerRecord.getIsFinish())) {
return Result.error("您已完成该调查");
}
JSONObject extJson = answerRecord.getExtJson();
if (extJson == null) {
extJson = new JSONObject();
}
float totalScore = 0;
boolean isQuiz = "QUIZ".equals(activity.getCategory());
for (QsvAnswerParam.Subject subject : qsvAnswerParam.getSubjects()) {
JSONObject entries = extJson.get(subject.getId(), JSONObject.class);
if (ObjectUtil.isEmpty(entries)) {
entries = new JSONObject();
}
entries.set("optionIds", subject.getUserSelectOptionIds());
entries.set("text", subject.getUserFillContent());
entries.set("optionFillContents", subject.getOptionFillContents());
if (isQuiz) {
QsvCheckAnswerResult answerResult = qsvQuizService.calcScore(subject.getId(), subject.getUserSelectOptionIds());
entries.set("score", answerResult.getScore());
entries.set("isCorrect", answerResult.isCorrect());
totalScore += answerResult.getScore();
}
extJson.set(subject.getId(), entries);
}
answerRecord.setExtJson(extJson);
if (isQuiz) {
answerRecord.setTotalScore(totalScore);
answerRecord.setAnswerTime(qsvAnswerParam.getAnswerTime());
}
answerRecord.setAttemptDate(new Date());
answerRecord.setSubmitTime(new Date());
answerRecord.setIsFinish(true);
dao.update(answerRecord);
if (isQuiz) {
qsvUserAnswerRecordService.calcByScoreMode(qsvAnswerParam.getActivityId(), SecurityUtil.getUserId());
}
return Result.success("提交成功");
}
private Result checkActivityStatus(QsvActivity activity) {
if (activity == null) {
return Result.error("调查不存在");
}
if (Boolean.FALSE.equals(activity.getEnabled())) {
return Result.error("活动已关闭,暂不能参与");
}
Date now = new Date();
if (activity.getStartTime() != null && activity.getStartTime().after(now)) {
return Result.error("调查尚未开始,请在开始后再参与");
}
if (activity.getEndTime() != null && activity.getEndTime().before(now)) {
return Result.error("调查已结束");
}
return null;
}
private Result checkUserScope(QsvActivity activity) {
int count = dao.count(ActivityUserScope.class, Cnd.where("groupId", "=", activity.getGroupId())
.and("userId", "=", SecurityUtil.getUserId()));
if (count != 1) {
return Result.error("您无需参加此次调查,感谢您的关注!");
}
return null;
}
private NutMap buildOnlineRow(QsvActivity activity, List<QsvUserAnswerRecord> records) {
boolean isAnswered = records.stream().anyMatch((record) -> Boolean.TRUE.equals(record.getIsFinish()));
boolean hasUnfinished = records.stream().anyMatch((record) -> !Boolean.TRUE.equals(record.getIsFinish()));
boolean canRepeat = "QUIZ".equals(activity.getCategory())
&& Boolean.TRUE.equals(activity.getRepeatable())
&& ("SCHEDULED".equals(activity.getMode()) || ("REGULAR".equals(activity.getMode()) && "ALL".equals(activity.getDisplayMode())));
boolean randomDisplayMode = "QUIZ".equals(activity.getCategory())
&& "REGULAR".equals(activity.getMode())
&& "RANDOM".equals(activity.getDisplayMode());
int maxAttempts = activity.getMaxAttempts() == null || activity.getMaxAttempts() < 1 ? 1 : activity.getMaxAttempts();
int totalRandom = activity.getTotalRandom() == null || activity.getTotalRandom() < 1 ? 1 : activity.getTotalRandom();
long attemptCount = records.size();
if ("SCHEDULED".equals(activity.getMode())) {
attemptCount = records.stream()
.filter((record) -> record.getAttemptDate() != null && DateUtil.isSameDay(record.getAttemptDate(), DateUtil.date()))
.count();
}
Date now = new Date();
boolean notStarted = activity.getStartTime() != null && activity.getStartTime().after(now);
boolean ended = activity.getEndTime() != null && activity.getEndTime().before(now);
boolean activityAvailable = Boolean.TRUE.equals(activity.getEnabled()) && !notStarted && !ended;
boolean canAnswerByAttempt = hasUnfinished || !isAnswered || (canRepeat && attemptCount < maxAttempts) || (randomDisplayMode && attemptCount < totalRandom);
boolean canAnswer = activityAvailable && canAnswerByAttempt;
boolean isVote = "VOTE".equals(activity.getCategory());
String actionText;
String actionType;
String statusText;
String statusType;
if (!activityAvailable && !isAnswered) {
if (!Boolean.TRUE.equals(activity.getEnabled())) {
actionText = "已关闭";
statusText = "已关闭";
} else if (notStarted) {
actionText = isVote ? "未开始投票" : "未开始答题";
statusText = "未开始";
} else {
actionText = isVote ? "投票已结束" : "答题已结束";
statusText = "已结束";
}
actionType = "unavailable";
statusType = "info";
} else if (hasUnfinished && canAnswer) {
actionText = isVote ? "继续投票" : "继续答题";
actionType = "answer";
statusText = isVote ? "投票中" : "答题中";
statusType = "warning";
} else if (!isAnswered && canAnswer) {
actionText = isVote ? "投票" : "答题";
actionType = "answer";
statusText = isVote ? "未投票" : "未答题";
statusType = "warning";
} else if (canAnswer) {
actionText = isVote ? "再次投票" : "再次答题";
actionType = "answer";
statusText = isVote ? "可再次投票" : "可再次答题";
statusType = "warning";
} else {
actionText = isVote ? "查看投票" : "查看答题";
actionType = "view";
statusText = isVote ? "已投票" : "已答题";
statusType = "success";
}
Optional<QsvUserAnswerRecord> latestFinishedRecord = records.stream()
.filter((record) -> Boolean.TRUE.equals(record.getIsFinish()))
.max((first, second) -> {
long firstTime = first.getSubmitTime() == null ? 0 : first.getSubmitTime().getTime();
long secondTime = second.getSubmitTime() == null ? 0 : second.getSubmitTime().getTime();
return Long.compare(firstTime, secondTime);
});
return NutMap.NEW()
.addv("id", activity.getId())
.addv("title", activity.getTitle())
.addv("category", activity.getCategory())
.addv("startTime", activity.getStartTime())
.addv("endTime", activity.getEndTime())
.addv("enabled", activity.getEnabled())
.addv("isAnswered", isAnswered)
.addv("hasUnfinished", hasUnfinished)
.addv("canAnswer", canAnswer)
.addv("notStarted", notStarted)
.addv("ended", ended)
.addv("canRepeat", canRepeat)
.addv("answeredCount", records.stream().filter((record) -> Boolean.TRUE.equals(record.getIsFinish())).count())
.addv("remainingAttempts", canRepeat ? Math.max(maxAttempts - attemptCount, 0) : (randomDisplayMode ? Math.max(totalRandom - attemptCount, 0) : 0))
.addv("actionText", actionText)
.addv("actionType", actionType)
.addv("statusText", statusText)
.addv("statusType", statusType)
.addv("totalScore", latestFinishedRecord.map(QsvUserAnswerRecord::getTotalScore).orElse(null));
}
private QsvUserAnswerRecord findCurrentAnswerRecord(String activityId, boolean viewOnly) {
Cnd cnd = Cnd.where("activityId", "=", activityId)
.and("userId", "=", SecurityUtil.getUserId());
if (viewOnly) {
cnd.and("isFinish", "=", true).desc("submitTime").desc("createdAt");
} else {
cnd.and(Cnd.exps("isFinish", "=", false).or("isFinish", "is", null)).desc("createdAt");
}
return dao.fetch(QsvUserAnswerRecord.class, cnd);
}
private QsvUserAnswerRecord prepareAnswerRecord(QsvActivity activity, QsvUserAnswerRecord answerRecord) {
if (answerRecord != null) {
return answerRecord;
}
if ("QUIZ".equals(activity.getCategory())
&& "REGULAR".equals(activity.getMode())
&& "RANDOM".equals(activity.getDisplayMode())) {
return createRandomAnswerRecord(activity);
}
List<QsvSubject> activitySubjects = queryActivitySubjectsForSync(activity);
return qsvUserAnswerRecordService.insertRecord(activity.getId(), activitySubjects.stream().map(QsvSubject::getId).collect(Collectors.toList()));
}
private QsvUserAnswerRecord createRandomAnswerRecord(QsvActivity activity) {
Integer randomCount = activity.getRandomCount();
Integer totalRandom = activity.getTotalRandom();
if (randomCount == null || randomCount < 1) {
return qsvUserAnswerRecordService.insertRecord(activity.getId(), new ArrayList<>());
}
if (totalRandom == null || totalRandom < 1) {
totalRandom = 1;
}
List<QsvUserAnswerRecord> answerRecords = dao.query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activity.getId())
.and("userId", "=", SecurityUtil.getUserId()));
if (answerRecords.size() >= totalRandom) {
return answerRecords.stream()
.max((first, second) -> Integer.compare(first.getRandomNumber() == null ? 0 : first.getRandomNumber(), second.getRandomNumber() == null ? 0 : second.getRandomNumber()))
.orElse(null);
}
List<String> usedSubjectIds = answerRecords.stream()
.filter((record) -> record.getSubjectIds() != null)
.flatMap((record) -> record.getSubjectIds().stream())
.collect(Collectors.toList());
Cnd subjectCnd = Cnd.where("activityId", "=", activity.getId());
if (!usedSubjectIds.isEmpty()) {
subjectCnd.and("id", "not in", usedSubjectIds);
}
List<QsvSubject> subjects = dao.query(QsvSubject.class, subjectCnd.asc("sortNum"));
if (subjects.size() < randomCount) {
throw new RuntimeException("题目数不够,无法生成题目");
}
Collections.shuffle(subjects);
List<String> randomSubjectIds = subjects.subList(0, randomCount).stream().map(QsvSubject::getId).collect(Collectors.toList());
return qsvUserAnswerRecordService.insertRecord(activity.getId(), randomSubjectIds);
}
private List<QsvSubject> queryActivitySubjectsForSync(QsvActivity activity) {
Cnd cnd = Cnd.where("activityId", "=", activity.getId());
if ("QUIZ".equals(activity.getCategory())
&& "SCHEDULED".equals(activity.getMode())) {
cnd.and("displayDate", "=", DateUtil.today());
}
return dao.query(QsvSubject.class, cnd.asc("sortNum"));
}
private void syncAnswerRecordSubjects(QsvUserAnswerRecord answerRecord, QsvActivity activity, List<QsvSubject> activitySubjects) {
if (answerRecord == null || Boolean.TRUE.equals(answerRecord.getIsFinish())) {
return;
}
List<String> currentSubjectIds = activitySubjects.stream().map(QsvSubject::getId).collect(Collectors.toList());
List<String> recordSubjectIds = answerRecord.getSubjectIds();
if (recordSubjectIds == null) {
recordSubjectIds = new ArrayList<>();
}
JSONObject extJson = answerRecord.getExtJson();
if (extJson == null) {
extJson = new JSONObject();
}
boolean randomDisplayMode = "QUIZ".equals(activity.getCategory())
&& "REGULAR".equals(activity.getMode())
&& "RANDOM".equals(activity.getDisplayMode());
LinkedHashSet<String> currentSubjectIdSet = new LinkedHashSet<>(currentSubjectIds);
List<String> normalizedSubjectIds = recordSubjectIds.stream()
.filter(currentSubjectIdSet::contains)
.collect(Collectors.toList());
if (randomDisplayMode) {
currentSubjectIds = normalizedSubjectIds;
} else {
for (String subjectId : currentSubjectIds) {
if (!normalizedSubjectIds.contains(subjectId)) {
normalizedSubjectIds.add(subjectId);
}
}
}
boolean changed = !recordSubjectIds.equals(normalizedSubjectIds);
boolean subjectOrderLocked = Boolean.TRUE.equals(extJson.get("_subjectOrderLocked"));
if ("QUIZ".equals(activity.getCategory())
&& Boolean.TRUE.equals(activity.getShuffleSubject())
&& !subjectOrderLocked
&& isAnswerRecordEmpty(extJson, currentSubjectIds)) {
Collections.shuffle(normalizedSubjectIds);
extJson.set("_subjectOrderLocked", true);
changed = true;
}
for (String subjectId : currentSubjectIds) {
if (ObjectUtil.isEmpty(extJson.get(subjectId, JSONObject.class))) {
extJson.set(subjectId, Dict.create()
.set("optionIds", new ArrayList<>())
.set("text", null)
.set("optionFillContents", new JSONObject()));
changed = true;
}
}
if (changed) {
answerRecord.setSubjectIds(normalizedSubjectIds);
answerRecord.setExtJson(extJson);
dao.update(answerRecord);
}
}
private boolean isAnswerRecordEmpty(JSONObject extJson, List<String> subjectIds) {
for (String subjectId : subjectIds) {
JSONObject answer = extJson.get(subjectId, JSONObject.class);
if (ObjectUtil.isEmpty(answer)) {
continue;
}
if (ObjectUtil.isNotEmpty(answer.get("optionIds"))
|| ObjectUtil.isNotEmpty(answer.get("text"))
|| ObjectUtil.isNotEmpty(answer.get("optionFillContents"))) {
return false;
}
}
return true;
}
private List<QsvSubject> querySubjectsByRecordOrder(List<String> subjectIds) {
if (subjectIds == null || subjectIds.isEmpty()) {
return new ArrayList<>();
}
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds));
Map<String, QsvSubject> subjectMap = subjects.stream()
.collect(Collectors.toMap(QsvSubject::getId, Function.identity(), (oldValue, newValue) -> oldValue));
return subjectIds.stream()
.map(subjectMap::get)
.filter(ObjectUtil::isNotEmpty)
.collect(Collectors.toList());
}
private NutMap buildVoteStats(String activityId) {
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum"));
Map<String, NutMap> optionStats = new LinkedHashMap<>();
for (QsvSubject subject : subjects) {
if (subject.getOptions() == null) {
continue;
}
for (QsvOption option : subject.getOptions()) {
optionStats.put(option.getId(), NutMap.NEW()
.addv("optionId", option.getId())
.addv("subjectId", subject.getId())
.addv("text", option.getText())
.addv("imgUrl", option.getImgUrl())
.addv("votes", 0)
.addv("percent", 0D));
}
}
List<QsvUserAnswerRecord> records = dao.query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
for (QsvUserAnswerRecord record : records) {
JSONObject extJson = record.getExtJson();
if (extJson == null) {
continue;
}
for (QsvSubject subject : subjects) {
JSONObject entry = extJson.get(subject.getId(), JSONObject.class);
if (entry == null) {
continue;
}
List<String> optionIds = entry.getBeanList("optionIds", String.class);
if (optionIds == null) {
continue;
}
for (String optionId : optionIds) {
NutMap stat = optionStats.get(optionId);
if (stat != null) {
stat.put("votes", stat.getInt("votes", 0) + 1);
}
}
}
}
int totalParticipants = records.size();
List<NutMap> rankList = new ArrayList<>(optionStats.values());
for (NutMap stat : rankList) {
int votes = stat.getInt("votes", 0);
double percent = totalParticipants == 0 ? 0D : votes * 100D / totalParticipants;
stat.put("percent", percent);
}
rankList.sort((left, right) -> Integer.compare(right.getInt("votes", 0), left.getInt("votes", 0)));
return NutMap.NEW()
.addv("totalParticipants", totalParticipants)
.addv("optionCount", optionStats.size())
.addv("optionStats", optionStats)
.addv("rankList", rankList);
}
}
@@ -1,16 +1,27 @@
package com.budwk.app.zhgh.dayofficework.qsv.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvActivityService;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvSurveyService;
import io.swagger.annotations.ApiOperation;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject;
@@ -21,6 +32,10 @@ import org.nutz.mvc.annotation.Ok;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.Base64;
import java.util.List;
@IocBean
@@ -43,12 +58,19 @@ public class QsvSurveyController {
}
@At("/reportPage")
@Ok("beetl:/platform/zhgh/dayofficework/qsv/survey/reportPage.html")
@SaCheckPermission("qsv.survey")
public void reportPage() {
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("分页查询")
public Result pageData(@Valid PageForm pageForm, Integer year) {
Cnd cnd = Cnd.where("category", "=", "SURVEY");
Cnd cnd = Cnd.where("category", "in", new String[]{"SURVEY", "VOTE"});
cnd.andEX("YEAR(startTime)", "=", year);
cnd.desc("category");
Pagination pagination = qsvActivityService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), cnd);
@@ -63,6 +85,43 @@ public class QsvSurveyController {
return Result.success(report);
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("查询调查信息")
public Result activityInfo(@Valid String activityId) {
QsvActivity activity = dao.fetch(QsvActivity.class, activityId);
NutMap data = NutMap.NEW();
if (activity != null) {
data.setv("id", activity.getId());
data.setv("title", activity.getTitle());
}
return Result.success(data);
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("生成分类报告")
public Result categoryReport(@Valid String activityId, String conditions) {
List<NutMap> report = qsvSurveyService.categoryReport(activityId, conditions);
return Result.success(report);
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("生成交叉分析报告")
public Result crossReport(@Valid String activityId, @Valid String xSubjectIds, @Valid String ySubjectIds, String conditions) {
NutMap report = qsvSurveyService.crossReport(activityId, xSubjectIds, ySubjectIds, conditions);
return Result.success(report);
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("生成对比分析报告")
public Result compareReport(@Valid String activityId, @Valid String subjectId, @Valid String optionId) {
NutMap report = qsvSurveyService.compareReport(activityId, subjectId, optionId);
return Result.success(report);
}
@At
@Ok("void")
@@ -72,13 +131,236 @@ public class QsvSurveyController {
qsvSurveyService.exportReportXlsx(activityId, response);
}
@At
@Ok("void")
@SaCheckPermission("qsv.survey")
@ApiOperation("下载PDF报告")
public void exportReportPdf(String reportData, HttpServletResponse response) {
if (StrUtil.isBlank(reportData)) {
throw new IllegalArgumentException("报告数据不能为空");
}
JSONObject report = JSONUtil.parseObj(reportData);
String title = StrUtil.blankToDefault(report.getStr("title"), "分析报告");
String tabLabel = StrUtil.blankToDefault(report.getStr("tabLabel"), "分析报告");
try {
CommonDownloadUtil.download(safeFileName(title + "-" + tabLabel) + ".pdf", buildReportPdf(report), response);
} catch (IOException e) {
throw new RuntimeException("生成PDF报告失败", e);
}
}
private byte[] buildReportPdf(JSONObject report) throws IOException {
try (PDDocument document = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
PdfReportWriter writer = new PdfReportWriter(document);
writer.title(StrUtil.blankToDefault(report.getStr("title"), "分析报告"));
writer.text("报告类型:" + StrUtil.blankToDefault(report.getStr("tabLabel"), "分析报告"), 10);
writer.text("生成时间:" + DateUtil.now(), 10);
writer.gap(8);
JSONArray sections = report.getJSONArray("sections");
if (sections == null || sections.isEmpty()) {
writer.text("暂无可下载内容", 12);
} else {
for (int i = 0; i < sections.size(); i++) {
JSONObject section = JSONUtil.parseObj(sections.get(i));
writer.section(StrUtil.blankToDefault(section.getStr("title"), "统计项"));
String description = section.getStr("description");
if (StrUtil.isNotBlank(description)) {
writer.text(description, 10);
}
String image = section.getStr("image");
if (StrUtil.isNotBlank(image)) {
writer.image(image);
}
writer.table(section.getJSONArray("rows"));
writer.gap(8);
}
}
writer.close();
document.save(out);
return out.toByteArray();
}
}
private String safeFileName(String fileName) {
return StrUtil.blankToDefault(fileName, "分析报告").replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_");
}
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"
};
for (String path : paths) {
File file = new File(path);
if (file.exists()) {
return PDType0Font.load(document, file);
}
}
throw new IOException("未找到可用的中文字体");
}
private class PdfReportWriter {
private final PDDocument document;
private final PDType0Font regularFont;
private final PDType0Font boldFont;
private final float margin = 42F;
private final float pageWidth = PDRectangle.A4.getWidth();
private final float pageHeight = PDRectangle.A4.getHeight();
private final float contentWidth = pageWidth - margin * 2;
private PDPageContentStream content;
private float y;
PdfReportWriter(PDDocument document) throws IOException {
this.document = document;
this.regularFont = loadChineseFont(document, false);
this.boldFont = loadChineseFont(document, true);
newPage();
}
void newPage() throws IOException {
if (content != null) {
content.close();
}
PDPage page = new PDPage(PDRectangle.A4);
document.addPage(page);
content = new PDPageContentStream(document, page);
y = pageHeight - margin;
}
void close() throws IOException {
if (content != null) {
content.close();
content = null;
}
}
void ensure(float height) throws IOException {
if (y - height < margin) {
newPage();
}
}
void gap(float height) throws IOException {
ensure(height);
y -= height;
}
void title(String text) throws IOException {
ensure(36);
drawText(text, margin, y, 18, boldFont);
y -= 32;
}
void section(String text) throws IOException {
ensure(30);
drawText(text, margin, y, 13, boldFont);
y -= 24;
}
void text(String text, float fontSize) throws IOException {
List<String> lines = wrap(StrUtil.blankToDefault(text, ""), fontSize, contentWidth, regularFont);
for (String line : lines) {
ensure(fontSize + 8);
drawText(line, margin, y, fontSize, regularFont);
y -= fontSize + 7;
}
}
void image(String dataUrl) throws IOException {
int commaIndex = dataUrl.indexOf(",");
if (commaIndex < 0) {
return;
}
byte[] bytes = Base64.getDecoder().decode(dataUrl.substring(commaIndex + 1));
PDImageXObject image = PDImageXObject.createFromByteArray(document, bytes, "chart");
float width = contentWidth;
float height = width * image.getHeight() / image.getWidth();
if (height > 260) {
height = 260;
width = height * image.getWidth() / image.getHeight();
}
ensure(height + 12);
content.drawImage(image, margin, y - height, width, height);
y -= height + 12;
}
void table(JSONArray rows) throws IOException {
if (rows == null || rows.isEmpty()) {
text("暂无统计数据", 10);
return;
}
float[] widths = new float[]{contentWidth * 0.46F, contentWidth * 0.15F, contentWidth * 0.17F, contentWidth * 0.22F};
drawTableRow(new String[]{"选项/内容", "数量", "占比", "备注"}, widths, true);
for (int i = 0; i < rows.size(); i++) {
JSONObject row = JSONUtil.parseObj(rows.get(i));
drawTableRow(new String[]{
StrUtil.blankToDefault(row.getStr("name"), ""),
StrUtil.blankToDefault(row.getStr("count"), ""),
StrUtil.blankToDefault(row.getStr("percent"), ""),
StrUtil.blankToDefault(row.getStr("remark"), "")
}, widths, false);
}
}
void drawTableRow(String[] values, float[] widths, boolean header) throws IOException {
float rowHeight = 24F;
ensure(rowHeight);
float x = margin;
for (int i = 0; i < values.length; i++) {
content.addRect(x, y - rowHeight, widths[i], rowHeight);
content.stroke();
drawText(clip(values[i], header ? 16 : 28), x + 5, y - 16, 9, header ? boldFont : regularFont);
x += widths[i];
}
y -= rowHeight;
}
void drawText(String text, float x, float y, float fontSize, PDType0Font font) throws IOException {
content.beginText();
content.setFont(font, fontSize);
content.newLineAtOffset(x, y);
content.showText(StrUtil.blankToDefault(text, "").replaceAll("[\\r\\n\\t]", " "));
content.endText();
}
List<String> wrap(String text, float fontSize, float maxWidth, PDType0Font font) throws IOException {
List<String> lines = new java.util.ArrayList<>();
StringBuilder line = new StringBuilder();
for (int i = 0; i < text.length(); i++) {
char ch = text.charAt(i);
String next = line.toString() + ch;
if (font.getStringWidth(next) / 1000 * fontSize > maxWidth && line.length() > 0) {
lines.add(line.toString());
line.setLength(0);
}
line.append(ch);
}
if (line.length() > 0 || lines.isEmpty()) {
lines.add(line.toString());
}
return lines;
}
String clip(String text, int maxLength) {
if (text == null || text.length() <= maxLength) {
return StrUtil.blankToDefault(text, "");
}
return text.substring(0, maxLength) + "...";
}
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("选项选择详情")
public Result selectOptionUsers(@Valid String activityId, @Valid String subjectId, @Valid String optionId) {
List<QsvUserAnswerRecord> answerRecords = dao.query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId));
List<QsvUserAnswerRecord> selectOptionUsers = answerRecords.stream().filter(ext -> ObjectUtil.isNotNull(ext.getExtJson().get(subjectId, JSONObject.class)) && ext.getExtJson().get(subjectId, JSONObject.class).getJSONArray("optionIds").contains(optionId)).toList();
public Result selectOptionUsers(@Valid String activityId, @Valid String subjectId, @Valid String optionId, String conditions) {
List<NutMap> selectOptionUsers = qsvSurveyService.selectOptionUsers(activityId, subjectId, optionId, conditions);
return Result.success(selectOptionUsers);
}
@@ -1,19 +1,29 @@
package com.budwk.app.zhgh.dayofficework.qsv.h5controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvActivityService;
import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
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;
import javax.validation.Valid;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@IocBean
@At("/platform/h5/qsv")
@@ -23,6 +33,8 @@ public class H5QsvController {
@Inject
private QsvActivityService qsvActivityService;
@Inject
private Dao dao;
@At("")
@Ok("beetl:/platform/zhghh5/dayofficework/qsv/index.html")
@@ -33,13 +45,69 @@ public class H5QsvController {
@At
@SaCheckPermission("h5.qsv")
public Result pageData(@Valid PageForm pageForm, @Valid String category) {
public Result pageData(@Valid PageForm pageForm, @Valid String category, @Param("title") String title) {
Cnd cnd = Cnd.NEW();
cnd.andEX("category", "=", category);
cnd.and(Cnd.likeEX("title", title));
cnd.and(Cnd.exps("enabled", "=", true).or("enabled", "is", null));
LocalDate currentYearFirstDay = LocalDate.now().withDayOfYear(1);
Date currentYearStart = Date.from(currentYearFirstDay.atStartOfDay(ZoneId.systemDefault()).toInstant());
Date nextYearStart = Date.from(currentYearFirstDay.plusYears(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
cnd.and("startTime", ">=", currentYearStart);
cnd.and("startTime", "<", nextYearStart);
cnd.desc("startTime");
Pagination pagination = qsvActivityService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), cnd);
fillAnsweredStatus(pagination);
return Result.success(pagination);
}
private void fillAnsweredStatus(Pagination pagination) {
List<NutMap> rows = pagination.getList();
if (rows == null || rows.isEmpty()) {
return;
}
List<String> activityIds = rows.stream()
.map(this::getRowActivityId)
.filter(id -> id != null && !id.isBlank())
.collect(Collectors.toList());
if (activityIds.isEmpty()) {
return;
}
Set<String> answeredActivityIds = dao.query(QsvUserAnswerRecord.class, Cnd.where("userId", "=", SecurityUtil.getUserId())
.and("activityId", "in", activityIds)
.and("isFinish", "=", true))
.stream()
.map(QsvUserAnswerRecord::getActivityId)
.collect(Collectors.toSet());
rows.forEach(row -> {
boolean isAnswered = answeredActivityIds.contains(getRowActivityId(row));
row.put("isAnswered", isAnswered);
row.put("answeredText", isAnswered ? getAnsweredText(row.getString("category")) : "");
});
}
private String getRowActivityId(NutMap row) {
String id = row.getString("id");
if (id == null || id.isBlank()) {
id = row.getString("ID");
}
if (id == null || id.isBlank()) {
id = row.getString("activityId");
}
return id;
}
private String getAnsweredText(String category) {
if ("QUIZ".equals(category)) {
return "已答题";
}
if ("SURVEY".equals(category)) {
return "已填写";
}
if ("VOTE".equals(category)) {
return "已投票";
}
return "已完成";
}
}
@@ -1,7 +1,6 @@
package com.budwk.app.zhgh.dayofficework.qsv.h5controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
@@ -9,7 +8,7 @@ import cn.hutool.json.JSONObject;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.result.Result;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.basic.service.ActivityBasicScopeService;
import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope;
import com.budwk.app.zhgh.dayofficework.qsv.dto.QsvCheckAnswerResult;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject;
@@ -44,8 +43,6 @@ public class H5QsvQuizController {
@Inject
private Dao dao;
@Inject
private ActivityBasicScopeService activityBasicScopeService;
@Inject
private QsvUserAnswerRecordService qsvUserAnswerRecordService;
@Inject
private QsvQuizService qsvQuizService;
@@ -71,7 +68,9 @@ public class H5QsvQuizController {
QsvActivity activity = dao.fetch(QsvActivity.class, activityId);
if (activity.getGroupId() != null) {
if (!activityBasicScopeService.isUserInGroup(activity.getGroupId(), SecurityUtil.getUserId())) {
int count = dao.count(ActivityUserScope.class, Cnd.where("groupId", "=", activity.getGroupId())
.and("userId", "=", SecurityUtil.getUserId()));
if (count != 1) {
return Result.error("您无需参加此次答题,感谢您的关注!");
}
}
@@ -103,7 +102,7 @@ public class H5QsvQuizController {
List<String> subjectIds = lastRecordOptional.get().getSubjectIds();
Cnd cnd = Cnd.where("id", "in", subjectIds);
String dynamic = subjectIds.stream().map(id -> "'" + id + "'").collect(Collectors.joining(","));
cnd.orderBy("sortNum","asc");
cnd.and(new Static("1=1 ORDER BY FIELD (id," + dynamic + ")"));
resultSubjects = dao.query(QsvSubject.class, cnd);
answerRecordId = lastRecordOptional.get().getId();
} else {
@@ -115,7 +114,7 @@ public class H5QsvQuizController {
List<String> subjectIds = answerRecord.getSubjectIds();
Cnd cnd = Cnd.where("id", "in", subjectIds);
String dynamic = subjectIds.stream().map(id -> "'" + id + "'").collect(Collectors.joining(","));
cnd.orderBy("sortNum","asc");
cnd.and(new Static("1=1 ORDER BY FIELD (id," + dynamic + ")"));
resultSubjects = dao.query(QsvSubject.class, cnd);
}
} else {
@@ -125,7 +124,9 @@ public class H5QsvQuizController {
//首次进来生成答题记录
if ("ALL".equals(displayMode)) {
resultSubjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
answerRecordId = qsvUserAnswerRecordService.insertRecord(activityId, resultSubjects.stream().map(QsvSubject::getId).collect(Collectors.toList())).getId();
QsvUserAnswerRecord answerRecord = qsvUserAnswerRecordService.insertRecord(activityId, resultSubjects.stream().map(QsvSubject::getId).collect(Collectors.toList()));
answerRecordId = answerRecord.getId();
resultSubjects = querySubjectsByRecordOrder(answerRecord.getSubjectIds());
} else if ("RANDOM".equals(displayMode)) {
//单次随机抽取题目数量
Integer randomCount = activity.getRandomCount();
@@ -144,7 +145,7 @@ public class H5QsvQuizController {
List<String> subjectIds = answerRecord.getSubjectIds();
Cnd cnd = Cnd.where("id", "in", subjectIds);
String dynamic = subjectIds.stream().map(id -> "'" + id + "'").collect(Collectors.joining(","));
cnd.orderBy("sortNum","asc");
cnd.and(new Static("1=1 ORDER BY FIELD (id," + dynamic + ")"));
resultSubjects = dao.query(QsvSubject.class, cnd);
}
} else {
@@ -170,7 +171,7 @@ public class H5QsvQuizController {
}
Cnd cnd = Cnd.where("id", "in", subjectIds);
String dynamic = subjectIds.stream().map(id -> "'" + id + "'").collect(Collectors.joining(","));
cnd.orderBy("sortNum","asc");
cnd.and(new Static("1=1 ORDER BY FIELD (id," + dynamic + ")"));
resultSubjects = dao.query(QsvSubject.class, cnd);
repeatTips = true;
} else {
@@ -180,7 +181,7 @@ public class H5QsvQuizController {
List<String> subjectIds = maxAnswerRecordOptional.get().getSubjectIds();
Cnd cnd = Cnd.where("id", "in", subjectIds);
String dynamic = subjectIds.stream().map(id -> "'" + id + "'").collect(Collectors.joining(","));
cnd.orderBy("sortNum","asc");
cnd.and(new Static("1=1 ORDER BY FIELD (id," + dynamic + ")"));
resultSubjects = dao.query(QsvSubject.class, cnd);
answerRecordId = maxAnswerRecordOptional.get().getId();
} else {
@@ -193,7 +194,7 @@ public class H5QsvQuizController {
List<String> subjectIds = maxAnswerRecordOptional.get().getSubjectIds();
Cnd cnd = Cnd.where("id", "in", subjectIds);
String dynamic = subjectIds.stream().map(id -> "'" + id + "'").collect(Collectors.joining(","));
cnd.orderBy("sortNum","asc");
cnd.and(new Static("1=1 ORDER BY FIELD (id," + dynamic + ")"));
resultSubjects = dao.query(QsvSubject.class, cnd);
answerRecordId = maxAnswerRecordOptional.get().getId();
} else {
@@ -207,7 +208,7 @@ public class H5QsvQuizController {
List<String> subjectIds = notFinishRecordOptional.get().getSubjectIds();
Cnd cnd = Cnd.where("id", "in", subjectIds);
String dynamic = subjectIds.stream().map(id -> "'" + id + "'").collect(Collectors.joining(","));
cnd.orderBy("sortNum","asc");
cnd.and(new Static("1=1 ORDER BY FIELD (id," + dynamic + ")"));
resultSubjects = dao.query(QsvSubject.class, cnd);
answerRecordId = notFinishRecordOptional.get().getId();
} else {
@@ -245,7 +246,7 @@ public class H5QsvQuizController {
List<String> subjectIds = maxAnswerRecordOptional.get().getSubjectIds();
Cnd cnd = Cnd.where("id", "in", subjectIds);
String dynamic = subjectIds.stream().map(id -> "'" + id + "'").collect(Collectors.joining(","));
cnd.orderBy("sortNum","asc");
cnd.and(new Static("1=1 ORDER BY FIELD (id," + dynamic + ")"));
resultSubjects = dao.query(QsvSubject.class, cnd);
answerRecordId = maxAnswerRecordOptional.get().getId();
} else {
@@ -281,7 +282,7 @@ public class H5QsvQuizController {
List<String> subjectIds = answerRecord.getSubjectIds();
Cnd cnd = Cnd.where("id", "in", subjectIds);
String dynamic = subjectIds.stream().map(id -> "'" + id + "'").collect(Collectors.joining(","));
cnd.orderBy("sortNum","asc");
cnd.and(new Static("1=1 ORDER BY FIELD (id," + dynamic + ")"));
resultSubjects = dao.query(QsvSubject.class, cnd);
} else {
//今天最大那次的记录
@@ -292,7 +293,7 @@ public class H5QsvQuizController {
List<String> subjectIds = maxTodayRecord.get().getSubjectIds();
Cnd cnd = Cnd.where("id", "in", subjectIds);
String dynamic = subjectIds.stream().map(id -> "'" + id + "'").collect(Collectors.joining(","));
cnd.orderBy("sortNum","asc");
cnd.and(new Static("1=1 ORDER BY FIELD (id," + dynamic + ")"));
resultSubjects = dao.query(QsvSubject.class, cnd);
} else {
throw new RuntimeException("业务异常");
@@ -306,12 +307,9 @@ public class H5QsvQuizController {
answerRecordId = answerRecord.getId();
List<String> subjectIds = answerRecord.getSubjectIds();
if(CollectionUtil.isEmpty(subjectIds)){
throw new RuntimeException("题目列表为空!请检查答题显示日期");
}
Cnd cnd = Cnd.where("id", "in", subjectIds);
String dynamic = subjectIds.stream().map(id -> "'" + id + "'").collect(Collectors.joining(","));
cnd.orderBy("sortNum","asc");
cnd.and(new Static("1=1 ORDER BY FIELD (id," + dynamic + ")"));
resultSubjects = dao.query(QsvSubject.class, cnd);
}
}
@@ -344,13 +342,6 @@ public class H5QsvQuizController {
float totalScore = 0;
for (QsvAnswerParam.Subject subject : qsvAnswerParam.getSubjects()) {
QsvSubject dbSubject = dao.fetch(QsvSubject.class, subject.getId());
if (ObjectUtil.isNotEmpty(dbSubject) && "checkbox".equals(dbSubject.getType())
&& ObjectUtil.isNotEmpty(dbSubject.getMaxMulti()) && dbSubject.getMaxMulti() > 0
&& CollectionUtil.size(subject.getUserSelectOptionIds()) > dbSubject.getMaxMulti()) {
// 后端兜底校验最大可选数,避免绕过前端直接提交超限答案。
return Result.error("题目【" + dbSubject.getTitle() + "】最多只能选择" + dbSubject.getMaxMulti() + "");
}
JSONObject entries = extJson.get(subject.getId(), JSONObject.class);
if (ObjectUtil.isNotEmpty(entries)) {
entries.set("optionIds", subject.getUserSelectOptionIds());
@@ -395,7 +386,7 @@ public class H5QsvQuizController {
Cnd cnd = Cnd.where("id", "in", subjectIds);
String dynamic = subjectIds.stream().map(id -> "'" + id + "'").collect(Collectors.joining(","));
cnd.orderBy("sortNum","asc");
cnd.and(new Static("1=1 ORDER BY FIELD (id," + dynamic + ")"));
List<QsvSubject> subjects = dao.query(QsvSubject.class, cnd);
dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum"));
@@ -407,4 +398,17 @@ public class H5QsvQuizController {
return Result.success(result);
}
private List<QsvSubject> querySubjectsByRecordOrder(List<String> subjectIds) {
if (subjectIds == null || subjectIds.isEmpty()) {
return new ArrayList<>();
}
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds));
Map<String, QsvSubject> subjectMap = subjects.stream()
.collect(Collectors.toMap(QsvSubject::getId, java.util.function.Function.identity(), (oldValue, newValue) -> oldValue));
return subjectIds.stream()
.map(subjectMap::get)
.filter(ObjectUtil::isNotEmpty)
.collect(Collectors.toList());
}
}
@@ -4,7 +4,7 @@ import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSONObject;
import com.budwk.app.base.result.Result;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.basic.service.ActivityBasicScopeService;
import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
@@ -12,6 +12,8 @@ import com.budwk.app.zhgh.dayofficework.qsv.param.QsvAnswerParam;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvUserAnswerRecordService;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.aop.interceptor.ioc.TransAop;
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;
@@ -22,7 +24,11 @@ import org.nutz.mvc.annotation.Param;
import javax.validation.Valid;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@IocBean
@At("/platform/h5/qsv/survey")
@@ -32,8 +38,6 @@ public class H5QsvSurveyController {
@Inject
private Dao dao;
@Inject
private ActivityBasicScopeService activityBasicScopeService;
@Inject
private QsvUserAnswerRecordService qsvUserAnswerRecordService;
@At("")
@@ -42,29 +46,37 @@ public class H5QsvSurveyController {
}
@At
@Aop(TransAop.READ_COMMITTED)
public Result subjects(String activityId) {
QsvActivity activity = dao.fetch(QsvActivity.class, activityId);
Result activityStatusResult = checkActivityStatus(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
if (!activityBasicScopeService.isUserInGroup(activity.getGroupId(), SecurityUtil.getUserId())) {
int count = dao.count(ActivityUserScope.class, Cnd.where("groupId", "=", activity.getGroupId())
.and("userId", "=", SecurityUtil.getUserId()));
if (count != 1) {
return Result.error("您无需参加此次投票,感谢您的关注!");
}
String answerRecordId = null;
List<QsvSubject> activitySubjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("userId", "=", SecurityUtil.getUserId()));
if (answerRecord == null) {
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
qsvUserAnswerRecordService.insertRecord(activityId, subjects.stream().map(QsvSubject::getId).toList());
qsvUserAnswerRecordService.insertRecord(activityId, activitySubjects.stream().map(QsvSubject::getId).toList());
}
QsvUserAnswerRecord answerRecord2 = dao.fetch(QsvUserAnswerRecord.class,
Cnd.where("activityId", "=", activityId)
.and("userId", "=", SecurityUtil.getUserId()));
syncAnswerRecordSubjects(answerRecord2, activitySubjects);
answerRecordId = answerRecord2.getId();
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", answerRecord2.getSubjectIds()).asc("sortNum"));
List<QsvSubject> subjects = querySubjectsByRecordOrder(answerRecord2.getSubjectIds());
dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum"));
for (QsvSubject subject : subjects) {
@@ -77,6 +89,95 @@ public class H5QsvSurveyController {
return Result.success(result);
}
/**
* 校验调查活动是否处于可答题状态。activity 为空表示活动不存在;
* enabled 为 false 表示活动已关闭,当前时间早于 startTime 表示未开始,当前时间晚于 endTime 表示已结束。
*
* @param activity 当前调查活动,包含开启状态、开始时间和结束时间
* @return 状态或时间不允许答题时返回错误结果,允许答题时返回 null
*/
private Result checkActivityStatus(QsvActivity activity) {
if (activity == null) {
return Result.error("调查不存在");
}
if (Boolean.FALSE.equals(activity.getEnabled())) {
return Result.error("活动已关闭,暂不能参与");
}
Date now = new Date();
if (activity.getStartTime() != null && activity.getStartTime().after(now)) {
return Result.error("调查尚未开始,请在开始后再参与");
}
if (activity.getEndTime() != null && activity.getEndTime().before(now)) {
return Result.error("调查已结束");
}
return null;
}
/**
* 同步问卷当前题目到未完成答题记录。后台新增题目后,已进入过问卷的用户记录里没有新题ID,
* 这里补齐 subjectIds 和 extJson,保证手机端能拿到最新题目并正常提交答案。
*
* @param answerRecord 用户当前问卷答题记录
* @param activitySubjects 当前活动下按排序查询出的最新题目列表
*/
private void syncAnswerRecordSubjects(QsvUserAnswerRecord answerRecord, List<QsvSubject> activitySubjects) {
if (answerRecord == null || Boolean.TRUE.equals(answerRecord.getIsFinish())) {
return;
}
List<String> currentSubjectIds = activitySubjects.stream().map(QsvSubject::getId).collect(Collectors.toList());
List<String> recordSubjectIds = answerRecord.getSubjectIds();
if (recordSubjectIds == null) {
recordSubjectIds = new ArrayList<>();
}
JSONObject extJson = answerRecord.getExtJson();
if (extJson == null) {
extJson = new JSONObject();
}
LinkedHashSet<String> currentSubjectIdSet = new LinkedHashSet<>(currentSubjectIds);
List<String> normalizedSubjectIds = recordSubjectIds.stream()
.filter(currentSubjectIdSet::contains)
.collect(Collectors.toList());
for (String subjectId : currentSubjectIds) {
if (!normalizedSubjectIds.contains(subjectId)) {
normalizedSubjectIds.add(subjectId);
}
}
boolean changed = !recordSubjectIds.equals(normalizedSubjectIds);
for (String subjectId : currentSubjectIds) {
if (ObjectUtil.isEmpty(extJson.get(subjectId, JSONObject.class))) {
JSONObject entry = new JSONObject();
entry.set("optionIds", new ArrayList<>());
entry.set("text", null);
entry.set("optionFillContents", new JSONObject());
extJson.set(subjectId, entry);
changed = true;
}
}
if (changed) {
answerRecord.setSubjectIds(normalizedSubjectIds);
answerRecord.setExtJson(extJson);
dao.update(answerRecord);
}
}
private List<QsvSubject> querySubjectsByRecordOrder(List<String> subjectIds) {
if (subjectIds == null || subjectIds.isEmpty()) {
return new ArrayList<>();
}
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds));
Map<String, QsvSubject> subjectMap = subjects.stream()
.collect(Collectors.toMap(QsvSubject::getId, Function.identity(), (oldValue, newValue) -> oldValue));
return subjectIds.stream()
.map(subjectMap::get)
.filter(ObjectUtil::isNotEmpty)
.collect(Collectors.toList());
}
@At
public Result answerRecord(@Valid String answerRecordId) {
QsvUserAnswerRecord record = dao.fetch(QsvUserAnswerRecord.class, answerRecordId);
@@ -84,7 +185,14 @@ public class H5QsvSurveyController {
}
@At
@Aop(TransAop.READ_COMMITTED)
public Result submitAnswer(@Valid @Param("answer") QsvAnswerParam qsvAnswerParam) {
QsvActivity activity = dao.fetch(QsvActivity.class, qsvAnswerParam.getActivityId());
Result activityStatusResult = checkActivityStatus(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId());
JSONObject extJson = answerRecord.getExtJson();
@@ -93,6 +201,7 @@ public class H5QsvSurveyController {
if (ObjectUtil.isNotEmpty(entries)) {
entries.set("optionIds", subject.getUserSelectOptionIds());
entries.set("text", subject.getUserFillContent());
entries.set("optionFillContents", subject.getOptionFillContents());
}
extJson.set(subject.getId(), entries);
}
@@ -0,0 +1,301 @@
package com.budwk.app.zhgh.dayofficework.qsv.h5controller;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSONObject;
import com.budwk.app.base.result.Result;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvOption;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.param.QsvAnswerParam;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvUserAnswerRecordService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
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;
import javax.validation.Valid;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@IocBean
@At("/platform/h5/qsv/vote")
@Ok("json:full")
public class H5QsvVoteController {
@Inject
private Dao dao;
@Inject
private QsvUserAnswerRecordService qsvUserAnswerRecordService;
@At("")
@Ok("beetl:/platform/zhghh5/dayofficework/qsv/vote/index.html")
public void index() {
}
@At
@Aop(TransAop.READ_COMMITTED)
public Result subjects(String activityId) {
QsvActivity activity = dao.fetch(QsvActivity.class, activityId);
Result activityStatusResult = checkActivityReadable(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
Result scopeResult = checkScope(activity);
if (scopeResult != null) {
return scopeResult;
}
List<QsvSubject> activitySubjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("userId", "=", SecurityUtil.getUserId()));
if (answerRecord == null) {
List<String> subjectIds = activitySubjects.stream()
.map(QsvSubject::getId)
.collect(Collectors.toList());
answerRecord = qsvUserAnswerRecordService.insertRecord(activityId, subjectIds);
}
syncAnswerRecordSubjects(answerRecord, activitySubjects);
List<QsvSubject> subjects = querySubjectsByRecordOrder(answerRecord.getSubjectIds());
dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum"));
for (QsvSubject subject : subjects) {
if (subject.getUserSelectOptionIds() == null) {
subject.setUserSelectOptionIds(new ArrayList<>());
}
}
NutMap result = NutMap.NEW()
.addv("subjects", subjects)
.addv("answerRecordId", answerRecord.getId())
.addv("answerRecord", answerRecord)
.addv("activity", activity)
.addv("voteStats", buildVoteStats(activityId));
return Result.success(result);
}
@At
public Result answerRecord(@Valid String answerRecordId) {
QsvUserAnswerRecord record = dao.fetch(QsvUserAnswerRecord.class, answerRecordId);
return Result.success(record);
}
@At
public Result rank(@Valid String activityId) {
return Result.success(buildVoteStats(activityId));
}
@At
@Aop(TransAop.READ_COMMITTED)
public Result submitAnswer(@Valid @Param("answer") QsvAnswerParam qsvAnswerParam) {
QsvActivity activity = dao.fetch(QsvActivity.class, qsvAnswerParam.getActivityId());
Result activityStatusResult = checkActivityWritable(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId());
if (answerRecord == null || !SecurityUtil.getUserId().equals(answerRecord.getUserId())) {
return Result.error("\u6295\u7968\u8bb0\u5f55\u4e0d\u5b58\u5728");
}
if (Boolean.TRUE.equals(answerRecord.getIsFinish())) {
return Result.error("\u60a8\u5df2\u5b8c\u6210\u6295\u7968");
}
JSONObject extJson = answerRecord.getExtJson();
if (extJson == null) {
extJson = new JSONObject();
}
for (QsvAnswerParam.Subject subject : qsvAnswerParam.getSubjects()) {
JSONObject entries = extJson.get(subject.getId(), JSONObject.class);
if (entries == null) {
entries = new JSONObject();
}
entries.set("optionIds", subject.getUserSelectOptionIds());
entries.set("text", subject.getUserFillContent());
entries.set("optionFillContents", subject.getOptionFillContents());
extJson.set(subject.getId(), entries);
}
answerRecord.setExtJson(extJson);
answerRecord.setAttemptDate(new Date());
answerRecord.setSubmitTime(new Date());
answerRecord.setIsFinish(true);
dao.update(answerRecord);
return Result.success("\u6295\u7968\u6210\u529f");
}
private Result checkActivityReadable(QsvActivity activity) {
if (activity == null) {
return Result.error("\u6295\u7968\u4e0d\u5b58\u5728");
}
if (Boolean.FALSE.equals(activity.getEnabled())) {
return Result.error("\u6d3b\u52a8\u5df2\u5173\u95ed\uff0c\u6682\u4e0d\u80fd\u53c2\u4e0e");
}
Date now = new Date();
if (activity.getStartTime() != null && activity.getStartTime().after(now)) {
return Result.error("\u6295\u7968\u5c1a\u672a\u5f00\u59cb\uff0c\u8bf7\u5728\u5f00\u59cb\u540e\u518d\u53c2\u4e0e");
}
return null;
}
private Result checkActivityWritable(QsvActivity activity) {
Result readableResult = checkActivityReadable(activity);
if (readableResult != null) {
return readableResult;
}
Date now = new Date();
if (activity.getEndTime() != null && activity.getEndTime().before(now)) {
return Result.error("\u6295\u7968\u5df2\u7ed3\u675f");
}
return null;
}
private Result checkScope(QsvActivity activity) {
if (activity.getGroupId() == null) {
return null;
}
int count = dao.count(ActivityUserScope.class, Cnd.where("groupId", "=", activity.getGroupId())
.and("userId", "=", SecurityUtil.getUserId()));
if (count != 1) {
return Result.error("\u60a8\u65e0\u9700\u53c2\u52a0\u6b64\u6b21\u6295\u7968\uff0c\u611f\u8c22\u60a8\u7684\u5173\u6ce8\uff01");
}
return null;
}
private void syncAnswerRecordSubjects(QsvUserAnswerRecord answerRecord, List<QsvSubject> activitySubjects) {
if (answerRecord == null || Boolean.TRUE.equals(answerRecord.getIsFinish())) {
return;
}
List<String> currentSubjectIds = activitySubjects.stream()
.map(QsvSubject::getId)
.collect(Collectors.toList());
List<String> recordSubjectIds = answerRecord.getSubjectIds();
if (recordSubjectIds == null) {
recordSubjectIds = new ArrayList<>();
}
JSONObject extJson = answerRecord.getExtJson();
if (extJson == null) {
extJson = new JSONObject();
}
LinkedHashSet<String> currentSubjectIdSet = new LinkedHashSet<>(currentSubjectIds);
List<String> normalizedSubjectIds = recordSubjectIds.stream()
.filter(currentSubjectIdSet::contains)
.collect(Collectors.toList());
for (String subjectId : currentSubjectIds) {
if (!normalizedSubjectIds.contains(subjectId)) {
normalizedSubjectIds.add(subjectId);
}
}
boolean changed = !recordSubjectIds.equals(normalizedSubjectIds);
for (String subjectId : currentSubjectIds) {
if (ObjectUtil.isEmpty(extJson.get(subjectId, JSONObject.class))) {
JSONObject entry = new JSONObject();
entry.set("optionIds", new ArrayList<>());
entry.set("text", null);
entry.set("optionFillContents", new JSONObject());
extJson.set(subjectId, entry);
changed = true;
}
}
if (changed) {
answerRecord.setSubjectIds(normalizedSubjectIds);
answerRecord.setExtJson(extJson);
dao.update(answerRecord);
}
}
private List<QsvSubject> querySubjectsByRecordOrder(List<String> subjectIds) {
if (subjectIds == null || subjectIds.isEmpty()) {
return new ArrayList<>();
}
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds));
Map<String, QsvSubject> subjectMap = subjects.stream()
.collect(Collectors.toMap(QsvSubject::getId, Function.identity(), (oldValue, newValue) -> oldValue));
return subjectIds.stream()
.map(subjectMap::get)
.filter(ObjectUtil::isNotEmpty)
.collect(Collectors.toList());
}
private NutMap buildVoteStats(String activityId) {
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum"));
Map<String, NutMap> optionStats = new LinkedHashMap<>();
for (QsvSubject subject : subjects) {
if (subject.getOptions() == null) {
continue;
}
for (QsvOption option : subject.getOptions()) {
optionStats.put(option.getId(), NutMap.NEW()
.addv("optionId", option.getId())
.addv("subjectId", subject.getId())
.addv("text", option.getText())
.addv("imgUrl", option.getImgUrl())
.addv("votes", 0)
.addv("percent", 0D));
}
}
List<QsvUserAnswerRecord> records = dao.query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
for (QsvUserAnswerRecord record : records) {
JSONObject extJson = record.getExtJson();
if (extJson == null) {
continue;
}
for (QsvSubject subject : subjects) {
JSONObject entry = extJson.get(subject.getId(), JSONObject.class);
if (entry == null) {
continue;
}
List<String> optionIds = entry.getBeanList("optionIds", String.class);
if (optionIds == null) {
continue;
}
for (String optionId : optionIds) {
NutMap stat = optionStats.get(optionId);
if (stat != null) {
stat.put("votes", stat.getInt("votes", 0) + 1);
}
}
}
}
int totalParticipants = records.size();
List<NutMap> rankList = new ArrayList<>(optionStats.values());
for (NutMap stat : rankList) {
int votes = stat.getInt("votes", 0);
double percent = totalParticipants == 0 ? 0D : votes * 100D / totalParticipants;
stat.put("percent", percent);
}
rankList.sort((left, right) -> Integer.compare(right.getInt("votes", 0), left.getInt("votes", 0)));
return NutMap.NEW()
.addv("totalParticipants", totalParticipants)
.addv("optionCount", optionStats.size())
.addv("optionStats", optionStats)
.addv("rankList", rankList);
}
}
@@ -1,6 +1,8 @@
package com.budwk.app.zhgh.dayofficework.qsv.models;
import com.budwk.app.base.model.BaseModel;
import com.budwk.app.sys.models.Sys_home_activity;
import com.budwk.app.sys.services.SysHomeConvert;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.*;
@@ -14,7 +16,7 @@ import java.util.Date;
@Table("qsv_activity")
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("问卷调查投票活动表")
public class QsvActivity extends BaseModel implements Serializable {
public class QsvActivity extends BaseModel implements Serializable, SysHomeConvert {
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@@ -46,6 +48,12 @@ public class QsvActivity extends BaseModel implements Serializable {
@ColDefine(type = ColType.DATETIME)
private Date endTime;
@Column
@Comment("是否开启")
@ColDefine(type = ColType.BOOLEAN)
@Default("1")
private Boolean enabled;
@Column
@Comment("活动分组ID")
@ColDefine(type = ColType.INT)
@@ -107,4 +115,35 @@ public class QsvActivity extends BaseModel implements Serializable {
@Comment("封面图片")
@ColDefine(type = ColType.VARCHAR, width = 255)
private String cover;
@Override
public Sys_home_activity covertToSysHomeActivity() {
Sys_home_activity sysHomeActivity = new Sys_home_activity();
sysHomeActivity.setId(this.getId());
sysHomeActivity.setName(this.getTitle());
sysHomeActivity.setCover(this.getCover());
sysHomeActivity.setContent(this.getDescription());
sysHomeActivity.setUrl("/platform/qsv/activity");
sysHomeActivity.setH5Url(getH5Url());
sysHomeActivity.setStartDate(this.getStartTime());
sysHomeActivity.setEndDate(this.getEndTime());
sysHomeActivity.setAllowUserGroupId(this.getGroupId());
sysHomeActivity.setEnable(!Boolean.FALSE.equals(this.getEnabled()));
sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName());
return sysHomeActivity;
}
/**
* 根据活动类型生成移动端首页跳转地址。
* QUIZ 跳转答题页,SURVEY 跳转调查页,VOTE 预留投票页地址,返回值为移动端路由字符串。
*/
private String getH5Url() {
if ("SURVEY".equals(this.getCategory())) {
return "/platform/h5/qsv/survey?id=" + this.getId();
}
if ("VOTE".equals(this.getCategory())) {
return "/platform/h5/qsv/vote?id=" + this.getId();
}
return "/platform/h5/qsv/quiz?id=" + this.getId();
}
}
@@ -51,6 +51,16 @@ public class QsvOption extends BaseModel implements Serializable {
@ColDefine(customType = "longtext")
private String description;
@Column
@Comment("选中后是否需要填写补充内容")
@ColDefine(type = ColType.BOOLEAN)
private Boolean fillRequired;
@Column
@Comment("补充内容输入提示")
@ColDefine(type = ColType.VARCHAR, width = 255)
private String fillPlaceholder;
@Column
@Comment("排序")
@ColDefine(type = ColType.INT)
@@ -58,11 +58,41 @@ public class QsvSubject extends BaseModel implements Serializable {
@ColDefine(type = ColType.INT, width = 1)
private Integer maxMulti;
@Column
@Comment("投票选项排列方式")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String optionLayout;
@Column
@Comment("投票选项横向列数")
@ColDefine(type = ColType.INT, width = 1)
private Integer optionColumns;
@Column
@Comment("排序")
@ColDefine(type = ColType.INT, width = 1)
private Integer sortNum;
@Column
@Comment("是否启用题目隐显逻辑")
@ColDefine(type = ColType.BOOLEAN)
private Boolean visibleRuleEnabled;
@Column
@Comment("隐显逻辑处理方式(show显示,hide隐藏)")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String visibleRuleAction;
@Column
@Comment("隐显逻辑条件关系(AND并且,OR或者)")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String visibleRuleLogic;
@Column
@Comment("隐显逻辑条件列表")
@ColDefine(type = ColType.MYSQL_JSON)
private List<VisibleRuleCondition> visibleRuleConditions;
/**
* 选项
*/
@@ -79,4 +109,52 @@ public class QsvSubject extends BaseModel implements Serializable {
*/
private String userFillContent;
/**
* 题目隐显逻辑条件,一条条件表示选择指定题目的指定选项后参与显示或隐藏判断。
*/
@Data
public static class VisibleRuleCondition implements Serializable {
/**
* 作为触发条件的题目ID。
*/
private String subjectId;
/**
* 作为触发条件的选项ID。
*/
private String optionId;
/**
* 条件节点类型:group 分组,item 条件项。
*/
private String type;
/**
* 分组内条件关系:and 并且,or 或者。
*/
private String logic;
/**
* 条件项引用的题目ID。
*/
private String field;
/**
* 比较符:==、!=、contains、>、<、>=、<=。
*/
private String operator;
/**
* 比较值,选择题为选项ID,填空题可为文本或数字。
*/
private Object value;
/**
* 子条件节点,支持条件分组嵌套。
*/
private List<VisibleRuleCondition> children;
}
}
@@ -3,6 +3,7 @@ package com.budwk.app.zhgh.dayofficework.qsv.param;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
//答题参数
@@ -21,6 +22,7 @@ public class QsvAnswerParam {
private String id;
private List<String> userSelectOptionIds;
private String userFillContent;
private Map<String, String> optionFillContents;
}
}
@@ -5,4 +5,10 @@ import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
public interface QsvActivityService extends BaseService<QsvActivity> {
QsvActivity saveActivity(QsvActivity qsvActivity);
void deleteActivity(String id);
void updateEnabled(String id, Boolean enabled);
}
@@ -12,6 +12,14 @@ public interface QsvSurveyService extends BaseService<QsvUserAnswerRecord> {
List<NutMap> report(String activityId);
List<NutMap> categoryReport(String activityId, String conditions);
NutMap crossReport(String activityId, String xSubjectIds, String ySubjectIds, String conditions);
NutMap compareReport(String activityId, String subjectId, String optionId);
List<NutMap> selectOptionUsers(String activityId, String subjectId, String optionId, String conditions);
void exportReportXlsx(String activityId, HttpServletResponse response);
void exportUserAnswerXlsx(String activityId, HttpServletResponse response);
@@ -1,15 +1,82 @@
package com.budwk.app.zhgh.dayofficework.qsv.service.impl;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.sys.models.Sys_home_activity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvOption;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvActivityService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import java.util.List;
import java.util.stream.Collectors;
@IocBean(args = {"refer:dao"})
public class QsvActivityServiceImpl extends BaseServiceImpl<QsvActivity> implements QsvActivityService {
public QsvActivityServiceImpl(Dao dao) {
super(dao);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public QsvActivity saveActivity(QsvActivity qsvActivity) {
fillQuizRepeatMode(qsvActivity);
dao().insertOrUpdate(qsvActivity);
syncHomeActivity(qsvActivity);
return qsvActivity;
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void deleteActivity(String id) {
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", id));
List<String> subjectIds = subjects.stream().map(QsvSubject::getId).collect(Collectors.toList());
if (!subjectIds.isEmpty()) {
dao().clear(QsvOption.class, Cnd.where("subjectId", "in", subjectIds));
dao().clear(QsvSubject.class, Cnd.where("id", "in", subjectIds));
}
dao().clear(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", id));
dao().delete(QsvActivity.class, id);
dao().delete(Sys_home_activity.class, id);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void updateEnabled(String id, Boolean enabled) {
QsvActivity activity = dao().fetch(QsvActivity.class, id);
if (activity == null) {
return;
}
activity.setEnabled(enabled);
dao().update(activity);
syncHomeActivity(activity);
}
/**
* 答题活动的重复模式由出题模式决定。定时定题按天统计,常规模式按活动总次数统计。
*/
private void fillQuizRepeatMode(QsvActivity qsvActivity) {
if (!"QUIZ".equals(qsvActivity.getCategory())) {
return;
}
if ("SCHEDULED".equals(qsvActivity.getMode())) {
qsvActivity.setRepeatMode("DAILY");
} else if ("REGULAR".equals(qsvActivity.getMode())) {
qsvActivity.setRepeatMode("TOTAL");
}
}
/**
* 同步移动端首页活动。问卷活动保存或启停后,都以活动自身 enabled、时间、人员范围生成首页记录。
*/
private void syncHomeActivity(QsvActivity activity) {
Sys_home_activity sysHomeActivity = activity.covertToSysHomeActivity();
dao().insertOrUpdate(sysHomeActivity);
}
}
@@ -119,17 +119,11 @@ public class QsvQuizRankServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord>
cnd.andEX("t1.unionId","=",pageForm.getUnionId());
cnd.andEX("t1.unitId","=",pageForm.getUnitId());
if ("SCHEDULED".equals(mode)) {
if ("HIGH".equals(scoreStatisticsMode)) {
cnd.and("t1.isHighestScore","=",1);
} else if ("LAST".equals(scoreStatisticsMode)) {
cnd.and("t1.isLatestScore","=",1);
}
} else if ("REGULAR".equals(mode)) {
if ("HIGH".equals(scoreStatisticsMode)) {
cnd.and("t1.isHighestScore","=",1);
} else if ("LAST".equals(scoreStatisticsMode)) {
cnd.and("t1.isLatestScore","=",1);
if ("SCHEDULED".equals(mode) || "REGULAR".equals(mode)) {
if ("HIGH".equals(scoreStatisticsMode) || "HIGHEST".equals(scoreStatisticsMode)) {
cnd.and("t1.isHighestScore", "=", 1);
} else {
cnd.and("t1.isLatestScore", "=", 1);
}
}
@@ -8,6 +8,7 @@ import cn.afterturn.easypoi.excel.export.ExcelExportService;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.impl.BaseServiceImpl;
@@ -68,47 +69,47 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
// 处理每个题目
for (NutMap subject : subjects) {
// 获取当前题目的选项列表
List<NutMap> subjectOptions = Lang.collection2list(optionsGroup.get(subject.getString("id")), NutMap.class);
List<QsvOption> currentOptions = optionsGroup.get(subject.getString("id"));
List<NutMap> subjectOptions = ObjectUtil.isEmpty(currentOptions) ? new ArrayList<>() : Lang.collection2list(currentOptions, NutMap.class);
// 获取题目类型
String subjectType = subject.getString("type");
// 处理文本类型题目
if ("text".equals(subjectType)) {
List<String> texts = answerExtList.stream()
.filter(ext -> ObjectUtil.isNull(ext.get(subject.getString("id"), JSONObject.class)))
.map(ext -> ext.get(subject.getString("id"), JSONObject.class).getStr("text"))
List<NutMap> textAnswers = buildTextAnswerDetails(answerRecords, subject.getString("id"));
List<String> texts = textAnswers.stream()
.map(textAnswer -> textAnswer.getString("text"))
.toList();
subject.put("texts", texts);
subject.put("textAnswers", textAnswers);
}
// 处理单选类型题目 处理多选类型题目
else if ("radio".equals(subjectType) || "checkbox".equals(subjectType)) {
long selectTotal = answerExtList.stream()
.filter(ext -> ext.containsKey(subject.getString("id")))
.filter(ext -> ObjectUtil.isNotEmpty(ext) && ext.containsKey(subject.getString("id")))
.count();
subjectOptions.forEach(subjectOption -> {
long selectCount = answerExtList.stream()
.filter(ext ->
{
if (ObjectUtil.isEmpty(ext)) {
return false;
}
JSONObject jsonObject = ext.get(subject.getString("id"), JSONObject.class);
return jsonObject != null
&& jsonObject.getJSONArray("optionIds") != null
&& jsonObject.getJSONArray("optionIds").contains(subjectOption.getString("id"));
})
.count();
long round = Math.round((double) selectCount / selectTotal * 100);
long round = selectTotal == 0 ? 0 : Math.round((double) selectCount / selectTotal * 100);
subjectOption.put("selectPercent", round + "%");
subjectOption.put("selectCount", selectCount);
});
subjectOptions.sort((o1, o2) -> {
int count1 = o1.getInt("selectCount");
int count2 = o2.getInt("selectCount");
if (count1 == count2) {
return Integer.compare(o1.getInt("sortNum"), o2.getInt("sortNum"));
}
return Integer.compare(count2, count1);
return Integer.compare(o1.getInt("sortNum"), o2.getInt("sortNum"));
});
subject.put("selectTotal", selectTotal);
@@ -125,6 +126,228 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
}
@Override
public List<NutMap> categoryReport(String activityId, String conditions) {
if (activityId == null || activityId.isEmpty()) {
throw new IllegalArgumentException("activityId不能为空");
}
try {
List<NutMap> conditionList = parseCategoryConditions(conditions);
List<QsvUserAnswerRecord> answerRecords = dao().query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
return buildReport(activityId, filterAnswerRecords(answerRecords, conditionList));
} catch (Exception e) {
log.error("分类报告生成失败", e);
throw new RuntimeException("分类报告生成失败", e);
}
}
@Override
public NutMap crossReport(String activityId, String xSubjectIds, String ySubjectIds, String conditions) {
if (activityId == null || activityId.isEmpty()) {
throw new IllegalArgumentException("activityId不能为空");
}
List<String> xSubjectIdList = parseSubjectIds(xSubjectIds);
List<String> ySubjectIdList = parseSubjectIds(ySubjectIds);
if (ObjectUtil.isEmpty(xSubjectIdList) || ObjectUtil.isEmpty(ySubjectIdList)) {
throw new IllegalArgumentException("请选择自变量X和因变量Y");
}
if (xSubjectIdList.stream().anyMatch(ySubjectIdList::contains)) {
throw new IllegalArgumentException("自变量X和因变量Y不能选择相同变量");
}
try {
List<NutMap> subjects = querySubjects(activityId);
Map<String, NutMap> subjectMap = subjects.stream()
.collect(Collectors.toMap(subject -> subject.getString("id"), subject -> subject));
if (!subjectMap.keySet().containsAll(xSubjectIdList) || !subjectMap.keySet().containsAll(ySubjectIdList)) {
throw new IllegalArgumentException("题目不存在");
}
List<String> allSubjectIds = new ArrayList<>();
allSubjectIds.addAll(xSubjectIdList);
allSubjectIds.addAll(ySubjectIdList);
List<QsvOption> options = dao().query(QsvOption.class, Cnd.where("subjectId", "in", allSubjectIds).asc("sortNum"));
Map<String, List<QsvOption>> optionGroup = options.stream().collect(Collectors.groupingBy(QsvOption::getSubjectId));
List<NutMap> xOptionMaps = buildVariableOptionCombinations(xSubjectIdList, subjectMap, optionGroup);
List<NutMap> conditionList = parseCategoryConditions(conditions);
List<QsvUserAnswerRecord> answerRecords = dao().query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
List<QsvUserAnswerRecord> filteredRecords = filterAnswerRecords(answerRecords, conditionList);
List<NutMap> reports = new ArrayList<>();
for (String ySubjectId : ySubjectIdList) {
NutMap ySubject = subjectMap.get(ySubjectId);
List<QsvOption> yOptions = optionGroup.get(ySubjectId);
if (ObjectUtil.isEmpty(yOptions)) {
yOptions = new ArrayList<>();
}
List<NutMap> yOptionMaps = ObjectUtil.isEmpty(yOptions) ? new ArrayList<>() : Lang.collection2list(yOptions, NutMap.class);
List<NutMap> rows = new ArrayList<>();
for (NutMap xOption : xOptionMaps) {
List<NutMap> xConditions = xOption.getList("conditions", NutMap.class);
List<QsvUserAnswerRecord> xMatchedRecords = filteredRecords.stream()
.filter(record -> isRecordMatched(record, xConditions))
.toList();
long rowTotal = xMatchedRecords.size();
List<NutMap> cells = new ArrayList<>();
for (QsvOption yOption : yOptions) {
List<NutMap> yCondition = List.of(NutMap.NEW()
.addv("subjectId", ySubjectId)
.addv("optionId", yOption.getId()));
long count = xMatchedRecords.stream()
.filter(record -> isRecordMatched(record, yCondition))
.count();
double percent = rowTotal == 0 ? 0 : Math.round((double) count / rowTotal * 10000) / 100.0;
cells.add(NutMap.NEW()
.addv("yOptionId", yOption.getId())
.addv("yOptionText", yOption.getText())
.addv("count", count)
.addv("percent", percent));
}
rows.add(NutMap.NEW()
.addv("xOptionId", xOption.getString("id"))
.addv("xOptionText", xOption.getString("text"))
.addv("cells", cells)
.addv("total", rowTotal));
}
reports.add(NutMap.NEW()
.addv("ySubject", ySubject)
.addv("yOptions", yOptionMaps)
.addv("rows", rows));
}
return NutMap.NEW()
.addv("xSubject", buildVariableSubject(xSubjectIdList, subjectMap))
.addv("xOptions", xOptionMaps)
.addv("reports", reports)
.addv("total", filteredRecords.size());
} catch (Exception e) {
log.error("交叉分析报告生成失败", e);
throw new RuntimeException("交叉分析报告生成失败", e);
}
}
@Override
public NutMap compareReport(String activityId, String subjectId, String optionId) {
if (activityId == null || activityId.isEmpty()) {
throw new IllegalArgumentException("activityId不能为空");
}
if (ObjectUtil.isEmpty(subjectId) || ObjectUtil.isEmpty(optionId)) {
throw new IllegalArgumentException("请选择对比变量和选项");
}
try {
List<NutMap> subjects = querySubjects(activityId);
Map<String, NutMap> subjectMap = subjects.stream()
.collect(Collectors.toMap(subject -> subject.getString("id"), subject -> subject));
NutMap compareSubject = subjectMap.get(subjectId);
if (compareSubject == null) {
throw new IllegalArgumentException("对比变量不存在");
}
List<String> subjectIds = subjects.stream().map(subject -> subject.getString("id")).toList();
List<QsvOption> options = dao().query(QsvOption.class, Cnd.where("subjectId", "in", subjectIds).asc("sortNum"));
Map<String, List<QsvOption>> optionGroup = options.stream().collect(Collectors.groupingBy(QsvOption::getSubjectId));
QsvOption compareOption = optionGroup.getOrDefault(subjectId, new ArrayList<>()).stream()
.filter(option -> optionId.equals(option.getId()))
.findFirst()
.orElse(null);
if (compareOption == null) {
throw new IllegalArgumentException("对比选项不存在");
}
List<NutMap> compareCondition = List.of(NutMap.NEW()
.addv("subjectId", subjectId)
.addv("optionId", optionId));
List<QsvUserAnswerRecord> answerRecords = dao().query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
List<QsvUserAnswerRecord> filteredRecords = filterAnswerRecords(answerRecords, compareCondition);
List<NutMap> reports = new ArrayList<>();
for (NutMap subject : subjects) {
String currentSubjectId = subject.getString("id");
String subjectType = subject.getString("type");
if (subjectId.equals(currentSubjectId) || (!"radio".equals(subjectType) && !"checkbox".equals(subjectType))) {
continue;
}
List<QsvOption> currentOptions = optionGroup.get(currentSubjectId);
if (ObjectUtil.isEmpty(currentOptions)) {
continue;
}
List<NutMap> cells = new ArrayList<>();
long rowTotal = filteredRecords.size();
for (QsvOption option : currentOptions) {
List<NutMap> optionCondition = List.of(NutMap.NEW()
.addv("subjectId", currentSubjectId)
.addv("optionId", option.getId()));
long count = filteredRecords.stream()
.filter(record -> isRecordMatched(record, optionCondition))
.count();
double percent = rowTotal == 0 ? 0 : Math.round((double) count / rowTotal * 10000) / 100.0;
cells.add(NutMap.NEW()
.addv("yOptionId", option.getId())
.addv("yOptionText", option.getText())
.addv("count", count)
.addv("percent", percent));
}
reports.add(NutMap.NEW()
.addv("ySubject", subject)
.addv("yOptions", Lang.collection2list(currentOptions, NutMap.class))
.addv("rows", List.of(NutMap.NEW()
.addv("xOptionId", optionId)
.addv("xOptionText", compareOption.getText())
.addv("cells", cells)
.addv("total", rowTotal))));
}
return NutMap.NEW()
.addv("compareSubject", compareSubject)
.addv("compareOption", Lang.obj2nutmap(compareOption))
.addv("reports", reports)
.addv("total", filteredRecords.size());
} catch (Exception e) {
log.error("对比分析报告生成失败", e);
throw new RuntimeException("对比分析报告生成失败", e);
}
}
@Override
public List<NutMap> selectOptionUsers(String activityId, String subjectId, String optionId, String conditions) {
List<NutMap> conditionList = parseCategoryConditions(conditions);
List<QsvUserAnswerRecord> answerRecords = dao().query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
return filterAnswerRecords(answerRecords, conditionList).stream().map(record -> {
JSONObject extJson = record.getExtJson();
if (ObjectUtil.isEmpty(extJson)) {
return null;
}
JSONObject subjectAnswer = extJson.get(subjectId, JSONObject.class);
if (ObjectUtil.isEmpty(subjectAnswer)) {
return null;
}
JSONArray optionIds = subjectAnswer.getJSONArray("optionIds");
if (ObjectUtil.isEmpty(optionIds) || !optionIds.contains(optionId)) {
return null;
}
JSONObject optionFillContents = subjectAnswer.getJSONObject("optionFillContents");
String fillContent = ObjectUtil.isEmpty(optionFillContents) ? "" : optionFillContents.getStr(optionId);
return NutMap.NEW()
.addv("userName", record.getUserName())
.addv("loginName", record.getLoginName())
.addv("unionName", record.getUnionName())
.addv("unitName", record.getUnitName())
.addv("attemptDate", record.getAttemptDate())
.addv("submitTime", record.getSubmitTime())
.addv("fillContent", fillContent);
})
.filter(ObjectUtil::isNotNull)
.toList();
}
@Override
public void exportReportXlsx(String activityId, HttpServletResponse response) {
List<NutMap> report = report(activityId);
@@ -134,10 +357,8 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
exportEntities.add(new ExcelExportEntity("选择人数", "selectCount", 20));
exportEntities.add(new ExcelExportEntity("选择比例", "selectPercent", 20));
Map<String, List<NutMap>> listMap = report.stream().collect(Collectors.groupingBy(v -> v.getString("id")));
Workbook workbook = new XSSFWorkbook();
listMap.forEach((k, v) -> {
NutMap nutMap = v.get(0);
report.forEach(nutMap -> {
ExcelExportService service = new ExcelExportService();
ExportParams exportParams = new ExportParams();
exportParams.setTitle(nutMap.getString("title"));
@@ -171,7 +392,7 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
List<JSONObject> answerExtList = answerRecords.stream().map(QsvUserAnswerRecord::getExtJson).toList();
// 查询活动题目列表
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId));
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
// 将题目列表转换为Map
Map<String, QsvSubject> subjectMap = subjects.stream().collect(Collectors.toMap(QsvSubject::getId, v -> v));
// 获取题目ID列表
@@ -185,7 +406,7 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
// 构建Excel导出实体
List<ExcelExportEntity> excelExportEntities = buildExcelExportEntities(subjects);
// 构建答题记录列表
List<NutMap> list = buildAnswerRecords(answerRecords, subjectMap, options);
List<NutMap> list = buildAnswerRecords(answerRecords, subjectMap, optionMap);
// 设置导出参数
ExportParams exportParams = new ExportParams();
@@ -223,7 +444,7 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
// List<JSONObject> answerExtList = answerRecords.stream().map(QsvUserAnswerRecord::getExtJson).toList();
// 查询活动题目列表
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId));
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
// 将题目列表转换为Map
Map<String, QsvSubject> subjectMap = subjects.stream().collect(Collectors.toMap(QsvSubject::getId, v -> v));
// 获取题目ID列表
@@ -245,18 +466,15 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
.addv("unitName", record.getString("unitName"))
.addv("unionName", record.getString("unionName"));
JSONObject extJson = record.getAs("extJson", JSONObject.class);
if (ObjectUtil.isEmpty(extJson)) {
return map;
}
extJson.forEach((k, v) -> {
JSONObject jsonVal = (JSONObject) v;
String type = subjectMap.get(k).getType();
if (type.equals("text")) {
map.addv(k, jsonVal.getStr("text"));
} else if (type.equals("radio") || type.equals("checkbox")) {
JSONArray optionIds = jsonVal.getJSONArray("optionIds");
String selectOptionTexts = options.stream().filter(option -> optionIds.contains(option.getId()))
.map(QsvOption::getText).collect(Collectors.joining(";"));
map.addv(k, selectOptionTexts);
QsvSubject subject = subjectMap.get(k);
if (subject == null || !(v instanceof JSONObject jsonVal)) {
return;
}
map.addv(k, buildAnswerText(jsonVal, subject, optionMap));
});
return map;
}).toList();
@@ -278,6 +496,171 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
}
}
private List<NutMap> buildReport(String activityId, List<QsvUserAnswerRecord> answerRecords) {
List<JSONObject> answerExtList = answerRecords.stream().map(QsvUserAnswerRecord::getExtJson).toList();
List<NutMap> subjects = querySubjects(activityId);
List<String> subjectIds = subjects.stream().map(v -> v.getString("id")).toList();
List<QsvOption> options = dao().query(QsvOption.class, Cnd.where("subjectId", "in", subjectIds).asc("sortNum"));
Map<String, List<QsvOption>> optionsGroup = options.stream().collect(Collectors.groupingBy(QsvOption::getSubjectId));
for (NutMap subject : subjects) {
List<QsvOption> currentOptions = optionsGroup.get(subject.getString("id"));
List<NutMap> subjectOptions = ObjectUtil.isEmpty(currentOptions) ? new ArrayList<>() : Lang.collection2list(currentOptions, NutMap.class);
String subjectType = subject.getString("type");
if ("text".equals(subjectType)) {
List<NutMap> textAnswers = buildTextAnswerDetails(answerRecords, subject.getString("id"));
List<String> texts = textAnswers.stream()
.map(textAnswer -> textAnswer.getString("text"))
.toList();
subject.put("texts", texts);
subject.put("textAnswers", textAnswers);
} else if ("radio".equals(subjectType) || "checkbox".equals(subjectType)) {
long selectTotal = answerExtList.stream()
.filter(ext -> ObjectUtil.isNotEmpty(ext) && ext.containsKey(subject.getString("id")))
.count();
subjectOptions.forEach(subjectOption -> {
long selectCount = answerExtList.stream()
.filter(ext -> {
if (ObjectUtil.isEmpty(ext)) {
return false;
}
JSONObject jsonObject = ext.get(subject.getString("id"), JSONObject.class);
return jsonObject != null
&& jsonObject.getJSONArray("optionIds") != null
&& jsonObject.getJSONArray("optionIds").contains(subjectOption.getString("id"));
})
.count();
long round = selectTotal == 0 ? 0 : Math.round((double) selectCount / selectTotal * 100);
subjectOption.put("selectPercent", round + "%");
subjectOption.put("selectCount", selectCount);
});
subjectOptions.sort((o1, o2) -> Integer.compare(o1.getInt("sortNum"), o2.getInt("sortNum")));
subject.put("selectTotal", selectTotal);
}
subject.addv("options", subjectOptions);
}
return subjects;
}
private List<NutMap> parseCategoryConditions(String conditions) {
if (ObjectUtil.isEmpty(conditions)) {
return new ArrayList<>();
}
return JSONUtil.parseArray(conditions).toList(NutMap.class).stream()
.filter(condition -> ObjectUtil.isNotEmpty(condition.getString("subjectId"))
&& ObjectUtil.isNotEmpty(condition.getString("optionId")))
.toList();
}
private List<String> parseSubjectIds(String subjectIds) {
if (ObjectUtil.isEmpty(subjectIds)) {
return new ArrayList<>();
}
String value = subjectIds.trim();
List<String> result = new ArrayList<>();
if (value.startsWith("[")) {
for (Object subjectId : JSONUtil.parseArray(value)) {
if (ObjectUtil.isNotEmpty(subjectId)) {
result.add(String.valueOf(subjectId));
}
}
return result.stream().distinct().toList();
}
for (String subjectId : value.split(",")) {
if (ObjectUtil.isNotEmpty(subjectId)) {
result.add(subjectId.trim());
}
}
return result.stream().distinct().toList();
}
private NutMap buildVariableSubject(List<String> subjectIds, Map<String, NutMap> subjectMap) {
String title = subjectIds.stream()
.map(subjectId -> subjectMap.get(subjectId).getString("title"))
.collect(Collectors.joining(" / "));
return NutMap.NEW()
.addv("id", String.join("|", subjectIds))
.addv("title", title);
}
private List<NutMap> buildVariableOptionCombinations(List<String> subjectIds, Map<String, NutMap> subjectMap, Map<String, List<QsvOption>> optionGroup) {
List<List<NutMap>> subjectOptionGroups = new ArrayList<>();
for (String subjectId : subjectIds) {
NutMap subject = subjectMap.get(subjectId);
List<QsvOption> options = optionGroup.get(subjectId);
if (ObjectUtil.isEmpty(options)) {
continue;
}
List<NutMap> optionMaps = options.stream()
.map(option -> NutMap.NEW()
.addv("subjectId", subjectId)
.addv("subjectTitle", subject.getString("title"))
.addv("optionId", option.getId())
.addv("optionText", option.getText()))
.toList();
subjectOptionGroups.add(optionMaps);
}
if (subjectOptionGroups.size() != subjectIds.size()) {
return new ArrayList<>();
}
List<NutMap> result = new ArrayList<>();
buildVariableOptionCombinations(subjectOptionGroups, 0, new ArrayList<>(), result);
return result;
}
private void buildVariableOptionCombinations(List<List<NutMap>> subjectOptionGroups, int index, List<NutMap> current, List<NutMap> result) {
if (index >= subjectOptionGroups.size()) {
String id = current.stream()
.map(option -> option.getString("subjectId") + ":" + option.getString("optionId"))
.collect(Collectors.joining("|"));
String text = current.stream()
.map(option -> option.getString("optionText"))
.collect(Collectors.joining(" / "));
List<NutMap> conditions = current.stream()
.map(option -> NutMap.NEW()
.addv("subjectId", option.getString("subjectId"))
.addv("optionId", option.getString("optionId")))
.toList();
result.add(NutMap.NEW()
.addv("id", id)
.addv("text", text)
.addv("conditions", conditions));
return;
}
for (NutMap option : subjectOptionGroups.get(index)) {
current.add(option);
buildVariableOptionCombinations(subjectOptionGroups, index + 1, current, result);
current.remove(current.size() - 1);
}
}
private List<QsvUserAnswerRecord> filterAnswerRecords(List<QsvUserAnswerRecord> answerRecords, List<NutMap> conditions) {
if (ObjectUtil.isEmpty(conditions)) {
return answerRecords;
}
return answerRecords.stream()
.filter(record -> isRecordMatched(record, conditions))
.toList();
}
private boolean isRecordMatched(QsvUserAnswerRecord record, List<NutMap> conditions) {
JSONObject extJson = record.getExtJson();
if (ObjectUtil.isEmpty(extJson)) {
return false;
}
for (NutMap condition : conditions) {
JSONObject subjectAnswer = extJson.get(condition.getString("subjectId"), JSONObject.class);
if (ObjectUtil.isEmpty(subjectAnswer)) {
return false;
}
JSONArray optionIds = subjectAnswer.getJSONArray("optionIds");
if (ObjectUtil.isEmpty(optionIds) || !optionIds.contains(condition.getString("optionId"))) {
return false;
}
}
return true;
}
private List<NutMap> querySubjects(String activityId) {
Sql subjectSql = Sqls.create("""
SELECT
@@ -309,7 +692,37 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
return excelExportEntities;
}
private List<NutMap> buildAnswerRecords(List<QsvUserAnswerRecord> answerRecords, Map<String, QsvSubject> subjectMap, List<QsvOption> options) {
/**
* 构建填空题填写详情。分析弹窗需要同时展示填写内容和答题人信息,
* 因此这里从已完成答题记录中提取当前题目的文本答案,并携带姓名、工号、所属工会和单位。
*
* @param answerRecords 当前活动已完成的答题记录
* @param subjectId 当前填空题ID,用于从 extJson 中取出该题填写内容
* @return 填空题填写详情列表,每条包含 loginName、userName、unionName、unitName、submitTime、text
*/
private List<NutMap> buildTextAnswerDetails(List<QsvUserAnswerRecord> answerRecords, String subjectId) {
List<NutMap> textAnswers = new ArrayList<>();
for (QsvUserAnswerRecord answerRecord : answerRecords) {
JSONObject extJson = answerRecord.getExtJson();
if (ObjectUtil.isEmpty(extJson)) {
continue;
}
JSONObject subjectAnswer = extJson.get(subjectId, JSONObject.class);
if (ObjectUtil.isEmpty(subjectAnswer) || ObjectUtil.isEmpty(subjectAnswer.getStr("text"))) {
continue;
}
textAnswers.add(NutMap.NEW()
.addv("loginName", answerRecord.getLoginName())
.addv("userName", answerRecord.getUserName())
.addv("unionName", answerRecord.getUnionName())
.addv("unitName", answerRecord.getUnitName())
.addv("submitTime", answerRecord.getSubmitTime())
.addv("text", subjectAnswer.getStr("text")));
}
return textAnswers;
}
private List<NutMap> buildAnswerRecords(List<QsvUserAnswerRecord> answerRecords, Map<String, QsvSubject> subjectMap, Map<String, QsvOption> optionMap) {
return answerRecords.stream().map(record -> {
NutMap map = NutMap.NEW()
.addv("id", record.getId())
@@ -318,20 +731,61 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
.addv("unitName", record.getUnitName())
.addv("unionName", record.getUnionName());
JSONObject extJson = record.getExtJson();
if (ObjectUtil.isEmpty(extJson)) {
return map;
}
extJson.forEach((k, v) -> {
JSONObject jsonVal = (JSONObject) v;
String type = subjectMap.get(k).getType();
if (type.equals("text")) {
map.addv(k, jsonVal.getStr("text"));
} else if (type.equals("radio") || type.equals("checkbox")) {
JSONArray optionIds = jsonVal.getJSONArray("optionIds");
String selectOptionTexts = options.stream().filter(option -> optionIds.contains(option.getId()))
.map(QsvOption::getText).collect(Collectors.joining(";"));
map.addv(k, selectOptionTexts);
QsvSubject subject = subjectMap.get(k);
if (subject == null || !(v instanceof JSONObject jsonVal)) {
return;
}
map.addv(k, buildAnswerText(jsonVal, subject, optionMap));
});
return map;
}).collect(Collectors.toList());
}
/**
* 组装单题答案展示内容。填空题返回填写文本;选择题返回选项文本,若选项配置了补充填写,
* 则追加对应补充内容,保证后台查看答卷和导出能看到用户填写的说明。
*
* @param jsonVal 答题记录 extJson 中当前题目的答案对象,包含 optionIds、text、optionFillContents
* @param subject 当前题目,用于判断题目类型
* @param optionMap 当前活动所有选项,key 为选项ID,value 为选项实体
* @return 当前题目的答案展示文本
*/
private String buildAnswerText(JSONObject jsonVal, QsvSubject subject, Map<String, QsvOption> optionMap) {
String type = subject.getType();
if ("text".equals(type)) {
return jsonVal.getStr("text");
}
if (!"radio".equals(type) && !"checkbox".equals(type)) {
return "";
}
JSONArray optionIds = jsonVal.getJSONArray("optionIds");
if (ObjectUtil.isEmpty(optionIds)) {
return "";
}
JSONObject optionFillContents = jsonVal.getJSONObject("optionFillContents");
List<String> answerTexts = new ArrayList<>();
for (Object optionIdObj : optionIds) {
String optionId = String.valueOf(optionIdObj);
QsvOption option = optionMap.get(optionId);
if (option == null) {
continue;
}
String answerText = option.getText();
if (ObjectUtil.isNotEmpty(optionFillContents)) {
String fillContent = optionFillContents.getStr(optionId);
if (ObjectUtil.isNotEmpty(fillContent)) {
answerText = answerText + "" + fillContent;
}
}
answerTexts.add(answerText);
}
return String.join(";", answerTexts);
}
}
@@ -121,11 +121,12 @@ public class QsvUserAnswerRecordServiceImpl extends BaseServiceImpl<QsvUserAnswe
String mode = activity.getMode();
String scoreMode = activity.getScoreMode();
boolean highestScoreMode = "HIGH".equals(scoreMode) || "HIGHEST".equals(scoreMode);
if (mode.equals("SCHEDULED")) {
processScoreRecords(activityId, SecurityUtil.getUserId(), true, scoreMode.equals("HIGH"));
processScoreRecords(activityId, SecurityUtil.getUserId(), true, highestScoreMode);
} else if (mode.equals("REGULAR")) {
processScoreRecords(activityId, SecurityUtil.getUserId(), false, scoreMode.equals("HIGH"));
processScoreRecords(activityId, SecurityUtil.getUserId(), false, highestScoreMode);
}
}
@@ -7,6 +7,8 @@ import com.alibaba.excel.EasyExcel;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.result.Result;
import com.budwk.app.sys.models.Sys_union;
import com.budwk.app.sys.models.Sys_union_cadre;
import com.budwk.app.zhgh.staffmanage.member.models.MemberHistory;
import com.budwk.app.zhgh.staffmanage.member.param.pageform.MemberInfoPageForm;
import com.budwk.app.zhgh.staffmanage.member.service.MemberInfoService;
@@ -14,6 +16,7 @@ import com.budwk.app.zhgh.staffmanage.member.vo.MemberImportVo;
import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
@@ -30,6 +33,7 @@ import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.stream.Collectors;
/**
* @version 1.0
@@ -72,6 +76,58 @@ public class MemberInfoGroupController {
return Result.success(pagination);
}
@At
@SaCheckPermission("member.info.group")
public Result unionOverview(String unionId) {
Sys_union union = memberInfoService.dao().fetch(Sys_union.class, unionId);
if (union == null) {
return Result.success(NutMap.NEW());
}
int staffTotal = memberInfoService.count(Sqls.create("""
select count(1)
from vw_user u
where u.unionId = @unionId
and u.personType in (
select dict.code
from sys_dict dict
left join sys_dict parent on parent.id = dict.parentId
where parent.code = 'USER_PERSON_TYPE'
and dict.name = '教职工'
)
""")
.setParam("unionId", unionId));
int memberTotal = memberInfoService.count(Sqls.create("select count(1) from vw_user where unionId = @unionId and member = 1")
.setParam("unionId", unionId));
int maleTotal = memberInfoService.count(Sqls.create("select count(1) from vw_user where unionId = @unionId and member = 1 and (sex = '男' or sex = '男性')")
.setParam("unionId", unionId));
int femaleTotal = memberInfoService.count(Sqls.create("select count(1) from vw_user where unionId = @unionId and member = 1 and (sex = '女' or sex = '女性')")
.setParam("unionId", unionId));
java.util.List<Sys_union_cadre> chairmen = memberInfoService.dao().query(Sys_union_cadre.class,
Cnd.where("unionId", "=", unionId)
.and("roleCode", "=", "BRANCH_UNION_CHAIRMAN")
.and(Cnd.exps("isServing", "=", true).or("isServing", "is", null)));
String chairman = chairmen.stream()
.map(item -> item.getUserName() + "(" + item.getLoginName() + ")")
.collect(Collectors.joining(""));
String chairmanMobile = chairmen.stream()
.map(Sys_union_cadre::getMobile)
.filter(mobile -> mobile != null && !mobile.isBlank())
.collect(Collectors.joining(""));
return Result.success(NutMap.NEW()
.addv("unionName", union.getName())
.addv("unionCode", union.getUnionCode())
.addv("chairman", chairman)
.addv("telephone", union.getTelephone())
.addv("chairmanMobile", chairmanMobile)
.addv("memberTotal", memberTotal)
.addv("staffTotal", staffTotal)
.addv("maleTotal", maleTotal)
.addv("femaleTotal", femaleTotal));
}
@At
@SaCheckPermission("member.info.group")
@@ -0,0 +1,160 @@
-- 问卷服务菜单初始化
-- 使用 permission 做幂等判断,避免同一环境重复执行时插入重复菜单。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT
'f6c0c37d2d7a4a22b5c7b1a1qsv0001',
'',
LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'),
'日常办公',
'Day Office Work',
'menu',
'',
'',
'ti-briefcase',
1,
0,
'dayofficework',
NULL,
600,
1,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0,
'PC',
NULL,
NULL,
'r',
0,
0
FROM sys_menu
WHERE (parentId = '' OR parentId IS NULL)
AND CHAR_LENGTH(path) = 4
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'dayofficework') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT
'f6c0c37d2d7a4a22b5c7b1a1qsv0002',
p.id,
CONCAT(p.path, LPAD(IFNULL(MAX(CAST(SUBSTRING(c.path, CHAR_LENGTH(p.path) + 1, 4) AS UNSIGNED)), 0) + 1, 4, '0')),
'问卷服务',
'Questionnaire Service',
'menu',
'',
'',
'fa fa-list-alt',
1,
0,
'qsv',
NULL,
1,
1,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0,
'PC',
p.moduleId,
NULL,
'w',
0,
0
FROM sys_menu p
LEFT JOIN sys_menu c ON c.parentId = p.id AND CHAR_LENGTH(c.path) = CHAR_LENGTH(p.path) + 4
WHERE p.permission = 'dayofficework'
GROUP BY p.id, p.path, p.moduleId
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'f6c0c37d2d7a4a22b5c7b1a1qsv0003', p.id, CONCAT(p.path, LPAD(IFNULL(MAX(CAST(SUBSTRING(c.path, CHAR_LENGTH(p.path) + 1, 4) AS UNSIGNED)), 0) + 1, 4, '0')), '问卷管理', 'Questionnaire Manage', 'menu', '/platform/qsv/activity', 'data-pjax', '', 1, 0, 'qsv.activity', NULL, 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', p.moduleId, NULL, 'w', 0, 0
FROM sys_menu p
LEFT JOIN sys_menu c ON c.parentId = p.id AND CHAR_LENGTH(c.path) = CHAR_LENGTH(p.path) + 4
WHERE p.permission = 'qsv'
GROUP BY p.id, p.path, p.moduleId
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.activity') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'f6c0c37d2d7a4a22b5c7b1a1qsv0004', p.id, CONCAT(p.path, LPAD(IFNULL(MAX(CAST(SUBSTRING(c.path, CHAR_LENGTH(p.path) + 1, 4) AS UNSIGNED)), 0) + 1, 4, '0')), '题库管理', 'Question Bank', 'menu', '/platform/qsv/bank', 'data-pjax', '', 1, 0, 'qsv.bank', NULL, 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', p.moduleId, NULL, 't', 0, 0
FROM sys_menu p
LEFT JOIN sys_menu c ON c.parentId = p.id AND CHAR_LENGTH(c.path) = CHAR_LENGTH(p.path) + 4
WHERE p.permission = 'qsv'
GROUP BY p.id, p.path, p.moduleId
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.bank') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'f6c0c37d2d7a4a22b5c7b1a1qsv0005', p.id, CONCAT(p.path, LPAD(IFNULL(MAX(CAST(SUBSTRING(c.path, CHAR_LENGTH(p.path) + 1, 4) AS UNSIGNED)), 0) + 1, 4, '0')), '调查统计', 'Survey Statistics', 'menu', '/platform/qsv/survey', 'data-pjax', '', 1, 0, 'qsv.survey', NULL, 3, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', p.moduleId, NULL, 'd', 0, 0
FROM sys_menu p
LEFT JOIN sys_menu c ON c.parentId = p.id AND CHAR_LENGTH(c.path) = CHAR_LENGTH(p.path) + 4
WHERE p.permission = 'qsv'
GROUP BY p.id, p.path, p.moduleId
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.survey') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'f6c0c37d2d7a4a22b5c7b1a1qsv0006', p.id, CONCAT(p.path, LPAD(IFNULL(MAX(CAST(SUBSTRING(c.path, CHAR_LENGTH(p.path) + 1, 4) AS UNSIGNED)), 0) + 1, 4, '0')), '答题排行', 'Quiz Rank', 'menu', '/platform/qsv/quizRank', 'data-pjax', '', 1, 0, 'qsv.quiz.rank', NULL, 4, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', p.moduleId, NULL, 'd', 0, 0
FROM sys_menu p
LEFT JOIN sys_menu c ON c.parentId = p.id AND CHAR_LENGTH(c.path) = CHAR_LENGTH(p.path) + 4
WHERE p.permission = 'qsv'
GROUP BY p.id, p.path, p.moduleId
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.quiz.rank') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'f6c0c37d2d7a4a22b5c7b1a1qsv0007', p.id, CONCAT(p.path, LPAD(IFNULL(MAX(CAST(SUBSTRING(c.path, CHAR_LENGTH(p.path) + 1, 4) AS UNSIGNED)), 0) + 1, 4, '0')), '在线答题', 'Online Quiz', 'menu', '/platform/qsv/online', 'data-pjax', '', 1, 0, 'qsv.online', NULL, 5, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', p.moduleId, NULL, 'z', 0, 0
FROM sys_menu p
LEFT JOIN sys_menu c ON c.parentId = p.id AND CHAR_LENGTH(c.path) = CHAR_LENGTH(p.path) + 4
WHERE p.permission = 'qsv'
GROUP BY p.id, p.path, p.moduleId
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.online') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT
'f6c0c37d2d7a4a22b5c7b1a1qsv0008',
'',
LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'),
'问卷服务',
'Questionnaire Service',
'menu',
'/platform/h5/qsv',
'data-pjax',
'',
1,
0,
'h5.qsv',
NULL,
601,
0,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0,
'H5',
NULL,
'/assets/mobile/svg/qsv/icon.svg',
'w',
1,
1
FROM sys_menu
WHERE (parentId = '' OR parentId IS NULL)
AND CHAR_LENGTH(path) = 4
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'h5.qsv') t);
-- 给系统管理员默认授权,其他角色可在角色管理中按需分配。
INSERT INTO sys_role_menu (roleId, menuId)
SELECT r.id, m.id
FROM sys_role r
JOIN sys_menu m ON m.permission IN (
'dayofficework',
'qsv',
'qsv.activity',
'qsv.bank',
'qsv.survey',
'qsv.quiz.rank',
'qsv.online',
'h5.qsv'
)
LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id
WHERE r.code = 'SYSADMIN'
AND rm.roleId IS NULL;
@@ -0,0 +1,3 @@
ALTER TABLE qsv_subject
ADD COLUMN option_layout VARCHAR(20) DEFAULT 'VERTICAL' COMMENT '投票选项排列方式',
ADD COLUMN option_columns INT DEFAULT 1 COMMENT '投票选项横向列数';
+24 -2
View File
@@ -365,6 +365,7 @@ layout("/layouts/v4/baseLayout.html"){
// 地址选中
hrefSelect() {
const targetHref = this.getMenuOwnerPath()
function findMenuPath(menus, targetHref) {
function search(items, path) {
for (const item of items) {
@@ -383,7 +384,7 @@ layout("/layouts/v4/baseLayout.html"){
return search(menus, []);
}
const result = findMenuPath(this.menus, window.location.pathname)
const result = findMenuPath(this.menus, targetHref)
if(result){
this.activeMenuIndex = result[result.length-1]['id']
this.openedMenus = result.map(v=>v.id)
@@ -396,6 +397,27 @@ layout("/layouts/v4/baseLayout.html"){
// 默认选中
getMenuOwnerPath() {
const pathname = window.location.pathname
if (pathname === "/platform/qsv/survey/reportPage") {
return "/platform/qsv/survey"
}
if (pathname === "/platform/qsv/new") {
return "/platform/qsv/activity"
}
return pathname
},
getMenuOwnerFullPath() {
if (window.location.pathname === "/platform/qsv/survey/reportPage") {
return "/platform/qsv/survey"
}
if (window.location.pathname === "/platform/qsv/new") {
return "/platform/qsv/activity"
}
return getFullSubAppPath()
},
defaultSelect() {
// 查找第一个有href的子菜单
function findFirstChildWithHref(menus) {
@@ -445,7 +467,7 @@ layout("/layouts/v4/baseLayout.html"){
// 页面加载时获取到正确的菜单
const pathname = window.location.pathname
if (!pathname.startsWith("/platform/v4/subApp")) {
const path = getFullSubAppPath()
const path = this.getMenuOwnerFullPath()
this.$axios.post("/platform/sys/user/rootMenuByPath", {pathname: path}).then((res) => {
if (res.code === 0) {
this.setAppInfo(res.data)
@@ -58,6 +58,17 @@ const basicForm = {
</div>
</el-form-item>
<el-form-item label="封面" prop="cover">
<file-upload
:upload_number="1"
:value.sync="formData.cover"
accept=".jpg,.jpeg,.png"
upload_mode="image"
upload_result_category="interval"
upload_result_type="url"
></file-upload>
</el-form-item>
<template v-if="formData.category==='QUIZ'">
<el-form-item label="出题模式" prop="mode">
<span slot="label">
@@ -171,17 +182,6 @@ const basicForm = {
</el-radio-group>
</el-form-item>
<!-- <el-form-item label="封面" prop="cover">-->
<!-- <file-upload-->
<!-- :upload_number="1"-->
<!-- :value.sync="formData.cover"-->
<!-- accept=".jpg,.jpeg,.png"-->
<!-- complete_result-->
<!-- upload_mode="image"-->
<!-- upload_result_category="interval"-->
<!-- ></file-upload>-->
<!-- </el-form-item>-->
</template>
</el-form>
</div>
@@ -204,7 +204,8 @@ const basicForm = {
return {
dialogVisible: false,
formData: {
category: ''
category: '',
cover: ''
},
formRules: {
category: [{ required: true, message: "请选择类型", trigger: "change" }],
@@ -229,7 +230,7 @@ const basicForm = {
})
} else {
this.formData = {
category: 'QUIZ'
cover: ''
}
}
},
@@ -2,6 +2,382 @@
layout("/layouts/platform.html"){
#-->
<style>
.qsv-logic-drawer .el-drawer__header {
margin-bottom: 0;
padding: 18px 24px;
border-bottom: 1px solid #ebeef5;
color: #303133;
font-weight: 600;
}
.qsv-logic-drawer {
top: 64px !important;
right: 0 !important;
bottom: auto !important;
width: calc(100% - 250px) !important;
height: calc(100vh - 64px) !important;
}
.qsv-logic-drawer .el-drawer__body {
height: calc(100% - 58px);
background: #f0f2f5;
overflow: hidden;
}
.qsv-logic-workspace {
height: 100%;
display: grid;
grid-template-columns: minmax(520px, 46%) minmax(0, 1fr);
gap: 20px;
padding: 20px;
box-sizing: border-box;
background-color: #f0f2f5;
}
.qsv-logic-section {
min-width: 0;
min-height: 0;
background: #ffffff;
border: 1px solid #ebeef5;
border-radius: 4px;
display: flex;
flex-direction: column;
box-shadow: none;
}
.qsv-logic-section-header {
min-height: 58px;
padding: 0 20px;
border-bottom: 1px solid #ebeef5;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
box-sizing: border-box;
}
.qsv-logic-section-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.qsv-logic-section-header .el-button--success {
min-width: 88px;
height: 32px;
font-weight: 600;
}
.qsv-logic-question-list,
.qsv-logic-preview-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
box-sizing: border-box;
}
.qsv-logic-question-card {
position: relative;
min-height: 66px;
border: 1px solid #e4e7ed;
border-radius: 12px;
margin-bottom: 12px;
padding: 16px 20px 16px 54px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: grab;
transition: all .2s ease-in-out;
box-sizing: border-box;
background-color: #ffffff;
}
.qsv-logic-question-card:hover,
.qsv-logic-question-card.active {
border-color: #409eff;
box-shadow: 0 6px 16px rgba(64, 158, 255, .12);
transform: translateY(-2px);
background: #ffffff;
}
.qsv-logic-question-card:active {
cursor: grabbing;
}
.qsv-question-card-main {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.qsv-question-index {
position: absolute;
left: 16px;
width: 26px;
height: 26px;
border-radius: 50%;
background: #f0f2f5;
border: 1px solid #e4e7ed;
color: #909399;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
}
.qsv-question-type {
height: 24px;
line-height: 24px;
min-width: 70px;
padding: 0 10px;
border-radius: 4px;
color: #ffffff;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
text-align: center;
}
.qsv-question-type.single {
background: #409eff;
}
.qsv-question-type.multiple {
background: #67c23a;
}
.qsv-question-type.text {
background: #e6a23c;
}
.qsv-question-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #303133;
font-weight: 600;
font-size: 15px;
}
.qsv-question-title.has-visible-rule {
color: #f56c6c;
}
.qsv-question-actions {
display: flex;
align-items: center;
gap: 8px;
opacity: 0;
transform: scale(.9);
pointer-events: none;
transition: all .2s cubic-bezier(.175, .885, .32, 1.275);
flex-shrink: 0;
}
.qsv-logic-question-card:hover .qsv-question-actions,
.qsv-logic-question-card.active .qsv-question-actions {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.qsv-question-actions .el-button-group .el-button {
padding: 10px 15px;
}
.qsv-preview-controls {
display: flex;
align-items: center;
gap: 12px;
}
.qsv-preview-question {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px 24px;
margin-bottom: 8px;
box-sizing: border-box;
transition: all .2s ease;
}
.qsv-preview-question.active {
background: #f8fbff;
}
.qsv-preview-index {
min-width: 22px;
line-height: 22px;
color: #409eff;
font-size: 14px;
font-weight: 700;
text-align: right;
flex-shrink: 0;
padding-top: 1px;
}
.qsv-preview-content {
flex: 1;
min-width: 0;
}
.qsv-preview-title {
margin-bottom: 14px;
line-height: 22px;
color: #4a5568;
font-size: 14px;
}
.qsv-preview-options .el-radio,
.qsv-preview-options .el-checkbox {
margin: 0 32px 12px 0;
color: #3f4a5a;
font-weight: 500;
}
.qsv-preview-options .el-radio__label,
.qsv-preview-options .el-checkbox__label {
font-size: 14px;
}
.qsv-condition-dialog .el-dialog__body {
padding: 18px 20px 8px;
}
.qsv-condition-group {
background-color: #f9f9f5;
border: 1px solid #e8e8e0;
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
box-sizing: border-box;
}
.qsv-condition-group.is-root {
box-shadow: none;
}
.qsv-condition-root-title {
margin-bottom: -4px;
font-size: 13px;
color: #606266;
}
.qsv-condition-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px dashed #dcdcdc;
gap: 12px;
}
.qsv-condition-group-actions {
display: flex;
align-items: center;
gap: 10px;
}
.qsv-condition-children {
display: flex;
flex-direction: column;
gap: 12px;
}
.qsv-condition-item-card {
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 10px;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
transition: all .2s ease-in-out;
}
.qsv-condition-item-card:hover {
border-color: #dcdfe6;
box-shadow: 0 2px 10px rgba(0, 0, 0, .03);
}
.qsv-condition-item-index {
width: 24px;
height: 24px;
border-radius: 50%;
background: #f0f2f5;
border: 1px solid #e4e7ed;
color: #909399;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 12px;
}
.qsv-condition-item-content {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.qsv-nested-group-wrapper {
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 10px;
padding: 12px;
}
.qsv-nested-group-label {
margin-bottom: 10px;
padding-left: 4px;
display: flex;
align-items: center;
gap: 4px;
color: #909399;
font-size: 12px;
}
.qsv-condition-empty-tip {
padding: 28px;
text-align: center;
color: #909399;
background: #ffffff;
border: 1px dashed #dcdfe6;
border-radius: 10px;
font-size: 13px;
}
.qsv-condition-group-footer {
padding-top: 12px;
border-top: 1px dashed #dcdcdc;
display: flex;
gap: 12px;
}
.qsv-add-group-text {
color: #67c23a;
}
@media (max-width: 1200px) {
.qsv-logic-workspace {
grid-template-columns: 1fr;
}
}
</style>
<div id="app">
<guava ref="guava">
<el-card shadow="never">
@@ -10,32 +386,54 @@ layout("/layouts/platform.html"){
<el-date-picker placeholder="请选择年度" style="width: 100%" type="year" v-model="pageForm.year"
value-format="yyyy"></el-date-picker>
</search-item>
<search-item label="标题">
<el-input placeholder="请输入标题" v-model="pageForm.title" clearable></el-input>
<search-item label="标题名称">
<el-input placeholder="请输入标题名称" v-model="pageForm.title" clearable></el-input>
</search-item>
</search>
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="管理列表">
<el-button @click="$refs.basicFormRef.onOpen()" size="small" type="primary" class="mr5">新增</el-button>
<table-tool label="标题列表">
</table-tool>
<el-table :data="tableData" ref="tableRef" @sort-change="pageOrder">
<el-table-column label="序号" width="50" type="index" :index="indexMethod"></el-table-column>
<el-table-column label="标题" prop="title" sortable></el-table-column>
<el-table-column label="标题名称" prop="title" sortable></el-table-column>
<el-table-column label="类型" prop="category" sortable width="200">
<template scope="{row}">
<dict-tag :options="dict.type.ACTIVITY_QSV_CATEGORY" :value="row.category"></dict-tag>
<el-tag size="mini" :type="getCategoryTagType(row.category)">
{{getCategoryText(row.category)}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="开始时间" prop="startTime" sortable></el-table-column>
<el-table-column label="结束时间" prop="endTime" sortable></el-table-column>
<el-table-column label="操作" fixed="right" width="450px">
<el-table-column label="状态" width="100">
<template scope="{row}">
<el-tag v-if="row.enabled !== false" type="success">开启</el-tag>
<el-tag v-else type="info">关闭</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="580px">
<template scope="{row}">
<el-button size="mini" type="primary" @click="openQrCode(row)">二维码</el-button>
<el-button size="mini" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="mini" type="primary" @click="openSubject(row.id)">题目设置</el-button>
<el-button
v-if="row.category === 'SURVEY'"
size="mini"
type="primary"
@click="openLogic(row.id)">逻辑</el-button>
<el-button
v-if="row.enabled === false"
size="mini"
type="success"
@click="updateEnabled(row, true)">开启</el-button>
<el-button
v-else
size="mini"
type="warning"
@click="updateEnabled(row, false)">关闭</el-button>
<el-button size="mini" type="danger" @click="onDelete(row.id)">删除</el-button>
</template>
</el-table-column>
@@ -48,22 +446,22 @@ layout("/layouts/platform.html"){
</template>
</guava>
<basic-form ref="basicFormRef" @refresh="pageData"></basic-form>
<logic-form ref="logicFormRef" @refresh="pageData"></logic-form>
<qr-code-plus :url="signatureAddress" ref="qrCodePlusRef"></qr-code-plus>
</div>
<script nonce="${cspNonce!}">
<!--#include('basicForm.js'){}#-->
<!--#include('subjectForm.js'){}#-->
<!--#include('logicForm.js'){}#-->
new Vue({
el: "#app",
dicts: ["ACTIVITY_QSV_CATEGORY"],
mixins: [initTableMixins],
components: {
"basic-form": basicForm,
"subject-form": subjectForm
"subject-form": subjectForm,
"logic-form": logicForm
},
data() {
return {
@@ -71,9 +469,24 @@ layout("/layouts/platform.html"){
}
},
methods: {
getCategoryText(category) {
const options = this.dict && this.dict.type ? (this.dict.type.ACTIVITY_QSV_CATEGORY || []) : []
const item = options.find(item => item.code === category)
return item ? item.name : category
},
getCategoryTagType(category) {
const map = {
SURVEY: "success",
QUIZ: "primary",
VOTE: "danger"
}
return map[category] || "info"
},
// 编辑
openEdit(row) {
this.$refs.basicFormRef.onOpen(row.id)
location.href = "/platform/qsv/new?id=" + row.id
},
// 打开二维码
@@ -94,9 +507,34 @@ layout("/layouts/platform.html"){
})
},
// 调查类型独立维护题目之间的显隐逻辑
openLogic(id) {
this.$refs.logicFormRef.onOpen(id)
},
// 开启或关闭活动,关闭后移动端首页不展示,直达手机端问卷也不能答题
updateEnabled(row, enabled) {
const actionText = enabled ? "开启" : "关闭"
this.$confirm("您确认" + actionText + "该活动吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
$.post("/platform/qsv/activity/updateEnabled", {
id: row.id,
enabled
}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.pageData()
}
})
})
},
// 删除
onDelete(id) {
this.$confirm("您确认删除吗, 是否继续?", "提示", {
this.$confirm("删除后将同步删除该任务的题目、选项、用户答题记录、统计分析和成绩数据,且不可恢复。确定删除吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
@@ -0,0 +1,744 @@
const conditionGroupEditor = {
name: "condition-group-editor",
props: {
group: {
type: Object,
required: true
},
questions: {
type: Array,
default() {
return []
}
},
isRoot: {
type: Boolean,
default: false
}
},
template: /*language=HTML*/ `
<div :class="['qsv-condition-group', {'is-root': isRoot}]">
<div v-if="isRoot" class="qsv-condition-root-title">条件规则</div>
<div class="qsv-condition-group-header">
<el-select v-model="group.logic" size="small" style="width: 120px;" @change="emitChange">
<el-option label="并且 (AND)" value="and"></el-option>
<el-option label="或者 (OR)" value="or"></el-option>
</el-select>
<div class="qsv-condition-group-actions">
<el-button type="primary" plain size="small" icon="el-icon-plus" @click="addItem">添加条件</el-button>
<el-button type="success" plain size="small" icon="el-icon-folder-add" @click="addGroup">添加分组</el-button>
<el-button
v-if="!isRoot"
type="danger"
size="small"
icon="el-icon-delete"
circle
@click="$emit('remove')">
</el-button>
</div>
</div>
<div class="qsv-condition-children">
<div v-for="(child, index) in group.children" :key="index" class="qsv-condition-row">
<template v-if="child.type === 'item'">
<div class="qsv-condition-item-card">
<span class="qsv-condition-item-index">{{index + 1}}</span>
<div class="qsv-condition-item-content">
<el-select
v-model="child.field"
placeholder="选择题目"
size="small"
filterable
style="flex: 1; min-width: 180px;"
@change="onFieldChange(child)">
<el-option
v-for="question in questions"
:key="question.id"
:label="question.title || '未填写题目'"
:value="question.id">
</el-option>
</el-select>
<el-select
v-model="child.operator"
size="small"
style="width: 110px;"
@change="emitChange">
<el-option label="等于" :value="'=='"></el-option>
<el-option label="不等于" :value="'!='"></el-option>
<el-option label="包含" :value="'contains'"></el-option>
<el-option label="大于" :value="'>'"></el-option>
<el-option label="小于" :value="'<'"></el-option>
<el-option label="大于等于" :value="'>='"></el-option>
<el-option label="小于等于" :value="'<='"></el-option>
</el-select>
<el-select
v-if="isChoiceQuestion(child.field)"
v-model="child.value"
placeholder="选择值"
size="small"
filterable
allow-create
style="width: 180px;"
@change="emitChange">
<el-option
v-for="option in getQuestionOptions(child.field)"
:key="option.id"
:label="option.text || '未填写选项'"
:value="option.id">
</el-option>
</el-select>
<el-input-number
v-else-if="isNumericOperator(child.operator)"
v-model="child.value"
size="small"
:controls="false"
style="width: 180px;"
@change="emitChange">
</el-input-number>
<el-input
v-else
v-model="child.value"
placeholder="输入值"
size="small"
style="width: 180px;"
@change="emitChange">
</el-input>
</div>
<el-button
type="danger"
size="small"
icon="el-icon-delete"
circle
@click="removeChild(index)">
</el-button>
</div>
</template>
<template v-else-if="child.type === 'group'">
<div class="qsv-nested-group-wrapper">
<div class="qsv-nested-group-label">
<i class="el-icon-folder"></i>
<span>嵌套分组</span>
</div>
<condition-group-editor
:group="child"
:questions="questions"
:is-root="false"
@change="emitChange"
@remove="removeChild(index)">
</condition-group-editor>
</div>
</template>
</div>
<div v-if="!group.children.length" class="qsv-condition-empty-tip">
暂无条件,请点击上方按钮添加
</div>
</div>
<div class="qsv-condition-group-footer">
<el-button type="text" icon="el-icon-plus" @click="addItem">添加条件</el-button>
<el-button type="text" icon="el-icon-folder-add" class="qsv-add-group-text" @click="addGroup">添加分组</el-button>
</div>
</div>
`,
methods: {
addItem() {
this.group.children.push({
type: "item",
field: "",
operator: "==",
value: ""
})
this.emitChange()
},
addGroup() {
this.group.children.push({
type: "group",
logic: "and",
children: []
})
this.emitChange()
},
removeChild(index) {
this.group.children.splice(index, 1)
this.emitChange()
},
onFieldChange(condition) {
condition.value = ""
const question = this.getQuestion(condition.field)
if (question && question.type === "checkbox" && condition.operator === "==") {
condition.operator = "contains"
}
this.emitChange()
},
emitChange() {
this.$emit("change")
},
getQuestion(questionId) {
return this.questions.find((question) => {
return question.id === questionId
})
},
isChoiceQuestion(questionId) {
const question = this.getQuestion(questionId)
return question && ["radio", "checkbox"].includes(question.type)
},
getQuestionOptions(questionId) {
const question = this.getQuestion(questionId)
return question && question.options ? question.options : []
},
isNumericOperator(operator) {
return [">", "<", ">=", "<="].includes(operator)
}
}
}
const logicForm = {
components: {
"condition-group-editor": conditionGroupEditor
},
template: /*language=HTML*/ `
<el-drawer
:visible.sync="visible"
:title="activity.title ? '题目逻辑 - ' + activity.title : '题目逻辑'"
direction="rtl"
size="calc(100% - 250px)"
:modal="false"
append-to-body
custom-class="qsv-logic-drawer">
<div class="qsv-logic-workspace" v-loading="loading">
<section class="qsv-logic-section qsv-logic-question-section">
<div class="qsv-logic-section-header">
<span class="qsv-logic-section-title">题目列表</span>
<el-button type="success" size="small" :loading="saveLoading" @click="saveLogic">
保存问卷
</el-button>
</div>
<div class="qsv-logic-question-list">
<div
v-for="(subject, index) in subjects"
:key="subject.id"
:class="['qsv-logic-question-card', {active: activeSubjectId === subject.id}]"
@click="selectSubject(subject)">
<div class="qsv-question-card-main">
<span class="qsv-question-index">{{index + 1}}</span>
<span :class="['qsv-question-type', getSubjectTypeClass(subject)]">
{{getSubjectTypeText(subject)}}
</span>
<span :class="['qsv-question-title', {'has-visible-rule': subject.visibleRuleEnabled}]">
{{subject.title || '未填写题目'}}
</span>
</div>
<div class="qsv-question-actions" v-if="index > 0">
<el-button-group>
<el-tooltip content="设置显隐逻辑" placement="top">
<el-button
type="warning"
size="small"
icon="el-icon-share"
@click.stop="openSubjectVisibleSetting(index, subject)">
</el-button>
</el-tooltip>
<el-tooltip content="删除逻辑" placement="top">
<el-button
type="danger"
size="small"
icon="el-icon-delete"
:disabled="!subject.visibleRuleEnabled"
@click.stop="clearSubjectVisibleRule(index)">
</el-button>
</el-tooltip>
</el-button-group>
</div>
</div>
<el-empty v-if="subjects.length === 0" description="暂无题目"></el-empty>
</div>
</section>
<section class="qsv-logic-section qsv-logic-preview-section">
<div class="qsv-logic-section-header">
<span class="qsv-logic-section-title">实时预览</span>
<div class="qsv-preview-controls">
<el-checkbox v-model="showPreviewIndex" size="small">显示序号</el-checkbox>
<el-tag type="info" size="small">配置即所得</el-tag>
</div>
</div>
<div class="qsv-logic-preview-list">
<div
v-for="(subject, index) in visiblePreviewSubjects"
:key="subject.id"
:data-preview-subject="subject.id"
:class="['qsv-preview-question', {active: activeSubjectId === subject.id}]">
<span v-if="showPreviewIndex" class="qsv-preview-index">{{index + 1}}.</span>
<div class="qsv-preview-content">
<div class="qsv-preview-title">{{subject.title || '未填写题目'}}</div>
<template v-if="subject.type === 'radio'">
<el-radio-group
class="qsv-preview-options"
:value="getPreviewRadioValue(subject)"
@input="setPreviewRadioValue(subject, $event)">
<el-radio
v-for="option in subject.options"
:key="option.id"
:label="option.id">
{{option.text || '未填写选项'}}
</el-radio>
</el-radio-group>
</template>
<template v-else-if="subject.type === 'checkbox'">
<el-checkbox-group
class="qsv-preview-options"
v-model="previewAnswers[subject.id]"
@change="refreshPreview">
<el-checkbox
v-for="option in subject.options"
:key="option.id"
:label="option.id">
{{option.text || '未填写选项'}}
</el-checkbox>
</el-checkbox-group>
</template>
<el-input
v-else
v-model="previewTextAnswers[subject.id]"
placeholder="请输入"
size="small"
@input="refreshPreview">
</el-input>
</div>
</div>
<el-empty v-if="visiblePreviewSubjects.length === 0" description="暂无可见题目"></el-empty>
</div>
</section>
</div>
<el-dialog
:title="subjectVisibleSetting.subjectTitle ? '配置题目逻辑 - ' + subjectVisibleSetting.subjectTitle : '配置题目逻辑'"
:visible.sync="subjectVisibleSetting.visible"
append-to-body
custom-class="qsv-condition-dialog"
width="800px">
<el-form :model="subjectVisibleSetting.formData" label-width="88px">
<el-form-item label="启用逻辑">
<el-switch
v-model="subjectVisibleSetting.formData.enabled"
active-text="启用"
inactive-text="关闭">
</el-switch>
</el-form-item>
<template v-if="subjectVisibleSetting.formData.enabled">
<el-form-item label="处理方式">
<el-radio-group v-model="subjectVisibleSetting.formData.action">
<el-radio label="show">条件满足后显示本题</el-radio>
<el-radio label="hide">条件满足后隐藏本题</el-radio>
</el-radio-group>
</el-form-item>
<condition-group-editor
:group="subjectVisibleSetting.formData.rootGroup"
:questions="subjectVisibleRuleSubjects"
:is-root="true"
@change="refreshConditionDialog">
</condition-group-editor>
</template>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="subjectVisibleSetting.visible = false">取 消</el-button>
<el-button type="primary" @click="onSubjectVisibleSettingConfirm">保存逻辑</el-button>
</div>
</el-dialog>
</el-drawer>
`,
data() {
return {
visible: false,
loading: false,
saveLoading: false,
id: null,
activity: {},
subjects: [],
activeSubjectId: null,
previewAnswers: {},
previewTextAnswers: {},
showPreviewIndex: true,
subjectVisibleSetting: {
visible: false,
subjectIndex: null,
subjectTitle: "",
formData: {
enabled: false,
action: "hide",
rootGroup: {
type: "group",
logic: "and",
children: []
}
}
}
}
},
computed: {
visiblePreviewSubjects() {
return this.subjects.filter((subject) => {
return this.isSubjectVisible(subject)
})
},
subjectVisibleRuleSubjects() {
if (this.subjectVisibleSetting.subjectIndex === null) {
return []
}
return this.subjects.filter((item, index) => {
return index < this.subjectVisibleSetting.subjectIndex
})
}
},
methods: {
onOpen(id) {
this.visible = true
this.loading = true
this.id = id
this.activity = {}
this.subjects = []
this.activeSubjectId = null
$.post("/platform/qsv/activity/findOne", {id}).then((res) => {
if (res.code === 0) {
this.activity = res.data || {}
}
})
$.post("/platform/qsv/activity/listSubjects", {activityId: id}).then((res) => {
if (res.code === 0) {
this.subjects = (res.data || []).map((subject) => {
return this.normalizeSubject(subject)
})
this.initPreviewAnswers()
if (this.subjects.length > 0) {
this.activeSubjectId = this.subjects[0].id
}
}
}).always(() => {
this.loading = false
})
},
normalizeSubject(subject) {
return {
...subject,
visibleRuleEnabled: !!subject.visibleRuleEnabled,
visibleRuleAction: subject.visibleRuleEnabled ? (subject.visibleRuleAction || "hide") : "",
visibleRuleLogic: subject.visibleRuleLogic || "AND",
visibleRuleConditions: this.normalizeVisibleRuleConditions(subject.visibleRuleConditions, subject.visibleRuleLogic)
}
},
normalizeVisibleRuleConditions(conditions, legacyLogic) {
let parsedConditions = conditions || []
if (typeof parsedConditions === "string") {
try {
parsedConditions = JSON.parse(parsedConditions)
} catch (e) {
parsedConditions = []
}
}
if (!Array.isArray(parsedConditions) || parsedConditions.length === 0) {
return []
}
if (parsedConditions.length === 1 && parsedConditions[0].type === "group") {
return [this.normalizeConditionGroup(parsedConditions[0])]
}
return [this.normalizeLegacyConditionGroup(parsedConditions, legacyLogic)]
},
normalizeConditionGroup(group) {
return {
type: "group",
logic: (group.logic || "and").toLowerCase() === "or" ? "or" : "and",
children: Array.isArray(group.children) ? group.children.map((child) => {
if (child.type === "group") {
return this.normalizeConditionGroup(child)
}
return this.normalizeConditionItem(child)
}) : []
}
},
normalizeConditionItem(item) {
return {
type: "item",
field: item.field || item.subjectId || "",
operator: item.operator || (item.optionId ? "contains" : "=="),
value: item.value !== undefined && item.value !== null ? item.value : (item.optionId || "")
}
},
normalizeLegacyConditionGroup(conditions, legacyLogic) {
return {
type: "group",
logic: legacyLogic === "OR" ? "or" : "and",
children: conditions.map((condition) => {
return {
type: "item",
field: condition.subjectId || condition.field || "",
operator: condition.operator || "contains",
value: condition.optionId || condition.value || ""
}
})
}
},
createDefaultConditionGroup() {
return {
type: "group",
logic: "and",
children: [
{
type: "item",
field: "",
operator: "==",
value: ""
}
]
}
},
initPreviewAnswers() {
const previewAnswers = {}
const previewTextAnswers = {}
this.subjects.forEach((subject) => {
if (["radio", "checkbox"].includes(subject.type)) {
previewAnswers[subject.id] = []
} else {
previewTextAnswers[subject.id] = ""
}
})
this.previewAnswers = previewAnswers
this.previewTextAnswers = previewTextAnswers
},
selectSubject(subject) {
this.activeSubjectId = subject.id
this.scrollPreviewToSubject(subject.id)
},
scrollPreviewToSubject(subjectId) {
this.$nextTick(() => {
const node = this.$el.querySelector('[data-preview-subject="' + subjectId + '"]')
if (node && node.scrollIntoView) {
node.scrollIntoView({behavior: "smooth", block: "center"})
}
})
},
getSubjectTypeText(subject) {
const typeMap = {
radio: "单选题",
checkbox: "多选题",
text: "填空题"
}
return typeMap[subject.type] || "题目"
},
getSubjectTypeClass(subject) {
if (subject.type === "checkbox") {
return "multiple"
}
if (subject.type === "text") {
return "text"
}
return "single"
},
getPreviewRadioValue(subject) {
const value = this.previewAnswers[subject.id]
return value && value.length > 0 ? value[0] : ""
},
setPreviewRadioValue(subject, optionId) {
this.$set(this.previewAnswers, subject.id, optionId ? [optionId] : [])
this.refreshPreview()
},
refreshPreview() {
this.clearHiddenPreviewAnswers()
},
refreshConditionDialog() {
this.$forceUpdate()
},
openSubjectVisibleSetting(subjectIndex, subject) {
const rootGroup = subject.visibleRuleConditions && subject.visibleRuleConditions.length > 0
? this.cloneConditionGroup(subject.visibleRuleConditions[0])
: this.createDefaultConditionGroup()
this.activeSubjectId = subject.id
this.subjectVisibleSetting.subjectIndex = subjectIndex
this.subjectVisibleSetting.subjectTitle = subject.title || "未填写题目"
this.subjectVisibleSetting.formData = {
enabled: !!subject.visibleRuleEnabled,
action: subject.visibleRuleEnabled ? (subject.visibleRuleAction || "hide") : "hide",
rootGroup
}
this.subjectVisibleSetting.visible = true
},
cloneConditionGroup(group) {
return JSON.parse(JSON.stringify(group))
},
clearSubjectVisibleRule(subjectIndex) {
const subject = this.subjects[subjectIndex]
if (!subject || !subject.visibleRuleEnabled) {
return
}
this.$set(subject, "visibleRuleEnabled", false)
this.$set(subject, "visibleRuleAction", "show")
this.$set(subject, "visibleRuleLogic", "AND")
this.$set(subject, "visibleRuleConditions", [])
this.activeSubjectId = subject.id
this.refreshPreview()
this.scrollPreviewToSubject(subject.id)
},
onSubjectVisibleSettingConfirm() {
const subjectIndex = this.subjectVisibleSetting.subjectIndex
if (subjectIndex === null) {
return
}
const formData = this.subjectVisibleSetting.formData
if (formData.enabled && !this.validateConditionGroup(formData.rootGroup)) {
return
}
const normalizedGroup = this.normalizeConditionGroup(formData.rootGroup)
this.$set(this.subjects[subjectIndex], "visibleRuleEnabled", formData.enabled)
this.$set(this.subjects[subjectIndex], "visibleRuleAction", formData.enabled ? formData.action : "show")
this.$set(this.subjects[subjectIndex], "visibleRuleLogic", normalizedGroup.logic === "or" ? "OR" : "AND")
this.$set(this.subjects[subjectIndex], "visibleRuleConditions", formData.enabled ? [normalizedGroup] : [])
this.subjectVisibleSetting.visible = false
this.refreshPreview()
this.scrollPreviewToSubject(this.subjects[subjectIndex].id)
},
validateConditionGroup(group) {
if (!group.children || group.children.length === 0) {
this.$message.warning("请至少添加一条条件")
return false
}
for (let i = 0; i < group.children.length; i++) {
const child = group.children[i]
if (child.type === "group") {
if (!this.validateConditionGroup(child)) {
return false
}
} else if (!child.field || !child.operator || child.value === "" || child.value === null || child.value === undefined) {
this.$message.warning("请完整配置条件" + (i + 1))
return false
}
}
return true
},
isSubjectVisible(subject) {
if (!subject.visibleRuleEnabled) {
return true
}
const rootGroup = this.getVisibleRuleRootGroup(subject)
if (!rootGroup) {
return true
}
const matched = this.evaluateConditionGroup(rootGroup)
return subject.visibleRuleAction === "hide" ? !matched : matched
},
getVisibleRuleRootGroup(subject) {
const conditions = this.normalizeVisibleRuleConditions(subject.visibleRuleConditions, subject.visibleRuleLogic)
return conditions.length > 0 ? conditions[0] : null
},
evaluateConditionGroup(group) {
const children = (group.children || []).filter((child) => {
return child.type === "group" || (child.field && child.operator)
})
if (children.length === 0) {
return true
}
const results = children.map((child) => {
if (child.type === "group") {
return this.evaluateConditionGroup(child)
}
return this.evaluateConditionItem(child)
})
return group.logic === "or"
? results.some((item) => {
return item
})
: results.every((item) => {
return item
})
},
evaluateConditionItem(condition) {
const subject = this.subjects.find((item) => {
return item.id === condition.field
})
if (!subject) {
return false
}
const answer = ["radio", "checkbox"].includes(subject.type)
? (this.previewAnswers[subject.id] || [])
: this.previewTextAnswers[subject.id]
return this.compareRuleValue(answer, condition.operator, condition.value)
},
compareRuleValue(answer, operator, value) {
if (Array.isArray(answer)) {
if (operator === "!=") {
return !answer.includes(value)
}
return answer.includes(value)
}
if ([">", "<", ">=", "<="].includes(operator)) {
const answerNumber = Number(answer)
const valueNumber = Number(value)
if (isNaN(answerNumber) || isNaN(valueNumber)) {
return false
}
if (operator === ">") {
return answerNumber > valueNumber
}
if (operator === "<") {
return answerNumber < valueNumber
}
if (operator === ">=") {
return answerNumber >= valueNumber
}
return answerNumber <= valueNumber
}
const answerText = answer === undefined || answer === null ? "" : String(answer)
const valueText = value === undefined || value === null ? "" : String(value)
if (operator === "!=") {
return answerText !== valueText
}
if (operator === "contains") {
return answerText.indexOf(valueText) !== -1
}
return answerText === valueText
},
clearHiddenPreviewAnswers() {
this.subjects.forEach((subject) => {
if (this.isSubjectVisible(subject)) {
return
}
if (["radio", "checkbox"].includes(subject.type)) {
this.$set(this.previewAnswers, subject.id, [])
} else {
this.$set(this.previewTextAnswers, subject.id, "")
}
})
},
buildSubmitSubjects() {
return this.subjects.map((subject) => {
const conditions = this.normalizeVisibleRuleConditions(subject.visibleRuleConditions, subject.visibleRuleLogic)
return {
...subject,
visibleRuleEnabled: !!subject.visibleRuleEnabled,
visibleRuleAction: subject.visibleRuleEnabled ? subject.visibleRuleAction : "show",
visibleRuleLogic: subject.visibleRuleEnabled && conditions[0] && conditions[0].logic === "or" ? "OR" : "AND",
visibleRuleConditions: subject.visibleRuleEnabled ? conditions : []
}
})
},
saveLogic() {
this.saveLoading = true
$.post("/platform/qsv/activity/saveSubjects", {
activityId: this.id,
subjects: JSON.stringify(this.buildSubmitSubjects())
}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg || "保存成功")
this.$emit("refresh")
}
}).always(() => {
this.saveLoading = false
})
}
}
}
@@ -0,0 +1,496 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
.qsv-new-page {
padding-bottom: 72px;
}
.qsv-new-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.qsv-new-title {
margin: 0;
font-size: 20px;
line-height: 32px;
font-weight: 600;
color: #303133;
}
.qsv-new-card {
margin-bottom: 12px;
}
.qsv-new-sub-title {
margin: 0 0 18px;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.qsv-new-footer {
position: fixed;
left: 250px;
right: 0;
bottom: 0;
z-index: 900;
padding: 12px 24px;
border-top: 1px solid #ebeef5;
background: #ffffff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);
text-align: right;
box-sizing: border-box;
}
.qsv-cover-tip {
margin-top: 8px;
line-height: 20px;
font-size: 12px;
color: #909399;
}
.qsv-cover-tip span {
color: #f56c6c;
}
@media (max-width: 768px) {
.qsv-new-footer {
left: 0;
}
.qsv-scope-select {
width: 100% !important;
min-width: 0 !important;
}
}
</style>
<div id="app" v-cloak>
<div class="qsv-new-page">
<div class="qsv-new-header">
<h2 class="qsv-new-title">{{pageTitle}}</h2>
<el-button icon="el-icon-back" @click="goBack">返回</el-button>
</div>
<el-card shadow="never" class="qsv-new-card">
<h3 class="qsv-new-sub-title">基础信息</h3>
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="150px">
<el-form-item label="类型" prop="category">
<el-radio-group v-model="formData.category" size="small" @change="onCategoryChange">
<el-radio v-for="item in categoryOptions" :label="item.code"
:key="item.code"
border>
{{item.label}}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" maxlength="50"
show-word-limit></el-input>
</el-form-item>
<el-form-item label="说明" prop="description">
<text-editor v-model="formData.description" :height="150"></text-editor>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="开始时间" prop="startTime">
<el-date-picker v-model="formData.startTime" type="datetime" placeholder="选择日期时间"
style="width: 100%"
value-format="yyyy-MM-dd HH:mm:ss"></el-date-picker>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束时间" prop="endTime">
<el-date-picker v-model="formData.endTime" type="datetime" placeholder="选择日期时间"
style="width: 100%"
value-format="yyyy-MM-dd HH:mm:ss"></el-date-picker>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="参加人员范围" prop="groupId">
<div style="display: flex;column-gap: 10px">
<el-select class="qsv-scope-select"
clearable filterable
placeholder="参加人员范围"
style="width: 640px; max-width: calc(100% - 82px);"
v-model="formData.groupId">
<el-option :key="item.groupId"
:label="item.groupName"
:value="item.groupId"
v-for="item in activityGroupOptions"></el-option>
</el-select>
<el-button
@click="$refs.drawerUserScope.userScopeDialog = true"
type="primary">设置
</el-button>
</div>
</el-form-item>
<h3 class="qsv-new-sub-title" v-if="formData.category==='QUIZ'">题目信息</h3>
<template v-if="formData.category==='QUIZ'">
<el-form-item label="答题模式" prop="mode">
<el-radio-group v-model="formData.mode" size="small" @change="onModeChange">
<el-radio v-for="item in modeOptions" :label="item.code"
:key="item.code"
border>
{{item.label}}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="常规题目范围" prop="displayMode"
v-if="isRegularMode()">
<el-radio-group v-model="formData.displayMode" size="small" @change="normalizeQuizConfig">
<el-radio label="ALL" border>显示全部题目</el-radio>
<el-radio label="RANDOM" border>随机抽取题目</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="题目展示顺序打乱" prop="shuffleSubject">
<el-switch v-model="formData.shuffleSubject" inactive-text="否" active-text="是"></el-switch>
</el-form-item>
<el-form-item label="每次随机题数" prop="randomCount"
v-if="isRegularMode() && isRandomDisplayMode()">
<el-input-number v-model="formData.randomCount" :min="1" :max="100"></el-input-number>
</el-form-item>
<el-form-item label="最多随机答题次数" prop="totalRandom"
v-if="isRegularMode() && isRandomDisplayMode()">
<el-input-number v-model="formData.totalRandom" :min="1" :max="100"></el-input-number>
</el-form-item>
<el-form-item label="允许重复答题" prop="repeatable"
v-if="canRepeatAnswer()">
<el-switch v-model="formData.repeatable" inactive-text="否" active-text="是"
@change="normalizeQuizConfig"></el-switch>
</el-form-item>
<el-form-item label="最多答题次数" prop="maxAttempts"
v-if="canRepeatAnswer() && formData.repeatable">
<el-input-number v-model="formData.maxAttempts" :min="1" :max="100"></el-input-number>
</el-form-item>
<el-form-item label="时间限制(min)" prop="timeLimit">
<el-input-number v-model="formData.timeLimit" :min="0" :max="100"
:precision="0"
placeholder="0表示不限时"></el-input-number>
</el-form-item>
<el-form-item label="重复答题计分方式" prop="scoreMode"
v-if="canRepeatAnswer() && formData.repeatable">
<el-radio-group v-model="formData.scoreMode" size="small">
<el-radio v-for="item in dict.type.ACTIVITY_QSV_SCORE_MODE" :label="item.code"
:key="item.code" border>
{{item.label}}
</el-radio>
</el-radio-group>
</el-form-item>
</template>
<el-form-item label="封面" prop="cover">
<file-upload
:upload_number="1"
:upload_size="1024 * 1024"
:value.sync="formData.cover"
accept=".jpg,.jpeg,.png"
upload_mode="image"
upload_result_category="interval"
upload_result_type="url"
></file-upload>
<div class="qsv-cover-tip">
只能上传<span>1</span>个文件;只能上传<span>.jpg,.jpeg,.png</span>文件;单个文件大小不能超过<span>1.00</span>M
</div>
</el-form-item>
</el-form>
</el-card>
<div class="qsv-new-footer">
<el-button @click="goBack">取消</el-button>
<el-button :loading="saveLoading" @click="saveDraft">保存</el-button>
<el-button type="primary" :loading="saveLoading" @click="submitActivity">提交</el-button>
</div>
<drawer-user-scope
@group_change="getActivityGroup"
ref="drawerUserScope"
:group_id.sync="formData.groupId"
></drawer-user-scope>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
dicts: ["ACTIVITY_QSV_CATEGORY", "ACTIVITY_QSV_MODE", "ACTIVITY_QSV_REPEAT_MODE", "ACTIVITY_QSV_SCORE_MODE"],
components: {
"drawer-user-scope": httpVueLoader("/components/module/activity/DrawerUserScope.vue")
},
data() {
return {
saveLoading: false,
activityId: "",
formData: {
category: "QUIZ",
cover: "",
enabled: false,
mode: "REGULAR",
shuffleSubject: false,
repeatable: false,
displayMode: "ALL",
timeLimit: 0
},
formRules: {
category: [{ required: true, message: "请选择类型", trigger: "change" }],
title: [{ required: true, message: "请输入标题", trigger: "change" }],
startTime: [{ required: true, message: "请选择开始时间", trigger: "change" }],
endTime: [{ required: true, message: "请选择结束时间", trigger: "change" }],
mode: [{ required: true, message: "请选择答题模式", trigger: "change" }],
scoreMode: [{ required: true, message: "请选择重复答题计分方式", trigger: "change" }]
},
activityGroupOptions: []
}
},
methods: {
getQueryString(name) {
const reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)")
const result = window.location.search.substr(1).match(reg)
return result ? decodeURIComponent(result[2]) : ""
},
onCategoryChange(category) {
this.formData.category = this.normalizeCategoryValue(category)
if (this.formData.category === "QUIZ") {
this.normalizeQuizConfig()
}
},
onModeChange(mode) {
this.$set(this.formData, "mode", this.normalizeModeValue(mode))
this.normalizeQuizConfig()
},
getDefaultScoreMode() {
const scoreModes = this.dict && this.dict.type ? (this.dict.type.ACTIVITY_QSV_SCORE_MODE || []) : []
const highMode = scoreModes.find(item => item.code === "HIGH")
const lastMode = scoreModes.find(item => item.code === "LAST")
return (lastMode || highMode || scoreModes[0] || { code: "LAST" }).code
},
normalizeCategoryValue(category) {
const value = (category || "").toString().trim().toUpperCase()
if (value === "QUIZ" || value === "VOTE") {
return value
}
return "SURVEY"
},
normalizeModeValue(mode) {
const value = (mode || "").toString().trim().toUpperCase()
if (value.indexOf("SCHEDULE") > -1 || value.indexOf("定时") > -1) {
return "SCHEDULED"
}
return "REGULAR"
},
normalizeModeOptionValue(item) {
const code = item && item.code ? item.code.toString().trim() : ""
if (code.toUpperCase().indexOf("SCHEDULE") > -1 || code.indexOf("定时") > -1) {
return "SCHEDULED"
}
if (code.toUpperCase().indexOf("REGULAR") > -1 || code.indexOf("常规") > -1) {
return "REGULAR"
}
return this.normalizeModeValue([item && item.name, item && item.label, item && item.text].join(" "))
},
getDefaultModeValue() {
const regularOption = this.modeOptions.find(item => this.normalizeModeOptionValue(item) === "REGULAR")
return regularOption ? regularOption.code : "REGULAR"
},
normalizeDisplayModeValue(displayMode) {
const value = (displayMode || "").toString().trim().toUpperCase()
if (value === "RANDOM") {
return "RANDOM"
}
return "ALL"
},
isRegularMode(data) {
const formData = data || this.formData
return this.normalizeModeValue(formData.mode) === "REGULAR"
},
isRandomDisplayMode(data) {
const formData = data || this.formData
return this.normalizeDisplayModeValue(formData.displayMode) === "RANDOM"
},
canRepeatAnswer(data) {
const formData = data || this.formData
const category = this.normalizeCategoryValue(formData.category)
const mode = this.normalizeModeValue(formData.mode)
const displayMode = this.normalizeDisplayModeValue(formData.displayMode)
return category === "QUIZ"
&& (mode === "SCHEDULED" || (mode === "REGULAR" && displayMode === "ALL"))
},
normalizeQuizConfig() {
this.formData.category = this.normalizeCategoryValue(this.formData.category)
if (this.formData.category !== "QUIZ") {
return
}
this.$set(this.formData, "mode", this.formData.mode ? this.normalizeModeValue(this.formData.mode) : this.getDefaultModeValue())
this.$set(this.formData, "displayMode", this.normalizeDisplayModeValue(this.formData.displayMode))
this.$set(this.formData, "timeLimit", this.formData.timeLimit || 0)
if (!this.isRegularMode()) {
this.$set(this.formData, "displayMode", "ALL")
this.$set(this.formData, "randomCount", null)
this.$set(this.formData, "totalRandom", null)
} else if (!this.isRandomDisplayMode()) {
this.$set(this.formData, "randomCount", null)
this.$set(this.formData, "totalRandom", null)
} else {
this.$set(this.formData, "repeatable", false)
this.$set(this.formData, "randomCount", this.formData.randomCount || 1)
this.$set(this.formData, "totalRandom", this.formData.totalRandom || 1)
}
if (!this.canRepeatAnswer()) {
this.$set(this.formData, "repeatable", false)
this.$set(this.formData, "maxAttempts", 1)
this.$set(this.formData, "scoreMode", this.getDefaultScoreMode())
return
}
if (this.formData.repeatable) {
this.$set(this.formData, "maxAttempts", this.formData.maxAttempts || 1)
this.$set(this.formData, "scoreMode", this.formData.scoreMode || this.getDefaultScoreMode())
return
}
this.$set(this.formData, "maxAttempts", 1)
this.$set(this.formData, "scoreMode", this.getDefaultScoreMode())
},
getActivityGroup() {
$.post("/platform/activity/basic/scope/getActivityUserScopeGroup").then((res) => {
if (res.code === 0) {
this.activityGroupOptions = res.data || []
}
})
},
loadActivity() {
if (!this.activityId) {
return
}
$.post("/platform/qsv/activity/findOne", { id: this.activityId }).then((res) => {
if (res.code === 0 && res.data) {
this.formData = Object.assign({}, this.formData, res.data)
this.normalizeQuizConfig()
}
})
},
saveDraft() {
const enabled = this.activityId ? this.formData.enabled !== false : false
this.saveActivity(enabled, "保存")
},
submitActivity() {
this.saveActivity(true, "提交")
},
buildSubmitData(enabled) {
const formData = Object.assign({}, this.formData, { enabled })
formData.category = this.normalizeCategoryValue(formData.category)
if (formData.category !== "QUIZ") {
delete formData.mode
delete formData.repeatable
delete formData.repeatMode
delete formData.maxAttempts
delete formData.displayMode
delete formData.randomCount
delete formData.totalRandom
delete formData.shuffleSubject
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") {
formData.displayMode = "ALL"
delete formData.randomCount
delete formData.totalRandom
} else if (formData.displayMode !== "RANDOM") {
delete formData.randomCount
delete formData.totalRandom
} else {
formData.repeatable = false
formData.maxAttempts = 1
formData.scoreMode = this.getDefaultScoreMode()
}
if (!this.canRepeatAnswer(formData) || !formData.repeatable) {
formData.repeatable = false
formData.maxAttempts = 1
formData.scoreMode = this.getDefaultScoreMode()
} else {
formData.maxAttempts = formData.maxAttempts || 1
formData.scoreMode = formData.scoreMode || this.getDefaultScoreMode()
}
}
return formData
},
saveActivity(enabled, actionText) {
this.$refs.formRef.validate((valid) => {
if (!valid) {
return
}
this.$confirm("确定" + actionText + "吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
this.saveLoading = true
const formData = this.buildSubmitData(enabled)
$.post("/platform/qsv/activity/save", formData).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"
}
},
mounted() {
this.activityId = this.getQueryString("id")
this.getActivityGroup()
this.loadActivity()
if (!this.activityId) {
this.normalizeQuizConfig()
}
},
computed: {
categoryOptions() {
const categoryList = this.dict && this.dict.type ? (this.dict.type.ACTIVITY_QSV_CATEGORY || []) : []
return categoryList.map(item => Object.assign({}, item, {
code: this.normalizeCategoryValue(item.code)
}))
},
modeOptions() {
return [
{ code: "REGULAR", label: "常规模式" },
{ code: "SCHEDULED", label: "定时定题" }
]
},
pageTitle() {
return this.activityId ? "编辑任务" : "新建任务"
}
}
})
</script>
<!--#
}
#-->
@@ -8,6 +8,20 @@ const setting = {
<el-form-item label="详情" prop="description">
<text-editor v-model="formData.description"></text-editor>
</el-form-item>
<el-form-item label="补充填写" prop="fillRequired">
<el-switch
v-model="formData.fillRequired"
active-text="需要填写"
inactive-text="无需填写">
</el-switch>
</el-form-item>
<el-form-item label="填写提示" prop="fillPlaceholder" v-if="formData.fillRequired">
<el-input
maxlength="255"
placeholder="例如:请填写原因"
v-model="formData.fillPlaceholder">
</el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="visible = false">取 消</el-button>
@@ -26,7 +40,11 @@ const setting = {
methods:{
onOpen(option,ext){
console.log(option)
this.formData = { ...option }
this.formData = {
...option,
fillRequired: !!option.fillRequired,
fillPlaceholder: option.fillPlaceholder || ""
}
this.ext = ext
this.visible = true
},
@@ -5,20 +5,20 @@
const subjectForm = {
template: /*language=HTML*/ `
<div class="subject-form-dialog">
<div class="subject-form-dialog qsv-subject-editor">
<el-form ref="form" :model="formData" :rules="formRules" label-width="120px">
<div style="min-height:50vh;overflow-y: auto">
<draggable v-model="subjects" handle=".drag-handler">
<transition-group>
<div v-for="(subject, subjectIndex) in subjects" :key="subject.id" class="subject-item">
<div class="subject-header">
<div style="display: flex; align-items: center;">
<div class="subject-title-row">
<i class="el-icon-rank drag-handler"></i>
<span style="font-weight: bold; margin-right: 10px;">第 {{subjectIndex + 1}} 题</span>
<el-input
class="subject-title-input"
v-model="subject.title"
placeholder="请输入题目"
style="flex: 1">
placeholder="请输入题目">
</el-input>
</div>
@@ -49,6 +49,27 @@ const subjectForm = {
最大选择数:
<el-input-number v-model="subject.maxMulti" placeholder="最多可选数量"></el-input-number>
</div>
<div v-if="activity.category==='VOTE'">
排列方式:
<el-select
v-model="subject.optionLayout"
@change="onVoteLayoutChange(subject)"
placeholder="请选择排列方式"
style="width: 120px;">
<el-option label="竖向排列" value="VERTICAL"></el-option>
<el-option label="横向排列" value="HORIZONTAL"></el-option>
</el-select>
</div>
<div v-if="activity.category==='VOTE' && subject.optionLayout === 'HORIZONTAL'">
横向列数:
<el-select
v-model="subject.optionColumns"
placeholder="请选择列数"
style="width: 100px;">
<el-option label="横向2列" :value="2"></el-option>
<el-option label="横向3列" :value="3"></el-option>
</el-select>
</div>
<!-- <el-input-->
<!-- v-model="subject.hint"-->
<!-- placeholder="题目提示信息"-->
@@ -91,7 +112,7 @@ const subjectForm = {
<div class="option-content">
<el-input
v-model="option.text"
:placeholder="'选项' + (optionIndex + 1)">
:placeholder="'选项'+optionIndex + 1">
<template slot="prepend">选项{{optionIndex + 1}}</template>
</el-input>
<!-- <el-input-->
@@ -119,8 +140,7 @@ const subjectForm = {
<el-switch
v-if="activity.category==='QUIZ'"
v-model="option.isCorrect"
active-text="正确答案"
@change="handleCorrectAnswerChange(subject, optionIndex)">
active-text="正确答案">
</el-switch>
<el-button
@@ -193,7 +213,7 @@ const subjectForm = {
</div>
</el-form>
<div style="border-top: 1px solid var(--border-color-lighter);padding: 12px;text-align: right;">
<div class="qsv-subject-footer">
<!-- <el-button @click="visible = false">取消</el-button>-->
<!--<el-button type="primary" icon="el-icon-plus" @click="openBank">
@@ -201,12 +221,15 @@ const subjectForm = {
</el-button>-->
<el-button type="primary" icon="el-icon-upload" @click="openTxtImport">
从文本导入
导入题目
</el-button>
<el-button type="primary" icon="el-icon-plus" @click="addSubject">
添加题目
</el-button>
<el-button type="primary" icon="el-icon-check" @click="saveQuestionnaire">
<el-button type="primary" icon="el-icon-collection" @click="openSaveBankDialog">
存到题库
</el-button>
<el-button type="primary" icon="el-icon-check" @click="saveQuestionnaire" v-if="!hideSaveButton">
保存题目
</el-button>
</div>
@@ -215,6 +238,22 @@ const subjectForm = {
<setting ref="settingRef" @confirm="onSettingConfirm"></setting>
<txt-import ref="txtImportRef" @confirm="onTxtImportConfirm"></txt-import>
<bank ref="bankRef" @confirm="onBankConfirm"></bank>
<el-dialog
:visible.sync="saveBankDialog.visible"
title="存到题库"
width="420px"
append-to-body
custom-class="save-bank-dialog">
<el-form :model="saveBankDialog.formData" :rules="saveBankDialog.rules" ref="saveBankFormRef" label-width="90px">
<el-form-item label="标题名称" prop="title">
<el-input v-model="saveBankDialog.formData.title" maxlength="50" show-word-limit placeholder="请输入题库标题"></el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="saveBankDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="saveBankDialog.loading" @click="saveToBank">确定</el-button>
</div>
</el-dialog>
</div>
`,
components: {
@@ -223,6 +262,12 @@ const subjectForm = {
'txt-import': txtImport,
'bank': subjectBank
},
props: {
hideSaveButton: {
type: Boolean,
default: false
}
},
data() {
return {
id: null,
@@ -231,7 +276,20 @@ const subjectForm = {
subjects: [],
previewDialogVisible: false,
activity: {},
formRules: {}
formRules: {},
saveBankDialog: {
visible: false,
loading: false,
formData: {
title: ""
},
rules: {
title: [
{ required: true, message: "请输入题库标题", trigger: "blur" },
{ max: 50, message: "标题名称不能超过50个字", trigger: "blur" }
]
}
}
}
},
methods: {
@@ -239,14 +297,19 @@ const subjectForm = {
this.visible = true
if (id) {
this.id = id
$.post("/platform/qsv/activity/findOne", {id}).then((res) => {
if (res.code === 0) {
this.activity = res.data
Promise.all([
$.post("/platform/qsv/activity/findOne", {id}),
$.post("/platform/qsv/activity/listSubjects", {activityId: id})
]).then(([activityRes, subjectsRes]) => {
if (activityRes.code === 0) {
this.activity = activityRes.data
}
})
$.post("/platform/qsv/activity/listSubjects", {activityId: id}).then((res) => {
if (res.code === 0) {
this.subjects = res.data
if (subjectsRes.code === 0) {
this.subjects = subjectsRes.data || []
this.normalizeVoteSubjectLayouts()
}
if (this.activity.category === "VOTE" && this.subjects.length === 0) {
this.addSubject()
}
})
}
@@ -266,6 +329,8 @@ const subjectForm = {
type: "radio",
score: 0,
displayDate: "",
optionLayout: "VERTICAL",
optionColumns: 1,
options: [
{
id: this.generateId(),
@@ -285,14 +350,41 @@ const subjectForm = {
})
},
//题目类型切换
subjectTypeChange(subject, subjectIndex) {
// 切换题型时先清空当前题目的正确答案状态,避免沿用旧题型下的答案配置。
;(subject.options || []).forEach((option) => {
if (option) {
this.$set(option, 'isCorrect', false)
}
// 投票选项排列默认值
normalizeVoteSubjectLayout(subject) {
if (!subject) {
return
}
if (!subject.optionLayout) {
this.$set(subject, "optionLayout", "VERTICAL")
}
if (subject.optionLayout === "HORIZONTAL") {
const columns = Number(subject.optionColumns)
this.$set(subject, "optionColumns", columns === 3 ? 3 : 2)
} else {
this.$set(subject, "optionLayout", "VERTICAL")
this.$set(subject, "optionColumns", 1)
}
},
normalizeVoteSubjectLayouts() {
if (this.activity.category !== "VOTE") {
return
}
this.subjects.forEach((subject) => {
this.normalizeVoteSubjectLayout(subject)
})
},
onVoteLayoutChange(subject) {
if (subject.optionLayout === "HORIZONTAL") {
this.$set(subject, "optionColumns", Number(subject.optionColumns) === 3 ? 3 : 2)
} else {
this.$set(subject, "optionColumns", 1)
}
},
subjectTypeChange(subject, subjectIndex) {
if (subject.type === 'text') {
subject.options = []
}
@@ -317,7 +409,7 @@ const subjectForm = {
addOption(subject) {
subject.options.push({
id: this.generateId(),
text: "选项" + (subject.options.length + 1),
text: "选项" + subject.options.length + 1,
hint: "",
imgUrl: null,
isCorrect: false
@@ -358,7 +450,7 @@ const subjectForm = {
//选项设置确认
onSettingConfirm({option, ext}) {
this.subjects[ext.subjectIndex].options[ext.optionIndex] = option
this.$set(this.subjects[ext.subjectIndex].options, ext.optionIndex, option)
console.log(this.subjects[ext.subjectIndex].options[ext.optionIndex])
// this.$set(this.subjects[ext.subjectIndex].options[ext.optionIndex], "isCorrect", option.isCorrect)
@@ -367,7 +459,7 @@ const subjectForm = {
//从文本导入
openTxtImport() {
this.$refs.txtImportRef.visible = true;
this.$refs.txtImportRef.onOpen(this.activity.category);
},
//处理导入的题目
@@ -382,6 +474,9 @@ const subjectForm = {
// 更新导入题目的序号
importedQuestions.forEach((question, index) => {
question.sortNum = startSortNum + index;
if (this.activity.category === "VOTE") {
this.normalizeVoteSubjectLayout(question)
}
});
// 将导入的题目添加到现有题目列表
@@ -400,67 +495,129 @@ const subjectForm = {
},
// 单选题切换正确答案时保持只有一个正确选项,避免界面状态与题型规则不一致。
handleCorrectAnswerChange(subject, optionIndex) {
if (!subject || subject.type !== 'radio') {
// 打开存到题库对话框
openSaveBankDialog() {
if (!this.subjects || this.subjects.length === 0) {
this.$message.warning("请先添加题目")
return
}
const options = subject.options || []
const currentOption = options[optionIndex]
if (!currentOption || !currentOption.isCorrect) {
return
}
for (let index = 0; index < options.length; index++) {
if (index !== optionIndex) {
this.$set(options[index], 'isCorrect', false)
this.saveBankDialog.formData.title = this.activity && this.activity.title ? this.activity.title : ""
this.saveBankDialog.visible = true
this.$nextTick(() => {
if (this.$refs.saveBankFormRef) {
this.$refs.saveBankFormRef.clearValidate()
}
}
})
},
// 保存前校验选项内容,避免题目存在空选项或缺少正确答案时仍然提交到后端。
validateSubjectOptions() {
for (let subjectIndex = 0; subjectIndex < this.subjects.length; subjectIndex++) {
const subject = this.subjects[subjectIndex]
if (!subject || subject.type === 'text') {
continue
}
const options = subject.options || []
if (options.length === 0) {
this.$message.warning('请设置第' + (subjectIndex + 1) + '题的选项')
return false
}
let correctAnswerCount = 0
for (let optionIndex = 0; optionIndex < options.length; optionIndex++) {
const option = options[optionIndex]
const optionText = option && option.text ? option.text.trim() : ''
if (!optionText) {
this.$message.warning('第' + (subjectIndex + 1) + '题的第' + (optionIndex + 1) + '个选项不能为空')
return false
}
if (option && option.isCorrect) {
correctAnswerCount++
}
}
if (this.activity.category === 'QUIZ') {
if (subject.type === 'radio' && correctAnswerCount > 1) {
this.$message.warning('第' + (subjectIndex + 1) + '题为单选题,只能设置一个正确答案')
return false
}
if (correctAnswerCount === 0) {
this.$message.warning('第' + (subjectIndex + 1) + '题请至少设置一个正确答案')
return false
}
// 复制题目到题库时重新生成ID,避免与活动题目关联
buildBankSubjects() {
const subjects = JSON.parse(JSON.stringify(this.subjects || []))
const subjectIdMap = {}
const optionIdMap = {}
const remapValue = (value, map) => {
if (Array.isArray(value)) {
return value.map((item) => map[item] || item)
}
return map[value] || value
}
return true
const remapVisibleConditions = (conditions) => {
if (!Array.isArray(conditions)) {
return conditions
}
return conditions.map((condition) => {
if (condition.subjectId) {
condition.subjectId = subjectIdMap[condition.subjectId] || condition.subjectId
}
if (condition.field) {
condition.field = subjectIdMap[condition.field] || condition.field
}
if (condition.optionId) {
condition.optionId = optionIdMap[condition.optionId] || condition.optionId
}
if (Object.prototype.hasOwnProperty.call(condition, "value")) {
condition.value = remapValue(condition.value, optionIdMap)
}
if (Array.isArray(condition.children)) {
condition.children = remapVisibleConditions(condition.children)
}
return condition
})
}
subjects.forEach((subject) => {
const oldSubjectId = subject.id
const newSubjectId = this.generateId()
subject._bankNewSubjectId = newSubjectId
if (oldSubjectId) {
subjectIdMap[oldSubjectId] = newSubjectId
}
if (Array.isArray(subject.options)) {
subject.options.forEach((option) => {
const oldOptionId = option.id
const newOptionId = this.generateId()
option._bankNewOptionId = newOptionId
if (oldOptionId) {
optionIdMap[oldOptionId] = newOptionId
}
})
}
})
return subjects.map((subject, index) => {
const newSubjectId = this.generateId()
subject.id = subject._bankNewSubjectId || newSubjectId
delete subject._bankNewSubjectId
subject.activityId = null
subject.sortNum = index + 1
if (Array.isArray(subject.options)) {
subject.options = subject.options.map((option, optionIndex) => {
option.id = option._bankNewOptionId || this.generateId()
delete option._bankNewOptionId
option.subjectId = subject.id
option.sortNum = optionIndex + 1
return option
})
}
if (Array.isArray(subject.correctAnswer)) {
subject.correctAnswer = remapValue(subject.correctAnswer, optionIdMap)
}
if (Array.isArray(subject.visibleRuleConditions)) {
subject.visibleRuleConditions = remapVisibleConditions(subject.visibleRuleConditions)
}
return subject
})
},
// 保存当前全部题目到题库
saveToBank() {
this.$refs.saveBankFormRef.validate((valid) => {
if (!valid) {
return
}
const qsvBank = {
title: this.saveBankDialog.formData.title,
category: this.activity.category,
subjects: this.buildBankSubjects()
}
this.saveBankDialog.loading = true
$.post("/platform/qsv/bank/save", { qsvBank: JSON.stringify(qsvBank) }).then((res) => {
this.saveBankDialog.loading = false
if (res.code === 0) {
this.$message.success("已存到题库")
this.saveBankDialog.visible = false
}
}).fail(() => {
this.saveBankDialog.loading = false
})
})
},
//保存
saveQuestionnaire() {
if (!this.validateSubjectOptions()) {
return
}
$.post("/platform/qsv/activity/saveSubjects", {
this.normalizeVoteSubjectLayouts()
$
.post("/platform/qsv/activity/saveSubjects", {
activityId: this.id,
subjects: JSON.stringify(this.subjects)
})
@@ -474,6 +631,38 @@ const subjectForm = {
}
},
style: /*language=CSS*/ `
/deep/ .qsv-subject-editor {
padding-bottom: 72px;
}
/deep/ .qsv-subject-footer {
position: fixed;
left: 0;
width: 100%;
right: 0;
bottom: 0;
z-index: 900;
padding: 12px 24px;
border-top: 1px solid #ebeef5;
background: #ffffff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);
text-align: right;
box-sizing: border-box;
}
@media (max-width: 768px) {
/deep/ .qsv-subject-footer {
left: 0;
width: 100%;
}
}
/deep/ .save-bank-dialog {
top: 50%;
margin-top: 0 !important;
transform: translateY(calc(-50% + 250px));
}
/*.subject-form-dialog,.subject-form-dialog .el-dialog__body {*/
/* background: rgb(248, 249, 250);*/
/*}*/
@@ -513,6 +702,15 @@ const subjectForm = {
margin-bottom: 15px;
}
/deep/ .subject-title-row {
display: flex;
align-items: center;
}
/deep/ .subject-title-input {
flex: 1;
}
/deep/ .subject-toolbar {
display: flex;
justify-content: space-between;
File diff suppressed because it is too large Load Diff
@@ -1,40 +1,52 @@
const basicForm = {
template: /*language=HTML*/ `
<el-dialog :visible.sync="dialogVisible" title="基础设置" width="70%">
<div style="overflow-y: auto">
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="150px">
<el-form-item label="类型" prop="category">
<el-radio-group v-model="formData.category" size="small">
<el-radio v-for="item in dict.type.ACTIVITY_QSV_CATEGORY" :label="item.code"
:key="item.code"
border>
{{item.label}}
</el-radio>
</el-radio-group>
</el-form-item>
<el-drawer
:title="formData.id ? '编辑题库' : '新增题库'"
:visible.sync="dialogVisible"
direction="rtl"
size="1024px"
custom-class="qsv-bank-basic-drawer"
append-to-body
:wrapper-closable="false"
:close-on-press-escape="false">
<div class="qsv-bank-basic-drawer-page" v-loading="saveLoading">
<el-card shadow="never" class="qsv-bank-basic-drawer-card">
<h3 class="qsv-bank-sub-title">基础信息</h3>
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="96px">
<el-form-item label="类型" prop="category">
<el-radio-group v-model="formData.category" size="small">
<el-radio v-for="item in dict.type.ACTIVITY_QSV_CATEGORY" :label="item.code"
:key="item.code"
border>
{{item.label || item.name}}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" maxlength="50"
show-word-limit></el-input>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" maxlength="50"
show-word-limit></el-input>
</el-form-item>
<el-form-item label="说明" prop="description">
<text-editor v-model="formData.description" :height="150"></text-editor>
</el-form-item>
</el-form>
<el-form-item label="说明" prop="description">
<text-editor v-model="formData.description" :height="180"></text-editor>
</el-form-item>
</el-form>
</el-card>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false"> </el-button>
<el-button type="primary" @click="onSubmit"> </el-button>
<div class="qsv-bank-basic-drawer-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saveLoading" @click="onSubmit">保存</el-button>
</div>
</el-dialog>
</el-drawer>
`,
dicts: ["ACTIVITY_QSV_CATEGORY", "ACTIVITY_QSV_MODE", "ACTIVITY_QSV_REPEAT_MODE", "ACTIVITY_QSV_SCORE_MODE"],
data() {
return {
dialogVisible: false,
saveLoading: false,
formData: {
category: ''
category: 'QUIZ'
},
formRules: {
category: [{required: true, message: "请选择类型", trigger: "change"}],
@@ -45,27 +57,42 @@ const basicForm = {
methods: {
onOpen(id) {
this.dialogVisible = true
this.saveLoading = false
if (id) {
$.post("/platform/qsv/bank/findOne", {id}).then((res) => {
if (res.code === 0) {
this.formData = res.data
this.formData = res.data || {category: 'QUIZ'}
}
})
} else {
this.formData = {}
this.formData = {
category: 'QUIZ'
}
this.$nextTick(() => {
if (this.$refs.formRef) {
this.$refs.formRef.clearValidate()
}
})
}
},
onSubmit() {
this.$refs.formRef.validate((valid) => {
if (valid) {
$.post("/platform/qsv/bank/" + (this.formData.id ? "update" : "save"), {qsvBank: JSON.stringify(this.formData)}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.dialogVisible = false
this.$emit("refresh", null)
}
})
if (!valid) {
return
}
this.saveLoading = true
$.post("/platform/qsv/bank/" + (this.formData.id ? "update" : "save"), {
qsvBank: JSON.stringify(this.formData)
}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg || "保存成功")
this.dialogVisible = false
this.$emit("refresh", null)
}
this.saveLoading = false
}).fail(() => {
this.saveLoading = false
})
})
}
}
@@ -2,30 +2,152 @@
layout("/layouts/platform.html"){
#-->
<style>
.el-drawer.qsv-bank-drawer {
top: 64px !important;
right: 0 !important;
bottom: auto !important;
width: calc(100% - 250px) !important;
height: calc(100vh - 64px) !important;
display: flex;
flex-direction: column;
}
.qsv-bank-drawer .el-drawer__header {
margin-bottom: 0;
padding: 18px 24px;
border-bottom: 1px solid #ebeef5;
color: #303133;
font-weight: 600;
}
.qsv-bank-drawer .el-drawer__body {
flex: 1;
min-height: 0;
padding: 0;
background: #f0f2f5;
overflow: hidden;
}
.qsv-bank-drawer-page {
height: 100%;
padding: 16px 24px 72px;
box-sizing: border-box;
overflow-y: auto;
}
.qsv-bank-drawer-card {
min-height: 100%;
}
.qsv-bank-sub-title {
margin: 0 0 18px;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.qsv-bank-drawer-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
padding: 12px 24px;
border-top: 1px solid #ebeef5;
background: #ffffff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);
text-align: right;
box-sizing: border-box;
}
.el-drawer.qsv-bank-basic-drawer {
top: 64px !important;
right: 0 !important;
bottom: auto !important;
width: 1024px !important;
height: calc(100vh - 64px) !important;
display: flex;
flex-direction: column;
}
.qsv-bank-basic-drawer .el-drawer__header {
margin-bottom: 0;
padding: 18px 24px;
border-bottom: 1px solid #ebeef5;
color: #303133;
font-weight: 600;
}
.qsv-bank-basic-drawer .el-drawer__body {
flex: 1;
min-height: 0;
padding: 0;
background: #f0f2f5;
overflow: hidden;
}
.qsv-bank-basic-drawer-page {
height: 100%;
padding: 16px 16px 72px;
box-sizing: border-box;
overflow-y: auto;
}
.qsv-bank-basic-drawer-card {
min-height: 100%;
}
.qsv-bank-basic-drawer-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
padding: 12px 24px;
border-top: 1px solid #ebeef5;
background: #ffffff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);
text-align: right;
box-sizing: border-box;
}
@media (max-width: 768px) {
.el-drawer.qsv-bank-drawer,
.el-drawer.qsv-bank-basic-drawer {
top: 0 !important;
width: 100% !important;
height: 100vh !important;
}
}
</style>
<div id="app">
<guava ref="guava">
<el-card shadow="never">
<search @search="doSearch">
<search-item label="标题">
<el-input placeholder="请输入标题" v-model="pageForm.title" clearable></el-input>
<search-item label="标题名称">
<el-input placeholder="请输入标题名称" v-model="pageForm.title" clearable></el-input>
</search-item>
</search>
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="管理列表">
<table-tool label="题库列表">
<el-button @click="$refs.basicFormRef.onOpen()" size="small" type="primary" class="mr5">新增</el-button>
</table-tool>
<el-table :data="tableData" ref="tableRef" @sort-change="pageOrder">
<el-table-column label="序号" width="50" type="index" :index="indexMethod"></el-table-column>
<el-table-column label="标题" prop="title" sortable></el-table-column>
<el-table-column label="标题名称" prop="title" sortable></el-table-column>
<el-table-column label="类型" prop="category" sortable width="200">
<template scope="{row}">
<dict-tag :options="dict.type.ACTIVITY_QSV_CATEGORY" :value="row.category"></dict-tag>
<el-tag size="mini" :type="getCategoryTagType(row.category)">
{{getCategoryText(row.category)}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="450px">
<el-table-column label="操作" width="450px">
<template scope="{row}">
<el-button size="mini" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="mini" type="primary" @click="openSubject(row.id)">题目设置</el-button>
@@ -61,6 +183,21 @@ layout("/layouts/platform.html"){
}
},
methods: {
getCategoryText(category) {
const options = this.dict && this.dict.type ? (this.dict.type.ACTIVITY_QSV_CATEGORY || []) : []
const item = options.find(item => item.code === category)
return item ? item.name : category
},
getCategoryTagType(category) {
const map = {
SURVEY: "success",
QUIZ: "primary",
VOTE: "danger"
}
return map[category] || "info"
},
// 编辑
openEdit(row) {
this.$refs.basicFormRef.onOpen(row.id)
@@ -80,7 +217,7 @@ layout("/layouts/platform.html"){
cancelButtonText: "取消",
type: "warning"
}).then(() => {
$.post("/platform/qsv/activity/delete", {id}).then((res) => {
$.post("/platform/qsv/bank/delete", {id}).then((res) => {
if (res.code === 0) {
this.$message.success("删除成功")
this.pageData()
@@ -4,7 +4,7 @@
const subjectForm = {
template: /*language=HTML*/ `
<div class="subject-form-dialog">
<div class="subject-form-dialog qsv-subject-editor">
<el-form ref="form" :model="formData" :rules="formRules" label-width="120px">
<div style="min-height:50vh;overflow-y: auto">
<draggable v-model="subjects" handle=".drag-handler">
@@ -192,10 +192,10 @@ const subjectForm = {
</div>
</el-form>
<div style="border-top: 1px solid var(--border-color-lighter);padding: 12px;text-align: right;">
<div class="qsv-subject-footer">
<!-- <el-button @click="visible = false">取消</el-button>-->
<el-button type="primary" icon="el-icon-upload" @click="openTxtImport">
从文本导入
导入题目
</el-button>
<el-button type="primary" icon="el-icon-plus" @click="addSubject">
添加题目
@@ -349,7 +349,9 @@ const subjectForm = {
//从文本导入
openTxtImport() {
this.$refs.txtImportRef.visible = true;
this.$refs.txtImportRef.onOpen(this.activity.category, {
excludeBankId: this.id
});
},
//处理导入的题目
@@ -387,6 +389,32 @@ const subjectForm = {
}
},
style: /*language=CSS*/ `
/deep/ .qsv-subject-editor {
padding-bottom: 72px;
}
/deep/ .qsv-subject-footer {
position: fixed;
left: 0;
width: 100%;
right: 0;
bottom: 0;
z-index: 900;
padding: 12px 24px;
border-top: 1px solid #ebeef5;
background: #ffffff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);
text-align: right;
box-sizing: border-box;
}
@media (max-width: 768px) {
/deep/ .qsv-subject-footer {
left: 0;
width: 100%;
}
}
/*.subject-form-dialog,.subject-form-dialog .el-dialog__body {*/
/* background: rgb(248, 249, 250);*/
/*}*/
File diff suppressed because it is too large Load Diff
@@ -54,7 +54,7 @@ layout("/layouts/platform.html"){
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="管理列表">
<table-tool label="统计列表">
<el-button @click="exportXlsx" icon="el-icon-download" size="small" type="primary" class="mr5">
导出xlsx
</el-button>
@@ -5,7 +5,7 @@ const answer = {
<el-row type="flex" justify="end" class="mb10">
<el-button type="primary" size="small" icon="el-icon-download" @click="exportUserAnswerXlsx">导出xlsx</el-button>
</el-row>
<el-table :data="tableData">
<el-table :data="tableData" v-loading="tableLoading">
<el-table-column label="序号" type="index" width="70"></el-table-column>
<el-table-column v-for="item in tableColumns" :key="item.prop" :label="item.label" :prop="item.prop"></el-table-column>
<el-table-column label="操作" fixed="right" width="100px">
@@ -30,17 +30,24 @@ const answer = {
onOpen(activityId) {
this.activityId = activityId
this.dialogVisible = true
this.tableColumns = []
this.tableData = []
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.pageData()
},
pageData() {
this.pageForm.activityId = this.activityId
this.tableLoading = true
$.post("/platform/qsv/survey/userAnswer", this.pageForm).then((res) => {
if (res.code === 0) {
this.tableColumns = res.data.tableColumns
this.tableData = res.data.tableData.list
this.pageForm.totalCount = res.data.tableData.totalCount
}
}).always(() => {
this.tableLoading = false
})
},
@@ -53,7 +60,7 @@ const answer = {
$.post("/platform/qsv/survey/deleteUserAnswer", { id }).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.pageData()()
this.pageData()
}
})
})
@@ -2,6 +2,42 @@
layout("/layouts/platform.html"){
#-->
<style>
.survey-report-dialog {
margin-top: 40px !important;
height: calc(100vh - 80px);
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.survey-report-dialog .el-dialog__header {
flex: 0 0 auto;
}
.survey-report-dialog .el-dialog__body {
flex: 1;
min-height: 0;
overflow: hidden;
padding-top: 10px;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.survey-report-toolbar {
flex: 0 0 auto;
}
.survey-report-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 8px;
box-sizing: border-box;
}
</style>
<div id="app" v-cloak>
<guava>
<el-card shadow="never">
@@ -24,17 +60,19 @@ layout("/layouts/platform.html"){
</table-tool>
<el-table :data="tableData" ref="tableRef" @sort-change="pageOrder">
<el-table-column label="序号" width="50" type="index" :index="indexMethod"></el-table-column>
<el-table-column label="标题" prop="title" width="320" sortable></el-table-column>
<el-table-column label="标题名称" prop="title" width="320" sortable></el-table-column>
<el-table-column label="类型" prop="category" sortable>
<template scope="{row}">
<dict-tag :options="dict.type.ACTIVITY_QSV_CATEGORY" :value="row.category"></dict-tag>
<el-tag size="mini" :type="getCategoryTagType(row.category)">
{{getCategoryText(row.category)}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="开始时间" prop="startTime" sortable></el-table-column>
<el-table-column label="结束时间" prop="endTime" sortable></el-table-column>
<el-table-column label="操作" fixed="right" width="200px">
<template scope="{row}">
<el-button size="mini" type="primary" @click="$refs.reportRef.onOpen(row.id)">查看分析
<el-button size="mini" type="primary" @click="openReportPage(row.id)">查看分析
</el-button>
<el-button size="mini" type="primary" @click="$refs.answerRef.onOpen(row.id)">查看答卷
</el-button>
@@ -44,22 +82,38 @@ layout("/layouts/platform.html"){
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
<report ref="reportRef"></report>
<answer ref="answerRef"></answer>
</div>
<script nonce="${cspNonce!}">
<!--#include('report.js'){}#-->
<!--#include('answer.js'){}#-->
new Vue({
el: "#app",
dicts: ["ACTIVITY_QSV_CATEGORY"],
components: {report, answer},
components: {answer},
mixins: [initTableMixins],
data() {
return {}
},
methods: {
getCategoryText(category) {
const options = this.dict && this.dict.type ? (this.dict.type.ACTIVITY_QSV_CATEGORY || []) : []
const item = options.find(item => item.code === category)
return item ? item.name : category
},
getCategoryTagType(category) {
const map = {
SURVEY: "success",
QUIZ: "primary",
VOTE: "danger"
}
return map[category] || "info"
},
openReportPage(activityId) {
window.location.href = "/platform/qsv/survey/reportPage?activityId=" + encodeURIComponent(activityId)
},
exportXlsx() {
this.$downLoad("/platform/qsv/survey/exportXlsx", this.pageForm)
}
@@ -1,41 +1,44 @@
const report = {
/*language=HTML*/
template: `
<el-dialog title="分析报告" :visible.sync="dialogVisible" width="50%">
<el-row type="flex" justify="end" class="mb10">
<el-button type="primary" size="small" icon="el-icon-download" @click="exportReportXlsx">导出xlsx</el-button>
</el-row>
<div v-for="(subject,subjectIndex) in subjects" class="subject">
<div>
<div class="title">
{{subjectIndex+1}} {{subject.title}}
<span class="type" v-if="subject.type==='text'">[填空题]</span>
<span class="type" v-else-if="subject.type==='radio'">[单选题]</span>
<span class="type" v-else-if="subject.type==='text'">[多选题]</span>
</div>
<el-dialog title="分析报告" :visible.sync="dialogVisible" width="50%" custom-class="survey-report-dialog">
<div class="survey-report-toolbar">
<el-row type="flex" justify="end" class="mb10">
<el-button type="primary" size="small" icon="el-icon-download" @click="exportReportXlsx">导出xlsx</el-button>
</el-row>
</div>
<div class="survey-report-body">
<div v-for="(subject,subjectIndex) in subjects" class="subject">
<div>
<div class="title">
{{subjectIndex+1}} {{subject.title}}
<span class="type" v-if="subject.type==='text'">[填空题]</span>
<span class="type" v-else-if="subject.type==='radio'">[单选题]</span>
<span class="type" v-else-if="subject.type==='checkbox'">[多选题]</span>
</div>
<div v-if="subject.type==='text'">
{{subject.texts.join('、')}}
</div>
<div v-if="subject.type==='text'">
<el-button type="text" @click="openTextDetail(subject)">查看填写内容</el-button>
</div>
<div v-else-if="subject.type==='radio' || subject.type==='checkbox'">
<el-table :data="subject.options" size="small">
<el-table-column label="选项" prop="text"></el-table-column>
<el-table-column label="小计" prop="selectCount" width="100"></el-table-column>
<el-table-column label="比例" width="500">
<template slot-scope="scope">
<el-progress
:percentage="Math.round(scope.row.selectCount / subject.selectTotal * 100)"></el-progress>
</template>
</el-table-column>
<el-table-column label="详情" width="100px">
<template slot-scope="{row}">
<el-button type="text" @click="openDetail(subject.id,row.id)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div v-else-if="subject.type==='radio' || subject.type==='checkbox'">
<el-table :data="subject.options" size="small">
<el-table-column label="选项" prop="text"></el-table-column>
<el-table-column label="小计" prop="selectCount" width="100"></el-table-column>
<el-table-column label="比例" width="500">
<template slot-scope="scope">
<el-progress
:percentage="Math.round(scope.row.selectCount / subject.selectTotal * 100)"></el-progress>
</template>
</el-table-column>
<el-table-column label="详情" width="100px">
<template slot-scope="{row}">
<el-button type="text" @click="openDetail(subject.id,row.id)">详情</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</div>
@@ -45,9 +48,22 @@ const report = {
<el-table-column label="工号" prop="loginName"></el-table-column>
<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>
</el-dialog>
<el-dialog :title="textSubject.title || '填写内容'" :visible.sync="textVisible" width="50%" append-to-body>
<el-table :data="textAnswers" size="small">
<el-table-column label="序号" type="index" width="70"></el-table-column>
<el-table-column label="姓名" prop="userName" width="100"></el-table-column>
<el-table-column label="工号" prop="loginName" width="120"></el-table-column>
<el-table-column label="所属工会" prop="unionName" width="140"></el-table-column>
<el-table-column label="所属单位" prop="unitName" width="180" show-overflow-tooltip></el-table-column>
<el-table-column label="填写内容" prop="text" show-overflow-tooltip></el-table-column>
<el-table-column label="提交时间" prop="submitTime" width="160"></el-table-column>
</el-table>
</el-dialog>
</el-dialog>
`,
@@ -59,7 +75,10 @@ const report = {
report: null,
subjects: [],
optionVisible: false,
optionUsers: []
optionUsers: [],
textVisible: false,
textSubject: {},
textAnswers: []
}
},
methods: {
@@ -73,8 +92,6 @@ const report = {
})
},
openDetail(subjectId, optionId) {
console.log(subjectId)
console.log(optionId)
$.post("/platform/qsv/survey/selectOptionUsers", {
activityId: this.activityId,
subjectId,
@@ -87,6 +104,13 @@ const report = {
}
})
},
openTextDetail(subject) {
this.textSubject = subject
this.textAnswers = subject.textAnswers || (subject.texts || []).map((text) => {
return { text }
})
this.textVisible = true
},
exportReportXlsx(){
this.$downLoad("/platform/qsv/survey/exportReportXlsx", { activityId: this.activityId })
}
File diff suppressed because it is too large Load Diff
@@ -20,7 +20,7 @@
<!-- <el-input placeholder="请输入内容" clearable v-model="pageForm.searchKeyword"></el-input>-->
</search-item>
<search-item label="所属工会">
<el-select @change="flushUnits" @clear="flushUnits" clearable filterable
<el-select @change="handleUnionChange" @clear="handleUnionClear" clearable filterable
placeholder="请选择所属工会" style="width: 100%;" v-model="pageForm.unionId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in unions"></el-option>
@@ -94,6 +94,17 @@
pageForm.preparedBys = JSON.stringify(this.pageForm.preparedBys)
this.$emit('search', pageForm)
},
handleUnionChange() {
this.flushUnits()
this.$emit("union-change", this.pageForm.unionId || "")
},
handleUnionClear() {
this.handleUnionChange()
},
setUnionFromTree(unionId) {
this.$set(this.pageForm, "unionId", unionId || "")
this.flushUnits()
},
async initData(){
if (this.$auth.hasRoleOr("SYSADMIN, SCHOOL_UNION_ADMIN, SCHOOL_UNION_MEMBER_ADMIN")) {
this.$businessTool.listUnion(this.pageForm.unionId).then((data) => {
@@ -4,88 +4,187 @@ layout("/layouts/platform.html"){
<div id="app" v-cloak>
<guava ref="guava">
<template>
<el-card shadow="never">
<common-query ref="commonQueryRef" @search="search"></common-query>
</el-card>
<el-row type="flex" :gutter="20" style="height: calc(100vh - 126px)">
<el-col :span="5">
<el-card shadow="never" style="height: 100%" :body-style="{ height: '100%' }">
<el-input placeholder="请输入关键字进行查找" v-model="filterText" clearable></el-input>
<div style="max-height: calc(100vh - 205px); overflow-y: auto; margin-top: 10px;">
<el-tree
:data="treeData"
ref="treeRef"
node-key="id"
:expand-on-click-node="false"
:props="{
children: 'children',
label: 'name'
}"
default-expand-all
:filter-node-method="filterNode"
highlight-current
@node-click="treeNodeClick"
>
<template slot-scope="{ node, data }">
<span class="el-tree-node__label">
<i class="el-icon-folder-opened" v-if="node.level===1"></i>
<i class="el-icon-folder" v-else></i>
{{node.label}}
</span>
</template>
</el-tree>
</div>
</el-card>
</el-col>
<el-col :span="19">
<el-card shadow="never" v-show="tabActive === 'memberList'">
<common-query ref="commonQueryRef" @search="search" @union-change="queryUnionChange"></common-query>
</el-card>
<el-card shadow="never" v-show="tabActive !== 'memberList' && currentUnionId">
<div style="display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 600; color: #303133;">
<i class="el-icon-office-building"></i>
<span>{{ unionOverview.unionName || '-' }}</span>
<el-tag size="mini" type="primary">基层工会</el-tag>
</div>
<div style="margin-top: 10px; color: #606266; font-size: 13px;">
<span>负责人:{{ unionOverview.chairman || '-' }}</span>
<span style="margin: 0 12px; color: #dcdfe6;">|</span>
<span>联系电话:{{ unionOverview.telephone || unionOverview.chairmanMobile || '-' }}</span>
<span style="margin: 0 12px; color: #dcdfe6;">|</span>
<span>当前会员总数:{{ unionOverview.memberTotal || 0 }} 人</span>
</div>
<el-row :gutter="12" style="margin-top: 18px;">
<el-col :span="6" v-for="card in unionOverviewCards" :key="card.label">
<div style="background: #f5f9ff; border: 1px solid #e6f0ff; border-radius: 6px; padding: 18px 20px;">
<div style="font-size: 28px; line-height: 1; font-weight: 700; color: #0b72c9;">{{ card.value }}</div>
<div style="margin-top: 10px; color: #606266; font-size: 13px;">{{ card.label }}</div>
</div>
</el-col>
</el-row>
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="会员列表">
<el-button type="primary" size="small" icon="el-icon-upload2" @click="openImport">导入会员</el-button>
<el-button
type="primary"
v-if="$auth.hasRoleOr(['SYSADMIN','SCHOOL_UNION_ADMIN'])"
size="small"
icon="el-icon-time"
@click="openArchive"
>
备份历史会员
</el-button>
<el-button type="primary" size="small" icon="el-icon-download" @click="exportMember">会员档案导出</el-button>
<el-popover
placement="bottom"
trigger="click"
width="200">
<el-card shadow="never" class="mt10">
<el-tabs v-model="tabActive" type="card" @tab-click="tabChange">
<el-tab-pane name="memberList">
<span slot="label">
<i class="el-icon-user"></i>
会员列表
</span>
<table-tool label="会员列表">
<el-button type="primary" size="small" icon="el-icon-upload2" @click="openImport">导入会员</el-button>
<el-button
type="primary"
v-if="$auth.hasRoleOr(['SYSADMIN','SCHOOL_UNION_ADMIN'])"
size="small"
icon="el-icon-time"
@click="openArchive"
>
备份历史会员
</el-button>
<el-button type="primary" size="small" icon="el-icon-download" @click="exportMember">会员档案导出</el-button>
<el-popover
placement="bottom"
trigger="click"
width="200">
<div style="padding:4px 0;border-bottom:1px solid #eee;">
<el-button size="mini" @click="checkAll">全选</el-button>
<!-- <el-button size="mini" @click="invertCheck">反选</el-button>-->
</div>
<div style="padding:4px 0;border-bottom:1px solid #eee;">
<el-button size="mini" @click="checkAll">全选</el-button>
<!-- <el-button size="mini" @click="invertCheck">反选</el-button>-->
</div>
<div style="max-height:40vh;overflow-y:auto;padding:6px 0;">
<el-checkbox-group v-model="checkedFields">
<el-checkbox
v-for="f in tableColumns"
:key="f.prop"
:label="f.prop"
style="display:block;margin:6px 0;">
{{ f.label }}
</el-checkbox>
</el-checkbox-group>
</div>
<div style="max-height:40vh;overflow-y:auto;padding:6px 0;">
<el-checkbox-group v-model="checkedFields">
<el-checkbox
v-for="f in tableColumns"
:key="f.prop"
:label="f.prop"
style="display:block;margin:6px 0;">
{{ f.label }}
</el-checkbox>
</el-checkbox-group>
</div>
<el-button
slot="reference"
type="text"
icon="el-icon-s-operation"
size="small"
style="margin-left:12px;">
列设置
</el-button>
</el-popover>
</table-tool>
<el-button
slot="reference"
type="text"
icon="el-icon-s-operation"
size="small"
style="margin-left:12px;">
列设置
</el-button>
</el-popover>
</table-tool>
<el-table :data="tableData" style="width: 100%" row-key="id" @sort-change="pageOrder" :size="tableSize" class="vi-table">
<el-table-column
align="center"
header-align="center"
type="index"
fixed="left"
:index="indexMethod"
label="序号"
width="80px"
></el-table-column>
<el-table-column
show-overflow-tooltip
align="center"
header-align="center"
v-for="column in tableColumns"
v-if="checkedFields.includes(column.prop)"
:label="column.label"
:fixed="column.fixed"
:prop="column.prop"
:sortable="column.sortable"
:width="column.width"
min-width="50"
></el-table-column>
<el-table :data="tableData" style="width: 100%" row-key="id" @sort-change="pageOrder" :size="tableSize" class="vi-table">
<el-table-column
align="center"
header-align="center"
type="index"
fixed="left"
:index="indexMethod"
label="序号"
width="80px"
></el-table-column>
<el-table-column
show-overflow-tooltip
align="center"
header-align="center"
v-for="column in tableColumns"
v-if="checkedFields.includes(column.prop)"
:label="column.label"
:fixed="column.fixed"
:prop="column.prop"
:sortable="column.sortable"
:width="column.width"
min-width="50"
></el-table-column>
<el-table-column prop="userOnline" align="center" header-align="center" label="操作" fixed="right" width="100px">
<template scope="scope">
<el-button size="mini" type="primary" @click="openView(scope.row.id)">查看</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
<el-table-column prop="userOnline" align="center" header-align="center" label="操作" fixed="right" width="100px">
<template scope="scope">
<el-button size="mini" type="primary" @click="openView(scope.row.id)">查看</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-tab-pane>
<el-tab-pane name="branchUnionUserManage">
<span slot="label">
<i class="el-icon-school"></i>
分工会干部
</span>
<sys-union-branch-union-user-manage
v-if="currentUnionId"
ref="branchUnionUserManageRef"
:union_id="currentUnionId"
></sys-union-branch-union-user-manage>
<el-empty v-else description="请选择一个分工会"></el-empty>
</el-tab-pane>
<el-tab-pane name="branchUnionPartUnitManage">
<span slot="label">
<i class="el-icon-school"></i>
组成单位
</span>
<sys-union-branch-union-part-unit-manage
v-if="currentUnionId"
ref="branchUnionPartUnitManageRef"
:union_id="currentUnionId"
></sys-union-branch-union-part-unit-manage>
<el-empty v-else description="请选择一个分工会"></el-empty>
</el-tab-pane>
<el-tab-pane name="branchUnionGroup">
<span slot="label">
<i class="el-icon-school"></i>
工会小组
</span>
<sys-union-branch-union-group-manage
v-if="currentUnionId"
ref="branchUnionGroupRef"
:union_id="currentUnionId"
></sys-union-branch-union-group-manage>
<el-empty v-else description="请选择一个分工会"></el-empty>
</el-tab-pane>
</el-tabs>
</el-card>
</el-col>
</el-row>
</template>
<template #view>
@@ -109,12 +208,21 @@ layout("/layouts/platform.html"){
<!--#include("archiveMember.js"){}#-->
<!--#include("../common/commonQuery.js"){}#-->
<!--#include("../../common/info/memberInfo.js"){}#-->
<!--#include("/platform/sys/union/branchUnionUserManage.js"){}#-->
<!--#include("/platform/sys/union/branchUnionPartUnitManage.js"){}#-->
<!--#include("/platform/sys/union/branchUnionGroupManage.js"){}#-->
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
filterText: null,
unionRootId: "member-union-root",
treeData: [],
currentTreeData: null,
tabActive: "memberList",
unionOverview: {},
pageForm: {},
tableColumns: [
{ prop: "loginname", label: "工号", width: 120, fixed: "left"},
@@ -122,17 +230,17 @@ layout("/layouts/platform.html"){
{ prop: "sex", label: "性别", width: 120},
{ prop: "birthday", label: "出生年月", width: 120, sortable: true },
{ prop: "nationality", label: "国籍", width: 120, sortable: true, checked: 0 },
{ prop: "nation", label: "民族", width: 120, sortable: true},
// { prop: "nation", label: "民族", width: 120, sortable: true},
{ prop: "mobile", label: "联系电话", width: 120 },
{ prop: "political", label: "政治面貌", width: 120, sortable: true},
// { prop: "political", label: "政治面貌", width: 120, sortable: true},
{ prop: "userState", label: "在职状态", width: 120, sortable: true },
{ prop: "personType", label: "人员类型", width: 120, sortable: true, checked: 0 },
// { prop: "preparedBy", label: "编制类别", width: 120, sortable: true },
{ prop: "postDoctoralJoinDate", label: "进站时间", sortable: true, checked: 0 },
{ prop: "unionName", label: "所属工会", width: 120, sortable: true },
{ prop: "unitName", label: "所属单位", width: 120, sortable: true },
{ prop: "aidFundMemberUserType", label: "人员分类", width: 120, sortable: true },
{ prop: "userAttribute", label: "人员属性", width: 120, sortable: true },
// { prop: "aidFundMemberUserType", label: "人员分类", width: 120, sortable: true },
// { prop: "userAttribute", label: "人员属性", width: 120, sortable: true },
{ prop: "idCardType", label: "证件类型", width: 120, sortable: true, checked: 0 },
{ prop: "idCard", label: "证件号码", width: 120, sortable: true, checked: 0 },
{ prop: "marriage", label: "婚姻状况", width: 120, sortable: true, checked: 0 },
@@ -154,11 +262,19 @@ layout("/layouts/platform.html"){
importDialog: false
}
},
watch: {
filterText(val) {
this.$refs.treeRef.filter(val)
}
},
components: {
"file-import": httpVueLoader("/components/plugins/sysImport/index.vue?v=" + new Date().getTime()),
"archive-member": ARCHIVE_MEMBER,
"member-info": MEMBER_INFO,
"common-query": COMMON_QUERY,
"sys-union-branch-union-user-manage": branchUnionUserManage,
"sys-union-branch-union-part-unit-manage": branchUnionPartUnitManage,
"sys-union-branch-union-group-manage": branchUnionGroupManage,
},
methods: {
openImport() {
@@ -188,6 +304,137 @@ layout("/layouts/platform.html"){
this.$refs.guava.view(() => {
this.$refs.infoRef.onOpen(userId)
})
},
initDefaultView() {
this.tabActive = "memberList"
this.currentTreeData = null
this.unionOverview = {}
this.pageForm = {
...this.pageForm,
unionId: "",
unitId: null
}
},
treeNodeClick(data) {
const unionId = data && data.type === "union" ? data.id : ""
this.currentTreeData = data
if (this.$refs.commonQueryRef) {
this.$refs.commonQueryRef.setUnionFromTree(unionId)
this.$nextTick(() => {
if (this.tabActive === "memberList") {
this.$refs.commonQueryRef.doSearch()
} else {
this.loadUnionOverview()
this.refreshCurrentTab()
}
})
} else {
this.pageForm = {
...this.pageForm,
unionId,
unitId: null
}
if (this.tabActive === "memberList") {
this.pageData()
} else {
this.loadUnionOverview()
this.refreshCurrentTab()
}
}
},
queryUnionChange(unionId) {
const nodeKey = unionId || this.unionRootId
this.currentTreeData = this.findTreeNode(this.treeData, nodeKey)
this.setCurrentTreeNode(nodeKey)
this.$nextTick(() => {
if (this.tabActive !== "memberList") {
this.loadUnionOverview()
this.refreshCurrentTab()
}
})
},
filterNode(value, data) {
if (!value) return true
return data.name.indexOf(value) !== -1
},
findTreeNode(list, id) {
for (let i = 0; i < (list || []).length; i++) {
const item = list[i]
if (item.id === id) {
return item
}
const child = this.findTreeNode(item.children, id)
if (child) {
return child
}
}
return null
},
getTreeData() {
const hasAdmin = this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN", "SCHOOL_UNION_MEMBER_ADMIN"])
const unionId = hasAdmin ? null : this.$store.state.user.union.id
this.$businessTool.listUnion(unionId).then((data) => {
this.treeData = [{
id: this.unionRootId,
name: "工会委员会",
type: "root",
children: (data || []).map((item) => ({
id: item.id,
name: item.name,
type: "union"
}))
}]
this.$nextTick(() => {
this.currentTreeData = this.treeData[0]
this.setCurrentTreeNode(this.unionRootId)
if (this.tabActive !== "memberList") {
this.tabActive = "memberList"
}
})
})
},
setCurrentTreeNode(nodeKey) {
this.$nextTick(() => {
if (this.$refs.treeRef) {
this.$refs.treeRef.setCurrentKey(nodeKey)
}
})
},
tabChange(tab) {
this.$nextTick(() => {
if (tab.name === "memberList") {
if (this.$refs.commonQueryRef) {
this.$refs.commonQueryRef.doSearch()
} else {
this.pageData()
}
} else {
this.loadUnionOverview()
this.refreshCurrentTab(tab.name)
}
})
},
loadUnionOverview() {
if (!this.currentUnionId) {
this.unionOverview = {}
return
}
this.$axios.post(loc() + "/unionOverview", { unionId: this.currentUnionId }).then((data) => {
if (data.code === 0) {
this.unionOverview = data.data || {}
} else {
this.$message.error(data.msg)
}
})
},
refreshCurrentTab(tabName = this.tabActive) {
if (tabName === "memberList" || !this.currentUnionId) {
return
}
const ref = this.$refs[tabName + "Ref"]
if (ref && ref.doSearch) {
ref.doSearch()
}
},
search(pageForm) {
this.pageForm = {
@@ -227,10 +474,23 @@ layout("/layouts/platform.html"){
computed: {
showColumns() {
return this.tableColumns.filter(c => this.checkedFields.includes(c.prop));
}
},
currentUnionId() {
return this.currentTreeData && this.currentTreeData.type === "union" ? this.currentTreeData.id : ""
},
unionOverviewCards() {
return [
{ label: "会员总数", value: this.unionOverview.memberTotal || 0 },
{ label: "教职工数", value: this.unionOverview.staffTotal || 0 },
{ label: "男性", value: this.unionOverview.maleTotal || 0 },
{ label: "女性", value: this.unionOverview.femaleTotal || 0 },
]
}
},
created() {
this.checkedFields = this.tableColumns.filter(c => c.checked !== 0).map(c => c.prop);
this.initDefaultView()
this.getTreeData()
this.pageData()
}
})
@@ -2,30 +2,71 @@
layout("/layouts/platform_h5.html"){
#-->
<div id="app" v-cloak>
<van-nav-bar @click-left="historyBack" left-arrow left-text="返回" title="问卷列表" placeholder fixed></van-nav-bar>
<div id="app" v-cloak class="qsv-h5-page">
<div class="qsv-h5-header">
<div class="qsv-h5-nav">
<button class="qsv-h5-back-btn" @click="historyBack">
<i class="fa fa-angle-left"></i>
</button>
<div class="qsv-h5-nav-title">问卷服务</div>
<div class="qsv-h5-nav-placeholder"></div>
</div>
<div class="qsv-h5-search-row">
<van-search
v-model="pageForm.title"
shape="round"
background="transparent"
placeholder="搜索标题名称"
clearable
@search="onRefresh"
@clear="onRefresh">
</van-search>
<button class="qsv-h5-search-btn" @click="onRefresh">
<i class="fa fa-search"></i>
</button>
</div>
<div class="qsv-h5-filter-row">
<button
v-for="item in categoryOptions"
:key="item.value"
:class="['qsv-h5-filter-btn', pageForm.category === item.value ? 'active' : '']"
@click="changeCategory(item.value)">
<i :class="pageForm.category === item.value ? 'fa fa-check-circle' : 'fa fa-circle-o'"></i>
<span>{{item.label}}</span>
</button>
</div>
</div>
<van-sticky offset-top="46px">
<van-tabs v-model="pageForm.category" @change="doSearch">
<van-tab v-for="item in dict.type.ACTIVITY_QSV_CATEGORY" :name="item.code" :title="item.label"
:key="item.code"></van-tab>
</van-tabs>
</van-sticky>
<table-list api="/platform/h5/qsv/pageData" :page_form.sync="pageForm" ref="tableListRef" title="title"
@ready="doSearch">
<!-- <template #header="{index,row}"></template>-->
<template v-slot="{index,row}">
<table-column label="开始时间">{{row.startTime}}</table-column>
<table-column label="结束时间">{{row.endTime}}</table-column>
</template>
<template #actions="{index,row}">
<div class="action-btn" @click="onEnter(row)">
<i class="fa fa-eye"></i>
<span>查看</span>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh" class="qsv-h5-list-wrap">
<van-list v-model="loading" :finished="finished" finished-text="暂无更多内容" @load="onLoad">
<div
v-for="row in list"
:key="row.id"
class="qsv-h5-card"
@click="onEnter(row)">
<div class="qsv-h5-cover">
<img :src="row.cover || defaultCover" alt="">
</div>
<div class="qsv-h5-card-body">
<div class="qsv-h5-title-row">
<div class="qsv-h5-title">{{row.title}}</div>
<div class="qsv-h5-answered" v-if="row.answeredText || row.isAnswered">{{row.answeredText || getAnsweredText(row.category)}}</div>
</div>
<div class="qsv-h5-meta-row">
<div class="qsv-h5-type">
<i :class="getCategoryIcon(row.category)"></i>
<span>{{getCategoryText(row.category)}}</span>
</div>
</div>
<div class="qsv-h5-time">
<div>开始时间:{{formatDateTime(row.startTime)}}</div>
<div>结束时间:{{formatDateTime(row.endTime)}}</div>
</div>
</div>
</div>
</template>
</table-list>
<van-empty v-if="!loading && list.length === 0" description="暂无内容"></van-empty>
</van-list>
</van-pull-refresh>
</div>
<script nonce="${cspNonce!}">
@@ -35,12 +76,26 @@ layout("/layouts/platform_h5.html"){
store,
data() {
return {
loading: false,
requesting: false,
finished: false,
refreshing: false,
cacheRestored: false,
list: [],
defaultCover: "/assets/mobile/svg/qsv/icon.svg",
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
category: "QUIZ"
title: "",
category: ""
},
categoryOptions: [
{label: "全部", value: ""},
{label: "答题", value: "QUIZ"},
{label: "问卷", value: "SURVEY"},
{label: "投票", value: "VOTE"}
],
categoryPaths: {
QUIZ: "/platform/h5/qsv/quiz",
SURVEY: "/platform/h5/qsv/survey",
@@ -50,30 +105,114 @@ layout("/layouts/platform_h5.html"){
},
methods: {
historyBack() {
this.$pjaxReplace('/platform/h5/home')
this.$pjaxReplace('/platform/h5/home')
},
doSearch() {
this.$nextTick(() => {
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.$refs.tableListRef.doSearch()
changeCategory(category) {
if (this.pageForm.category === category) {
return
}
this.pageForm.category = category
this.onRefresh()
},
onRefresh() {
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.list = []
this.finished = false
this.clearListCache()
this.onLoad()
},
onLoad() {
if (this.requesting || this.finished) {
return
}
this.requesting = true
this.loading = true
this.$axios.post("/platform/h5/qsv/pageData", this.pageForm).then((res) => {
if (res.code === 0) {
const pageData = res.data || {}
const rows = pageData.list || []
this.list = this.list.concat(rows)
this.pageForm.totalCount = pageData.totalCount || 0
if (this.list.length >= this.pageForm.totalCount || rows.length === 0) {
this.finished = true
}
this.pageForm.pageNumber++
this.saveListCache()
}
}).finally(() => {
this.requesting = false
this.loading = false
this.refreshing = false
})
},
async onEnter(item) {
if (item.groupId) {
const {
code,
data
} = await $.get('/platform/activity/basic/scope/getScopeUser', {activityGroupId: item.groupId})
if (code === 0 && data === 0) {
this.$toast.fail("您没有权限参与")
return
}
refreshListSilently() {
if (this.requesting) {
return
}
const requestForm = Object.assign({}, this.pageForm, {
pageNumber: 1
})
this.requesting = true
this.$axios.post("/platform/h5/qsv/pageData", requestForm).then((res) => {
if (res.code === 0) {
const pageData = res.data || {}
const rows = pageData.list || []
this.list = rows
this.pageForm = Object.assign({}, this.pageForm, {
pageNumber: 2,
totalCount: pageData.totalCount || 0
})
this.finished = rows.length >= this.pageForm.totalCount || rows.length === 0
this.saveListCache()
}
}).finally(() => {
this.requesting = false
this.loading = false
this.refreshing = false
})
},
const {startTime, endTime} = item
getCategoryText(category) {
const map = {
QUIZ: "答题",
SURVEY: "问卷",
VOTE: "投票"
}
return map[category] || "活动"
},
getCategoryIcon(category) {
const map = {
QUIZ: "fa fa-check-square-o",
SURVEY: "fa fa-list-alt",
VOTE: "fa fa-bar-chart"
}
return map[category] || "fa fa-tags"
},
getAnsweredText(category) {
const map = {
QUIZ: "已答题",
SURVEY: "已填写",
VOTE: "已投票"
}
return map[category] || "已完成"
},
formatDateTime(value) {
if (!value) {
return "-"
}
return this.$moment(value).format("YYYY-MM-DD HH:mm")
},
onEnter(item) {
const {startTime} = item
if (this.$moment(startTime).unix() > this.$moment().unix()) {
this.$toast("未开始")
return
@@ -81,13 +220,315 @@ layout("/layouts/platform_h5.html"){
const path = this.categoryPaths[item.category]
if (path) {
this.saveListCache()
pjaxReplace(path + "?id=" + item.id)
}
},
getCacheKey() {
return "qsv:h5:list"
},
saveListCache() {
try {
sessionStorage.setItem(this.getCacheKey(), JSON.stringify({
time: Date.now(),
list: this.list,
pageForm: this.pageForm,
finished: this.finished
}))
} catch (e) {
}
},
restoreListCache() {
try {
const cacheText = sessionStorage.getItem(this.getCacheKey())
if (!cacheText) {
return
}
const cache = JSON.parse(cacheText)
if (!cache || Date.now() - cache.time > 5 * 60 * 1000) {
this.clearListCache()
return
}
this.list = cache.list || []
this.pageForm = Object.assign({}, this.pageForm, cache.pageForm || {})
this.finished = !!cache.finished
this.cacheRestored = this.list.length > 0
} catch (e) {
this.clearListCache()
}
},
clearListCache() {
try {
sessionStorage.removeItem(this.getCacheKey())
} catch (e) {
}
}
},
created() {
this.restoreListCache()
},
mounted() {
if (this.cacheRestored) {
setTimeout(() => {
this.refreshListSilently()
}, 200)
}
}
})
</script>
<style>
.qsv-h5-page {
min-height: 100vh;
background: #f4f8ff;
padding-bottom: 18px;
box-sizing: border-box;
}
.qsv-h5-header {
padding: 10px 14px 16px;
background: linear-gradient(180deg, #1688f8 0%, #0f83ee 100%);
box-sizing: border-box;
}
.qsv-h5-nav {
height: 38px;
display: flex;
align-items: center;
margin-bottom: 8px;
color: #ffffff;
}
.qsv-h5-back-btn,
.qsv-h5-nav-placeholder {
width: 36px;
height: 36px;
flex-shrink: 0;
}
.qsv-h5-back-btn {
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, .16);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
padding: 0;
}
.qsv-h5-nav-title {
flex: 1;
min-width: 0;
text-align: center;
font-size: 18px;
font-weight: 700;
line-height: 36px;
}
.qsv-h5-search-row {
display: flex;
align-items: center;
gap: 10px;
}
.qsv-h5-header .van-search {
flex: 1;
min-width: 0;
padding: 0;
}
.qsv-h5-header .van-search__content {
background: rgba(255, 255, 255, .92);
}
.qsv-h5-search-btn {
width: 42px;
height: 42px;
border: 1px solid rgba(255, 255, 255, .45);
border-radius: 50%;
background: rgba(255, 255, 255, .18);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 18px;
box-sizing: border-box;
}
.qsv-h5-filter-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
padding-bottom: 1px;
}
.qsv-h5-filter-row::-webkit-scrollbar {
display: none;
}
.qsv-h5-filter-btn {
height: 32px;
width: 100%;
padding: 0 6px;
border: 0;
border-radius: 17px;
background: rgba(255, 255, 255, .92);
color: #1688f8;
font-size: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
}
.qsv-h5-filter-btn.active {
background: #ffffff;
font-weight: 600;
}
.qsv-h5-filter-btn i {
font-size: 15px;
}
.qsv-h5-list-wrap {
min-height: calc(100vh - 170px);
padding: 14px;
box-sizing: border-box;
}
.qsv-h5-card {
display: flex;
gap: 14px;
padding: 14px;
margin-bottom: 14px;
background: #ffffff;
border-radius: 13px;
box-shadow: 0 8px 24px rgba(35, 102, 168, .1);
box-sizing: border-box;
}
.qsv-h5-cover {
width: 116px;
height: 87px;
aspect-ratio: 4 / 3;
border-radius: 8px;
overflow: hidden;
background: #eaf2ff;
flex-shrink: 0;
}
.qsv-h5-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.qsv-h5-card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
padding: 2px 0;
}
.qsv-h5-title-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.qsv-h5-title {
flex: 1;
min-width: 0;
color: #1f2f46;
font-size: 16px;
font-weight: 700;
line-height: 1.35;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.qsv-h5-answered {
height: 22px;
min-width: 50px;
padding: 0 8px;
border: 1px solid #19a05f;
border-radius: 11px;
background: #f0fff8;
color: #149356;
font-size: 12px;
font-weight: 700;
line-height: 20px;
text-align: center;
white-space: nowrap;
flex-shrink: 0;
box-sizing: border-box;
}
.qsv-h5-meta-row {
display: flex;
align-items: center;
min-height: 20px;
}
.qsv-h5-type {
display: inline-flex;
align-items: center;
gap: 4px;
height: 20px;
padding: 0 6px;
border: 1px solid #ff9f43;
border-radius: 4px;
background: #fff7ec;
color: #f28b18;
font-size: 12px;
font-weight: 600;
line-height: 18px;
flex-shrink: 0;
box-sizing: border-box;
}
.qsv-h5-type i {
color: #f28b18;
font-size: 12px;
}
.qsv-h5-time {
color: #71829b;
font-size: 12px;
line-height: 1.75;
}
@media (max-width: 360px) {
.qsv-h5-card {
gap: 10px;
padding: 12px;
}
.qsv-h5-cover {
width: 96px;
height: 72px;
}
.qsv-h5-title {
font-size: 16px;
}
}
</style>
<!--#
}
@@ -3,17 +3,19 @@ layout("/layouts/platform_h5.html"){
#-->
<div id="app" v-cloak>
<van-nav-bar @click-left="historyBack" left-arrow left-text="返回" title="问卷列表" placeholder fixed></van-nav-bar>
<van-nav-bar @click-left="historyBack" left-arrow left-text="返回" :title="pageTitle" placeholder fixed></van-nav-bar>
<div class="container" :style="{'padding-bottom': answerRecord.isFinish ? 0 : '84px'}">
<van-sticky offset-top="46px">
<!--开启计时 未完成 活动未结束-->
<div class="timer" v-if="activity.timeLimit > 0 && !answerRecord.isFinish && !isEnd">⏰{{ remainingTime }}s
<div class="quiz-hero" :style="heroStyle">
<div class="quiz-hero-mask">
<div class="timer" v-if="activity.timeLimit > 0 && !answerRecord.isFinish && !isEnd">
⏰ {{ remainingTime }}s
</div>
<h2 class="quiz-title">{{ activity.title }}</h2>
</div>
<h2 class="title">{{ activity.title }}</h2>
</van-sticky>
</div>
<div v-if="!answerRecord.isFinish">
<div class="quiz-card-scroll" v-if="!answerRecord.isFinish">
<div v-for="(subject, index) in subjects" :key="index" class="subject">
<p>{{index+1}}、{{ subject.title }}</p>
<div class="tag">
@@ -84,9 +86,28 @@ layout("/layouts/platform_h5.html"){
return this.activity.timeLimit * 60 - this.answerTime
}
return 0
},
pageTitle() {
return this.getCategoryPageTitle(this.activity && this.activity.category, "在线答题")
},
heroStyle() {
if (this.activity && this.activity.cover) {
return {
backgroundImage: "url(" + this.activity.cover + ")"
}
}
return {}
}
},
methods: {
getCategoryPageTitle(category, defaultTitle) {
const titleMap = {
QUIZ: "在线答题",
SURVEY: "问卷调查",
VOTE: "投票"
}
return titleMap[category] || defaultTitle
},
//获取活动、题目
listSubjects() {
this.$axios.post("/platform/h5/qsv/quiz/subjects", {activityId: this.id}).then((res) => {
@@ -245,14 +266,6 @@ layout("/layouts/platform_h5.html"){
return
}
const selectedOptionIds = subject.userSelectOptionIds || []
const hasSelected = selectedOptionIds.includes(optionId)
// 多选题达到最大可选数量后禁止继续勾选,但允许取消已选项。
if (subject.type === "checkbox" && !hasSelected && subject.maxMulti && subject.maxMulti > 0 && selectedOptionIds.length >= subject.maxMulti) {
this.$toast("本题最多选择" + subject.maxMulti + "项")
return
}
if (subject.type === "radio") {
this.$refs["subject" + subject.id][0].toggleAll(false)
}
@@ -341,20 +354,53 @@ layout("/layouts/platform_h5.html"){
style: /*language=CSS*/ `
.container {
padding: 0;
background: #f4f8ff;
height: calc(100vh - 46px);
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
/deep/ .container .title {
text-align: center;
padding: 20px;
background: #ffffff;
margin: 0 0 10px 0 !important;
color: var(--color-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
/deep/ .quiz-hero {
flex: 0 0 auto;
min-height: 170px;
background: linear-gradient(135deg, #1688f8, #0a55c5);
background-size: cover;
background-position: center;
position: relative;
overflow: hidden;
margin: 12px 12px 14px;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(35, 102, 168, .14);
}
.container .card {
padding: 10px;
background: #ffffff;
/deep/ .quiz-hero-mask {
min-height: 170px;
padding: 24px 20px 22px;
display: flex;
flex-direction: column;
justify-content: flex-end;
box-sizing: border-box;
background: linear-gradient(180deg, rgba(0, 0, 0, .16), rgba(0, 0, 0, .56));
}
/deep/ .quiz-title {
margin: 0;
color: #fff;
font-size: 22px;
font-weight: 700;
line-height: 1.35;
text-shadow: 0 2px 8px rgba(0, 0, 0, .28);
}
.quiz-card-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0 12px;
box-sizing: border-box;
}
.subject {
@@ -377,10 +423,15 @@ layout("/layouts/platform_h5.html"){
}
/deep/ .timer {
text-align: center;
font-size: 18px;
background: #fff;
padding: 10px 0;
align-self: flex-start;
margin-bottom: 12px;
padding: 5px 10px;
border-radius: 14px;
background: rgba(255, 255, 255, .22);
color: #fff;
font-size: 14px;
font-weight: 600;
backdrop-filter: blur(6px);
}
.submit-button {
@@ -6,15 +6,15 @@ layout("/layouts/platform_h5.html"){
<van-nav-bar @click-left="historyBack" left-arrow left-text="返回" title="问卷列表" placeholder fixed></van-nav-bar>
<div class="container" :style="{'padding-bottom': answerRecord.isFinish ? 0 : '84px'}">
<van-sticky offset-top="46px">
<h2 class="title">{{ activity.title }}</h2>
<div class="score-form">
<div class="score-form-total">
<div class="score-font-style">{{totalScore}}</div>
<i class="score-underline"></i>
<div class="quiz-hero" :style="heroStyle">
<div class="quiz-hero-mask">
<h2 class="quiz-title">{{ activity.title }}</h2>
<div class="quiz-score">
<span>{{totalScore}}</span>
<em></em>
</div>
</div>
</van-sticky>
</div>
<div>
<div v-for="(subject, index) in subjects" :key="index" class="subject">
@@ -111,6 +111,14 @@ layout("/layouts/platform_h5.html"){
}
}
return totalScore
},
heroStyle() {
if (this.activity && this.activity.cover) {
return {
backgroundImage: "url(" + this.activity.cover + ")"
}
}
return {}
}
},
filters: {
@@ -199,23 +207,60 @@ layout("/layouts/platform_h5.html"){
style: /*language=CSS*/ `
.container {
padding: 0;
background: #f4f8ff;
min-height: 100vh;
}
/deep/ .container .title {
text-align: center;
padding: 20px;
margin: 0 !important;
background: #ffffff;
color: var(--color-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
/deep/ .quiz-hero {
min-height: 190px;
background: linear-gradient(135deg, #1688f8, #0a55c5);
background-size: cover;
background-position: center;
position: relative;
overflow: hidden;
margin: 12px 12px 14px;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(35, 102, 168, .14);
}
.container .score {
/deep/ .quiz-hero-mask {
min-height: 190px;
padding: 28px 20px 24px;
display: flex;
flex-direction: column;
justify-content: flex-end;
box-sizing: border-box;
background: linear-gradient(180deg, rgba(0, 0, 0, .18), rgba(0, 0, 0, .58));
}
.container .card {
padding: 10px;
background: #ffffff;
/deep/ .quiz-title {
margin: 0 0 14px;
color: #fff;
font-size: 22px;
font-weight: 700;
line-height: 1.35;
text-shadow: 0 2px 8px rgba(0, 0, 0, .28);
}
/deep/ .quiz-score {
display: flex;
align-items: flex-end;
color: #fff;
text-shadow: 0 2px 10px rgba(0, 0, 0, .3);
}
/deep/ .quiz-score span {
font-size: 48px;
font-weight: 800;
line-height: 1;
}
/deep/ .quiz-score em {
margin-left: 6px;
margin-bottom: 5px;
font-style: normal;
font-size: 18px;
font-weight: 600;
}
.subject {
@@ -237,46 +282,6 @@ layout("/layouts/platform_h5.html"){
top: 0;
}
/deep/ .score-form {
background: #ffffff;
display: flex;
margin-bottom: 10px;
border-top: 1px solid #f1f1f1;
align-items: center;
}
/deep/ .score-form-total {
flex: 1;
background: #fff;
display: flex;
padding: 10px 20px 10px 0;
flex-direction: column;
align-items: flex-end;
}
/deep/ .score-text-news {
flex: 1;
background: #fff;
}
/deep/ .score-font-style {
font-size: 32px;
color: #ff6a00;
word-break: keep-all;
line-height: 38px;
min-width: 47px;
text-align: center;
}
/deep/ .score-underline {
background: url(//image.wjx.cn/images/newimg/score-form/score-underline@2x.png) no-repeat center;
background-size: 47px 16px;
display: inline-block;
height: 16px;
width: 47px;
margin-top: 7px;
}
.van-checkbox__icon--checked .van-icon {
color: #fff !important;
background-color: var(--color-primary) !important;
@@ -5,18 +5,53 @@ layout("/layouts/platform_h5.html"){
<style>
.container {
padding: 0;
background: #f4f8ff;
height: calc(100vh - 46px);
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.container .title {
text-align: center;
padding: 20px;
background: #ffffff;
margin: 0 0 10px 0;
.quiz-hero {
flex: 0 0 auto;
min-height: 170px;
background: linear-gradient(135deg, #1688f8, #0a55c5);
background-size: cover;
background-position: center;
position: relative;
overflow: hidden;
margin: 12px 12px 14px;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(35, 102, 168, .14);
}
.container .card {
padding: 10px;
background: #ffffff;
.quiz-hero-mask {
min-height: 170px;
padding: 24px 20px 22px;
display: flex;
flex-direction: column;
justify-content: flex-end;
box-sizing: border-box;
background: linear-gradient(180deg, rgba(0, 0, 0, .16), rgba(0, 0, 0, .56));
}
.quiz-title {
margin: 0;
color: #fff;
font-size: 22px;
font-weight: 700;
line-height: 1.35;
text-shadow: 0 2px 8px rgba(0, 0, 0, .28);
}
.quiz-card-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0 12px;
box-sizing: border-box;
}
.subject {
@@ -49,16 +84,30 @@ layout("/layouts/platform_h5.html"){
background: #fff;
}
.van-checkbox__icon--checked .van-icon {
.van-checkbox__icon--checked .van-icon,
.van-radio__icon--checked .van-icon {
color: #fff !important;
background-color: var(--color-primary) !important;
border-color: var(--color-primary) !important;
}
.van-checkbox__icon--disabled .van-icon {
.van-checkbox__icon--disabled .van-icon,
.van-radio__icon--disabled .van-icon {
background-color: #fff;
}
.survey-option-finished-selected .survey-option-text {
color: #00a000;
font-weight: 600;
}
.survey-option-finished-selected .van-radio__icon--disabled .van-icon,
.survey-option-finished-selected .van-checkbox__icon--disabled .van-icon {
color: #fff !important;
background-color: #d8d8d8 !important;
border-color: #d8d8d8 !important;
}
.ui-input-box {
border: 1px solid #e3e3e3;
margin: 5px 0;
@@ -80,57 +129,145 @@ layout("/layouts/platform_h5.html"){
resize: none;
width: 100%;
}
.option-fill-box {
border: 1px solid #e3e3e3;
margin: 0 16px 8px 46px;
background-color: #fff;
padding: 0;
display: flex;
}
.option-fill-box input {
background-color: #fff;
border: none !important;
padding: 10px;
font-size: 14px;
line-height: 18px;
display: inline-block;
margin: 0;
-webkit-appearance: none;
resize: none;
width: 100%;
}
.required-star {
color: #ee0a24;
margin-left: 4px;
font-weight: bold;
}
</style>
<div id="app" v-cloak>
<van-nav-bar title="问卷调查" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
<div class="container" :style="{'padding-bottom': answerRecord.isFinish ? 0 : '84px'}">
<h2 class="title">{{ activity.title }}</h2>
<div v-if="!isFinished">
<div v-for="(subject, index) in subjects" :key="index" class="subject">
<p>{{index+1}}、{{ subject.title }}</p>
<van-nav-bar :title="pageTitle" left-arrow left-text="返回" @click-left="historyBack" placeholder fixed></van-nav-bar>
<div class="container" :style="{'padding-bottom': '84px'}">
<div class="quiz-hero" :style="heroStyle">
<div class="quiz-hero-mask">
<h2 class="quiz-title">{{ activity.title }}</h2>
</div>
</div>
<div class="quiz-card-scroll" v-if="!isFinished">
<div v-for="(subject, index) in visibleSubjects" :key="subject.id" class="subject">
<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="subject.score" type="primary" size="large">{{subject.score}}分</van-tag>
</div>
<!--单选、多选-->
<van-checkbox-group
v-model="subject.userSelectOptionIds"
:ref="'subject'+subject.id"
v-if="['radio','checkbox'].includes(subject.type)"
<!--单选-->
<van-radio-group
:value="getRadioValue(subject)"
v-if="subject.type === 'radio'"
>
<van-cell-group>
<van-cell
v-for="(option,index) in subject.options"
clickable
:key="option.id"
:title="option.text"
@click="cellToggle(subject,option.id)"
>
<template #icon>
<van-checkbox :name="option.id" :ref="'option'+option.id" style="margin-right: 10px"></van-checkbox>
</template>
<img
slot="right-icon"
v-if="option.imgUrl"
:src="option.imgUrl"
alt=""
style="width: 40px; height: 40px"
@click.stop="previewOptionImg(option.imgUrl)"
/>
</van-cell>
<template v-for="(option,index) in subject.options">
<van-cell
clickable
:key="option.id"
:title="option.text"
:class="getFinishedOptionClass(subject, option)"
@click="cellToggle(subject,option.id)"
>
<template #title>
<span class="survey-option-text">{{option.text}}</span>
</template>
<template #icon>
<van-radio :name="option.id" :disabled="answerRecord.isFinish" style="margin-right: 10px"></van-radio>
</template>
<img
slot="right-icon"
v-if="option.imgUrl"
:src="option.imgUrl"
alt=""
style="width: 40px; height: 40px"
@click.stop="previewOptionImg(option.imgUrl)"
/>
</van-cell>
<div
class="option-fill-box"
v-if="isOptionFillVisible(subject, option)"
:key="option.id + '-fill'">
<input
v-model="subject.optionFillContents[option.id]"
:placeholder="option.fillPlaceholder || '请输入补充内容'">
</div>
</template>
</van-cell-group>
</van-radio-group>
<!--多选-->
<van-checkbox-group
v-model="subject.userSelectOptionIds"
v-if="subject.type === 'checkbox'"
>
<van-cell-group>
<template v-for="(option,index) in subject.options">
<van-cell
clickable
:key="option.id"
:title="option.text"
:class="getFinishedOptionClass(subject, option)"
@click="cellToggle(subject,option.id)"
>
<template #title>
<span class="survey-option-text">{{option.text}}</span>
</template>
<template #icon>
<van-checkbox :name="option.id" :ref="'option'+option.id" :disabled="answerRecord.isFinish" style="margin-right: 10px"></van-checkbox>
</template>
<img
slot="right-icon"
v-if="option.imgUrl"
:src="option.imgUrl"
alt=""
style="width: 40px; height: 40px"
@click.stop="previewOptionImg(option.imgUrl)"
/>
</van-cell>
<div
class="option-fill-box"
:key="option.id + '-fill'"
v-if="isOptionFillVisible(subject, option)">
<input
type="text"
v-model="subject.optionFillContents[option.id]"
:placeholder="option.fillPlaceholder || '请填写补充内容'"
:readonly="answerRecord.isFinish"
@click.stop />
</div>
</template>
</van-cell-group>
</van-checkbox-group>
<!--填空题-->
<div class="ui-input-box" v-if="subject.type==='text'">
<input type="text" v-model="subject.userFillContent" :readonly="answerRecord.isFinish" />
<input type="text" v-model="subject.userFillContent" :placeholder="'请输入' + subject.title" :readonly="answerRecord.isFinish" />
</div>
</div>
<div class="button-control" v-if="!answerRecord.isFinish">
<van-button type="primary" @click="onSubmit" block :disabled="isEnd">{{isEnd ? '已结束' : '提交'}}</van-button>
<div class="button-control">
<van-button v-if="!answerRecord.isFinish" type="primary" @click="onSubmit" block :disabled="isEnd">{{isEnd ? '已结束' : '提交'}}</van-button>
<van-button v-else type="primary" @click="historyBack" block>返回</van-button>
</div>
</div>
</div>
@@ -158,10 +295,34 @@ layout("/layouts/platform_h5.html"){
return this.$moment().unix() > this.$moment(this.activity.endTime).unix()
}
return true
},
visibleSubjects() {
return this.subjects.filter((subject) => {
return this.isSubjectVisible(subject)
})
},
pageTitle() {
return this.getCategoryPageTitle(this.activity && this.activity.category, "问卷调查")
},
heroStyle() {
if (this.activity && this.activity.cover) {
return {
backgroundImage: "url(" + this.activity.cover + ")"
}
}
return {}
}
},
methods: {
getCategoryPageTitle(category, defaultTitle) {
const titleMap = {
QUIZ: "在线答题",
SURVEY: "问卷调查",
VOTE: "投票"
}
return titleMap[category] || defaultTitle
},
//获取活动、题目
listSubjects() {
this.$axios.post("/platform/h5/qsv/survey/subjects", { activityId: this.id }).then((res) => {
@@ -193,14 +354,214 @@ layout("/layouts/platform_h5.html"){
const answer = this.answerRecord.extJson[subject.id]
if (["radio", "checkbox"].includes(subject.type)) {
this.$refs["subject" + subject.id][0].toggleAll(false)
answer?.optionIds.forEach((optionId) => {
this.$nextTick(() => {
this.$refs["option" + optionId][0].toggle()
})
})
const optionIds = answer?.optionIds || []
this.$set(subject, "userSelectOptionIds", subject.type === "radio" ? optionIds.slice(0, 1) : optionIds)
this.$set(subject, "optionFillContents", answer?.optionFillContents || {})
} else if (subject.type === "text") {
subject.userFillContent = answer?.text
this.$set(subject, "userFillContent", answer?.text)
}
})
if (!this.answerRecord.isFinish) {
this.clearHiddenSubjectAnswers()
}
},
//判断题目是否显示,未配置隐显逻辑的题目默认显示;已配置的题目按条件树递归计算后再执行显示或隐藏动作
isSubjectVisible(subject) {
if (!subject.visibleRuleEnabled) {
return true
}
const rootGroup = this.getVisibleRuleRootGroup(subject)
if (!rootGroup) {
return true
}
const matched = this.evaluateVisibleRuleGroup(rootGroup)
if (subject.visibleRuleAction === "hide") {
return !matched
}
return matched
},
//读取隐显逻辑根分组,兼容旧版 subjectId/optionId 平铺条件
getVisibleRuleRootGroup(subject) {
let conditions = subject.visibleRuleConditions || []
if (typeof conditions === "string") {
try {
conditions = JSON.parse(conditions)
} catch (e) {
conditions = []
}
}
if (!Array.isArray(conditions) || conditions.length === 0) {
return null
}
if (conditions.length === 1 && conditions[0].type === "group") {
return this.normalizeVisibleRuleGroup(conditions[0])
}
const children = conditions.filter((condition) => {
return condition.subjectId && condition.optionId
}).map((condition) => {
return {
type: "item",
field: condition.subjectId,
operator: "contains",
value: condition.optionId
}
})
if (children.length === 0) {
return null
}
return {
type: "group",
logic: subject.visibleRuleLogic === "OR" ? "or" : "and",
children
}
},
//规整条件分组,避免历史数据或空字段影响递归判断
normalizeVisibleRuleGroup(group) {
return {
type: "group",
logic: (group.logic || "and").toLowerCase() === "or" ? "or" : "and",
children: Array.isArray(group.children) ? group.children.map((child) => {
if (child.type === "group") {
return this.normalizeVisibleRuleGroup(child)
}
return {
type: "item",
field: child.field || child.subjectId || "",
operator: child.operator || (child.optionId ? "contains" : "=="),
value: child.value !== undefined && child.value !== null ? child.value : (child.optionId || "")
}
}) : []
}
},
//递归计算条件分组,支持并且、或者和嵌套分组
evaluateVisibleRuleGroup(group) {
const children = (group.children || []).filter((child) => {
return child.type === "group" || (child.field && child.operator)
})
if (children.length === 0) {
return true
}
const results = children.map((child) => {
if (child.type === "group") {
return this.evaluateVisibleRuleGroup(child)
}
return this.evaluateVisibleRuleItem(child)
})
return group.logic === "or"
? results.some((item) => {
return item
})
: results.every((item) => {
return item
})
},
//计算单条条件,选择题按选项ID判断,填空题支持文本包含和数字大小比较
evaluateVisibleRuleItem(condition) {
const triggerSubject = this.subjects.find((item) => {
return item.id === condition.field
})
if (!triggerSubject) {
return false
}
const answer = ["radio", "checkbox"].includes(triggerSubject.type)
? (triggerSubject.userSelectOptionIds || [])
: triggerSubject.userFillContent
return this.compareVisibleRuleValue(answer, condition.operator, condition.value)
},
//比较条件值,和后台逻辑抽屉实时预览保持同一套语义
compareVisibleRuleValue(answer, operator, value) {
if (Array.isArray(answer)) {
if (operator === "!=") {
return !answer.includes(value)
}
return answer.includes(value)
}
if ([">", "<", ">=", "<="].includes(operator)) {
const answerNumber = Number(answer)
const valueNumber = Number(value)
if (isNaN(answerNumber) || isNaN(valueNumber)) {
return false
}
if (operator === ">") {
return answerNumber > valueNumber
}
if (operator === "<") {
return answerNumber < valueNumber
}
if (operator === ">=") {
return answerNumber >= valueNumber
}
return answerNumber <= valueNumber
}
const answerText = answer === undefined || answer === null ? "" : String(answer)
const valueText = value === undefined || value === null ? "" : String(value)
if (operator === "!=") {
return answerText !== valueText
}
if (operator === "contains") {
return answerText.indexOf(valueText) !== -1
}
return answerText === valueText
},
//清空当前隐藏题目的答案,避免隐藏题残留旧选项或填空内容参与后续提交
clearHiddenSubjectAnswers() {
this.subjects.forEach((subject) => {
if (this.isSubjectVisible(subject)) {
return
}
if (["radio", "checkbox"].includes(subject.type)) {
this.$set(subject, "userSelectOptionIds", [])
this.$set(subject, "optionFillContents", {})
} else if (subject.type === "text") {
this.$set(subject, "userFillContent", null)
}
})
},
//判断选项是否已被选择,用于控制选项补充填写框显示和提交校验
isOptionSelected(subject, optionId) {
return subject.userSelectOptionIds && subject.userSelectOptionIds.includes(optionId)
},
getFinishedOptionClass(subject, option) {
return this.answerRecord.isFinish && this.isOptionSelected(subject, option.id)
? "survey-option-finished-selected"
: ""
},
//单选题仍按数组保存,这里只取第一个值用于 radio 组件展示
getRadioValue(subject) {
return subject.userSelectOptionIds && subject.userSelectOptionIds.length > 0
? subject.userSelectOptionIds[0]
: ""
},
//选中需要补充填写的选项后显示输入框,例如选择“否”后填写原因
isOptionFillVisible(subject, option) {
return option.fillRequired && this.isOptionSelected(subject, option.id)
},
//清空未选中选项的补充填写内容,避免取消选择后残留旧内容
clearUnselectedOptionFillContents(subject) {
if (!subject.optionFillContents) {
this.$set(subject, "optionFillContents", {})
}
if (!subject.options) {
return
}
subject.options.forEach((option) => {
if (!this.isOptionSelected(subject, option.id)) {
this.$delete(subject.optionFillContents, option.id)
}
})
},
@@ -212,9 +573,14 @@ layout("/layouts/platform_h5.html"){
}
if (subject.type === "radio") {
this.$refs["subject" + subject.id][0].toggleAll(false)
this.$set(subject, "userSelectOptionIds", [optionId])
} else {
this.$refs["option" + optionId][0].toggle()
}
this.$refs["option" + optionId][0].toggle()
this.$nextTick(() => {
this.clearUnselectedOptionFillContents(subject)
this.clearHiddenSubjectAnswers()
})
},
//预览图片
@@ -231,13 +597,20 @@ layout("/layouts/platform_h5.html"){
}
//提示那些题没有作答
for (let i = 0; i < this.subjects.length; i++) {
const subject = this.subjects[i]
for (let i = 0; i < this.visibleSubjects.length; i++) {
const subject = this.visibleSubjects[i]
if (["radio", "checkbox"].includes(subject.type)) {
if (!subject.userSelectOptionIds || subject.userSelectOptionIds.length === 0) {
this.$toast.fail("第" + (i + 1) + "题未做答")
return
}
for (let j = 0; j < subject.options.length; j++) {
const option = subject.options[j]
if (this.isOptionFillVisible(subject, option) && !subject.optionFillContents[option.id]) {
this.$toast.fail("请填写第" + (i + 1) + "题的补充内容")
return
}
}
} else if ("text" === subject.type) {
if (!subject.userFillContent) {
this.$toast.fail("第" + (i + 1) + "题未作答")
@@ -255,11 +628,12 @@ layout("/layouts/platform_h5.html"){
answer: JSON.stringify({
activityId: this.id,
answerRecordId: this.answerRecordId,
subjects: this.subjects.map((subject) => {
subjects: this.visibleSubjects.map((subject) => {
return {
id: subject.id,
userSelectOptionIds: ["checkbox", "radio"].includes(subject.type) ? subject.userSelectOptionIds : [],
userFillContent: subject.type === "text" ? subject.userFillContent : null
userFillContent: subject.type === "text" ? subject.userFillContent : null,
optionFillContents: ["checkbox", "radio"].includes(subject.type) ? subject.optionFillContents : {}
}
})
})
@@ -0,0 +1,872 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<div id="app" v-cloak class="vote-page">
<van-nav-bar :title="pageTitle" left-arrow left-text="返回" @click-left="historyBack" placeholder fixed></van-nav-bar>
<div class="vote-header">
<h1>{{activity.title}}</h1>
<div class="vote-stat-box">
<div class="vote-stat-item">
<strong>{{voteStats.optionCount || optionList.length}}</strong>
<span>参与选项</span>
</div>
<div class="vote-stat-item">
<strong>{{voteStats.totalParticipants || 0}}</strong>
<span>投票总人数</span>
</div>
<button class="vote-rank-btn" @click="rankShow = true">
<i class="fa fa-bar-chart"></i>
<span>排行榜</span>
<van-icon name="arrow"></van-icon>
</button>
</div>
</div>
<div class="vote-countdown" v-if="!isEnd">
距离投票结束还有:
<b>{{countdown.days}}</b>
<b>{{countdown.hours}}</b>
<b>{{countdown.minutes}}</b>
<b>{{countdown.seconds}}</b>
</div>
<div class="vote-countdown" v-else>投票已结束</div>
<div class="vote-search">
<van-search
v-model="keyword"
placeholder="请输入选项编号/名称"
clearable
shape="square"
background="transparent">
</van-search>
<button @click="noop">搜索</button>
</div>
<div class="vote-subject" v-if="currentSubject">
<div class="vote-subject-title">
<span>*</span>
{{currentSubject.title}}
<em v-if="currentSubject.type === 'checkbox' && currentSubject.maxMulti">
【请选择1-{{currentSubject.maxMulti}}项,已选择{{selectedOptionIds.length}}项】
</em>
<em v-else>【请选择{{currentSubject.type === 'radio' ? '1' : '至少1'}}项】</em>
</div>
<div :class="getVoteOptionListClass(currentSubject)">
<div
v-for="(option, index) in filteredOptions"
:key="option.id"
:class="['vote-option-card', isOptionSelected(option.id) ? 'active' : '']"
@click="toggleOption(option)">
<div class="vote-option-img" v-if="option.imgUrl">
<img :src="option.imgUrl" alt="">
</div>
<div class="vote-option-img vote-option-placeholder" v-else>
<span>{{index + 1}}</span>
</div>
<div class="vote-option-info">
<input
class="vote-option-control"
:type="currentSubject.type === 'radio' ? 'radio' : 'checkbox'"
:checked="isOptionSelected(option.id)"
:disabled="answerRecord.isFinish || isEnd"
@click.stop.prevent="toggleOption(option)">
<div class="vote-option-text">
<strong>{{getOptionVotes(option.id)}}票({{getOptionPercent(option.id)}}%)</strong>
<span>{{option.text}}</span>
</div>
<button
class="vote-option-detail-btn"
v-if="hasOptionDetail(option)"
@click.stop="openOptionDetail(option)">
详情
</button>
</div>
</div>
</div>
</div>
<van-empty v-if="!currentSubject" description="暂无投票题目"></van-empty>
<div class="vote-bottom">
<van-button
type="primary"
block
color="#ff4d00"
:disabled="answerRecord.isFinish || isEnd"
@click="onSubmit">
{{answerRecord.isFinish ? '已投票' : '投票'}}
</van-button>
</div>
<van-action-sheet v-model="rankShow" title="排行榜">
<div class="vote-rank-list">
<div v-for="(item, index) in voteStats.rankList || []" :key="item.optionId" class="vote-rank-item">
<span class="vote-rank-index">{{index + 1}}</span>
<div class="vote-rank-name">{{item.text}}</div>
<div class="vote-rank-votes">{{item.votes}}票</div>
</div>
</div>
</van-action-sheet>
<div class="vote-detail-page" v-if="detailVisible && detailOption">
<div class="vote-detail-hero">
<h2>{{activity.title}}</h2>
<h3>》 {{detailOption.text}} 《</h3>
<div class="vote-detail-stats">
<span><i class="fa fa-th-large"></i> {{getOptionVotes(detailOption.id)}}票</span>
<span><i class="fa fa-institution"></i> 第{{getOptionRank(detailOption.id)}}名</span>
</div>
</div>
<div class="vote-countdown vote-detail-countdown" v-if="!isEnd">
距离投票结束还有:
<b>{{countdown.days}}</b>
<b>{{countdown.hours}}</b>
<b>{{countdown.minutes}}</b>
<b>{{countdown.seconds}}</b>
</div>
<div class="vote-countdown vote-detail-countdown" v-else>投票已结束</div>
<div class="vote-detail-section">
<h4>投票规则</h4>
<p><i class="fa fa-play-circle"></i> 开始时间:{{activity.startTime || '-'}}</p>
<p><i class="fa fa-stop-circle"></i> 结束时间:{{activity.endTime || '-'}}</p>
<a v-if="detailOption.link" :href="detailOption.link" target="_blank">查看规则详情</a>
</div>
<div class="vote-detail-section">
<h4>详情介绍</h4>
<div class="vote-detail-content" v-if="detailOption.description" v-html="detailOption.description"></div>
<div class="vote-detail-empty" v-else>暂无详情</div>
</div>
<div class="vote-detail-bottom">
<button @click="closeOptionDetail">返回</button>
<button
class="primary"
:disabled="answerRecord.isFinish || isEnd"
@click="voteFromDetail">
{{answerRecord.isFinish ? '已投票' : '投票'}}
</button>
</div>
</div>
</div>
<script nonce="${cspNonce!}">
const vue = new Vue({
el: "#app",
data() {
return {
id: GetQueryString("id"),
activity: {},
subjects: [],
answerRecordId: null,
answerRecord: {},
voteStats: {},
keyword: "",
selectedOptionIds: [],
countdown: {
days: 0,
hours: 0,
minutes: 0,
seconds: 0
},
countdownTimer: null,
rankShow: false,
detailVisible: false,
detailOption: null
}
},
computed: {
currentSubject() {
return this.subjects.length > 0 ? this.subjects[0] : null
},
optionList() {
return this.currentSubject && this.currentSubject.options ? this.currentSubject.options : []
},
filteredOptions() {
const keyword = (this.keyword || "").trim()
if (!keyword) {
return this.optionList
}
return this.optionList.filter((option, index) => {
return String(index + 1).indexOf(keyword) > -1 || (option.text || "").indexOf(keyword) > -1
})
},
pageTitle() {
return this.getCategoryPageTitle(this.activity && this.activity.category, "投票")
},
isEnd() {
if (!this.activity || !this.activity.endTime) {
return false
}
return this.$moment().unix() > this.$moment(this.activity.endTime).unix()
}
},
methods: {
getCategoryPageTitle(category, defaultTitle) {
const titleMap = {
QUIZ: "在线答题",
SURVEY: "问卷调查",
VOTE: "投票"
}
return titleMap[category] || defaultTitle
},
noop() {
},
getVoteOptionColumns(subject) {
if (!subject || subject.optionLayout !== "HORIZONTAL") {
return 1
}
return Number(subject.optionColumns) === 3 ? 3 : 2
},
getVoteOptionListClass(subject) {
return "vote-option-list vote-option-list-" + this.getVoteOptionColumns(subject)
},
hasOptionDetail(option) {
return !!(option && ((option.description && option.description.trim()) || option.link))
},
openOptionDetail(option) {
this.detailOption = option
this.detailVisible = true
},
closeOptionDetail() {
this.detailVisible = false
this.detailOption = null
},
getOptionRank(optionId) {
const list = this.voteStats.rankList || []
const index = list.findIndex((item) => String(item.optionId) === String(optionId))
return index > -1 ? index + 1 : "-"
},
voteFromDetail() {
if (!this.detailOption || this.answerRecord.isFinish || this.isEnd) {
return
}
if (!this.isOptionSelected(this.detailOption.id)) {
this.toggleOption(this.detailOption)
}
if (this.isOptionSelected(this.detailOption.id)) {
this.onSubmit()
}
},
listSubjects() {
this.$axios.post("/platform/h5/qsv/vote/subjects", {activityId: this.id}).then((res) => {
if (res.code === 0) {
this.activity = res.data.activity || {}
this.subjects = res.data.subjects || []
this.answerRecordId = res.data.answerRecordId
this.answerRecord = res.data.answerRecord || {}
this.voteStats = res.data.voteStats || {}
this.initAnswer()
this.startCountdown()
} else {
this.$dialog.alert({
title: "提示",
message: res.msg
}).then(() => {
pjaxReplace("/platform/h5/qsv")
})
}
})
},
initAnswer() {
if (!this.currentSubject || !this.answerRecord || !this.answerRecord.extJson) {
this.selectedOptionIds = []
return
}
const answer = this.answerRecord.extJson[this.currentSubject.id]
this.selectedOptionIds = answer && answer.optionIds ? answer.optionIds.map(String) : []
},
startCountdown() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
}
this.updateCountdown()
this.countdownTimer = setInterval(() => {
this.updateCountdown()
}, 1000)
},
updateCountdown() {
if (!this.activity || !this.activity.endTime) {
return
}
const diff = Math.max(0, this.$moment(this.activity.endTime).unix() - this.$moment().unix())
this.countdown.days = Math.floor(diff / 86400)
this.countdown.hours = Math.floor((diff % 86400) / 3600)
this.countdown.minutes = Math.floor((diff % 3600) / 60)
this.countdown.seconds = diff % 60
},
toggleOption(option) {
if (this.answerRecord.isFinish || this.isEnd) {
return
}
const optionId = String(option.id)
const index = this.selectedOptionIds.indexOf(optionId)
if (this.currentSubject.type === "radio") {
this.selectedOptionIds = index > -1 ? [] : [optionId]
return
}
if (index > -1) {
this.selectedOptionIds.splice(index, 1)
return
}
if (this.currentSubject.maxMulti && this.selectedOptionIds.length >= this.currentSubject.maxMulti) {
this.$toast("最多选择" + this.currentSubject.maxMulti + "项")
return
}
this.selectedOptionIds.push(optionId)
},
isOptionSelected(optionId) {
return this.selectedOptionIds.indexOf(String(optionId)) > -1
},
getOptionStats(optionId) {
return this.voteStats.optionStats && this.voteStats.optionStats[optionId]
? this.voteStats.optionStats[optionId]
: {votes: 0, percent: 0}
},
getOptionVotes(optionId) {
return this.getOptionStats(optionId).votes || 0
},
getOptionPercent(optionId) {
const percent = this.getOptionStats(optionId).percent || 0
return Number(percent).toFixed(2)
},
previewOptionImg(img) {
vant.ImagePreview([img])
},
onSubmit() {
if (!this.currentSubject) {
return
}
if (this.isEnd) {
this.$toast("投票已结束")
return
}
if (this.selectedOptionIds.length === 0) {
this.$toast.fail("请选择投票选项")
return
}
this.$axios.post("/platform/h5/qsv/vote/submitAnswer", {
answer: JSON.stringify({
activityId: this.id,
answerRecordId: this.answerRecordId,
subjects: [
{
id: this.currentSubject.id,
userSelectOptionIds: this.selectedOptionIds
}
]
})
}).then((res) => {
if (res.code === 0) {
this.$toast.success(res.msg)
this.listSubjects()
}
})
},
historyBack() {
pjaxReplace("/platform/h5/qsv")
}
},
created() {
if (this.id) {
this.listSubjects()
}
},
beforeDestroy() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
}
}
})
</script>
<style>
.vote-page {
min-height: 100vh;
background: #fff1e9;
padding-bottom: calc(20px + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.vote-header {
padding: 36px 20px 28px;
background: radial-gradient(circle at right top, #fff 0, #fff7f2 38%, #fffdfb 100%);
}
.vote-header h1 {
margin: 0 0 34px;
color: #ff4d00;
font-size: 22px;
text-align: center;
font-weight: 700;
}
.vote-stat-box {
height: 106px;
border: 1px solid #ff6a2b;
border-radius: 8px;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
position: relative;
background: rgba(255, 255, 255, .62);
}
.vote-stat-box:before,
.vote-stat-box:after {
content: "";
position: absolute;
top: -1px;
width: 14px;
height: 14px;
background: #fff7f2;
}
.vote-stat-box:before {
left: -1px;
border-right: 1px solid #ff6a2b;
border-bottom: 1px solid #ff6a2b;
border-radius: 0 0 8px 0;
}
.vote-stat-box:after {
right: -1px;
border-left: 1px solid #ff6a2b;
border-bottom: 1px solid #ff6a2b;
border-radius: 0 0 0 8px;
}
.vote-stat-item {
text-align: center;
color: #999;
}
.vote-stat-item strong {
display: block;
margin-bottom: 8px;
color: #ff4d00;
font-size: 24px;
font-weight: 500;
}
.vote-stat-item span {
font-size: 13px;
}
.vote-rank-btn {
justify-self: center;
min-width: 82px;
height: 30px;
border: 1px solid #ff6a2b;
border-radius: 4px;
background: #fff;
color: #ff4d00;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 13px;
}
.vote-rank-btn .fa {
margin-right: 8px;
font-size: 16px;
}
.vote-countdown {
padding: 12px 16px;
color: #ff4d00;
font-size: 13px;
background: #fff7f2;
}
.vote-countdown b {
display: inline-block;
min-width: 22px;
height: 20px;
margin: 0 2px;
border-radius: 2px;
background: #ff5a1f;
color: #fff;
text-align: center;
line-height: 20px;
font-size: 14px;
}
.vote-search {
display: flex;
align-items: center;
margin: 8px 4px 0;
background: #fff;
border: 1px solid #f0f0f0;
}
.vote-search .van-search {
flex: 1;
min-width: 0;
padding: 0;
}
.vote-search button {
width: 58px;
height: 44px;
border: 0;
border-left: 1px solid #f0f0f0;
background: #fff;
color: #ff4d00;
}
.vote-subject {
padding: 14px 15px 18px;
}
.vote-subject-title {
margin-bottom: 12px;
color: #333;
font-size: 16px;
line-height: 1.55;
}
.vote-subject-title span {
color: #e61f1f;
}
.vote-subject-title em {
color: #666;
font-style: normal;
}
.vote-option-list {
display: block;
}
.vote-option-list-2,
.vote-option-list-3 {
display: grid;
gap: 12px;
}
.vote-option-list-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.vote-option-list-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.vote-option-card {
margin-bottom: 14px;
background: #fff;
border: 1px solid transparent;
box-sizing: border-box;
transition: border-color .2s, background-color .2s;
}
.vote-option-list-2 .vote-option-card,
.vote-option-list-3 .vote-option-card {
min-width: 0;
margin-bottom: 0;
}
.vote-option-card.active {
border-color: #ff4d00;
background: #fff8f4;
}
.vote-option-img {
position: relative;
width: 100%;
height: 172px;
border-radius: 6px;
overflow: hidden;
background: #f4f4f4;
}
.vote-option-list-2 .vote-option-img {
height: 126px;
}
.vote-option-list-3 .vote-option-img {
height: 92px;
}
.vote-option-img img {
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
display: block;
}
.vote-option-img .van-icon {
position: absolute;
right: 8px;
top: 8px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(255, 255, 255, .78);
color: #5a8ee8;
text-align: center;
line-height: 18px;
}
.vote-option-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: #ff7b3c;
font-size: 42px;
font-weight: 700;
background: linear-gradient(135deg, #fff7f2, #ffe4d7);
}
.vote-option-info {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
min-height: 50px;
box-sizing: border-box;
}
.vote-option-control {
flex-shrink: 0;
width: 20px;
height: 20px;
margin: 0;
accent-color: #ff4d00;
cursor: pointer;
}
.vote-option-control:disabled {
cursor: not-allowed;
}
.vote-option-text {
flex: 1;
min-width: 0;
}
.vote-option-text strong {
display: block;
margin-bottom: 5px;
color: #ff4d00;
font-size: 14px;
font-weight: 600;
}
.vote-option-text span {
display: block;
color: #333;
font-size: 15px;
line-height: 20px;
overflow-wrap: anywhere;
}
.vote-option-detail-btn {
flex-shrink: 0;
align-self: center;
margin: 0;
padding: 4px 0 4px 8px;
border: 0;
background: transparent;
color: #ff4d00;
font-size: 14px;
line-height: 22px;
}
.vote-bottom {
position: relative;
width: auto;
margin: 0 15px;
padding: 14px 19px calc(14px + env(safe-area-inset-bottom));
background: #fff1e9;
z-index: 1;
box-sizing: border-box;
}
.vote-rank-list {
padding: 12px;
background: #f8f8f8;
}
.vote-rank-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
margin-bottom: 8px;
border-radius: 8px;
background: #fff;
}
.vote-rank-index {
width: 24px;
height: 24px;
border-radius: 50%;
background: #ff5a1f;
color: #fff;
text-align: center;
line-height: 24px;
}
.vote-rank-name {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.vote-rank-votes {
color: #ff4d00;
font-weight: 600;
}
.vote-detail-page {
position: fixed;
inset: 0;
z-index: 1000;
overflow-y: auto;
padding-bottom: calc(88px + env(safe-area-inset-bottom));
background: #f5f8fb;
box-sizing: border-box;
-webkit-overflow-scrolling: touch;
}
.vote-detail-hero {
padding: 30px 18px 28px;
min-height: 150px;
color: #fff;
text-align: center;
background: linear-gradient(135deg, #ff4d00 0%, #ff6a1f 48%, #f24a00 100%);
box-sizing: border-box;
}
.vote-detail-hero h2 {
margin: 0 0 24px;
font-size: 21px;
font-weight: 700;
}
.vote-detail-hero h3 {
margin: 0 0 28px;
font-size: 20px;
font-weight: 700;
}
.vote-detail-stats {
display: flex;
justify-content: space-around;
font-size: 15px;
font-weight: 700;
}
.vote-detail-countdown {
margin-bottom: 10px;
}
.vote-detail-section {
margin: 10px 0;
padding: 16px;
background: #fff;
}
.vote-detail-section h4 {
margin: 0 0 14px;
color: #222;
font-size: 16px;
}
.vote-detail-section p {
margin: 10px 0;
color: #333;
font-size: 14px;
}
.vote-detail-section i {
margin-right: 8px;
color: #ff4d00;
}
.vote-detail-section a {
color: #ff4d00;
font-size: 14px;
}
.vote-detail-content {
color: #333;
font-size: 14px;
line-height: 1.7;
overflow-wrap: anywhere;
}
.vote-detail-content img {
max-width: 100%;
border-radius: 6px;
}
.vote-detail-empty {
color: #999;
font-size: 14px;
}
.vote-detail-bottom {
position: fixed;
left: 50%;
bottom: 0;
width: 100%;
max-width: 750px;
transform: translateX(-50%);
display: grid;
grid-template-columns: 1fr 1fr;
min-height: calc(54px + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
border-top: 1px solid #eee;
background: #fff;
z-index: 1001;
box-sizing: border-box;
}
.vote-detail-bottom button {
border: 0;
background: #fff;
color: #ff4d00;
font-size: 16px;
}
.vote-detail-bottom button.primary {
background: #ff4d00;
color: #fff;
}
.vote-detail-bottom button:disabled {
background: #f3f3f3;
color: #bbb;
}
</style>
<!--#
}
#-->