Changes
This commit is contained in:
@@ -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() {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
+562
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+301
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 '投票选项横向列数';
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
<!--#
|
||||||
|
}
|
||||||
|
#-->
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|
||||||
|
<!--#
|
||||||
|
}
|
||||||
|
#-->
|
||||||
Reference in New Issue
Block a user