This commit is contained in:
2026-07-02 15:46:51 +08:00
parent 72cdae177e
commit 2af94299dd
72 changed files with 10324 additions and 2405 deletions
@@ -31,6 +31,7 @@ RoleConstant {
SCHOOL_UNION_ARTICLE_FGH_ADMIN("校工会分工会新闻审批管理员"),
SCHOOL_UNION_ARTICLE_ADMIN("校工会新闻审批管理员"),
SCHOOL_OUTLAY_ADMIN("校工会经费管理员"),
TOUR_APPLICANT("疗休养领办人"),
BRANCH_UNION_ADMIN("分工会管理员"),
BRANCH_UNION_CHAIRMAN("分工会主席"),
@@ -59,18 +59,12 @@ public class QsvActivityController {
//保存问卷基础信息
@At
@SaCheckPermission("qsv.activity")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("保存问卷基础信息")
@SLog(type = "qsv.activity", tag = "保存问卷基础信息", msg = "保存问卷基础信息")
public Result save(QsvActivity qsvActivity) {
if (qsvActivity.getCategory().equals("QUIZ")) {
if (qsvActivity.getMode().equals("SCHEDULED")) {
qsvActivity.setRepeatMode("DAILY");
} else if (qsvActivity.getMode().equals("REGULAR")) {
qsvActivity.setRepeatMode("TOTAL");
}
}
dao.insertOrUpdate(qsvActivity);
return Result.success();
QsvActivity activity = qsvActivityService.saveActivity(qsvActivity);
return Result.success(activity);
}
@At
@@ -83,13 +77,25 @@ public class QsvActivityController {
// 删除问卷
@At
@SaCheckPermission("qsv.activity")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("删除问卷")
@SLog(type = "qsv.activity", tag = "删除问卷", msg = "删除问卷")
public Result delete(@Valid String id) {
dao.delete(QsvActivity.class, id);
qsvActivityService.deleteActivity(id);
return Result.success();
}
// 更新活动开启状态
@At
@SaCheckPermission("qsv.activity")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("更新活动开启状态")
@SLog(type = "qsv.activity", tag = "更新活动开启状态", msg = "更新活动开启状态")
public Result updateEnabled(@Valid String id, Boolean enabled) {
qsvActivityService.updateEnabled(id, enabled);
return Result.success(enabled ? "开启成功" : "关闭成功");
}
// 保存问卷题目
@At
@@ -9,8 +9,10 @@ import com.budwk.app.zhgh.dayofficework.qsv.models.QsvBank;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvBankService;
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.mvc.annotation.At;
@@ -51,6 +53,7 @@ public class QsvBankController {
@At
@SaCheckPermission("qsv.bank")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("保存题库")
public Result save(@Param("qsvBank") QsvBank qsvBank) {
dao.insert(qsvBank);
@@ -59,6 +62,7 @@ public class QsvBankController {
@At
@SaCheckPermission("qsv.bank")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("修改题库")
public Result update(@Param("qsvBank") QsvBank qsvBank) {
dao.updateIgnoreNull(qsvBank);
@@ -75,6 +79,7 @@ public class QsvBankController {
@At
@SaCheckPermission("qsv.bank")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("删除题库")
public Result delete(@Valid String id) {
dao.delete(QsvBank.class, id);
@@ -83,6 +88,7 @@ public class QsvBankController {
@At
@SaCheckPermission("qsv.bank")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("保存题库题目")
public Result saveSubjects(@Valid String id, @Param("subjects") QsvSubject[] subjects) {
QsvBank qsvBank = dao.fetch(QsvBank.class, id);
@@ -0,0 +1,27 @@
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("问卷调查新建")
/**
* 问卷新建页面控制器,负责承载 PC 端问卷创建向导入口。
*/
public class QsvNewController {
/**
* 打开问卷新建页面,页面内继续完成基础信息、题目与发布配置。
*/
@At("/new")
@Ok("beetl:/platform/zhgh/dayofficework/qsv/activity/new.html")
@SaCheckPermission("qsv.new")
public void newPage() {
}
}
@@ -0,0 +1,606 @@
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("在线答题")
/**
* PC 端在线答题控制器,负责答题/投票任务列表、题目读取、提交和投票统计。
*/
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")
/**
* 打开 PC 端在线答题页面。
*/
public void index() {
}
/**
* 分页查询当前用户可参与的问卷活动,并根据是否已答过滤列表。
*
* @param pageForm 分页参数
* @param year 活动年度
* @param title 活动标题关键字
* @param isAnswered 是否只查询已答记录
* @return 当前用户可见的在线答题分页数据
*/
@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("在线答题题目")
/**
* 查询在线答题题目,并在用户首次进入时生成答题记录。
*
* @param activityId 活动 ID
* @param viewOnly 是否仅查看已完成答题
* @return 活动、题目和答题记录信息
*/
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("在线答题记录")
/**
* 查询单条答题记录,用于恢复用户已填内容。
*
* @param answerRecordId 答题记录 ID
* @return 答题记录
*/
public Result answerRecord(@Valid String answerRecordId) {
QsvUserAnswerRecord record = dao.fetch(QsvUserAnswerRecord.class, answerRecordId);
return Result.success(record);
}
@At
@SaCheckPermission("qsv.online")
@ApiOperation("在线投票统计")
/**
* 查询投票活动的选项票数、占比和排名。
*
* @param activityId 投票活动 ID
* @return 投票统计数据
*/
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("提交在线答题")
/**
* 提交在线答题或投票结果,并写入用户答题记录。
*
* @param qsvAnswerParam 用户提交的题目答案
* @return 提交结果
*/
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()) {
// 将每道题的选项、填空内容和得分写入 extJson,保持答题记录结构统一。
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);
}
}
@@ -4,11 +4,14 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.param.QsvQuizRankPageForm;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvQuizRankService;
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.mvc.annotation.At;
@@ -64,5 +67,17 @@ public class QsvQuizRankController {
qsvQuizRankService.exportXlsx(pageForm, response);
}
@At
@SaCheckPermission("qsv.quiz.rank.delete")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("删除答题记录")
public Result deleteAnswerRecord(@Valid String id) {
QsvUserAnswerRecord record = dao.fetch(QsvUserAnswerRecord.class, id);
if (record == null) {
return Result.error("答题记录不存在");
}
dao.delete(QsvUserAnswerRecord.class, id);
return Result.success("删除成功");
}
}
@@ -1,18 +1,31 @@
package com.budwk.app.zhgh.dayofficework.qsv.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvActivityService;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvSurveyService;
import io.swagger.annotations.ApiOperation;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.nutz.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;
@@ -21,6 +34,10 @@ import org.nutz.mvc.annotation.Ok;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.Base64;
import java.util.List;
@IocBean
@@ -43,12 +60,19 @@ public class QsvSurveyController {
}
@At("/reportPage")
@Ok("beetl:/platform/zhgh/dayofficework/qsv/survey/reportPage.html")
@SaCheckPermission("qsv.survey")
public void reportPage() {
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("分页查询")
public Result pageData(@Valid PageForm pageForm, Integer year) {
Cnd cnd = Cnd.where("category", "=", "SURVEY");
Cnd cnd = Cnd.where("category", "in", new String[]{"SURVEY", "VOTE"});
cnd.andEX("YEAR(startTime)", "=", year);
cnd.desc("category");
Pagination pagination = qsvActivityService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), cnd);
@@ -63,6 +87,43 @@ public class QsvSurveyController {
return Result.success(report);
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("查询调查信息")
public Result activityInfo(@Valid String activityId) {
QsvActivity activity = dao.fetch(QsvActivity.class, activityId);
NutMap data = NutMap.NEW();
if (activity != null) {
data.setv("id", activity.getId());
data.setv("title", activity.getTitle());
}
return Result.success(data);
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("生成分类报告")
public Result categoryReport(@Valid String activityId, String conditions) {
List<NutMap> report = qsvSurveyService.categoryReport(activityId, conditions);
return Result.success(report);
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("生成交叉分析报告")
public Result crossReport(@Valid String activityId, @Valid String xSubjectIds, @Valid String ySubjectIds, String conditions) {
NutMap report = qsvSurveyService.crossReport(activityId, xSubjectIds, ySubjectIds, conditions);
return Result.success(report);
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("生成对比分析报告")
public Result compareReport(@Valid String activityId, @Valid String subjectId, @Valid String optionId) {
NutMap report = qsvSurveyService.compareReport(activityId, subjectId, optionId);
return Result.success(report);
}
@At
@Ok("void")
@@ -72,13 +133,236 @@ public class QsvSurveyController {
qsvSurveyService.exportReportXlsx(activityId, response);
}
@At
@Ok("void")
@SaCheckPermission("qsv.survey")
@ApiOperation("下载PDF报告")
public void exportReportPdf(String reportData, HttpServletResponse response) {
if (StrUtil.isBlank(reportData)) {
throw new IllegalArgumentException("报告数据不能为空");
}
JSONObject report = JSONUtil.parseObj(reportData);
String title = StrUtil.blankToDefault(report.getStr("title"), "分析报告");
String tabLabel = StrUtil.blankToDefault(report.getStr("tabLabel"), "分析报告");
try {
CommonDownloadUtil.download(safeFileName(title + "-" + tabLabel) + ".pdf", buildReportPdf(report), response);
} catch (IOException e) {
throw new RuntimeException("生成PDF报告失败", e);
}
}
private byte[] buildReportPdf(JSONObject report) throws IOException {
try (PDDocument document = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
PdfReportWriter writer = new PdfReportWriter(document);
writer.title(StrUtil.blankToDefault(report.getStr("title"), "分析报告"));
writer.text("报告类型:" + StrUtil.blankToDefault(report.getStr("tabLabel"), "分析报告"), 10);
writer.text("生成时间:" + DateUtil.now(), 10);
writer.gap(8);
JSONArray sections = report.getJSONArray("sections");
if (sections == null || sections.isEmpty()) {
writer.text("暂无可下载内容", 12);
} else {
for (int i = 0; i < sections.size(); i++) {
JSONObject section = JSONUtil.parseObj(sections.get(i));
writer.section(StrUtil.blankToDefault(section.getStr("title"), "统计项"));
String description = section.getStr("description");
if (StrUtil.isNotBlank(description)) {
writer.text(description, 10);
}
String image = section.getStr("image");
if (StrUtil.isNotBlank(image)) {
writer.image(image);
}
writer.table(section.getJSONArray("rows"));
writer.gap(8);
}
}
writer.close();
document.save(out);
return out.toByteArray();
}
}
private String safeFileName(String fileName) {
return StrUtil.blankToDefault(fileName, "分析报告").replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_");
}
private PDType0Font loadChineseFont(PDDocument document, boolean bold) throws IOException {
String[] paths = bold ? new String[]{
"C:/Windows/Fonts/simhei.ttf",
"C:/Windows/Fonts/simsunb.ttf",
"C:/Windows/Fonts/msyhbd.ttf"
} : new String[]{
"C:/Windows/Fonts/simfang.ttf",
"C:/Windows/Fonts/simsun.ttf",
"C:/Windows/Fonts/msyh.ttf"
};
for (String path : paths) {
File file = new File(path);
if (file.exists()) {
return PDType0Font.load(document, file);
}
}
throw new IOException("未找到可用的中文字体");
}
private class PdfReportWriter {
private final PDDocument document;
private final PDType0Font regularFont;
private final PDType0Font boldFont;
private final float margin = 42F;
private final float pageWidth = PDRectangle.A4.getWidth();
private final float pageHeight = PDRectangle.A4.getHeight();
private final float contentWidth = pageWidth - margin * 2;
private PDPageContentStream content;
private float y;
PdfReportWriter(PDDocument document) throws IOException {
this.document = document;
this.regularFont = loadChineseFont(document, false);
this.boldFont = loadChineseFont(document, true);
newPage();
}
void newPage() throws IOException {
if (content != null) {
content.close();
}
PDPage page = new PDPage(PDRectangle.A4);
document.addPage(page);
content = new PDPageContentStream(document, page);
y = pageHeight - margin;
}
void close() throws IOException {
if (content != null) {
content.close();
content = null;
}
}
void ensure(float height) throws IOException {
if (y - height < margin) {
newPage();
}
}
void gap(float height) throws IOException {
ensure(height);
y -= height;
}
void title(String text) throws IOException {
ensure(36);
drawText(text, margin, y, 18, boldFont);
y -= 32;
}
void section(String text) throws IOException {
ensure(30);
drawText(text, margin, y, 13, boldFont);
y -= 24;
}
void text(String text, float fontSize) throws IOException {
List<String> lines = wrap(StrUtil.blankToDefault(text, ""), fontSize, contentWidth, regularFont);
for (String line : lines) {
ensure(fontSize + 8);
drawText(line, margin, y, fontSize, regularFont);
y -= fontSize + 7;
}
}
void image(String dataUrl) throws IOException {
int commaIndex = dataUrl.indexOf(",");
if (commaIndex < 0) {
return;
}
byte[] bytes = Base64.getDecoder().decode(dataUrl.substring(commaIndex + 1));
PDImageXObject image = PDImageXObject.createFromByteArray(document, bytes, "chart");
float width = contentWidth;
float height = width * image.getHeight() / image.getWidth();
if (height > 260) {
height = 260;
width = height * image.getWidth() / image.getHeight();
}
ensure(height + 12);
content.drawImage(image, margin, y - height, width, height);
y -= height + 12;
}
void table(JSONArray rows) throws IOException {
if (rows == null || rows.isEmpty()) {
text("暂无统计数据", 10);
return;
}
float[] widths = new float[]{contentWidth * 0.46F, contentWidth * 0.15F, contentWidth * 0.17F, contentWidth * 0.22F};
drawTableRow(new String[]{"选项/内容", "数量", "占比", "备注"}, widths, true);
for (int i = 0; i < rows.size(); i++) {
JSONObject row = JSONUtil.parseObj(rows.get(i));
drawTableRow(new String[]{
StrUtil.blankToDefault(row.getStr("name"), ""),
StrUtil.blankToDefault(row.getStr("count"), ""),
StrUtil.blankToDefault(row.getStr("percent"), ""),
StrUtil.blankToDefault(row.getStr("remark"), "")
}, widths, false);
}
}
void drawTableRow(String[] values, float[] widths, boolean header) throws IOException {
float rowHeight = 24F;
ensure(rowHeight);
float x = margin;
for (int i = 0; i < values.length; i++) {
content.addRect(x, y - rowHeight, widths[i], rowHeight);
content.stroke();
drawText(clip(values[i], header ? 16 : 28), x + 5, y - 16, 9, header ? boldFont : regularFont);
x += widths[i];
}
y -= rowHeight;
}
void drawText(String text, float x, float y, float fontSize, PDType0Font font) throws IOException {
content.beginText();
content.setFont(font, fontSize);
content.newLineAtOffset(x, y);
content.showText(StrUtil.blankToDefault(text, "").replaceAll("[\\r\\n\\t]", " "));
content.endText();
}
List<String> wrap(String text, float fontSize, float maxWidth, PDType0Font font) throws IOException {
List<String> lines = new java.util.ArrayList<>();
StringBuilder line = new StringBuilder();
for (int i = 0; i < text.length(); i++) {
char ch = text.charAt(i);
String next = line.toString() + ch;
if (font.getStringWidth(next) / 1000 * fontSize > maxWidth && line.length() > 0) {
lines.add(line.toString());
line.setLength(0);
}
line.append(ch);
}
if (line.length() > 0 || lines.isEmpty()) {
lines.add(line.toString());
}
return lines;
}
String clip(String text, int maxLength) {
if (text == null || text.length() <= maxLength) {
return StrUtil.blankToDefault(text, "");
}
return text.substring(0, maxLength) + "...";
}
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("选项选择详情")
public Result selectOptionUsers(@Valid String activityId, @Valid String subjectId, @Valid String optionId) {
List<QsvUserAnswerRecord> answerRecords = dao.query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId));
List<QsvUserAnswerRecord> selectOptionUsers = answerRecords.stream().filter(ext -> ObjectUtil.isNotNull(ext.getExtJson().get(subjectId, JSONObject.class)) && ext.getExtJson().get(subjectId, JSONObject.class).getJSONArray("optionIds").contains(optionId)).toList();
public Result selectOptionUsers(@Valid String activityId, @Valid String subjectId, @Valid String optionId, String conditions) {
List<NutMap> selectOptionUsers = qsvSurveyService.selectOptionUsers(activityId, subjectId, optionId, conditions);
return Result.success(selectOptionUsers);
}
@@ -95,6 +379,7 @@ public class QsvSurveyController {
@At
@SaCheckPermission("qsv.survey")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("删除用户答题记录")
@SLog(type = "qsv.survey", tag = "删除用户答题记录", msg = "删除用户答题记录")
public Result deleteUserAnswer(@Valid String id) {
@@ -1,19 +1,29 @@
package com.budwk.app.zhgh.dayofficework.qsv.h5controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvActivityService;
import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import javax.validation.Valid;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@IocBean
@At("/platform/h5/qsv")
@@ -23,6 +33,8 @@ public class H5QsvController {
@Inject
private QsvActivityService qsvActivityService;
@Inject
private Dao dao;
@At("")
@Ok("beetl:/platform/zhghh5/dayofficework/qsv/index.html")
@@ -33,13 +45,69 @@ public class H5QsvController {
@At
@SaCheckPermission("h5.qsv")
public Result pageData(@Valid PageForm pageForm, @Valid String category) {
public Result pageData(@Valid PageForm pageForm, @Valid String category, @Param("title") String title) {
Cnd cnd = Cnd.NEW();
cnd.andEX("category", "=", category);
cnd.and(Cnd.likeEX("title", title));
cnd.and(Cnd.exps("enabled", "=", true).or("enabled", "is", null));
LocalDate currentYearFirstDay = LocalDate.now().withDayOfYear(1);
Date currentYearStart = Date.from(currentYearFirstDay.atStartOfDay(ZoneId.systemDefault()).toInstant());
Date nextYearStart = Date.from(currentYearFirstDay.plusYears(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
cnd.and("startTime", ">=", currentYearStart);
cnd.and("startTime", "<", nextYearStart);
cnd.desc("startTime");
Pagination pagination = qsvActivityService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), cnd);
fillAnsweredStatus(pagination);
return Result.success(pagination);
}
private void fillAnsweredStatus(Pagination pagination) {
List<NutMap> rows = pagination.getList();
if (rows == null || rows.isEmpty()) {
return;
}
List<String> activityIds = rows.stream()
.map(this::getRowActivityId)
.filter(id -> id != null && !id.isBlank())
.collect(Collectors.toList());
if (activityIds.isEmpty()) {
return;
}
Set<String> answeredActivityIds = dao.query(QsvUserAnswerRecord.class, Cnd.where("userId", "=", SecurityUtil.getUserId())
.and("activityId", "in", activityIds)
.and("isFinish", "=", true))
.stream()
.map(QsvUserAnswerRecord::getActivityId)
.collect(Collectors.toSet());
rows.forEach(row -> {
boolean isAnswered = answeredActivityIds.contains(getRowActivityId(row));
row.put("isAnswered", isAnswered);
row.put("answeredText", isAnswered ? getAnsweredText(row.getString("category")) : "");
});
}
private String getRowActivityId(NutMap row) {
String id = row.getString("id");
if (id == null || id.isBlank()) {
id = row.getString("ID");
}
if (id == null || id.isBlank()) {
id = row.getString("activityId");
}
return id;
}
private String getAnsweredText(String category) {
if ("QUIZ".equals(category)) {
return "已答题";
}
if ("SURVEY".equals(category)) {
return "已填写";
}
if ("VOTE".equals(category)) {
return "已投票";
}
return "已完成";
}
}
@@ -124,7 +124,9 @@ public class H5QsvQuizController {
//首次进来生成答题记录
if ("ALL".equals(displayMode)) {
resultSubjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
answerRecordId = qsvUserAnswerRecordService.insertRecord(activityId, resultSubjects.stream().map(QsvSubject::getId).collect(Collectors.toList())).getId();
QsvUserAnswerRecord answerRecord = qsvUserAnswerRecordService.insertRecord(activityId, resultSubjects.stream().map(QsvSubject::getId).collect(Collectors.toList()));
answerRecordId = answerRecord.getId();
resultSubjects = querySubjectsByRecordOrder(answerRecord.getSubjectIds());
} else if ("RANDOM".equals(displayMode)) {
//单次随机抽取题目数量
Integer randomCount = activity.getRandomCount();
@@ -333,6 +335,7 @@ public class H5QsvQuizController {
@At
@ApiOperation("提交答题")
@SLog(type = "qsv.quiz", tag = "提交答题", msg = "问卷调查提交答题")
@Aop(TransAop.READ_COMMITTED)
public Result submitAnswer(@Valid @Param("answer") QsvAnswerParam qsvAnswerParam) {
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId());
JSONObject extJson = answerRecord.getExtJson();
@@ -396,4 +399,17 @@ public class H5QsvQuizController {
return Result.success(result);
}
private List<QsvSubject> querySubjectsByRecordOrder(List<String> subjectIds) {
if (subjectIds == null || subjectIds.isEmpty()) {
return new ArrayList<>();
}
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds));
Map<String, QsvSubject> subjectMap = subjects.stream()
.collect(Collectors.toMap(QsvSubject::getId, java.util.function.Function.identity(), (oldValue, newValue) -> oldValue));
return subjectIds.stream()
.map(subjectMap::get)
.filter(ObjectUtil::isNotEmpty)
.collect(Collectors.toList());
}
}
@@ -12,6 +12,8 @@ import com.budwk.app.zhgh.dayofficework.qsv.param.QsvAnswerParam;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvUserAnswerRecordService;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
@@ -22,7 +24,11 @@ import org.nutz.mvc.annotation.Param;
import javax.validation.Valid;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@IocBean
@At("/platform/h5/qsv/survey")
@@ -40,8 +46,13 @@ public class H5QsvSurveyController {
}
@At
@Aop(TransAop.READ_COMMITTED)
public Result subjects(String activityId) {
QsvActivity activity = dao.fetch(QsvActivity.class, activityId);
Result activityStatusResult = checkActivityStatus(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
int count = dao.count(ActivityUserScope.class, Cnd.where("groupId", "=", activity.getGroupId())
.and("userId", "=", SecurityUtil.getUserId()));
@@ -51,20 +62,21 @@ public class H5QsvSurveyController {
String answerRecordId = null;
List<QsvSubject> activitySubjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("userId", "=", SecurityUtil.getUserId()));
if (answerRecord == null) {
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
qsvUserAnswerRecordService.insertRecord(activityId, subjects.stream().map(QsvSubject::getId).toList());
qsvUserAnswerRecordService.insertRecord(activityId, activitySubjects.stream().map(QsvSubject::getId).toList());
}
QsvUserAnswerRecord answerRecord2 = dao.fetch(QsvUserAnswerRecord.class,
Cnd.where("activityId", "=", activityId)
.and("userId", "=", SecurityUtil.getUserId()));
syncAnswerRecordSubjects(answerRecord2, activitySubjects);
answerRecordId = answerRecord2.getId();
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", answerRecord2.getSubjectIds()).asc("sortNum"));
List<QsvSubject> subjects = querySubjectsByRecordOrder(answerRecord2.getSubjectIds());
dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum"));
for (QsvSubject subject : subjects) {
@@ -77,6 +89,95 @@ public class H5QsvSurveyController {
return Result.success(result);
}
/**
* 校验调查活动是否处于可答题状态。activity 为空表示活动不存在;
* enabled 为 false 表示活动已关闭,当前时间早于 startTime 表示未开始,当前时间晚于 endTime 表示已结束。
*
* @param activity 当前调查活动,包含开启状态、开始时间和结束时间
* @return 状态或时间不允许答题时返回错误结果,允许答题时返回 null
*/
private Result checkActivityStatus(QsvActivity activity) {
if (activity == null) {
return Result.error("调查不存在");
}
if (Boolean.FALSE.equals(activity.getEnabled())) {
return Result.error("活动已关闭,暂不能参与");
}
Date now = new Date();
if (activity.getStartTime() != null && activity.getStartTime().after(now)) {
return Result.error("调查尚未开始,请在开始后再参与");
}
if (activity.getEndTime() != null && activity.getEndTime().before(now)) {
return Result.error("调查已结束");
}
return null;
}
/**
* 同步问卷当前题目到未完成答题记录。后台新增题目后,已进入过问卷的用户记录里没有新题ID,
* 这里补齐 subjectIds 和 extJson,保证手机端能拿到最新题目并正常提交答案。
*
* @param answerRecord 用户当前问卷答题记录
* @param activitySubjects 当前活动下按排序查询出的最新题目列表
*/
private void syncAnswerRecordSubjects(QsvUserAnswerRecord answerRecord, List<QsvSubject> activitySubjects) {
if (answerRecord == null || Boolean.TRUE.equals(answerRecord.getIsFinish())) {
return;
}
List<String> currentSubjectIds = activitySubjects.stream().map(QsvSubject::getId).collect(Collectors.toList());
List<String> recordSubjectIds = answerRecord.getSubjectIds();
if (recordSubjectIds == null) {
recordSubjectIds = new ArrayList<>();
}
JSONObject extJson = answerRecord.getExtJson();
if (extJson == null) {
extJson = new JSONObject();
}
LinkedHashSet<String> currentSubjectIdSet = new LinkedHashSet<>(currentSubjectIds);
List<String> normalizedSubjectIds = recordSubjectIds.stream()
.filter(currentSubjectIdSet::contains)
.collect(Collectors.toList());
for (String subjectId : currentSubjectIds) {
if (!normalizedSubjectIds.contains(subjectId)) {
normalizedSubjectIds.add(subjectId);
}
}
boolean changed = !recordSubjectIds.equals(normalizedSubjectIds);
for (String subjectId : currentSubjectIds) {
if (ObjectUtil.isEmpty(extJson.get(subjectId, JSONObject.class))) {
JSONObject entry = new JSONObject();
entry.set("optionIds", new ArrayList<>());
entry.set("text", null);
entry.set("optionFillContents", new JSONObject());
extJson.set(subjectId, entry);
changed = true;
}
}
if (changed) {
answerRecord.setSubjectIds(normalizedSubjectIds);
answerRecord.setExtJson(extJson);
dao.update(answerRecord);
}
}
private List<QsvSubject> querySubjectsByRecordOrder(List<String> subjectIds) {
if (subjectIds == null || subjectIds.isEmpty()) {
return new ArrayList<>();
}
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds));
Map<String, QsvSubject> subjectMap = subjects.stream()
.collect(Collectors.toMap(QsvSubject::getId, Function.identity(), (oldValue, newValue) -> oldValue));
return subjectIds.stream()
.map(subjectMap::get)
.filter(ObjectUtil::isNotEmpty)
.collect(Collectors.toList());
}
@At
public Result answerRecord(@Valid String answerRecordId) {
QsvUserAnswerRecord record = dao.fetch(QsvUserAnswerRecord.class, answerRecordId);
@@ -84,7 +185,14 @@ public class H5QsvSurveyController {
}
@At
@Aop(TransAop.READ_COMMITTED)
public Result submitAnswer(@Valid @Param("answer") QsvAnswerParam qsvAnswerParam) {
QsvActivity activity = dao.fetch(QsvActivity.class, qsvAnswerParam.getActivityId());
Result activityStatusResult = checkActivityStatus(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId());
JSONObject extJson = answerRecord.getExtJson();
@@ -93,6 +201,7 @@ public class H5QsvSurveyController {
if (ObjectUtil.isNotEmpty(entries)) {
entries.set("optionIds", subject.getUserSelectOptionIds());
entries.set("text", subject.getUserFillContent());
entries.set("optionFillContents", subject.getOptionFillContents());
}
extJson.set(subject.getId(), entries);
}
@@ -0,0 +1,334 @@
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() {
}
/**
* 查询投票题目,并为首次进入的用户创建投票记录。
*
* @param activityId 投票活动 ID
* @return 投票活动、题目、答题记录和统计数据
*/
@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
/**
* 查询投票记录,用于恢复用户已选择的投票项。
*
* @param answerRecordId 投票记录 ID
* @return 投票记录
*/
public Result answerRecord(@Valid String answerRecordId) {
QsvUserAnswerRecord record = dao.fetch(QsvUserAnswerRecord.class, answerRecordId);
return Result.success(record);
}
@At
/**
* 查询投票排行榜和统计数据。
*
* @param activityId 投票活动 ID
* @return 投票统计数据
*/
public Result rank(@Valid String activityId) {
return Result.success(buildVoteStats(activityId));
}
/**
* 提交移动端投票结果,投票成功后标记记录为完成。
*
* @param qsvAnswerParam 用户提交的投票答案
* @return 提交结果
*/
@At
@Aop(TransAop.READ_COMMITTED)
public Result submitAnswer(@Valid @Param("answer") QsvAnswerParam qsvAnswerParam) {
QsvActivity activity = dao.fetch(QsvActivity.class, qsvAnswerParam.getActivityId());
Result activityStatusResult = checkActivityWritable(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId());
if (answerRecord == null || !SecurityUtil.getUserId().equals(answerRecord.getUserId())) {
return Result.error("\u6295\u7968\u8bb0\u5f55\u4e0d\u5b58\u5728");
}
if (Boolean.TRUE.equals(answerRecord.getIsFinish())) {
return Result.error("\u60a8\u5df2\u5b8c\u6210\u6295\u7968");
}
JSONObject extJson = answerRecord.getExtJson();
if (extJson == null) {
extJson = new JSONObject();
}
for (QsvAnswerParam.Subject subject : qsvAnswerParam.getSubjects()) {
// 逐题保存用户选择和补充内容,便于统计票数与查看详情。
JSONObject entries = extJson.get(subject.getId(), JSONObject.class);
if (entries == null) {
entries = new JSONObject();
}
entries.set("optionIds", subject.getUserSelectOptionIds());
entries.set("text", subject.getUserFillContent());
entries.set("optionFillContents", subject.getOptionFillContents());
extJson.set(subject.getId(), entries);
}
answerRecord.setExtJson(extJson);
answerRecord.setAttemptDate(new Date());
answerRecord.setSubmitTime(new Date());
answerRecord.setIsFinish(true);
dao.update(answerRecord);
return Result.success("\u6295\u7968\u6210\u529f");
}
private Result checkActivityReadable(QsvActivity activity) {
if (activity == null) {
return Result.error("\u6295\u7968\u4e0d\u5b58\u5728");
}
if (Boolean.FALSE.equals(activity.getEnabled())) {
return Result.error("\u6d3b\u52a8\u5df2\u5173\u95ed\uff0c\u6682\u4e0d\u80fd\u53c2\u4e0e");
}
Date now = new Date();
if (activity.getStartTime() != null && activity.getStartTime().after(now)) {
return Result.error("\u6295\u7968\u5c1a\u672a\u5f00\u59cb\uff0c\u8bf7\u5728\u5f00\u59cb\u540e\u518d\u53c2\u4e0e");
}
return null;
}
private Result checkActivityWritable(QsvActivity activity) {
Result readableResult = checkActivityReadable(activity);
if (readableResult != null) {
return readableResult;
}
Date now = new Date();
if (activity.getEndTime() != null && activity.getEndTime().before(now)) {
return Result.error("\u6295\u7968\u5df2\u7ed3\u675f");
}
return null;
}
private Result checkScope(QsvActivity activity) {
if (activity.getGroupId() == null) {
return null;
}
int count = dao.count(ActivityUserScope.class, Cnd.where("groupId", "=", activity.getGroupId())
.and("userId", "=", SecurityUtil.getUserId()));
if (count != 1) {
return Result.error("\u60a8\u65e0\u9700\u53c2\u52a0\u6b64\u6b21\u6295\u7968\uff0c\u611f\u8c22\u60a8\u7684\u5173\u6ce8\uff01");
}
return null;
}
private void syncAnswerRecordSubjects(QsvUserAnswerRecord answerRecord, List<QsvSubject> activitySubjects) {
if (answerRecord == null || Boolean.TRUE.equals(answerRecord.getIsFinish())) {
return;
}
// 后台调整投票题目后,未完成记录需要同步当前有效题目,避免提交旧题目。
List<String> currentSubjectIds = activitySubjects.stream()
.map(QsvSubject::getId)
.collect(Collectors.toList());
List<String> recordSubjectIds = answerRecord.getSubjectIds();
if (recordSubjectIds == null) {
recordSubjectIds = new ArrayList<>();
}
JSONObject extJson = answerRecord.getExtJson();
if (extJson == null) {
extJson = new JSONObject();
}
LinkedHashSet<String> currentSubjectIdSet = new LinkedHashSet<>(currentSubjectIds);
List<String> normalizedSubjectIds = recordSubjectIds.stream()
.filter(currentSubjectIdSet::contains)
.collect(Collectors.toList());
for (String subjectId : currentSubjectIds) {
if (!normalizedSubjectIds.contains(subjectId)) {
normalizedSubjectIds.add(subjectId);
}
}
boolean changed = !recordSubjectIds.equals(normalizedSubjectIds);
for (String subjectId : currentSubjectIds) {
if (ObjectUtil.isEmpty(extJson.get(subjectId, JSONObject.class))) {
JSONObject entry = new JSONObject();
entry.set("optionIds", new ArrayList<>());
entry.set("text", null);
entry.set("optionFillContents", new JSONObject());
extJson.set(subjectId, entry);
changed = true;
}
}
if (changed) {
answerRecord.setSubjectIds(normalizedSubjectIds);
answerRecord.setExtJson(extJson);
dao.update(answerRecord);
}
}
private List<QsvSubject> querySubjectsByRecordOrder(List<String> subjectIds) {
if (subjectIds == null || subjectIds.isEmpty()) {
return new ArrayList<>();
}
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds));
Map<String, QsvSubject> subjectMap = subjects.stream()
.collect(Collectors.toMap(QsvSubject::getId, Function.identity(), (oldValue, newValue) -> oldValue));
return subjectIds.stream()
.map(subjectMap::get)
.filter(ObjectUtil::isNotEmpty)
.collect(Collectors.toList());
}
private NutMap buildVoteStats(String activityId) {
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum"));
Map<String, NutMap> optionStats = new LinkedHashMap<>();
for (QsvSubject subject : subjects) {
if (subject.getOptions() == null) {
continue;
}
for (QsvOption option : subject.getOptions()) {
optionStats.put(option.getId(), NutMap.NEW()
.addv("optionId", option.getId())
.addv("subjectId", subject.getId())
.addv("text", option.getText())
.addv("imgUrl", option.getImgUrl())
.addv("votes", 0)
.addv("percent", 0D));
}
}
List<QsvUserAnswerRecord> records = dao.query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
for (QsvUserAnswerRecord record : records) {
JSONObject extJson = record.getExtJson();
if (extJson == null) {
continue;
}
for (QsvSubject subject : subjects) {
JSONObject entry = extJson.get(subject.getId(), JSONObject.class);
if (entry == null) {
continue;
}
List<String> optionIds = entry.getBeanList("optionIds", String.class);
if (optionIds == null) {
continue;
}
for (String optionId : optionIds) {
NutMap stat = optionStats.get(optionId);
if (stat != null) {
stat.put("votes", stat.getInt("votes", 0) + 1);
}
}
}
}
int totalParticipants = records.size();
List<NutMap> rankList = new ArrayList<>(optionStats.values());
for (NutMap stat : rankList) {
int votes = stat.getInt("votes", 0);
double percent = totalParticipants == 0 ? 0D : votes * 100D / totalParticipants;
stat.put("percent", percent);
}
rankList.sort((left, right) -> Integer.compare(right.getInt("votes", 0), left.getInt("votes", 0)));
return NutMap.NEW()
.addv("totalParticipants", totalParticipants)
.addv("optionCount", optionStats.size())
.addv("optionStats", optionStats)
.addv("rankList", rankList);
}
}
@@ -1,6 +1,8 @@
package com.budwk.app.zhgh.dayofficework.qsv.models;
import com.budwk.app.base.model.BaseModel;
import com.budwk.app.sys.models.Sys_home_activity;
import com.budwk.app.sys.services.SysHomeConvert;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.*;
@@ -14,7 +16,7 @@ import java.util.Date;
@Table("qsv_activity")
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("问卷调查投票活动表")
public class QsvActivity extends BaseModel implements Serializable {
public class QsvActivity extends BaseModel implements Serializable, SysHomeConvert {
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@@ -46,6 +48,12 @@ public class QsvActivity extends BaseModel implements Serializable {
@ColDefine(type = ColType.DATETIME)
private Date endTime;
@Column
@Comment("是否开启")
@ColDefine(type = ColType.BOOLEAN)
@Default("1")
private Boolean enabled;
@Column
@Comment("活动分组ID")
@ColDefine(type = ColType.INT)
@@ -107,4 +115,35 @@ public class QsvActivity extends BaseModel implements Serializable {
@Comment("封面图片")
@ColDefine(type = ColType.VARCHAR, width = 255)
private String cover;
@Override
public Sys_home_activity covertToSysHomeActivity() {
Sys_home_activity sysHomeActivity = new Sys_home_activity();
sysHomeActivity.setId(this.getId());
sysHomeActivity.setName(this.getTitle());
sysHomeActivity.setCover(this.getCover());
sysHomeActivity.setContent(this.getDescription());
sysHomeActivity.setUrl("/platform/qsv/online");
sysHomeActivity.setH5Url(getH5Url());
sysHomeActivity.setStartDate(this.getStartTime());
sysHomeActivity.setEndDate(this.getEndTime());
sysHomeActivity.setAllowUserGroupId(this.getGroupId());
sysHomeActivity.setEnable(!Boolean.FALSE.equals(this.getEnabled()));
sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName());
return sysHomeActivity;
}
/**
* 根据活动类型生成移动端首页跳转地址。
* QUIZ 跳转答题页,SURVEY 跳转调查页,VOTE 预留投票页地址,返回值为移动端路由字符串。
*/
private String getH5Url() {
if ("SURVEY".equals(this.getCategory())) {
return "/platform/h5/qsv/survey?id=" + this.getId();
}
if ("VOTE".equals(this.getCategory())) {
return "/platform/h5/qsv/vote?id=" + this.getId();
}
return "/platform/h5/qsv/quiz?id=" + this.getId();
}
}
@@ -51,6 +51,16 @@ public class QsvOption extends BaseModel implements Serializable {
@ColDefine(customType = "longtext")
private String description;
@Column
@Comment("选中后是否需要填写补充内容")
@ColDefine(type = ColType.BOOLEAN)
private Boolean fillRequired;
@Column
@Comment("补充内容输入提示")
@ColDefine(type = ColType.VARCHAR, width = 255)
private String fillPlaceholder;
@Column
@Comment("排序")
@ColDefine(type = ColType.INT)
@@ -58,11 +58,41 @@ public class QsvSubject extends BaseModel implements Serializable {
@ColDefine(type = ColType.INT, width = 1)
private Integer maxMulti;
@Column
@Comment("投票选项排列方式")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String optionLayout;
@Column
@Comment("投票选项横向列数")
@ColDefine(type = ColType.INT, width = 1)
private Integer optionColumns;
@Column
@Comment("排序")
@ColDefine(type = ColType.INT, width = 1)
private Integer sortNum;
@Column
@Comment("是否启用题目隐显逻辑")
@ColDefine(type = ColType.BOOLEAN)
private Boolean visibleRuleEnabled;
@Column
@Comment("隐显逻辑处理方式(show显示,hide隐藏)")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String visibleRuleAction;
@Column
@Comment("隐显逻辑条件关系(AND并且,OR或者)")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String visibleRuleLogic;
@Column
@Comment("隐显逻辑条件列表")
@ColDefine(type = ColType.MYSQL_JSON)
private List<VisibleRuleCondition> visibleRuleConditions;
/**
* 选项
*/
@@ -79,4 +109,52 @@ public class QsvSubject extends BaseModel implements Serializable {
*/
private String userFillContent;
/**
* 题目隐显逻辑条件,一条条件表示选择指定题目的指定选项后参与显示或隐藏判断。
*/
@Data
public static class VisibleRuleCondition implements Serializable {
/**
* 作为触发条件的题目ID。
*/
private String subjectId;
/**
* 作为触发条件的选项ID。
*/
private String optionId;
/**
* 条件节点类型:group 分组,item 条件项。
*/
private String type;
/**
* 分组内条件关系:and 并且,or 或者。
*/
private String logic;
/**
* 条件项引用的题目ID。
*/
private String field;
/**
* 比较符:==、!=、contains、>、<、>=、<=。
*/
private String operator;
/**
* 比较值,选择题为选项ID,填空题可为文本或数字。
*/
private Object value;
/**
* 子条件节点,支持条件分组嵌套。
*/
private List<VisibleRuleCondition> children;
}
}
@@ -3,6 +3,7 @@ package com.budwk.app.zhgh.dayofficework.qsv.param;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
//答题参数
@@ -21,6 +22,7 @@ public class QsvAnswerParam {
private String id;
private List<String> userSelectOptionIds;
private String userFillContent;
private Map<String, String> optionFillContents;
}
}
@@ -5,4 +5,10 @@ import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
public interface QsvActivityService extends BaseService<QsvActivity> {
QsvActivity saveActivity(QsvActivity qsvActivity);
void deleteActivity(String id);
void updateEnabled(String id, Boolean enabled);
}
@@ -12,6 +12,14 @@ public interface QsvSurveyService extends BaseService<QsvUserAnswerRecord> {
List<NutMap> report(String activityId);
List<NutMap> categoryReport(String activityId, String conditions);
NutMap crossReport(String activityId, String xSubjectIds, String ySubjectIds, String conditions);
NutMap compareReport(String activityId, String subjectId, String optionId);
List<NutMap> selectOptionUsers(String activityId, String subjectId, String optionId, String conditions);
void exportReportXlsx(String activityId, HttpServletResponse response);
void exportUserAnswerXlsx(String activityId, HttpServletResponse response);
@@ -1,15 +1,82 @@
package com.budwk.app.zhgh.dayofficework.qsv.service.impl;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.sys.models.Sys_home_activity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvOption;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvActivityService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import java.util.List;
import java.util.stream.Collectors;
@IocBean(args = {"refer:dao"})
public class QsvActivityServiceImpl extends BaseServiceImpl<QsvActivity> implements QsvActivityService {
public QsvActivityServiceImpl(Dao dao) {
super(dao);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public QsvActivity saveActivity(QsvActivity qsvActivity) {
fillQuizRepeatMode(qsvActivity);
dao().insertOrUpdate(qsvActivity);
syncHomeActivity(qsvActivity);
return qsvActivity;
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void deleteActivity(String id) {
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", id));
List<String> subjectIds = subjects.stream().map(QsvSubject::getId).collect(Collectors.toList());
if (!subjectIds.isEmpty()) {
dao().clear(QsvOption.class, Cnd.where("subjectId", "in", subjectIds));
dao().clear(QsvSubject.class, Cnd.where("id", "in", subjectIds));
}
dao().clear(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", id));
dao().delete(QsvActivity.class, id);
dao().delete(Sys_home_activity.class, id);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void updateEnabled(String id, Boolean enabled) {
QsvActivity activity = dao().fetch(QsvActivity.class, id);
if (activity == null) {
return;
}
activity.setEnabled(enabled);
dao().update(activity);
syncHomeActivity(activity);
}
/**
* 答题活动的重复模式由出题模式决定。定时定题按天统计,常规模式按活动总次数统计。
*/
private void fillQuizRepeatMode(QsvActivity qsvActivity) {
if (!"QUIZ".equals(qsvActivity.getCategory())) {
return;
}
if ("SCHEDULED".equals(qsvActivity.getMode())) {
qsvActivity.setRepeatMode("DAILY");
} else if ("REGULAR".equals(qsvActivity.getMode())) {
qsvActivity.setRepeatMode("TOTAL");
}
}
/**
* 同步移动端首页活动。问卷活动保存或启停后,都以活动自身 enabled、时间、人员范围生成首页记录。
*/
private void syncHomeActivity(QsvActivity activity) {
Sys_home_activity sysHomeActivity = activity.covertToSysHomeActivity();
dao().insertOrUpdate(sysHomeActivity);
}
}
@@ -90,6 +90,7 @@ public class QsvQuizRankServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord>
private Sql buildQuerySql(String activityId, String mode, String scoreStatisticsMode, QsvQuizRankPageForm pageForm) {
StringBuilder sqlBuilder = new StringBuilder("""
SELECT
t1.id,
t1.userId,
t1.userName,
t1.loginName,
@@ -119,17 +120,11 @@ public class QsvQuizRankServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord>
cnd.andEX("t1.unionId","=",pageForm.getUnionId());
cnd.andEX("t1.unitId","=",pageForm.getUnitId());
if ("SCHEDULED".equals(mode)) {
if ("HIGH".equals(scoreStatisticsMode)) {
cnd.and("t1.isHighestScore","=",1);
} else if ("LAST".equals(scoreStatisticsMode)) {
cnd.and("t1.isLatestScore","=",1);
}
} else if ("REGULAR".equals(mode)) {
if ("HIGH".equals(scoreStatisticsMode)) {
cnd.and("t1.isHighestScore","=",1);
} else if ("LAST".equals(scoreStatisticsMode)) {
cnd.and("t1.isLatestScore","=",1);
if ("SCHEDULED".equals(mode) || "REGULAR".equals(mode)) {
if ("HIGH".equals(scoreStatisticsMode) || "HIGHEST".equals(scoreStatisticsMode)) {
cnd.and("t1.isHighestScore", "=", 1);
} else {
cnd.and("t1.isLatestScore", "=", 1);
}
}
@@ -8,6 +8,7 @@ import cn.afterturn.easypoi.excel.export.ExcelExportService;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.impl.BaseServiceImpl;
@@ -68,47 +69,47 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
// 处理每个题目
for (NutMap subject : subjects) {
// 获取当前题目的选项列表
List<NutMap> subjectOptions = Lang.collection2list(optionsGroup.get(subject.getString("id")), NutMap.class);
List<QsvOption> currentOptions = optionsGroup.get(subject.getString("id"));
List<NutMap> subjectOptions = ObjectUtil.isEmpty(currentOptions) ? new ArrayList<>() : Lang.collection2list(currentOptions, NutMap.class);
// 获取题目类型
String subjectType = subject.getString("type");
// 处理文本类型题目
if ("text".equals(subjectType)) {
List<String> texts = answerExtList.stream()
.filter(ext -> ObjectUtil.isNull(ext.get(subject.getString("id"), JSONObject.class)))
.map(ext -> ext.get(subject.getString("id"), JSONObject.class).getStr("text"))
List<NutMap> textAnswers = buildTextAnswerDetails(answerRecords, subject.getString("id"));
List<String> texts = textAnswers.stream()
.map(textAnswer -> textAnswer.getString("text"))
.toList();
subject.put("texts", texts);
subject.put("textAnswers", textAnswers);
}
// 处理单选类型题目 处理多选类型题目
else if ("radio".equals(subjectType) || "checkbox".equals(subjectType)) {
long selectTotal = answerExtList.stream()
.filter(ext -> ext.containsKey(subject.getString("id")))
.filter(ext -> ObjectUtil.isNotEmpty(ext) && ext.containsKey(subject.getString("id")))
.count();
subjectOptions.forEach(subjectOption -> {
long selectCount = answerExtList.stream()
.filter(ext ->
{
if (ObjectUtil.isEmpty(ext)) {
return false;
}
JSONObject jsonObject = ext.get(subject.getString("id"), JSONObject.class);
return jsonObject != null
&& jsonObject.getJSONArray("optionIds") != null
&& jsonObject.getJSONArray("optionIds").contains(subjectOption.getString("id"));
})
.count();
long round = Math.round((double) selectCount / selectTotal * 100);
long round = selectTotal == 0 ? 0 : Math.round((double) selectCount / selectTotal * 100);
subjectOption.put("selectPercent", round + "%");
subjectOption.put("selectCount", selectCount);
});
subjectOptions.sort((o1, o2) -> {
int count1 = o1.getInt("selectCount");
int count2 = o2.getInt("selectCount");
if (count1 == count2) {
return Integer.compare(o1.getInt("sortNum"), o2.getInt("sortNum"));
}
return Integer.compare(count2, count1);
return Integer.compare(o1.getInt("sortNum"), o2.getInt("sortNum"));
});
subject.put("selectTotal", selectTotal);
@@ -125,6 +126,228 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
}
@Override
public List<NutMap> categoryReport(String activityId, String conditions) {
if (activityId == null || activityId.isEmpty()) {
throw new IllegalArgumentException("activityId不能为空");
}
try {
List<NutMap> conditionList = parseCategoryConditions(conditions);
List<QsvUserAnswerRecord> answerRecords = dao().query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
return buildReport(activityId, filterAnswerRecords(answerRecords, conditionList));
} catch (Exception e) {
log.error("分类报告生成失败", e);
throw new RuntimeException("分类报告生成失败", e);
}
}
@Override
public NutMap crossReport(String activityId, String xSubjectIds, String ySubjectIds, String conditions) {
if (activityId == null || activityId.isEmpty()) {
throw new IllegalArgumentException("activityId不能为空");
}
List<String> xSubjectIdList = parseSubjectIds(xSubjectIds);
List<String> ySubjectIdList = parseSubjectIds(ySubjectIds);
if (ObjectUtil.isEmpty(xSubjectIdList) || ObjectUtil.isEmpty(ySubjectIdList)) {
throw new IllegalArgumentException("请选择自变量X和因变量Y");
}
if (xSubjectIdList.stream().anyMatch(ySubjectIdList::contains)) {
throw new IllegalArgumentException("自变量X和因变量Y不能选择相同变量");
}
try {
List<NutMap> subjects = querySubjects(activityId);
Map<String, NutMap> subjectMap = subjects.stream()
.collect(Collectors.toMap(subject -> subject.getString("id"), subject -> subject));
if (!subjectMap.keySet().containsAll(xSubjectIdList) || !subjectMap.keySet().containsAll(ySubjectIdList)) {
throw new IllegalArgumentException("题目不存在");
}
List<String> allSubjectIds = new ArrayList<>();
allSubjectIds.addAll(xSubjectIdList);
allSubjectIds.addAll(ySubjectIdList);
List<QsvOption> options = dao().query(QsvOption.class, Cnd.where("subjectId", "in", allSubjectIds).asc("sortNum"));
Map<String, List<QsvOption>> optionGroup = options.stream().collect(Collectors.groupingBy(QsvOption::getSubjectId));
List<NutMap> xOptionMaps = buildVariableOptionCombinations(xSubjectIdList, subjectMap, optionGroup);
List<NutMap> conditionList = parseCategoryConditions(conditions);
List<QsvUserAnswerRecord> answerRecords = dao().query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
List<QsvUserAnswerRecord> filteredRecords = filterAnswerRecords(answerRecords, conditionList);
List<NutMap> reports = new ArrayList<>();
for (String ySubjectId : ySubjectIdList) {
NutMap ySubject = subjectMap.get(ySubjectId);
List<QsvOption> yOptions = optionGroup.get(ySubjectId);
if (ObjectUtil.isEmpty(yOptions)) {
yOptions = new ArrayList<>();
}
List<NutMap> yOptionMaps = ObjectUtil.isEmpty(yOptions) ? new ArrayList<>() : Lang.collection2list(yOptions, NutMap.class);
List<NutMap> rows = new ArrayList<>();
for (NutMap xOption : xOptionMaps) {
List<NutMap> xConditions = xOption.getList("conditions", NutMap.class);
List<QsvUserAnswerRecord> xMatchedRecords = filteredRecords.stream()
.filter(record -> isRecordMatched(record, xConditions))
.toList();
long rowTotal = xMatchedRecords.size();
List<NutMap> cells = new ArrayList<>();
for (QsvOption yOption : yOptions) {
List<NutMap> yCondition = List.of(NutMap.NEW()
.addv("subjectId", ySubjectId)
.addv("optionId", yOption.getId()));
long count = xMatchedRecords.stream()
.filter(record -> isRecordMatched(record, yCondition))
.count();
double percent = rowTotal == 0 ? 0 : Math.round((double) count / rowTotal * 10000) / 100.0;
cells.add(NutMap.NEW()
.addv("yOptionId", yOption.getId())
.addv("yOptionText", yOption.getText())
.addv("count", count)
.addv("percent", percent));
}
rows.add(NutMap.NEW()
.addv("xOptionId", xOption.getString("id"))
.addv("xOptionText", xOption.getString("text"))
.addv("cells", cells)
.addv("total", rowTotal));
}
reports.add(NutMap.NEW()
.addv("ySubject", ySubject)
.addv("yOptions", yOptionMaps)
.addv("rows", rows));
}
return NutMap.NEW()
.addv("xSubject", buildVariableSubject(xSubjectIdList, subjectMap))
.addv("xOptions", xOptionMaps)
.addv("reports", reports)
.addv("total", filteredRecords.size());
} catch (Exception e) {
log.error("交叉分析报告生成失败", e);
throw new RuntimeException("交叉分析报告生成失败", e);
}
}
@Override
public NutMap compareReport(String activityId, String subjectId, String optionId) {
if (activityId == null || activityId.isEmpty()) {
throw new IllegalArgumentException("activityId不能为空");
}
if (ObjectUtil.isEmpty(subjectId) || ObjectUtil.isEmpty(optionId)) {
throw new IllegalArgumentException("请选择对比变量和选项");
}
try {
List<NutMap> subjects = querySubjects(activityId);
Map<String, NutMap> subjectMap = subjects.stream()
.collect(Collectors.toMap(subject -> subject.getString("id"), subject -> subject));
NutMap compareSubject = subjectMap.get(subjectId);
if (compareSubject == null) {
throw new IllegalArgumentException("对比变量不存在");
}
List<String> subjectIds = subjects.stream().map(subject -> subject.getString("id")).toList();
List<QsvOption> options = dao().query(QsvOption.class, Cnd.where("subjectId", "in", subjectIds).asc("sortNum"));
Map<String, List<QsvOption>> optionGroup = options.stream().collect(Collectors.groupingBy(QsvOption::getSubjectId));
QsvOption compareOption = optionGroup.getOrDefault(subjectId, new ArrayList<>()).stream()
.filter(option -> optionId.equals(option.getId()))
.findFirst()
.orElse(null);
if (compareOption == null) {
throw new IllegalArgumentException("对比选项不存在");
}
List<NutMap> compareCondition = List.of(NutMap.NEW()
.addv("subjectId", subjectId)
.addv("optionId", optionId));
List<QsvUserAnswerRecord> answerRecords = dao().query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
List<QsvUserAnswerRecord> filteredRecords = filterAnswerRecords(answerRecords, compareCondition);
List<NutMap> reports = new ArrayList<>();
for (NutMap subject : subjects) {
String currentSubjectId = subject.getString("id");
String subjectType = subject.getString("type");
if (subjectId.equals(currentSubjectId) || (!"radio".equals(subjectType) && !"checkbox".equals(subjectType))) {
continue;
}
List<QsvOption> currentOptions = optionGroup.get(currentSubjectId);
if (ObjectUtil.isEmpty(currentOptions)) {
continue;
}
List<NutMap> cells = new ArrayList<>();
long rowTotal = filteredRecords.size();
for (QsvOption option : currentOptions) {
List<NutMap> optionCondition = List.of(NutMap.NEW()
.addv("subjectId", currentSubjectId)
.addv("optionId", option.getId()));
long count = filteredRecords.stream()
.filter(record -> isRecordMatched(record, optionCondition))
.count();
double percent = rowTotal == 0 ? 0 : Math.round((double) count / rowTotal * 10000) / 100.0;
cells.add(NutMap.NEW()
.addv("yOptionId", option.getId())
.addv("yOptionText", option.getText())
.addv("count", count)
.addv("percent", percent));
}
reports.add(NutMap.NEW()
.addv("ySubject", subject)
.addv("yOptions", Lang.collection2list(currentOptions, NutMap.class))
.addv("rows", List.of(NutMap.NEW()
.addv("xOptionId", optionId)
.addv("xOptionText", compareOption.getText())
.addv("cells", cells)
.addv("total", rowTotal))));
}
return NutMap.NEW()
.addv("compareSubject", compareSubject)
.addv("compareOption", Lang.obj2nutmap(compareOption))
.addv("reports", reports)
.addv("total", filteredRecords.size());
} catch (Exception e) {
log.error("对比分析报告生成失败", e);
throw new RuntimeException("对比分析报告生成失败", e);
}
}
@Override
public List<NutMap> selectOptionUsers(String activityId, String subjectId, String optionId, String conditions) {
List<NutMap> conditionList = parseCategoryConditions(conditions);
List<QsvUserAnswerRecord> answerRecords = dao().query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
return filterAnswerRecords(answerRecords, conditionList).stream().map(record -> {
JSONObject extJson = record.getExtJson();
if (ObjectUtil.isEmpty(extJson)) {
return null;
}
JSONObject subjectAnswer = extJson.get(subjectId, JSONObject.class);
if (ObjectUtil.isEmpty(subjectAnswer)) {
return null;
}
JSONArray optionIds = subjectAnswer.getJSONArray("optionIds");
if (ObjectUtil.isEmpty(optionIds) || !optionIds.contains(optionId)) {
return null;
}
JSONObject optionFillContents = subjectAnswer.getJSONObject("optionFillContents");
String fillContent = ObjectUtil.isEmpty(optionFillContents) ? "" : optionFillContents.getStr(optionId);
return NutMap.NEW()
.addv("userName", record.getUserName())
.addv("loginName", record.getLoginName())
.addv("unionName", record.getUnionName())
.addv("unitName", record.getUnitName())
.addv("attemptDate", record.getAttemptDate())
.addv("submitTime", record.getSubmitTime())
.addv("fillContent", fillContent);
})
.filter(ObjectUtil::isNotNull)
.toList();
}
@Override
public void exportReportXlsx(String activityId, HttpServletResponse response) {
List<NutMap> report = report(activityId);
@@ -134,10 +357,8 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
exportEntities.add(new ExcelExportEntity("选择人数", "selectCount", 20));
exportEntities.add(new ExcelExportEntity("选择比例", "selectPercent", 20));
Map<String, List<NutMap>> listMap = report.stream().collect(Collectors.groupingBy(v -> v.getString("id")));
Workbook workbook = new XSSFWorkbook();
listMap.forEach((k, v) -> {
NutMap nutMap = v.get(0);
report.forEach(nutMap -> {
ExcelExportService service = new ExcelExportService();
ExportParams exportParams = new ExportParams();
exportParams.setTitle(nutMap.getString("title"));
@@ -171,7 +392,7 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
List<JSONObject> answerExtList = answerRecords.stream().map(QsvUserAnswerRecord::getExtJson).toList();
// 查询活动题目列表
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId));
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
// 将题目列表转换为Map
Map<String, QsvSubject> subjectMap = subjects.stream().collect(Collectors.toMap(QsvSubject::getId, v -> v));
// 获取题目ID列表
@@ -185,7 +406,7 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
// 构建Excel导出实体
List<ExcelExportEntity> excelExportEntities = buildExcelExportEntities(subjects);
// 构建答题记录列表
List<NutMap> list = buildAnswerRecords(answerRecords, subjectMap, options);
List<NutMap> list = buildAnswerRecords(answerRecords, subjectMap, optionMap);
// 设置导出参数
ExportParams exportParams = new ExportParams();
@@ -223,7 +444,7 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
// List<JSONObject> answerExtList = answerRecords.stream().map(QsvUserAnswerRecord::getExtJson).toList();
// 查询活动题目列表
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId));
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
// 将题目列表转换为Map
Map<String, QsvSubject> subjectMap = subjects.stream().collect(Collectors.toMap(QsvSubject::getId, v -> v));
// 获取题目ID列表
@@ -245,18 +466,15 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
.addv("unitName", record.getString("unitName"))
.addv("unionName", record.getString("unionName"));
JSONObject extJson = record.getAs("extJson", JSONObject.class);
if (ObjectUtil.isEmpty(extJson)) {
return map;
}
extJson.forEach((k, v) -> {
JSONObject jsonVal = (JSONObject) v;
String type = subjectMap.get(k).getType();
if (type.equals("text")) {
map.addv(k, jsonVal.getStr("text"));
} else if (type.equals("radio") || type.equals("checkbox")) {
JSONArray optionIds = jsonVal.getJSONArray("optionIds");
String selectOptionTexts = options.stream().filter(option -> optionIds.contains(option.getId()))
.map(QsvOption::getText).collect(Collectors.joining(";"));
map.addv(k, selectOptionTexts);
QsvSubject subject = subjectMap.get(k);
if (subject == null || !(v instanceof JSONObject jsonVal)) {
return;
}
map.addv(k, buildAnswerText(jsonVal, subject, optionMap));
});
return map;
}).toList();
@@ -278,6 +496,171 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
}
}
private List<NutMap> buildReport(String activityId, List<QsvUserAnswerRecord> answerRecords) {
List<JSONObject> answerExtList = answerRecords.stream().map(QsvUserAnswerRecord::getExtJson).toList();
List<NutMap> subjects = querySubjects(activityId);
List<String> subjectIds = subjects.stream().map(v -> v.getString("id")).toList();
List<QsvOption> options = dao().query(QsvOption.class, Cnd.where("subjectId", "in", subjectIds).asc("sortNum"));
Map<String, List<QsvOption>> optionsGroup = options.stream().collect(Collectors.groupingBy(QsvOption::getSubjectId));
for (NutMap subject : subjects) {
List<QsvOption> currentOptions = optionsGroup.get(subject.getString("id"));
List<NutMap> subjectOptions = ObjectUtil.isEmpty(currentOptions) ? new ArrayList<>() : Lang.collection2list(currentOptions, NutMap.class);
String subjectType = subject.getString("type");
if ("text".equals(subjectType)) {
List<NutMap> textAnswers = buildTextAnswerDetails(answerRecords, subject.getString("id"));
List<String> texts = textAnswers.stream()
.map(textAnswer -> textAnswer.getString("text"))
.toList();
subject.put("texts", texts);
subject.put("textAnswers", textAnswers);
} else if ("radio".equals(subjectType) || "checkbox".equals(subjectType)) {
long selectTotal = answerExtList.stream()
.filter(ext -> ObjectUtil.isNotEmpty(ext) && ext.containsKey(subject.getString("id")))
.count();
subjectOptions.forEach(subjectOption -> {
long selectCount = answerExtList.stream()
.filter(ext -> {
if (ObjectUtil.isEmpty(ext)) {
return false;
}
JSONObject jsonObject = ext.get(subject.getString("id"), JSONObject.class);
return jsonObject != null
&& jsonObject.getJSONArray("optionIds") != null
&& jsonObject.getJSONArray("optionIds").contains(subjectOption.getString("id"));
})
.count();
long round = selectTotal == 0 ? 0 : Math.round((double) selectCount / selectTotal * 100);
subjectOption.put("selectPercent", round + "%");
subjectOption.put("selectCount", selectCount);
});
subjectOptions.sort((o1, o2) -> Integer.compare(o1.getInt("sortNum"), o2.getInt("sortNum")));
subject.put("selectTotal", selectTotal);
}
subject.addv("options", subjectOptions);
}
return subjects;
}
private List<NutMap> parseCategoryConditions(String conditions) {
if (ObjectUtil.isEmpty(conditions)) {
return new ArrayList<>();
}
return JSONUtil.parseArray(conditions).toList(NutMap.class).stream()
.filter(condition -> ObjectUtil.isNotEmpty(condition.getString("subjectId"))
&& ObjectUtil.isNotEmpty(condition.getString("optionId")))
.toList();
}
private List<String> parseSubjectIds(String subjectIds) {
if (ObjectUtil.isEmpty(subjectIds)) {
return new ArrayList<>();
}
String value = subjectIds.trim();
List<String> result = new ArrayList<>();
if (value.startsWith("[")) {
for (Object subjectId : JSONUtil.parseArray(value)) {
if (ObjectUtil.isNotEmpty(subjectId)) {
result.add(String.valueOf(subjectId));
}
}
return result.stream().distinct().toList();
}
for (String subjectId : value.split(",")) {
if (ObjectUtil.isNotEmpty(subjectId)) {
result.add(subjectId.trim());
}
}
return result.stream().distinct().toList();
}
private NutMap buildVariableSubject(List<String> subjectIds, Map<String, NutMap> subjectMap) {
String title = subjectIds.stream()
.map(subjectId -> subjectMap.get(subjectId).getString("title"))
.collect(Collectors.joining(" / "));
return NutMap.NEW()
.addv("id", String.join("|", subjectIds))
.addv("title", title);
}
private List<NutMap> buildVariableOptionCombinations(List<String> subjectIds, Map<String, NutMap> subjectMap, Map<String, List<QsvOption>> optionGroup) {
List<List<NutMap>> subjectOptionGroups = new ArrayList<>();
for (String subjectId : subjectIds) {
NutMap subject = subjectMap.get(subjectId);
List<QsvOption> options = optionGroup.get(subjectId);
if (ObjectUtil.isEmpty(options)) {
continue;
}
List<NutMap> optionMaps = options.stream()
.map(option -> NutMap.NEW()
.addv("subjectId", subjectId)
.addv("subjectTitle", subject.getString("title"))
.addv("optionId", option.getId())
.addv("optionText", option.getText()))
.toList();
subjectOptionGroups.add(optionMaps);
}
if (subjectOptionGroups.size() != subjectIds.size()) {
return new ArrayList<>();
}
List<NutMap> result = new ArrayList<>();
buildVariableOptionCombinations(subjectOptionGroups, 0, new ArrayList<>(), result);
return result;
}
private void buildVariableOptionCombinations(List<List<NutMap>> subjectOptionGroups, int index, List<NutMap> current, List<NutMap> result) {
if (index >= subjectOptionGroups.size()) {
String id = current.stream()
.map(option -> option.getString("subjectId") + ":" + option.getString("optionId"))
.collect(Collectors.joining("|"));
String text = current.stream()
.map(option -> option.getString("optionText"))
.collect(Collectors.joining(" / "));
List<NutMap> conditions = current.stream()
.map(option -> NutMap.NEW()
.addv("subjectId", option.getString("subjectId"))
.addv("optionId", option.getString("optionId")))
.toList();
result.add(NutMap.NEW()
.addv("id", id)
.addv("text", text)
.addv("conditions", conditions));
return;
}
for (NutMap option : subjectOptionGroups.get(index)) {
current.add(option);
buildVariableOptionCombinations(subjectOptionGroups, index + 1, current, result);
current.remove(current.size() - 1);
}
}
private List<QsvUserAnswerRecord> filterAnswerRecords(List<QsvUserAnswerRecord> answerRecords, List<NutMap> conditions) {
if (ObjectUtil.isEmpty(conditions)) {
return answerRecords;
}
return answerRecords.stream()
.filter(record -> isRecordMatched(record, conditions))
.toList();
}
private boolean isRecordMatched(QsvUserAnswerRecord record, List<NutMap> conditions) {
JSONObject extJson = record.getExtJson();
if (ObjectUtil.isEmpty(extJson)) {
return false;
}
for (NutMap condition : conditions) {
JSONObject subjectAnswer = extJson.get(condition.getString("subjectId"), JSONObject.class);
if (ObjectUtil.isEmpty(subjectAnswer)) {
return false;
}
JSONArray optionIds = subjectAnswer.getJSONArray("optionIds");
if (ObjectUtil.isEmpty(optionIds) || !optionIds.contains(condition.getString("optionId"))) {
return false;
}
}
return true;
}
private List<NutMap> querySubjects(String activityId) {
Sql subjectSql = Sqls.create("""
SELECT
@@ -309,7 +692,37 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
return excelExportEntities;
}
private List<NutMap> buildAnswerRecords(List<QsvUserAnswerRecord> answerRecords, Map<String, QsvSubject> subjectMap, List<QsvOption> options) {
/**
* 构建填空题填写详情。分析弹窗需要同时展示填写内容和答题人信息,
* 因此这里从已完成答题记录中提取当前题目的文本答案,并携带姓名、工号、所属工会和单位。
*
* @param answerRecords 当前活动已完成的答题记录
* @param subjectId 当前填空题ID,用于从 extJson 中取出该题填写内容
* @return 填空题填写详情列表,每条包含 loginName、userName、unionName、unitName、submitTime、text
*/
private List<NutMap> buildTextAnswerDetails(List<QsvUserAnswerRecord> answerRecords, String subjectId) {
List<NutMap> textAnswers = new ArrayList<>();
for (QsvUserAnswerRecord answerRecord : answerRecords) {
JSONObject extJson = answerRecord.getExtJson();
if (ObjectUtil.isEmpty(extJson)) {
continue;
}
JSONObject subjectAnswer = extJson.get(subjectId, JSONObject.class);
if (ObjectUtil.isEmpty(subjectAnswer) || ObjectUtil.isEmpty(subjectAnswer.getStr("text"))) {
continue;
}
textAnswers.add(NutMap.NEW()
.addv("loginName", answerRecord.getLoginName())
.addv("userName", answerRecord.getUserName())
.addv("unionName", answerRecord.getUnionName())
.addv("unitName", answerRecord.getUnitName())
.addv("submitTime", answerRecord.getSubmitTime())
.addv("text", subjectAnswer.getStr("text")));
}
return textAnswers;
}
private List<NutMap> buildAnswerRecords(List<QsvUserAnswerRecord> answerRecords, Map<String, QsvSubject> subjectMap, Map<String, QsvOption> optionMap) {
return answerRecords.stream().map(record -> {
NutMap map = NutMap.NEW()
.addv("id", record.getId())
@@ -318,20 +731,61 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
.addv("unitName", record.getUnitName())
.addv("unionName", record.getUnionName());
JSONObject extJson = record.getExtJson();
if (ObjectUtil.isEmpty(extJson)) {
return map;
}
extJson.forEach((k, v) -> {
JSONObject jsonVal = (JSONObject) v;
String type = subjectMap.get(k).getType();
if (type.equals("text")) {
map.addv(k, jsonVal.getStr("text"));
} else if (type.equals("radio") || type.equals("checkbox")) {
JSONArray optionIds = jsonVal.getJSONArray("optionIds");
String selectOptionTexts = options.stream().filter(option -> optionIds.contains(option.getId()))
.map(QsvOption::getText).collect(Collectors.joining(";"));
map.addv(k, selectOptionTexts);
QsvSubject subject = subjectMap.get(k);
if (subject == null || !(v instanceof JSONObject jsonVal)) {
return;
}
map.addv(k, buildAnswerText(jsonVal, subject, optionMap));
});
return map;
}).collect(Collectors.toList());
}
/**
* 组装单题答案展示内容。填空题返回填写文本;选择题返回选项文本,若选项配置了补充填写,
* 则追加对应补充内容,保证后台查看答卷和导出能看到用户填写的说明。
*
* @param jsonVal 答题记录 extJson 中当前题目的答案对象,包含 optionIds、text、optionFillContents
* @param subject 当前题目,用于判断题目类型
* @param optionMap 当前活动所有选项,key 为选项ID,value 为选项实体
* @return 当前题目的答案展示文本
*/
private String buildAnswerText(JSONObject jsonVal, QsvSubject subject, Map<String, QsvOption> optionMap) {
String type = subject.getType();
if ("text".equals(type)) {
return jsonVal.getStr("text");
}
if (!"radio".equals(type) && !"checkbox".equals(type)) {
return "";
}
JSONArray optionIds = jsonVal.getJSONArray("optionIds");
if (ObjectUtil.isEmpty(optionIds)) {
return "";
}
JSONObject optionFillContents = jsonVal.getJSONObject("optionFillContents");
List<String> answerTexts = new ArrayList<>();
for (Object optionIdObj : optionIds) {
String optionId = String.valueOf(optionIdObj);
QsvOption option = optionMap.get(optionId);
if (option == null) {
continue;
}
String answerText = option.getText();
if (ObjectUtil.isNotEmpty(optionFillContents)) {
String fillContent = optionFillContents.getStr(optionId);
if (ObjectUtil.isNotEmpty(fillContent)) {
answerText = answerText + ":" + fillContent;
}
}
answerTexts.add(answerText);
}
return String.join(";", answerTexts);
}
}
@@ -121,11 +121,12 @@ public class QsvUserAnswerRecordServiceImpl extends BaseServiceImpl<QsvUserAnswe
String mode = activity.getMode();
String scoreMode = activity.getScoreMode();
boolean highestScoreMode = "HIGH".equals(scoreMode) || "HIGHEST".equals(scoreMode);
if (mode.equals("SCHEDULED")) {
processScoreRecords(activityId, SecurityUtil.getUserId(), true, scoreMode.equals("HIGH"));
processScoreRecords(activityId, SecurityUtil.getUserId(), true, highestScoreMode);
} else if (mode.equals("REGULAR")) {
processScoreRecords(activityId, SecurityUtil.getUserId(), false, scoreMode.equals("HIGH"));
processScoreRecords(activityId, SecurityUtil.getUserId(), false, highestScoreMode);
}
}
@@ -1,6 +1,7 @@
package com.budwk.app.zhgh.dayofficework.tour.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination;
@@ -207,7 +208,7 @@ public class TourGroupController {
}
@At
@SaCheckPermission("tour.group")
@SaCheckPermission(value = {"tour.group", "tour.signup"}, mode = SaMode.OR)
public Result signupPageData(PageForm pageForm, String matterId, String keyword, String unionId, String groupCode) {
if (StrUtil.isBlank(matterId)) {
return Result.error("参数错误");
@@ -216,55 +217,114 @@ public class TourGroupController {
pageForm.defaultSortAsc("groupCode");
}
Cnd cnd = buildSignupQueryCnd(matterId, keyword, unionId, groupCode);
boolean maskOtherUnionPeople = shouldMaskOtherUnionSignupPeople();
String currentUnionId = StrUtil.blankToDefault(SecurityUtil.getUnionId(), "");
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
$condition
FROM (
SELECT t.id AS rowId
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
$condition
UNION ALL
SELECT f.id AS rowId
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
INNER JOIN tour_ledger_family f ON f.ledgerId = t.id AND f.delFlag = 0
$condition
AND (@maskOtherUnionPeople = 0 OR IFNULL(t.unionId, '') = @currentUnionId)
) p
""");
countSql.setCondition(cnd);
countSql.setParam("currentUnionId", currentUnionId);
countSql.setParam("maskOtherUnionPeople", maskOtherUnionPeople ? 1 : 0);
countSql.setCallback(Sqls.callback.integer());
tourLedgerService.dao().execute(countSql);
int count = countSql.getInt();
Sql listSql = Sqls.create("""
SELECT
t.id,
t.jobNo,
t.userName,
t.gender,
'' AS mobile,
t.idCard,
t.unionId,
t.unionName,
t.signupTime,
t.groupCode,
t.groupName,
IFNULL(f.familyCount, 0) AS familyCount,
1 + IFNULL(f.familyCount, 0) AS totalPeople
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) f ON f.ledgerId = t.id
$condition
ORDER BY $orderColumn $orderBy, t.signupTime ASC, t.createdAt ASC
SELECT *
FROM (
SELECT
t.id,
t.id AS ledgerId,
t.jobNo,
t.userName,
t.gender,
'' AS mobile,
CASE WHEN @maskOtherUnionPeople = 1 AND IFNULL(t.unionId, '') <> @currentUnionId THEN '' ELSE t.idCard END AS idCard,
t.unionId,
t.unionName,
t.signupTime,
t.groupCode,
t.groupName,
CASE WHEN @maskOtherUnionPeople = 1 AND IFNULL(t.unionId, '') <> @currentUnionId THEN 0 ELSE IFNULL(fc.familyCount, 0) END AS familyCount,
CASE WHEN @maskOtherUnionPeople = 1 AND IFNULL(t.unionId, '') <> @currentUnionId THEN 1 ELSE 1 + IFNULL(fc.familyCount, 0) END AS totalPeople,
1 AS staffRow,
1 AS selectable,
'教职工' AS rowTypeName,
'' AS relationship,
t.userName AS relatedStaffName,
0 AS rowSort,
t.createdAt AS ledgerCreatedAt,
t.createdAt
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) fc ON fc.ledgerId = t.id
$condition
UNION ALL
SELECT
f.id,
t.id AS ledgerId,
'' AS jobNo,
f.familyName AS userName,
f.gender,
'' AS mobile,
f.idCard,
t.unionId,
'' AS unionName,
t.signupTime,
t.groupCode,
t.groupName,
0 AS familyCount,
'' AS totalPeople,
0 AS staffRow,
0 AS selectable,
'家属' AS rowTypeName,
f.relationship,
t.userName AS relatedStaffName,
1 AS rowSort,
t.createdAt AS ledgerCreatedAt,
f.createdAt
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
INNER JOIN tour_ledger_family f ON f.ledgerId = t.id AND f.delFlag = 0
$condition
AND (@maskOtherUnionPeople = 0 OR IFNULL(t.unionId, '') = @currentUnionId)
) p
ORDER BY $orderColumn $orderBy, p.signupTime ASC, p.ledgerCreatedAt ASC, p.rowSort ASC, p.createdAt ASC
""");
listSql.setCondition(cnd);
listSql.setVar("orderColumn", getSignupOrderColumn(pageForm.getPageOrderName()));
listSql.setParam("currentUnionId", currentUnionId);
listSql.setParam("maskOtherUnionPeople", maskOtherUnionPeople ? 1 : 0);
listSql.setVar("orderColumn", getSignupUnionOrderColumn(pageForm.getPageOrderName()));
listSql.setVar("orderBy", "ascending".equals(pageForm.getPageOrderBy()) ? "asc" : "desc");
listSql.setPager(tourLedgerService.dao().createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
tourLedgerService.dao().execute(listSql);
List<NutMap> list = listSql.getList(NutMap.class);
fillSignupMobile(list);
flattenSignupFamilies(list);
// 人员列表已在 SQL 层按教职工和家属平铺后分页,这里只补齐教职工手机号,避免分页后再次追加家属行。
fillSignupMobile(list, maskOtherUnionPeople);
Pagination<NutMap> pagination = new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, list);
return Result.success(pagination);
}
@@ -324,7 +384,6 @@ public class TourGroupController {
cnd.andEX("t.unionId", "=", unionId);
// 团标签联动查询时只筛选当前团;未选择团标签时默认查询全部报名人员。
cnd.andEX("t.groupCode", "=", groupCode);
applyMatterDataScope(cnd);
return cnd;
}
@@ -554,12 +613,16 @@ public class TourGroupController {
}
}
private void fillSignupMobile(List<NutMap> list) {
private void fillSignupMobile(List<NutMap> list, boolean maskOtherUnionPeople) {
if (list == null || list.isEmpty()) {
return;
}
List<String> jobNos = new ArrayList<>();
String currentUnionId = StrUtil.blankToDefault(SecurityUtil.getUnionId(), "");
for (NutMap item : list) {
if (maskOtherUnionPeople && !currentUnionId.equals(item.getString("unionId", ""))) {
continue;
}
String jobNo = item.getString("jobNo", "");
if (StrUtil.isNotBlank(jobNo) && !jobNos.contains(jobNo)) {
jobNos.add(jobNo);
@@ -579,6 +642,10 @@ public class TourGroupController {
mobileMap.put(user.getString("loginname", ""), user.getString("mobile", ""));
}
for (NutMap item : list) {
if (maskOtherUnionPeople && !currentUnionId.equals(item.getString("unionId", ""))) {
item.put("mobile", "");
continue;
}
item.put("mobile", mobileMap.getOrDefault(item.getString("jobNo", ""), ""));
}
}
@@ -700,6 +767,15 @@ public class TourGroupController {
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name());
}
/**
* Branch union chairmen can browse all staff rows, but sensitive fields and family rows outside their union are hidden.
*/
private boolean shouldMaskOtherUnionSignupPeople() {
return AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())
&& !AuthUtil.hasRole(RoleConstant.SYSADMIN.name())
&& !AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_ADMIN.name());
}
private boolean hasBranchUnionScope() {
return AuthUtil.hasRole(RoleConstant.BRANCH_UNION_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())
@@ -749,28 +825,28 @@ public class TourGroupController {
return "l.lineName";
}
private String getSignupOrderColumn(String orderName) {
private String getSignupUnionOrderColumn(String orderName) {
if ("jobNo".equals(orderName)) {
return "t.jobNo";
return "p.jobNo";
}
if ("userName".equals(orderName)) {
return "t.userName";
return "p.userName";
}
if ("gender".equals(orderName)) {
return "t.gender";
return "p.gender";
}
if ("idCard".equals(orderName)) {
return "t.idCard";
return "p.idCard";
}
if ("unionName".equals(orderName)) {
return "t.unionName";
return "p.unionName";
}
if ("groupCode".equals(orderName) || "groupName".equals(orderName)) {
return "t.groupCode";
return "p.groupCode";
}
if ("totalPeople".equals(orderName)) {
return "totalPeople";
return "p.totalPeople";
}
return "t.signupTime";
return "p.signupTime";
}
}
@@ -448,6 +448,7 @@ public class TourMatterController {
oldMatter.setContactPhone(matter.getContactPhone());
oldMatter.setMinGroupPeople(matter.getMinGroupPeople());
oldMatter.setMaxGroupPeople(matter.getMaxGroupPeople());
oldMatter.setMaxSignupPeople(matter.getMaxSignupPeople());
oldMatter.setEstimatedCost(matter.getEstimatedCost());
tourMatterService.update(oldMatter);
return Result.success();
@@ -547,6 +548,10 @@ public class TourMatterController {
if (matter.getMinGroupPeople() == null || matter.getMinGroupPeople() <= 0) {
return Result.error("最少成团人数必须大于0");
}
// 最多报名人数只控制报名提交容量;为空或0表示不限制,不能影响成团分配人数。
if (matter.getMaxSignupPeople() != null && matter.getMaxSignupPeople() < 0) {
return Result.error("最多报名人数不能小于0");
}
if (matter.getEstimatedCost() == null || matter.getEstimatedCost().signum() < 0) {
return Result.error("预计费用不能小于0");
}
@@ -290,6 +290,7 @@ public class TourMySignupController {
m.unionId AS matterUnionId,
m.travelStartTime,
m.travelEndTime,
m.maxSignupPeople,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
@@ -440,7 +441,7 @@ public class TourMySignupController {
return familyResult;
}
TourMatter matter = tourMatterService.fetch(ledger.getMatterId());
Result ruleResult = checkSignupRule(ledger, matter, oldLedger);
Result ruleResult = checkSignupRule(ledger, matter, oldLedger, calculateCurrentSignupPeople(familyList));
if (ruleResult != null) {
return ruleResult;
}
@@ -600,7 +601,7 @@ public class TourMySignupController {
return null;
}
private Result checkSignupRule(TourLedger ledger, TourMatter matter, TourLedger oldLedger) {
private Result checkSignupRule(TourLedger ledger, TourMatter matter, TourLedger oldLedger, int currentPeopleCount) {
if (matter == null || Boolean.TRUE.equals(matter.getDelFlag()) || !Boolean.TRUE.equals(matter.getEnabled())) {
return Result.error("报名出行时段不存在或已停用");
}
@@ -618,6 +619,10 @@ public class TourMySignupController {
if (setting == null || Boolean.TRUE.equals(setting.getDelFlag()) || !Boolean.TRUE.equals(setting.getEnabled())) {
return Result.error("报名事项配置不存在或已停用");
}
Result maxSignupResult = checkMaxSignupPeople(matter, oldLedger, currentPeopleCount);
if (maxSignupResult != null) {
return maxSignupResult;
}
Integer activityGroupId = parseInteger(setting.getActivityGroupId());
if (activityGroupId == null || !activityBasicScopeService.isUserInGroup(activityGroupId, SecurityUtil.getUserId())) {
return Result.error("您不在本次疗休养报名范围内");
@@ -657,6 +662,8 @@ public class TourMySignupController {
if (tourLedgerService.count(outProvinceCnd) > 0) {
return Result.error("您已参加过省外线路,不能报名省外线路,请选择省内线路");
}
// Out-province quota is shared by year; lock only before the quota count calculation.
tourLedgerService.lockTourSignupQuotaYear(matter.getYear());
OutProvinceQuota quota = calculateOutProvinceQuota(matter, setting, oldLedger);
if (!quota.canApply()) {
return Result.error(buildOutProvinceQuotaNotice(quota));
@@ -688,6 +695,61 @@ public class TourMySignupController {
return null;
}
/**
* Checks the matter maximum signup capacity at final submit time.
*/
private Result checkMaxSignupPeople(TourMatter matter, TourLedger oldLedger, int currentPeopleCount) {
int maxSignupPeople = matter == null || matter.getMaxSignupPeople() == null ? 0 : matter.getMaxSignupPeople();
if (maxSignupPeople <= 0) {
return null;
}
// Maximum signup capacity is owned by the current matter, so only the same matter is serialized.
tourLedgerService.lockTourSignupMatter(matter.getId());
String excludeLedgerId = oldLedger == null ? null : oldLedger.getId();
int signedPeople = countMatterSignupPeople(matter.getId(), excludeLedgerId);
if (signedPeople + currentPeopleCount > maxSignupPeople) {
return Result.error("当前线路最多报名人数为" + maxSignupPeople + "人,当前已报名" + signedPeople
+ "人,本次报名" + currentPeopleCount + "人,不能超过最多报名人数");
}
return null;
}
/**
* Counts current submit people as one staff member plus all submitted family members.
*/
private int calculateCurrentSignupPeople(List<TourLedgerFamily> familyList) {
return 1 + (familyList == null ? 0 : familyList.size());
}
/**
* Counts valid signed people for a matter, including staff and their family members.
*/
private int countMatterSignupPeople(String matterId, String excludeLedgerId) {
if (StrUtil.isBlank(matterId)) {
return 0;
}
String excludeSql = StrUtil.isBlank(excludeLedgerId) ? "" : " AND t.id <> @excludeLedgerId";
Sql sql = Sqls.create("""
SELECT IFNULL(SUM(1 + IFNULL(f.familyCount, 0)), 0) AS peopleCount
FROM tour_ledger t
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) f ON f.ledgerId = t.id
WHERE t.delFlag = 0
AND t.matterId = @matterId
""" + excludeSql);
sql.setParam("matterId", matterId);
if (StrUtil.isNotBlank(excludeLedgerId)) {
sql.setParam("excludeLedgerId", excludeLedgerId);
}
sql.setCallback(Sqls.callback.integer());
tourLedgerService.dao().execute(sql);
return sql.getInt();
}
private TourLedger fetchLatestLedger(Cnd cnd) {
cnd.desc(TourLedger::getSignupTime);
cnd.desc(TourLedger::getCreatedAt);
@@ -97,18 +97,7 @@ public class TourSchoolUnionApprovalController {
task.taskParentId,
task.variable AS taskVariable,
IFNULL(GROUP_CONCAT(DISTINCT nt.displayName), '结束') AS curTaskName,
CASE
WHEN m.id IS NULL THEN COALESCE(NULLIF(l.lineName, ''), t.lineName)
ELSE CONCAT(
COALESCE(NULLIF(l.lineName, ''), t.lineName),
'(',
CASE
WHEN IFNULL(m.unionId, '') = '' THEN '校工会'
ELSE IFNULL(mu.name, '')
END,
')'
)
END AS currentLineName,
COALESCE(NULLIF(l.lineName, ''), t.lineName) AS currentLineName,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
@@ -479,18 +468,7 @@ public class TourSchoolUnionApprovalController {
return "";
}
Sql sql = Sqls.create("""
SELECT CASE
WHEN m.id IS NULL THEN COALESCE(NULLIF(l.lineName, ''), t.lineName)
ELSE CONCAT(
COALESCE(NULLIF(l.lineName, ''), t.lineName),
'(',
CASE
WHEN IFNULL(m.unionId, '') = '' THEN '校工会'
ELSE IFNULL(mu.name, '')
END,
')'
)
END AS lineName
SELECT COALESCE(NULLIF(l.lineName, ''), t.lineName) AS lineName
FROM tour_ledger t
LEFT JOIN tour_matter m ON m.id = t.matterId AND m.delFlag = 0
LEFT JOIN tour_line l ON l.id = t.lineId AND l.delFlag = 0
@@ -153,7 +153,7 @@ public class TourSettingController {
if (Lang.isNotEmpty(lotDeleteList)) {
tourSettingService.dao().clear(TourSettingLot.class, Cnd.where(TourSettingLot::getId, "in", lotDeleteList));
}
tourSettingService.updateIgnoreNull(tourSetting);
tourSettingService.updateWithNullableSignupTime(tourSetting);
saveLots(tourSetting);
}
return Result.success();
@@ -205,10 +205,9 @@ public class TourSettingController {
if (StrUtil.isBlank(tourSetting.getConfigName())) {
return Result.error("配置名称不能为空");
}
if (StrUtil.isBlank(tourSetting.getSignupStartTime()) || StrUtil.isBlank(tourSetting.getSignupEndTime())) {
return Result.error("报名时间不能为空");
}
if (tourSetting.getSignupStartTime().compareTo(tourSetting.getSignupEndTime()) >= 0) {
// 报名时间允许不配置;仅在开始和结束都填写时校验先后顺序。
if (StrUtil.isNotBlank(tourSetting.getSignupStartTime()) && StrUtil.isNotBlank(tourSetting.getSignupEndTime())
&& tourSetting.getSignupStartTime().compareTo(tourSetting.getSignupEndTime()) >= 0) {
return Result.error("开始报名时间必须小于结束报名时间");
}
if (tourSetting.getCycleStartYear() != null && tourSetting.getCycleEndYear() != null
@@ -1,5 +1,9 @@
package com.budwk.app.zhgh.dayofficework.tour.controller;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.lang.Dict;
@@ -7,6 +11,7 @@ import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.flow.constant.FlowConst;
import com.budwk.app.flow.engine.FlowEngine;
import com.budwk.app.flow.entity.ProcessInstance;
@@ -44,10 +49,13 @@ 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 org.apache.poi.ss.usermodel.Workbook;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@@ -171,6 +179,9 @@ public class TourSignupController {
lot.lotName,
m.travelStartTime,
m.travelEndTime,
COALESCE(NULLIF(s.signupStartTime, ''), m.signupStartTime) AS signupStartTime,
COALESCE(NULLIF(s.signupEndTime, ''), m.signupEndTime) AS signupEndTime,
m.maxSignupPeople,
IFNULL(sc.signupCount, 0) AS signupCount,
IFNULL(sc.familyCount, 0) AS familyCount,
my.ledgerId,
@@ -183,6 +194,7 @@ public class TourSignupController {
FROM tour_matter m
INNER JOIN tour_line l ON l.id = m.lineId
LEFT JOIN sys_union u ON u.id = m.unionId
LEFT JOIN tour_setting s ON s.id = m.settingId
LEFT JOIN tour_travel_agency a ON a.id = l.travelAgencyId
LEFT JOIN tour_setting_lot lot ON lot.id = l.lotId
LEFT JOIN (
@@ -265,6 +277,7 @@ public class TourSignupController {
m.matterName,
m.minGroupPeople,
m.maxGroupPeople,
m.maxSignupPeople,
m.unionId,
COALESCE(u.name, '校工会') AS unionName,
m.lineId,
@@ -330,7 +343,7 @@ public class TourSignupController {
listSql.setCondition(cnd);
listSql.setParam("currentJobNo", currentJobNo);
listSql.setParam("abandonState", ProcessInstanceStateEnum.ABANDON.getCode());
listSql.setVar("orderColumn", getOrderColumn(pageForm.getPageOrderName()));
listSql.setVar("orderColumn", tourLedgerService.getTourSignupOrderColumn(pageForm.getPageOrderName()));
listSql.setVar("orderBy", "descending".equals(pageForm.getPageOrderBy()) ? "desc" : "asc");
listSql.setPager(tourMatterService.dao().createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
@@ -340,6 +353,28 @@ public class TourSignupController {
return Result.success(pagination);
}
/**
* 导出 PC 端疗休养报名列表,导出范围与当前页面查询条件保持一致。
*/
@At
@SaCheckPermission("tour.signup")
@Ok("void")
public void exportData(PageForm pageForm, Integer year, String lineName, String lineType, String unionId,
HttpServletResponse response) {
if (StrUtil.isBlank(pageForm.getPageOrderName())) {
pageForm.defaultSortAsc("lineCode");
}
// 导出不分页,按当前查询条件和排序口径查询全量列表数据。
List<NutMap> rows = querySignupExportRows(pageForm, year, lineName, lineType, unionId);
List<ExcelExportEntity> entities = buildSignupExportEntities();
// 导出文件统一使用 XSSF,避免大数据量时生成旧版 xls 格式。
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, entities, rows);
CommonDownloadUtil.download("疗休养报名.xlsx", workbook, response);
}
@At
@SaCheckPermission(value = {"tour.signup", "h5.tour.signup"}, mode = SaMode.OR)
public Result unionOptions() {
@@ -464,6 +499,7 @@ public class TourSignupController {
m.travelStartTime,
m.travelEndTime,
m.contactName,
m.maxSignupPeople,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
@@ -529,19 +565,6 @@ public class TourSignupController {
.and(TourLedger::getJobNo, "=", staff.getString("jobNo"));
ledgerCnd.desc(TourLedger::getCreatedAt);
TourLedger ledger = tourLedgerService.fetch(ledgerCnd);
TourMatter matterEntity = tourMatterService.fetch(matter.getString("matterId"));
TourLedger ruleLedger = new TourLedger();
ruleLedger.setId(ledger == null ? null : ledger.getId());
ruleLedger.setYear(matterEntity == null ? matter.getInt("year") : matterEntity.getYear());
ruleLedger.setMatterId(matter.getString("matterId"));
ruleLedger.setLineId(matter.getString("lineId"));
ruleLedger.setLineName(matter.getString("lineName"));
if (!Boolean.TRUE.equals(noticeChecked)) {
Result ruleResult = checkSignupRule(ruleLedger, matterEntity, ledger);
if (ruleResult != null) {
return ruleResult;
}
}
List<TourLedgerFamily> families = Collections.emptyList();
TourLedgerDirectRelative directRelative = null;
if (ledger != null) {
@@ -583,47 +606,8 @@ public class TourSignupController {
if (activityGroupId == null || !activityBasicScopeService.isUserInGroup(activityGroupId, SecurityUtil.getUserId())) {
return Result.error("您不在本次疗休养报名范围内");
}
TourLedger oldLedger = fetchCurrentUserMatterLedger(matter.getId());
CycleAllowedTimes cycleAllowedTimes = calculateCycleAllowedTimes(matter, setting, oldLedger);
if (!cycleAllowedTimes.canApply()) {
return Result.success(NutMap.NEW()
.addv("canApply", false)
.addv("noticeRequired", true)
.addv("noticeType", "cycleAllowedTimes")
.addv("cycleStartYear", cycleAllowedTimes.startYear())
.addv("cycleEndYear", cycleAllowedTimes.endYear())
.addv("cycleAllowedTimes", cycleAllowedTimes.allowedTimes())
.addv("cycleJoinedTimes", cycleAllowedTimes.joinedTimes())
.addv("message", buildCycleAllowedTimesNotice(cycleAllowedTimes)));
}
CycleTotalCost cycleTotalCost = calculateCycleTotalCost(matter, setting, line, oldLedger);
if (!cycleTotalCost.canApply()) {
return Result.success(NutMap.NEW()
.addv("canApply", false)
.addv("noticeRequired", true)
.addv("noticeType", "cycleTotalCost")
.addv("cycleStartYear", cycleTotalCost.startYear())
.addv("cycleEndYear", cycleTotalCost.endYear())
.addv("cycleTotalCost", cycleTotalCost.totalCost())
.addv("cycleJoinedCost", cycleTotalCost.joinedCost())
.addv("currentLineCost", cycleTotalCost.currentCost())
.addv("message", buildCycleTotalCostNotice(cycleTotalCost)));
}
if (!isOutProvinceLine(line.getLineType())) {
return Result.success(NutMap.NEW().addv("canApply", true).addv("noticeRequired", false));
}
OutProvinceQuota quota = calculateOutProvinceQuota(matter, setting, oldLedger);
return Result.success(NutMap.NEW()
.addv("noticeRequired", true)
.addv("noticeType", "outProvinceQuota")
.addv("canApply", quota.canApply())
.addv("ratioType", quota.ratioType())
.addv("basePeople", quota.basePeople())
.addv("totalSignupPeople", quota.totalSignupPeople())
.addv("outProvinceSignupPeople", quota.outProvinceSignupPeople())
.addv("allowPeople", quota.allowPeople())
.addv("ratioText", quota.ratioText())
.addv("message", buildOutProvinceQuotaNotice(quota)));
// 我要报名入口不再做周期次数、周期费用和省外名额提示,最终报名规则由 doSignup 统一校验。
return Result.success(NutMap.NEW().addv("canApply", true).addv("noticeRequired", false));
}
@At
@@ -740,7 +724,8 @@ public class TourSignupController {
return Result.error("不能修改他人的报名记录");
}
TourMatter matter = tourMatterService.fetch(ledger.getMatterId());
Result ruleResult = checkSignupRule(ledger, matter, update ? oldLedger : null);
TourLedger ruleOldLedger = update ? oldLedger : fetchCurrentUserMatterLedger(ledger.getMatterId());
Result ruleResult = checkSignupRule(ledger, matter, update ? oldLedger : null, ruleOldLedger, calculateCurrentSignupPeople(familyList));
if (ruleResult != null) {
return ruleResult;
}
@@ -837,6 +822,12 @@ public class TourSignupController {
if (!currentJobNo().equals(ledger.getJobNo())) {
return Result.error("不能取消他人的报名记录");
}
TourMatter matter = tourMatterService.fetch(ledger.getMatterId());
TourSetting setting = matter == null ? null : tourLedgerService.dao().fetch(TourSetting.class, matter.getSettingId());
Result signupTimeResult = tourLedgerService.checkSignupTimeWindow(matter, setting);
if (signupTimeResult != null) {
return signupTimeResult;
}
tourLedgerFamilyService.clear(Cnd.where(TourLedgerFamily::getLedgerId, "=", ledger.getId()));
tourLedgerDirectRelativeService.clear(Cnd.where(TourLedgerDirectRelative::getLedgerId, "=", ledger.getId()));
tourLedgerService.clear(Cnd.where(TourLedger::getId, "=", ledger.getId()));
@@ -963,7 +954,7 @@ public class TourSignupController {
&& idCard.matches("^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dX]$");
}
private Result checkSignupRule(TourLedger ledger, TourMatter matter, TourLedger oldLedger) {
private Result checkSignupRule(TourLedger ledger, TourMatter matter, TourLedger oldLedger, TourLedger maxGroupOldLedger, int currentPeopleCount) {
if (matter == null || Boolean.TRUE.equals(matter.getDelFlag()) || !Boolean.TRUE.equals(matter.getEnabled())) {
return Result.error("报名出行时段不存在或已停用");
}
@@ -978,6 +969,10 @@ public class TourSignupController {
if (setting == null || Boolean.TRUE.equals(setting.getDelFlag()) || !Boolean.TRUE.equals(setting.getEnabled())) {
return Result.error("报名事项配置不存在或已停用");
}
Result maxSignupResult = checkMaxSignupPeople(matter, maxGroupOldLedger, currentPeopleCount);
if (maxSignupResult != null) {
return maxSignupResult;
}
Integer activityGroupId = parseInteger(setting.getActivityGroupId());
if (activityGroupId == null || !activityBasicScopeService.isUserInGroup(activityGroupId, SecurityUtil.getUserId())) {
return Result.error("您不在本次疗休养报名范围内");
@@ -1017,6 +1012,8 @@ public class TourSignupController {
if (tourLedgerService.count(outProvinceCnd) > 0) {
return Result.error("您已参加过省外线路,不能报名省外线路,请选择省内线路");
}
// Out-province quota is shared by year; lock only before the quota count calculation.
tourLedgerService.lockTourSignupQuotaYear(matter.getYear());
OutProvinceQuota quota = calculateOutProvinceQuota(matter, setting, oldLedger);
if (!quota.canApply()) {
return Result.error(buildOutProvinceQuotaNotice(quota));
@@ -1048,6 +1045,61 @@ public class TourSignupController {
return null;
}
/**
* Checks the matter maximum signup capacity at final submit time.
*/
private Result checkMaxSignupPeople(TourMatter matter, TourLedger oldLedger, int currentPeopleCount) {
int maxSignupPeople = matter == null || matter.getMaxSignupPeople() == null ? 0 : matter.getMaxSignupPeople();
if (maxSignupPeople <= 0) {
return null;
}
// Maximum signup capacity is owned by the current matter, so only the same matter is serialized.
tourLedgerService.lockTourSignupMatter(matter.getId());
String excludeLedgerId = oldLedger == null ? null : oldLedger.getId();
int signedPeople = countMatterSignupPeople(matter.getId(), excludeLedgerId);
if (signedPeople + currentPeopleCount > maxSignupPeople) {
return Result.error("当前线路最多报名人数为" + maxSignupPeople + "人,当前已报名" + signedPeople
+ "人,本次报名" + currentPeopleCount + "人,不能超过最多报名人数");
}
return null;
}
/**
* Counts current submit people as one staff member plus all submitted family members.
*/
private int calculateCurrentSignupPeople(List<TourLedgerFamily> familyList) {
return 1 + (familyList == null ? 0 : familyList.size());
}
/**
* Counts valid signed people for a matter, including staff and their family members.
*/
private int countMatterSignupPeople(String matterId, String excludeLedgerId) {
if (StrUtil.isBlank(matterId)) {
return 0;
}
String excludeSql = StrUtil.isBlank(excludeLedgerId) ? "" : " AND t.id <> @excludeLedgerId";
Sql sql = Sqls.create("""
SELECT IFNULL(SUM(1 + IFNULL(f.familyCount, 0)), 0) AS peopleCount
FROM tour_ledger t
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) f ON f.ledgerId = t.id
WHERE t.delFlag = 0
AND t.matterId = @matterId
""" + excludeSql);
sql.setParam("matterId", matterId);
if (StrUtil.isNotBlank(excludeLedgerId)) {
sql.setParam("excludeLedgerId", excludeLedgerId);
}
sql.setCallback(Sqls.callback.integer());
tourLedgerService.dao().execute(sql);
return sql.getInt();
}
private TourLedger fetchLatestLedger(Cnd cnd) {
cnd.desc(TourLedger::getSignupTime);
cnd.desc(TourLedger::getCreatedAt);
@@ -1435,6 +1487,79 @@ public class TourSignupController {
return null;
}
/**
* 按 PC 疗休养报名列表同一批查询条件导出全量数据,导出不分页但保留当前排序口径。
*/
private List<NutMap> querySignupExportRows(PageForm pageForm, Integer year, String lineName, String lineType, String unionId) {
Cnd cnd = buildQueryCnd(year, lineName, lineType, unionId);
Sql sql = Sqls.create("""
SELECT
l.lineName,
l.lineType,
m.minGroupPeople,
m.maxGroupPeople,
m.maxSignupPeople,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
ELSE ''
END AS travelPeriod,
IFNULL(sc.signupCount, 0) AS signupCount
FROM tour_matter m
INNER JOIN tour_line l ON l.id = m.lineId
LEFT JOIN sys_union u ON u.id = m.unionId
LEFT JOIN tour_setting_lot lot ON lot.id = l.lotId
LEFT JOIN (
SELECT t.matterId, SUM(1 + IFNULL(f.familyCount, 0)) AS signupCount
FROM tour_ledger t
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) f ON f.ledgerId = t.id
WHERE t.delFlag = 0
AND t.matterId IS NOT NULL
AND t.matterId <> ''
GROUP BY t.matterId
) sc ON sc.matterId = m.id
$condition
ORDER BY $orderColumn $orderBy, CAST(l.lineCode AS UNSIGNED) ASC, m.`year` DESC, m.travelStartTime ASC, l.lineName ASC, m.createdAt DESC
""");
sql.setCondition(cnd);
sql.setVar("orderColumn", tourLedgerService.getTourSignupOrderColumn(pageForm.getPageOrderName()));
sql.setVar("orderBy", "descending".equals(pageForm.getPageOrderBy()) ? "desc" : "asc");
sql.setCallback(Sqls.callback.maps());
tourMatterService.dao().execute(sql);
List<NutMap> rows = sql.getList(NutMap.class);
for (int i = 0; i < rows.size(); i++) {
NutMap row = rows.get(i);
row.put("indexNo", i + 1);
row.put("minGroupPeopleText", row.getInt("minGroupPeople", 0) + "人");
row.put("maxGroupPeopleText", row.getInt("maxGroupPeople", 0) + "人");
row.put("maxSignupPeopleText", row.getInt("maxSignupPeople", 0) + "人");
row.put("signupCountText", row.getInt("signupCount", 0) + "人");
}
return rows;
}
/**
* Builds export columns consistent with the PC list; signup count is always included.
*/
private List<ExcelExportEntity> buildSignupExportEntities() {
List<ExcelExportEntity> entities = new ArrayList<>();
entities.add(new ExcelExportEntity("序号", "indexNo", 10));
entities.add(new ExcelExportEntity("线路名称", "lineName", 30));
entities.add(new ExcelExportEntity("出行时段", "travelPeriod", 28));
entities.add(new ExcelExportEntity("线路类型", "lineType", 16));
entities.add(new ExcelExportEntity("最少成团人数", "minGroupPeopleText", 16));
entities.add(new ExcelExportEntity("最多成团人数", "maxGroupPeopleText", 16));
entities.add(new ExcelExportEntity("最多报名人数", "maxSignupPeopleText", 16));
entities.add(new ExcelExportEntity("报名人数", "signupCountText", 16));
return entities;
}
private Result checkSignupApprovalWorkflowCanSubmit(String ledgerId) {
ProcessInstance instance = tourLedgerService.dao().fetch(ProcessInstance.class, Cnd.where(ProcessInstance::getBusinessNo, "=", ledgerId)
.and(ProcessInstance::getState, "<>", ProcessInstanceStateEnum.ABANDON.getCode())
@@ -1518,6 +1643,9 @@ public class TourSignupController {
if ("maxGroupPeople".equals(orderName)) {
return "m.maxGroupPeople";
}
if ("maxSignupPeople".equals(orderName)) {
return "m.maxSignupPeople";
}
if ("signupCount".equals(orderName)) {
return "signupCount";
}
@@ -452,7 +452,9 @@ public class TourUndertakeController {
* 线路成团按事项归属控制数据范围:校工会查看全部;分工会查看本工会事项;其它角色仅查看自己创建的事项。
*/
private void applyMatterDataScope(Cnd cnd) {
if (hasSchoolUnionScope() || AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())) {
if (hasSchoolUnionScope()
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())
|| hasTourApplicantScope()) {
return;
}
if (hasBranchUnionScope()) {
@@ -480,6 +482,13 @@ public class TourUndertakeController {
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name());
}
/**
* 疗休养领办人只扩大领办线路查询范围,不改变领办、解除等操作权限。
*/
private boolean hasTourApplicantScope() {
return AuthUtil.hasRole(RoleConstant.TOUR_APPLICANT.name());
}
private boolean hasBranchUnionScope() {
return AuthUtil.hasRole(RoleConstant.BRANCH_UNION_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())
@@ -97,18 +97,7 @@ public class TourUnionApprovalController {
task.taskParentId,
task.variable AS taskVariable,
IFNULL(GROUP_CONCAT(DISTINCT nt.displayName), '结束') AS curTaskName,
CASE
WHEN m.id IS NULL THEN COALESCE(NULLIF(l.lineName, ''), t.lineName)
ELSE CONCAT(
COALESCE(NULLIF(l.lineName, ''), t.lineName),
'(',
CASE
WHEN IFNULL(m.unionId, '') = '' THEN '校工会'
ELSE IFNULL(mu.name, '')
END,
')'
)
END AS currentLineName,
COALESCE(NULLIF(l.lineName, ''), t.lineName) AS currentLineName,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
@@ -479,18 +468,7 @@ public class TourUnionApprovalController {
return "";
}
Sql sql = Sqls.create("""
SELECT CASE
WHEN m.id IS NULL THEN COALESCE(NULLIF(l.lineName, ''), t.lineName)
ELSE CONCAT(
COALESCE(NULLIF(l.lineName, ''), t.lineName),
'(',
CASE
WHEN IFNULL(m.unionId, '') = '' THEN '校工会'
ELSE IFNULL(mu.name, '')
END,
')'
)
END AS lineName
SELECT COALESCE(NULLIF(l.lineName, ''), t.lineName) AS lineName
FROM tour_ledger t
LEFT JOIN tour_matter m ON m.id = t.matterId AND m.delFlag = 0
LEFT JOIN tour_line l ON l.id = t.lineId AND l.delFlag = 0
@@ -94,18 +94,7 @@ public class TourUnionLedgerController {
ins.processDefineId AS instanceProcessDefineId,
IFNULL(l.directFamilyUnitLine, 0) AS directFamilyUnitLine,
IFNULL(GROUP_CONCAT(DISTINCT task.displayName), IF(ins.id IS NULL, '', '结束')) AS curTaskName,
CASE
WHEN m.id IS NULL THEN COALESCE(NULLIF(l.lineName, ''), t.lineName)
ELSE CONCAT(
COALESCE(NULLIF(l.lineName, ''), t.lineName),
'(',
CASE
WHEN IFNULL(m.unionId, '') = '' THEN '校工会'
ELSE IFNULL(mu.name, '')
END,
')'
)
END AS currentLineName,
COALESCE(NULLIF(l.lineName, ''), t.lineName) AS currentLineName,
IFNULL(f.familyCount, 0) AS familyCount,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
@@ -804,18 +793,7 @@ public class TourUnionLedgerController {
return "";
}
Sql sql = Sqls.create("""
SELECT CASE
WHEN m.id IS NULL THEN COALESCE(NULLIF(l.lineName, ''), t.lineName)
ELSE CONCAT(
COALESCE(NULLIF(l.lineName, ''), t.lineName),
'(',
CASE
WHEN IFNULL(m.unionId, '') = '' THEN '校工会'
ELSE IFNULL(mu.name, '')
END,
')'
)
END AS lineName
SELECT COALESCE(NULLIF(l.lineName, ''), t.lineName) AS lineName
FROM tour_ledger t
LEFT JOIN tour_matter m ON m.id = t.matterId AND m.delFlag = 0
LEFT JOIN tour_line l ON l.id = t.lineId AND l.delFlag = 0
@@ -117,6 +117,11 @@ public class TourMatter extends BaseModel implements Serializable {
@ColDefine(type = ColType.INT)
private Integer maxGroupPeople;
@Column
@Comment("最多报名人数")
@ColDefine(type = ColType.INT)
private Integer maxSignupPeople;
@Column
@Comment("预计费用")
@ColDefine(customType = "decimal(10,2)")
@@ -3,6 +3,8 @@ package com.budwk.app.zhgh.dayofficework.tour.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.dayofficework.tour.models.TourLedger;
import com.budwk.app.zhgh.dayofficework.tour.models.TourMatter;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSetting;
import org.nutz.dao.Cnd;
import org.nutz.lang.util.NutMap;
@@ -55,4 +57,36 @@ public interface TourLedgerService extends BaseService<TourLedger> {
* @return 解除结果,包含参数和当前团是否已领办校验
*/
Result releaseUndertakeGroup(String matterId, String groupCode);
/**
* Locks the active yearly tour setting row before out-province quota calculation.
*
* @param year tour signup year that shares one out-province quota pool.
*/
void lockTourSignupQuotaYear(Integer year);
/**
* Locks the current tour matter row before matter-level signup capacity calculation.
*
* @param matterId tour matter id that owns the maximum signup capacity limit.
*/
void lockTourSignupMatter(String matterId);
/**
* Checks whether the current time is inside the effective tour signup window.
* Setting-level signup time keeps the existing priority and falls back to matter-level time when blank.
*
* @param matter selected tour matter that carries the fallback signup window.
* @param setting tour setting that carries the primary signup window.
* @return null when current time is allowed; otherwise a business error result.
*/
Result checkSignupTimeWindow(TourMatter matter, TourSetting setting);
/**
* Resolves the tour signup page order column and keeps frontend sorting within known list fields.
*
* @param orderName requested frontend sort property.
* @return safe SQL order column for the tour signup list.
*/
String getTourSignupOrderColumn(String orderName);
}
@@ -4,4 +4,11 @@ import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSetting;
public interface TourSettingService extends BaseService<TourSetting> {
/**
* 更新疗休养配置,并允许报名开始时间、报名结束时间被清空。
*
* @param tourSetting 页面提交的疗休养配置
*/
void updateWithNullableSignupTime(TourSetting tourSetting);
}
@@ -7,6 +7,8 @@ import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.dayofficework.tour.models.TourLedger;
import com.budwk.app.zhgh.dayofficework.tour.models.TourMatter;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSetting;
import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Chain;
@@ -19,6 +21,8 @@ import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
@@ -186,6 +190,121 @@ public class TourLedgerServiceImpl extends BaseServiceImpl<TourLedger> implement
return Result.success();
}
/**
* Locks the yearly setting row so concurrent out-province signup checks read updated quota counts one by one.
*/
@Override
public void lockTourSignupQuotaYear(Integer year) {
if (year == null) {
return;
}
// MySQL/InnoDB holds this row lock until the current signup transaction commits.
Sql sql = Sqls.create("""
SELECT id
FROM tour_setting
WHERE delFlag = 0
AND `year` = @year
ORDER BY id
FOR UPDATE
""");
sql.setParam("year", year);
sql.setCallback(Sqls.callback.maps());
dao().execute(sql);
}
/**
* Locks one matter row so concurrent submissions for the same travel period check capacity one by one.
*/
@Override
public void lockTourSignupMatter(String matterId) {
if (StrUtil.isBlank(matterId)) {
return;
}
// MySQL/InnoDB holds this matter row lock until the current signup transaction commits.
Sql sql = Sqls.create("""
SELECT id
FROM tour_matter
WHERE delFlag = 0
AND id = @matterId
FOR UPDATE
""");
sql.setParam("matterId", matterId);
sql.setCallback(Sqls.callback.maps());
dao().execute(sql);
}
/**
* Checks the current time against the effective signup window used by tour signup and cancellation.
*/
@Override
public Result checkSignupTimeWindow(TourMatter matter, TourSetting setting) {
if (matter == null || setting == null) {
return Result.error("报名事项配置不存在或已停用");
}
String signupStartTime = StrUtil.blankToDefault(setting.getSignupStartTime(), matter.getSignupStartTime());
String signupEndTime = StrUtil.blankToDefault(setting.getSignupEndTime(), matter.getSignupEndTime());
if (StrUtil.isBlank(signupStartTime) || StrUtil.isBlank(signupEndTime)) {
return Result.error("报名时间未配置");
}
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
if (now.compareTo(normalizeSignupDateTime(signupStartTime, false)) < 0) {
return Result.error("报名未开始");
}
if (now.compareTo(normalizeSignupDateTime(signupEndTime, true)) > 0) {
return Result.error("报名已结束");
}
return null;
}
/**
* Normalizes date-only signup values so the end date includes the whole day.
*/
private String normalizeSignupDateTime(String value, boolean endOfDay) {
String text = StrUtil.blankToDefault(value, "").trim();
if (text.length() == 10) {
return text + (endOfDay ? " 23:59:59" : " 00:00:00");
}
return text;
}
/**
* Keeps controller SQL ordering safe by resolving only known list fields.
*/
@Override
public String getTourSignupOrderColumn(String orderName) {
if ("lineCode".equals(orderName)) {
return "CAST(l.lineCode AS UNSIGNED)";
}
if ("lineName".equals(orderName)) {
return "l.lineName";
}
if ("travelPeriod".equals(orderName)) {
return "m.travelStartTime";
}
if ("matterName".equals(orderName)) {
return "m.matterName";
}
if ("unionName".equals(orderName)) {
return "u.name";
}
if ("lineType".equals(orderName)) {
return "l.lineType";
}
if ("minGroupPeople".equals(orderName)) {
return "m.minGroupPeople";
}
if ("maxGroupPeople".equals(orderName)) {
return "m.maxGroupPeople";
}
if ("maxSignupPeople".equals(orderName)) {
return "m.maxSignupPeople";
}
if ("signupCount".equals(orderName)) {
return "signupCount";
}
return "CAST(l.lineCode AS UNSIGNED)";
}
private NutMap fetchAssignMatter(String matterId) {
Cnd cnd = buildAssignMatterCnd(matterId);
Sql sql = Sqls.create("""
@@ -448,7 +567,9 @@ public class TourLedgerServiceImpl extends BaseServiceImpl<TourLedger> implement
* 自动分配按事项归属控制数据范围:校工会查看全部;分工会查看本工会事项;其它角色仅处理自己创建的事项。
*/
private void applyMatterDataScope(Cnd cnd) {
if (hasSchoolUnionScope() || AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())) {
if (hasSchoolUnionScope()
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())
|| hasTourApplicantScope()) {
return;
}
if (hasBranchUnionScope()) {
@@ -479,6 +600,13 @@ public class TourLedgerServiceImpl extends BaseServiceImpl<TourLedger> implement
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name());
}
/**
* 疗休养领办人需要与领办列表保持一致,可在领办前查询到全部成团数据。
*/
private boolean hasTourApplicantScope() {
return AuthUtil.hasRole(RoleConstant.TOUR_APPLICANT.name());
}
/**
* 判断当前用户是否具备分工会级别的事项处理范围。
*/
@@ -1,8 +1,11 @@
package com.budwk.app.zhgh.dayofficework.tour.service.impl;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSetting;
import com.budwk.app.zhgh.dayofficework.tour.service.TourSettingService;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.IocBean;
@@ -12,4 +15,19 @@ public class TourSettingServiceImpl extends BaseServiceImpl<TourSetting> impleme
public TourSettingServiceImpl(Dao dao) {
super(dao);
}
/**
* 更新疗休养配置,并对报名时间做显式空值覆盖。
*
* @param tourSetting 页面提交的疗休养配置
*/
@Override
public void updateWithNullableSignupTime(TourSetting tourSetting) {
updateIgnoreNull(tourSetting);
// updateIgnoreNull 会忽略 null;报名时间允许清空时,需要额外将空值覆盖到数据库。
dao().update(TourSetting.class,
Chain.make("signupStartTime", StrUtil.blankToDefault(tourSetting.getSignupStartTime(), ""))
.add("signupEndTime", StrUtil.blankToDefault(tourSetting.getSignupEndTime(), "")),
Cnd.where(TourSetting::getId, "=", tourSetting.getId()));
}
}
@@ -0,0 +1,137 @@
-- 问卷服务菜单初始化脚本。
-- 使用 permission 做幂等判断,避免同一环境重复执行时插入重复菜单。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT
'8f3d9c78a9f04b5b8b7f0f6a00010001',
'',
LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'),
'问卷服务',
'Qsv',
'menu',
'',
'',
'ti-check-box',
1,
0,
'qsv',
NULL,
992,
1,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0,
'PC',
NULL,
NULL,
'w',
0,
0
FROM sys_menu
WHERE (parentId = '' OR parentId IS NULL)
AND CHAR_LENGTH(path) = 4
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv') t);
-- 新建问卷入口,对应 /platform/qsv/new。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '8f3d9c78a9f04b5b8b7f0f6a00010002', p.id, CONCAT(p.path, '0001'), '新建问卷', 'Qsv New', 'menu', '/platform/qsv/new', 'data-pjax', '', 1, 0, 'qsv.new', NULL, 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'x', 0, 0
FROM sys_menu p
WHERE p.permission = 'qsv'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.new') t);
-- 问卷活动管理入口,对应 /platform/qsv/activity。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '8f3d9c78a9f04b5b8b7f0f6a00010003', p.id, CONCAT(p.path, '0002'), '问卷管理', 'Qsv Activity', 'menu', '/platform/qsv/activity', 'data-pjax', '', 1, 0, 'qsv.activity', NULL, 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'w', 0, 0
FROM sys_menu p
WHERE p.permission = 'qsv'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.activity') t);
-- 在线答题管理入口,对应 /platform/qsv/online。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '8f3d9c78a9f04b5b8b7f0f6a00010004', p.id, CONCAT(p.path, '0003'), '在线答题', 'Qsv Online', 'menu', '/platform/qsv/online', 'data-pjax', '', 1, 0, 'qsv.online', NULL, 3, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'z', 0, 0
FROM sys_menu p
WHERE p.permission = 'qsv'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.online') t);
-- 问卷统计入口,对应 /platform/qsv/survey。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '8f3d9c78a9f04b5b8b7f0f6a00010005', p.id, CONCAT(p.path, '0004'), '问卷统计', 'Qsv Survey', 'menu', '/platform/qsv/survey', 'data-pjax', '', 1, 0, 'qsv.survey', NULL, 4, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'w', 0, 0
FROM sys_menu p
WHERE p.permission = 'qsv'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.survey') t);
-- 答题排名入口,对应 /platform/qsv/quizRank。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '8f3d9c78a9f04b5b8b7f0f6a00010006', p.id, CONCAT(p.path, '0005'), '答题排名', 'Qsv Quiz Rank', 'menu', '/platform/qsv/quizRank', 'data-pjax', '', 1, 0, 'qsv.quiz.rank', NULL, 5, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'd', 0, 0
FROM sys_menu p
WHERE p.permission = 'qsv'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.quiz.rank') t);
-- 题库管理入口,对应 /platform/qsv/bank。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '8f3d9c78a9f04b5b8b7f0f6a00010007', p.id, CONCAT(p.path, '0006'), '题库管理', 'Qsv Bank', 'menu', '/platform/qsv/bank', 'data-pjax', '', 1, 0, 'qsv.bank', NULL, 6, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 't', 0, 0
FROM sys_menu p
WHERE p.permission = 'qsv'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.bank') t);
-- 答题记录删除按钮权限,对应 qsv.quiz.rank.delete。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '8f3d9c78a9f04b5b8b7f0f6a00010008', p.id, CONCAT(p.path, '0001'), '删除答题记录', 'Delete Quiz Record', 'data', '', '', '', 0, 0, 'qsv.quiz.rank.delete', NULL, 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 's', 0, 0
FROM sys_menu p
WHERE p.permission = 'qsv.quiz.rank'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.quiz.rank.delete') t);
-- 移动端问卷服务入口,对应 /platform/h5/qsv。
-- @SaCheckPermission 手机端权限需要使用 h5. 前缀。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT
'8f3d9c78a9f04b5b8b7f0f6a00011001',
'',
LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'),
'问卷服务',
'Qsv H5',
'menu',
'/platform/h5/qsv',
'data-pjax',
'records-o',
1,
0,
'h5.qsv',
NULL,
992,
0,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0,
'H5',
NULL,
'records-o',
'w',
1,
0
FROM sys_menu
WHERE (parentId = '' OR parentId IS NULL)
AND CHAR_LENGTH(path) = 4
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'h5.qsv') t);
-- 默认给系统管理员授权问卷服务所有菜单与按钮权限。
INSERT INTO sys_role_menu (roleId, menuId)
SELECT r.id, m.id
FROM sys_role r
JOIN sys_menu m ON m.permission IN (
'qsv',
'qsv.new',
'qsv.activity',
'qsv.online',
'qsv.survey',
'qsv.quiz.rank',
'qsv.quiz.rank.delete',
'qsv.bank',
'h5.qsv'
)
LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id
WHERE r.code = 'SYSADMIN'
AND rm.roleId IS NULL;
@@ -136,7 +136,7 @@ JOIN sys_menu m ON m.permission IN (
'tour.undertake'
)
LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id
WHERE r.code = 'BRANCH_UNION_CHAIRMAN'
WHERE r.code IN ('BRANCH_UNION_CHAIRMAN', 'TOUR_APPLICANT')
AND rm.roleId IS NULL;
-- 普惠疗休养移动端菜单。
+13 -1
View File
@@ -10,6 +10,17 @@ CREATE TABLE IF NOT EXISTS `tour_matter` (
`undertakeStatus` varchar(20) DEFAULT 'unclaimed' COMMENT '领办状态 claimed已领 unclaimed未领',
`organizationType` varchar(100) DEFAULT NULL COMMENT '组织形式',
`mobileThumb` varchar(500) DEFAULT NULL COMMENT '移动端缩略图',
`lineId` varchar(32) DEFAULT NULL COMMENT '线路ID',
`signupStartTime` varchar(20) DEFAULT NULL COMMENT '报名开始时间',
`signupEndTime` varchar(20) DEFAULT NULL COMMENT '报名结束时间',
`travelStartTime` varchar(20) DEFAULT NULL COMMENT '出行开始时间',
`travelEndTime` varchar(20) DEFAULT NULL COMMENT '出行结束时间',
`contactName` varchar(30) DEFAULT NULL COMMENT '联系人',
`contactPhone` varchar(30) DEFAULT NULL COMMENT '联系方式',
`minGroupPeople` int DEFAULT NULL COMMENT '最少成团人数',
`maxGroupPeople` int DEFAULT NULL COMMENT '最多成团人数',
`maxSignupPeople` int DEFAULT NULL COMMENT '最多报名人数',
`estimatedCost` decimal(10,2) DEFAULT NULL COMMENT '预计费用',
`enabled` tinyint(1) DEFAULT 1 COMMENT '事项状态',
`createdBy` varchar(32) DEFAULT NULL COMMENT '创建人',
`createdAt` bigint DEFAULT NULL COMMENT '创建时间',
@@ -22,5 +33,6 @@ CREATE TABLE IF NOT EXISTS `tour_matter` (
KEY `idx_tour_matter_creator` (`creatorUserId`),
KEY `idx_tour_matter_setting` (`settingId`),
KEY `idx_tour_matter_union` (`unionId`),
KEY `idx_tour_matter_org_type` (`organizationType`)
KEY `idx_tour_matter_org_type` (`organizationType`),
KEY `idx_tour_matter_line` (`lineId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='普惠疗休养事项';
+13 -1
View File
@@ -121,6 +121,17 @@ CREATE TABLE IF NOT EXISTS `tour_matter` (
`undertakeStatus` varchar(20) DEFAULT 'unclaimed' COMMENT '领办状态 claimed已领 unclaimed未领',
`organizationType` varchar(100) DEFAULT NULL COMMENT '组织形式',
`mobileThumb` varchar(500) DEFAULT NULL COMMENT '移动端缩略图',
`lineId` varchar(32) DEFAULT NULL COMMENT '线路ID',
`signupStartTime` varchar(20) DEFAULT NULL COMMENT '报名开始时间',
`signupEndTime` varchar(20) DEFAULT NULL COMMENT '报名结束时间',
`travelStartTime` varchar(20) DEFAULT NULL COMMENT '出行开始时间',
`travelEndTime` varchar(20) DEFAULT NULL COMMENT '出行结束时间',
`contactName` varchar(30) DEFAULT NULL COMMENT '联系人',
`contactPhone` varchar(30) DEFAULT NULL COMMENT '联系方式',
`minGroupPeople` int DEFAULT NULL COMMENT '最少成团人数',
`maxGroupPeople` int DEFAULT NULL COMMENT '最多成团人数',
`maxSignupPeople` int DEFAULT NULL COMMENT '最多报名人数',
`estimatedCost` decimal(10,2) DEFAULT NULL COMMENT '预计费用',
`enabled` tinyint(1) DEFAULT 1 COMMENT '事项状态',
`createdBy` varchar(32) DEFAULT NULL COMMENT '创建人',
`createdAt` bigint DEFAULT NULL COMMENT '创建时间',
@@ -133,7 +144,8 @@ CREATE TABLE IF NOT EXISTS `tour_matter` (
KEY `idx_tour_matter_creator` (`creatorUserId`),
KEY `idx_tour_matter_setting` (`settingId`),
KEY `idx_tour_matter_union` (`unionId`),
KEY `idx_tour_matter_org_type` (`organizationType`)
KEY `idx_tour_matter_org_type` (`organizationType`),
KEY `idx_tour_matter_line` (`lineId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='普惠疗休养事项';
-- 疗休养事项批次表。
@@ -12,5 +12,5 @@ JOIN sys_menu m ON m.permission IN (
'tour.undertake'
)
LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id
WHERE r.code IN ('SYSADMIN', 'BRANCH_UNION_CHAIRMAN')
WHERE r.code IN ('SYSADMIN', 'BRANCH_UNION_CHAIRMAN', 'TOUR_APPLICANT')
AND rm.roleId IS NULL;
@@ -244,6 +244,13 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="最多报名人数" prop="maxSignupPeople">
<el-input-number v-model="batchForm.maxSignupPeople" :min="0" :precision="0" controls-position="right" style="width: 100%" @change="peopleChange"></el-input-number>
</el-form-item>
</el-col>
</el-row>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="batchFormVisible = false">取消</el-button>
@@ -309,6 +316,13 @@ layout("/layouts/platform.html"){
callback()
}
}
const validateMaxSignupPeople = (rule, value, callback) => {
if (value === "" || value === null || value === undefined || value >= 0) {
callback()
} else {
callback(new Error("最多报名人数不能小于0"))
}
}
const validateBatchTime = (rule, value, callback) => {
if (!value) {
callback(new Error("必填"))
@@ -378,6 +392,7 @@ layout("/layouts/platform.html"){
contactName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
contactPhone: [{ validator: validateMobile, trigger: ["blur", "change"] }],
minGroupPeople: [{ validator: validatePeople, trigger: ["blur", "change"] }],
maxSignupPeople: [{ validator: validateMaxSignupPeople, trigger: ["blur", "change"] }],
estimatedCost: [{ validator: validateMoney, trigger: ["blur", "change"] }]
}
}
@@ -606,6 +621,7 @@ layout("/layouts/platform.html"){
contactPhone: "",
minGroupPeople: null,
maxGroupPeople: null,
maxSignupPeople: null,
estimatedCost: ""
}
},
@@ -733,7 +749,7 @@ layout("/layouts/platform.html"){
}
this.$nextTick(() => {
if (this.$refs.batchFormRef) {
this.$refs.batchFormRef.validateField(["minGroupPeople", "maxGroupPeople"])
this.$refs.batchFormRef.validateField(["minGroupPeople", "maxGroupPeople", "maxSignupPeople"])
}
})
},
@@ -751,7 +767,7 @@ layout("/layouts/platform.html"){
peopleChange() {
this.$nextTick(() => {
if (this.$refs.batchFormRef) {
this.$refs.batchFormRef.validateField(["minGroupPeople", "maxGroupPeople"])
this.$refs.batchFormRef.validateField(["minGroupPeople", "maxGroupPeople", "maxSignupPeople"])
}
})
},
@@ -333,6 +333,13 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="最多报名人数" prop="maxSignupPeople">
<el-input-number v-model="batchForm.maxSignupPeople" :min="0" :precision="0" controls-position="right" style="width: 100%" @change="matterPeopleChange"></el-input-number>
</el-form-item>
</el-col>
</el-row>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="backToMatterStep">上一步</el-button>
@@ -396,6 +403,13 @@ layout("/layouts/platform.html"){
callback()
}
}
const validateMatterMaxSignupPeople = (rule, value, callback) => {
if (value === "" || value === null || value === undefined || value >= 0) {
callback()
} else {
callback(new Error("最多报名人数不能小于0"))
}
}
const validateMatterTime = (rule, value, callback) => {
if (!value) {
callback(new Error("必填"))
@@ -472,6 +486,7 @@ layout("/layouts/platform.html"){
contactName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
contactPhone: [{ validator: validateMatterMobile, trigger: ["blur", "change"] }],
minGroupPeople: [{ validator: validateMatterPeople, trigger: ["blur", "change"] }],
maxSignupPeople: [{ validator: validateMatterMaxSignupPeople, trigger: ["blur", "change"] }],
estimatedCost: [{ validator: validateMatterMoney, trigger: ["blur", "change"] }]
}
}
@@ -600,6 +615,7 @@ layout("/layouts/platform.html"){
contactPhone: "",
minGroupPeople: null,
maxGroupPeople: null,
maxSignupPeople: null,
estimatedCost: ""
}
},
@@ -790,7 +806,7 @@ layout("/layouts/platform.html"){
}
this.$nextTick(() => {
if (this.$refs.batchFormRef) {
this.$refs.batchFormRef.validateField(["minGroupPeople", "maxGroupPeople"])
this.$refs.batchFormRef.validateField(["minGroupPeople", "maxGroupPeople", "maxSignupPeople"])
}
})
},
@@ -807,7 +823,7 @@ layout("/layouts/platform.html"){
matterPeopleChange() {
this.$nextTick(() => {
if (this.$refs.batchFormRef) {
this.$refs.batchFormRef.validateField(["minGroupPeople", "maxGroupPeople"])
this.$refs.batchFormRef.validateField(["minGroupPeople", "maxGroupPeople", "maxSignupPeople"])
}
})
},
@@ -113,25 +113,39 @@ layout("/layouts/platform.html"){
<div class="process-title">
{{ formData.taskName }}
</div>
<el-form
:model="formData"
ref="formRef"
label-width="0"
label-suffix=":"
class="flow-task-form">
<el-form-item
label="审批意见"
prop="tf_opinion"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
<user-opinion-textarea v-model="formData.tf_opinion"></user-opinion-textarea>
</el-form-item>
</el-form>
<el-row type="flex" justify="end">
<el-button @click="$refs.guava.index()" size="small">取消</el-button>
<el-button @click="handleTaskAction(6)" size="small" type="info">退回到发起人</el-button>
<el-button @click="handleTaskAction(2)" size="small" type="danger">拒绝申请</el-button>
<el-button @click="handleTaskAction(1)" size="small" type="primary">同意申请</el-button>
</el-row>
<div class="tour-audit-panel">
<el-form
:model="formData"
ref="formRef"
label-width="100px"
label-suffix=":"
class="tour-audit-form">
<el-form-item class="tour-audit-result-item" label="审核结果" prop="auditResult" required>
<el-radio-group class="tour-audit-result-group" v-model="formData.auditResult" size="small" @change="auditResultChange">
<el-radio :label="1" border>同意申请</el-radio>
<el-radio :label="2" border>拒绝申请</el-radio>
<el-radio :label="6" border>退回发起人</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="formData.auditResult !== 1"
label="审核意见"
prop="tf_opinion"
:rules="[{validator: validateAuditOpinion, trigger:['change','blur']}]">
<user-opinion-textarea
class="tour-audit-opinion"
v-model="formData.tf_opinion"
:rows="5"
maxlength="500"
:placeholder="auditOpinionPlaceholder()">
</user-opinion-textarea>
</el-form-item>
</el-form>
<div class="tour-audit-action">
<el-button class="tour-audit-cancel-btn" @click="$refs.guava.index()" size="medium">取消</el-button>
<el-button class="tour-audit-submit-btn" @click="handleTaskAction" size="medium" type="primary">提交</el-button>
</div>
</div>
</div>
</tour-approval-info>
</template>
@@ -169,6 +183,60 @@ layout("/layouts/platform.html"){
.tour-approval-empty {
color: #909399;
}
.tour-audit-panel {
position: relative;
min-height: 230px;
padding-bottom: 62px;
}
.tour-audit-form {
padding: 0;
}
.tour-audit-form .el-form-item__label {
display: block;
color: #606266;
font-weight: 400;
white-space: nowrap;
}
.tour-audit-form .el-form-item__content {
display: block;
}
.tour-audit-result-item {
margin-bottom: 22px;
}
.tour-audit-result-group {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.tour-audit-result-group .el-radio {
margin-right: 0;
}
.tour-audit-opinion .el-textarea__inner {
min-height: 112px !important;
}
.tour-audit-opinion .opinion-wrap {
justify-content: flex-end;
padding: 8px 10px;
}
.tour-audit-opinion .opinion-wrap .el-select {
width: 190px;
}
.tour-audit-action {
position: absolute;
right: 0;
bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.tour-audit-cancel-btn,
.tour-audit-submit-btn {
min-width: 86px;
height: 40px;
font-size: 14px;
font-weight: 600;
}
</style>
<script nonce="${cspNonce!}">
@@ -355,6 +423,7 @@ layout("/layouts/platform.html"){
lineTypeOptions: [],
showApprovalForm: false,
formData: {
auditResult: 1,
tf_opinion: ""
},
pageForm: {
@@ -402,6 +471,7 @@ layout("/layouts/platform.html"){
this.$refs.guava.edit(() => {
this.showApprovalForm = false
this.formData = {
auditResult: 1,
tf_opinion: ""
}
this.$refs.infoRef.onOpen(row)
@@ -413,12 +483,32 @@ layout("/layouts/platform.html"){
this.formData = {
processTaskId: row.taskId,
taskName: row.curTaskName || row.taskName,
auditResult: 1,
tf_opinion: ""
}
this.$refs.infoRef.onOpen(row)
})
},
handleTaskAction(val) {
auditResultChange() {
if (this.formData.auditResult === 1) {
this.formData.tf_opinion = ""
}
this.$nextTick(() => {
this.$refs.formRef && this.$refs.formRef.clearValidate("tf_opinion")
})
},
auditOpinionPlaceholder() {
return this.formData.auditResult === 1 ? "" : "拒绝申请或退回发起人时,请填写审核意见"
},
validateAuditOpinion(rule, value, callback) {
// 同意申请不要求审核意见;拒绝申请和退回发起人必须说明原因,便于申请人查看处理依据。
if (this.formData.auditResult === 1 || (value && String(value).trim())) {
callback()
return
}
callback(new Error("拒绝申请或退回发起人时,请填写审核意见"))
},
handleTaskAction() {
this.$refs.formRef.validate((valid) => {
if (!valid) return
this.$confirm("您确定要提交吗?", "提示", {
@@ -430,7 +520,7 @@ layout("/layouts/platform.html"){
this.$axios.post("/flow/common/executeTask", {
data: JSON.stringify({
...this.formData,
submitType: val
submitType: this.formData.auditResult
})
}).then((res) => {
if (res.code === 0) {
@@ -313,14 +313,10 @@ layout("/layouts/platform.html"){
el: "#app",
mixins: [initTableMixins],
data() {
const checkSignupTime = (rule, value, callback) => {
if (!value) {
callback(new Error("必填"))
return
}
if (this.formData.signupStartTime && this.formData.signupEndTime
&& this.formData.signupStartTime >= this.formData.signupEndTime) {
callback(new Error("开始报名时间必须小于结束报名时间"))
const checkSignupTime = (rule, value, callback) => {
if (this.formData.signupStartTime && this.formData.signupEndTime
&& this.formData.signupStartTime >= this.formData.signupEndTime) {
callback(new Error("开始报名时间必须小于结束报名时间"))
} else {
callback()
}
@@ -537,6 +533,9 @@ layout("/layouts/platform.html"){
if (submitData.outProvinceRatioType !== "固定人数") {
submitData.outProvinceFixedPeople = 0
}
// 报名时间允许清空,日期组件清空后的 null 统一转为空字符串提交,便于后端覆盖旧值。
submitData.signupStartTime = submitData.signupStartTime || ""
submitData.signupEndTime = submitData.signupEndTime || ""
submitData.lots = JSON.stringify(this.formData.lots || [])
submitData.lotDeleteList = JSON.stringify(this.lotDeleteList)
this.$axios.post(loc() + "/doSubmit", submitData).then((res) => {
@@ -42,7 +42,10 @@ layout("/layouts/platform.html"){
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="线路列表"></table-tool>
<div class="tour-signup-list-toolbar">
<table-tool :app="this" label="线路列表"></table-tool>
<el-button class="tour-signup-export-btn" size="medium" type="primary" icon="el-icon-download" @click="exportData">导出</el-button>
</div>
<el-table
v-loading="tableLoading"
@@ -72,6 +75,11 @@ layout("/layouts/platform.html"){
<span>{{ row.maxGroupPeople || 0 }}人</span>
</template>
</el-table-column>
<el-table-column label="最多报名人数" prop="maxSignupPeople" width="140" sortable="custom" align="center" header-align="center">
<template slot-scope="{row}">
<span>{{ row.maxSignupPeople || 0 }}人</span>
</template>
</el-table-column>
<el-table-column label="报名人数" prop="signupCount" width="130" sortable="custom" align="center" header-align="center">
<template slot-scope="{row}">
<el-tag size="mini" :type="Number(row.signupCount || 0) > 0 ? 'success' : 'info'">{{ row.signupCount || 0 }}人</el-tag>
@@ -373,6 +381,7 @@ layout("/layouts/platform.html"){
<el-descriptions-item label="创建人">{{ lineDetail.creatorName || '' }}</el-descriptions-item>
<el-descriptions-item label="所在单位">{{ lineDetail.unitName || '' }}</el-descriptions-item>
<el-descriptions-item label="是否对外开放">{{ lineDetail.openFlag ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="最多报名人数">{{ lineDetail.maxSignupPeople || 0 }}人</el-descriptions-item>
<el-descriptions-item label="激活状态">{{ lineDetail.enabled ? '启用' : '禁用' }}</el-descriptions-item>
</el-descriptions>
@@ -415,7 +424,9 @@ layout("/layouts/platform.html"){
</el-card>
<el-card class="mt10" shadow="never">
<div class="tour-group-view-title">报名人员列表</div>
<div class="tour-group-view-header">
<div class="tour-group-view-title">报名人员列表</div>
</div>
<el-table
v-loading="peopleViewLoading"
:data="peopleViewTableData"
@@ -425,8 +436,19 @@ layout("/layouts/platform.html"){
<el-table-column label="序号" type="index" :index="peopleViewIndexMethod" width="80" align="center" header-align="center"></el-table-column>
<el-table-column label="工号" prop="jobNo" width="130" sortable="custom" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="姓名" prop="userName" width="120" sortable="custom" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column v-if="peopleViewGroupMode" label="性别" prop="gender" width="90" sortable="custom" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column v-if="peopleViewGroupMode" label="手机号码" prop="mobile" width="140" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column v-if="peopleViewGroupMode" label="身份证号" prop="idCard" min-width="190" sortable="custom" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="所属工会" prop="unionName" min-width="180" sortable="custom" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="报名时间" prop="signupTime" width="170" sortable="custom" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column v-if="!peopleViewGroupMode" label="报名时间" prop="signupTime" width="170" sortable="custom" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column v-if="peopleViewGroupMode" label="人员类型" prop="rowTypeName" width="90" align="center" header-align="center"></el-table-column>
<el-table-column v-if="peopleViewGroupMode" label="亲属关系" prop="relationship" width="120" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column v-if="peopleViewGroupMode" label="成团信息" prop="groupName" width="160" sortable="custom" align="center" header-align="center" show-overflow-tooltip>
<template slot-scope="{row}">
<span>{{ formatPeopleGroupInfo(row) }}</span>
</template>
</el-table-column>
<el-table-column v-if="peopleViewGroupMode" label="关联教职工名称" prop="relatedStaffName" width="150" align="center" header-align="center" show-overflow-tooltip></el-table-column>
</el-table>
<el-row class="el-pagination-container tour-group-view-pagination">
<el-pagination
@@ -548,6 +570,23 @@ layout("/layouts/platform.html"){
margin-top: 12px;
text-align: right;
}
.tour-signup-list-toolbar {
align-items: center;
display: flex;
gap: 12px;
margin-bottom: 10px;
}
.tour-signup-export-btn {
margin-left: auto;
}
.tour-group-view-header {
align-items: flex-start;
display: flex;
gap: 12px;
justify-content: flex-start;
margin-bottom: 12px;
max-width: 100%;
}
.tour-group-view-title {
border-left: 4px solid #0079c2;
color: #0079c2;
@@ -620,6 +659,7 @@ layout("/layouts/platform.html"){
peopleViewTitle: "",
peopleViewRow: {},
peopleViewTableData: [],
peopleViewGroupMode: false,
pageForm: {
pageNumber: 1,
pageSize: 10,
@@ -635,10 +675,11 @@ layout("/layouts/platform.html"){
pageNumber: 1,
pageSize: 10,
totalCount: 0,
pageOrderName: "signupTime",
pageOrderBy: "descending",
pageOrderName: "groupCode",
pageOrderBy: "ascending",
keyword: "",
unionId: ""
unionId: "",
groupCode: ""
}
}
},
@@ -651,6 +692,16 @@ layout("/layouts/platform.html"){
this.loadLineTypeOptions()
this.doSearch()
},
exportData() {
const params = new URLSearchParams()
;["year", "lineName", "lineType", "unionId", "pageOrderName", "pageOrderBy"].forEach(key => {
const value = this.pageForm[key]
if (value !== undefined && value !== null && value !== "") {
params.append(key, value)
}
})
window.location.href = loc() + "/exportData?" + params.toString()
},
signupActionText(row) {
if (row && row.signed) {
if (this.canRevokeSignup(row)) {
@@ -719,17 +770,27 @@ layout("/layouts/platform.html"){
|| row.directFamilyUnitLine === 1
|| row.directFamilyUnitLine === "1"
},
formatPeopleGroupInfo(row) {
if (!row || !row.groupCode) return "未成团"
return row.groupName ? row.groupCode + " " + row.groupName : row.groupCode
},
canUseGroupPeopleView() {
// Only these roles keep the grouped staff/family popup; other users use the original staff-only popup.
return this.$auth && this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN", "BRANCH_UNION_CHAIRMAN"])
},
openPeopleView(row) {
this.peopleViewRow = row || {}
this.peopleViewGroupMode = this.canUseGroupPeopleView()
this.peopleViewTitle = (this.peopleViewRow.lineName || "报名人员") + " - 报名人员"
this.peopleViewVisible = true
this.peopleViewForm.pageNumber = 1
this.peopleViewForm.pageSize = 10
this.peopleViewForm.totalCount = 0
this.peopleViewForm.pageOrderName = "signupTime"
this.peopleViewForm.pageOrderBy = "descending"
this.peopleViewForm.pageOrderName = this.peopleViewGroupMode ? "groupCode" : "signupTime"
this.peopleViewForm.pageOrderBy = this.peopleViewGroupMode ? "ascending" : "descending"
this.peopleViewForm.keyword = ""
this.peopleViewForm.unionId = ""
this.peopleViewForm.groupCode = ""
this.peopleViewTableData = []
this.loadPeopleViewData()
},
@@ -740,6 +801,7 @@ layout("/layouts/platform.html"){
peopleViewReset() {
this.peopleViewForm.keyword = ""
this.peopleViewForm.unionId = ""
this.peopleViewForm.groupCode = ""
this.peopleViewForm.pageNumber = 1
this.loadPeopleViewData()
},
@@ -756,8 +818,8 @@ layout("/layouts/platform.html"){
this.loadPeopleViewData()
},
peopleViewPageOrder({ prop, order }) {
this.peopleViewForm.pageOrderName = prop || "signupTime"
this.peopleViewForm.pageOrderBy = order || "descending"
this.peopleViewForm.pageOrderName = prop || (this.peopleViewGroupMode ? "groupCode" : "signupTime")
this.peopleViewForm.pageOrderBy = order || (this.peopleViewGroupMode ? "ascending" : "descending")
this.peopleViewForm.pageNumber = 1
this.loadPeopleViewData()
},
@@ -769,15 +831,21 @@ layout("/layouts/platform.html"){
}
this.peopleViewLoading = true
const requestSeq = ++this.peopleViewRequestSeq
this.$axios.post(loc() + "/signupPageData", {
const requestUrl = this.peopleViewGroupMode ? "/platform/tour/group/signupPageData" : loc() + "/signupPageData"
const requestData = {
matterId: this.peopleViewRow.matterId,
lineId: this.peopleViewRow.lineId,
keyword: this.peopleViewForm.keyword,
unionId: this.peopleViewForm.unionId,
pageNumber: this.peopleViewForm.pageNumber,
pageSize: this.peopleViewForm.pageSize,
pageOrderName: this.peopleViewForm.pageOrderName,
pageOrderBy: this.peopleViewForm.pageOrderBy
}).then((res) => {
}
if (this.peopleViewGroupMode) {
requestData.groupCode = this.peopleViewForm.groupCode
}
this.$axios.post(requestUrl, requestData).then((res) => {
if (requestSeq !== this.peopleViewRequestSeq) return
if (res.code === 0) {
const data = res.data || {}
@@ -801,7 +869,10 @@ layout("/layouts/platform.html"){
openLineView(row) {
this.$axios.post(loc() + "/lineDetail", { lineId: row.lineId }).then((res) => {
if (res.code === 0) {
this.lineDetail = res.data || {}
// 线路详情接口只返回线路资料,最多报名人数属于事项选线配置,打开抽屉时从当前列表行补入。
this.lineDetail = Object.assign({}, res.data || {}, {
maxSignupPeople: row.maxSignupPeople
})
this.lineContentHtml = ""
this.lineVisible = true
this.buildLineContentHtmlAsync(this.lineDetail.lineContent).then((html) => {
@@ -113,25 +113,39 @@ layout("/layouts/platform.html"){
<div class="process-title">
{{ formData.taskName }}
</div>
<el-form
:model="formData"
ref="formRef"
label-width="0"
label-suffix=":"
class="flow-task-form">
<el-form-item
label="审批意见"
prop="tf_opinion"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
<user-opinion-textarea v-model="formData.tf_opinion"></user-opinion-textarea>
</el-form-item>
</el-form>
<el-row type="flex" justify="end">
<el-button @click="$refs.guava.index()" size="small">取消</el-button>
<el-button @click="handleTaskAction(6)" size="small" type="info">退回到发起人</el-button>
<el-button @click="handleTaskAction(2)" size="small" type="danger">拒绝申请</el-button>
<el-button @click="handleTaskAction(1)" size="small" type="primary">同意申请</el-button>
</el-row>
<div class="tour-audit-panel">
<el-form
:model="formData"
ref="formRef"
label-width="100px"
label-suffix=":"
class="tour-audit-form">
<el-form-item class="tour-audit-result-item" label="审核结果" prop="auditResult" required>
<el-radio-group class="tour-audit-result-group" v-model="formData.auditResult" size="small" @change="auditResultChange">
<el-radio :label="1" border>同意申请</el-radio>
<el-radio :label="2" border>拒绝申请</el-radio>
<el-radio :label="6" border>退回发起人</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="formData.auditResult !== 1"
label="审核意见"
prop="tf_opinion"
:rules="[{validator: validateAuditOpinion, trigger:['change','blur']}]">
<user-opinion-textarea
class="tour-audit-opinion"
v-model="formData.tf_opinion"
:rows="5"
maxlength="500"
:placeholder="auditOpinionPlaceholder()">
</user-opinion-textarea>
</el-form-item>
</el-form>
<div class="tour-audit-action">
<el-button class="tour-audit-cancel-btn" @click="$refs.guava.index()" size="medium">取消</el-button>
<el-button class="tour-audit-submit-btn" @click="handleTaskAction" size="medium" type="primary">提交</el-button>
</div>
</div>
</div>
</tour-approval-info>
</template>
@@ -169,6 +183,60 @@ layout("/layouts/platform.html"){
.tour-approval-empty {
color: #909399;
}
.tour-audit-panel {
position: relative;
min-height: 230px;
padding-bottom: 62px;
}
.tour-audit-form {
padding: 0;
}
.tour-audit-form .el-form-item__label {
display: block;
color: #606266;
font-weight: 400;
white-space: nowrap;
}
.tour-audit-form .el-form-item__content {
display: block;
}
.tour-audit-result-item {
margin-bottom: 22px;
}
.tour-audit-result-group {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.tour-audit-result-group .el-radio {
margin-right: 0;
}
.tour-audit-opinion .el-textarea__inner {
min-height: 112px !important;
}
.tour-audit-opinion .opinion-wrap {
justify-content: flex-end;
padding: 8px 10px;
}
.tour-audit-opinion .opinion-wrap .el-select {
width: 190px;
}
.tour-audit-action {
position: absolute;
right: 0;
bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.tour-audit-cancel-btn,
.tour-audit-submit-btn {
min-width: 86px;
height: 40px;
font-size: 14px;
font-weight: 600;
}
</style>
<script nonce="${cspNonce!}">
@@ -355,6 +423,7 @@ layout("/layouts/platform.html"){
lineTypeOptions: [],
showApprovalForm: false,
formData: {
auditResult: 1,
tf_opinion: ""
},
pageForm: {
@@ -402,6 +471,7 @@ layout("/layouts/platform.html"){
this.$refs.guava.edit(() => {
this.showApprovalForm = false
this.formData = {
auditResult: 1,
tf_opinion: ""
}
this.$refs.infoRef.onOpen(row)
@@ -413,12 +483,32 @@ layout("/layouts/platform.html"){
this.formData = {
processTaskId: row.taskId,
taskName: row.curTaskName || row.taskName,
auditResult: 1,
tf_opinion: ""
}
this.$refs.infoRef.onOpen(row)
})
},
handleTaskAction(val) {
auditResultChange() {
if (this.formData.auditResult === 1) {
this.formData.tf_opinion = ""
}
this.$nextTick(() => {
this.$refs.formRef && this.$refs.formRef.clearValidate("tf_opinion")
})
},
auditOpinionPlaceholder() {
return this.formData.auditResult === 1 ? "" : "拒绝申请或退回发起人时,请填写审核意见"
},
validateAuditOpinion(rule, value, callback) {
// 同意申请不要求审核意见;拒绝申请和退回发起人必须说明原因,便于申请人查看处理依据。
if (this.formData.auditResult === 1 || (value && String(value).trim())) {
callback()
return
}
callback(new Error("拒绝申请或退回发起人时,请填写审核意见"))
},
handleTaskAction() {
this.$refs.formRef.validate((valid) => {
if (!valid) return
this.$confirm("您确定要提交吗?", "提示", {
@@ -430,7 +520,7 @@ layout("/layouts/platform.html"){
this.$axios.post("/flow/common/executeTask", {
data: JSON.stringify({
...this.formData,
submitType: val
submitType: this.formData.auditResult
})
}).then((res) => {
if (res.code === 0) {
@@ -1,9 +1,5 @@
const subjectBank = {
template: /*language=HTML*/ `
<el-dialog :visible.sync="visible" title="题库" append-to-body width="700px">
</el-dialog>
`,
template: /*language=HTML*/ "\n <el-dialog :visible.sync=\"visible\" title=\"题库\" append-to-body width=\"700px\">\n\n </el-dialog>\n ",
data() {
return {
visible: false,
@@ -26,9 +22,7 @@ const subjectBank = {
})
},
},
style: /*language=CSS*/ `
`
style: /*language=CSS*/ "\n\n "
}
File diff suppressed because one or more lines are too long
@@ -2,6 +2,382 @@
layout("/layouts/platform.html"){
#-->
<style>
.qsv-logic-drawer .el-drawer__header {
margin-bottom: 0;
padding: 18px 24px;
border-bottom: 1px solid #ebeef5;
color: #303133;
font-weight: 600;
}
.qsv-logic-drawer {
top: 64px !important;
right: 0 !important;
bottom: auto !important;
width: calc(100% - 250px) !important;
height: calc(100vh - 64px) !important;
}
.qsv-logic-drawer .el-drawer__body {
height: calc(100% - 58px);
background: #f0f2f5;
overflow: hidden;
}
.qsv-logic-workspace {
height: 100%;
display: grid;
grid-template-columns: minmax(520px, 46%) minmax(0, 1fr);
gap: 20px;
padding: 20px;
box-sizing: border-box;
background-color: #f0f2f5;
}
.qsv-logic-section {
min-width: 0;
min-height: 0;
background: #ffffff;
border: 1px solid #ebeef5;
border-radius: 4px;
display: flex;
flex-direction: column;
box-shadow: none;
}
.qsv-logic-section-header {
min-height: 58px;
padding: 0 20px;
border-bottom: 1px solid #ebeef5;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
box-sizing: border-box;
}
.qsv-logic-section-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.qsv-logic-section-header .el-button--success {
min-width: 88px;
height: 32px;
font-weight: 600;
}
.qsv-logic-question-list,
.qsv-logic-preview-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
box-sizing: border-box;
}
.qsv-logic-question-card {
position: relative;
min-height: 66px;
border: 1px solid #e4e7ed;
border-radius: 12px;
margin-bottom: 12px;
padding: 16px 20px 16px 54px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: grab;
transition: all .2s ease-in-out;
box-sizing: border-box;
background-color: #ffffff;
}
.qsv-logic-question-card:hover,
.qsv-logic-question-card.active {
border-color: #409eff;
box-shadow: 0 6px 16px rgba(64, 158, 255, .12);
transform: translateY(-2px);
background: #ffffff;
}
.qsv-logic-question-card:active {
cursor: grabbing;
}
.qsv-question-card-main {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.qsv-question-index {
position: absolute;
left: 16px;
width: 26px;
height: 26px;
border-radius: 50%;
background: #f0f2f5;
border: 1px solid #e4e7ed;
color: #909399;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
}
.qsv-question-type {
height: 24px;
line-height: 24px;
min-width: 70px;
padding: 0 10px;
border-radius: 4px;
color: #ffffff;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
text-align: center;
}
.qsv-question-type.single {
background: #409eff;
}
.qsv-question-type.multiple {
background: #67c23a;
}
.qsv-question-type.text {
background: #e6a23c;
}
.qsv-question-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #303133;
font-weight: 600;
font-size: 15px;
}
.qsv-question-title.has-visible-rule {
color: #f56c6c;
}
.qsv-question-actions {
display: flex;
align-items: center;
gap: 8px;
opacity: 0;
transform: scale(.9);
pointer-events: none;
transition: all .2s cubic-bezier(.175, .885, .32, 1.275);
flex-shrink: 0;
}
.qsv-logic-question-card:hover .qsv-question-actions,
.qsv-logic-question-card.active .qsv-question-actions {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.qsv-question-actions .el-button-group .el-button {
padding: 10px 15px;
}
.qsv-preview-controls {
display: flex;
align-items: center;
gap: 12px;
}
.qsv-preview-question {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px 24px;
margin-bottom: 8px;
box-sizing: border-box;
transition: all .2s ease;
}
.qsv-preview-question.active {
background: #f8fbff;
}
.qsv-preview-index {
min-width: 22px;
line-height: 22px;
color: #409eff;
font-size: 14px;
font-weight: 700;
text-align: right;
flex-shrink: 0;
padding-top: 1px;
}
.qsv-preview-content {
flex: 1;
min-width: 0;
}
.qsv-preview-title {
margin-bottom: 14px;
line-height: 22px;
color: #4a5568;
font-size: 14px;
}
.qsv-preview-options .el-radio,
.qsv-preview-options .el-checkbox {
margin: 0 32px 12px 0;
color: #3f4a5a;
font-weight: 500;
}
.qsv-preview-options .el-radio__label,
.qsv-preview-options .el-checkbox__label {
font-size: 14px;
}
.qsv-condition-dialog .el-dialog__body {
padding: 18px 20px 8px;
}
.qsv-condition-group {
background-color: #f9f9f5;
border: 1px solid #e8e8e0;
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
box-sizing: border-box;
}
.qsv-condition-group.is-root {
box-shadow: none;
}
.qsv-condition-root-title {
margin-bottom: -4px;
font-size: 13px;
color: #606266;
}
.qsv-condition-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px dashed #dcdcdc;
gap: 12px;
}
.qsv-condition-group-actions {
display: flex;
align-items: center;
gap: 10px;
}
.qsv-condition-children {
display: flex;
flex-direction: column;
gap: 12px;
}
.qsv-condition-item-card {
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 10px;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
transition: all .2s ease-in-out;
}
.qsv-condition-item-card:hover {
border-color: #dcdfe6;
box-shadow: 0 2px 10px rgba(0, 0, 0, .03);
}
.qsv-condition-item-index {
width: 24px;
height: 24px;
border-radius: 50%;
background: #f0f2f5;
border: 1px solid #e4e7ed;
color: #909399;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 12px;
}
.qsv-condition-item-content {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.qsv-nested-group-wrapper {
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 10px;
padding: 12px;
}
.qsv-nested-group-label {
margin-bottom: 10px;
padding-left: 4px;
display: flex;
align-items: center;
gap: 4px;
color: #909399;
font-size: 12px;
}
.qsv-condition-empty-tip {
padding: 28px;
text-align: center;
color: #909399;
background: #ffffff;
border: 1px dashed #dcdfe6;
border-radius: 10px;
font-size: 13px;
}
.qsv-condition-group-footer {
padding-top: 12px;
border-top: 1px dashed #dcdcdc;
display: flex;
gap: 12px;
}
.qsv-add-group-text {
color: #67c23a;
}
@media (max-width: 1200px) {
.qsv-logic-workspace {
grid-template-columns: 1fr;
}
}
</style>
<div id="app">
<guava ref="guava">
<el-card shadow="never">
@@ -10,32 +386,54 @@ layout("/layouts/platform.html"){
<el-date-picker placeholder="请选择年度" style="width: 100%" type="year" v-model="pageForm.year"
value-format="yyyy"></el-date-picker>
</search-item>
<search-item label="标题">
<el-input placeholder="请输入标题" v-model="pageForm.title" clearable></el-input>
<search-item label="标题名称">
<el-input placeholder="请输入标题名称" v-model="pageForm.title" clearable></el-input>
</search-item>
</search>
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="管理列表">
<el-button @click="$refs.basicFormRef.onOpen()" size="small" type="primary" class="mr5">新增</el-button>
<table-tool label="标题列表">
</table-tool>
<el-table :data="tableData" ref="tableRef" @sort-change="pageOrder">
<el-table-column label="序号" width="50" type="index" :index="indexMethod"></el-table-column>
<el-table-column label="标题" prop="title" sortable></el-table-column>
<el-table-column label="标题名称" prop="title" sortable></el-table-column>
<el-table-column label="类型" prop="category" sortable width="200">
<template scope="{row}">
<dict-tag :options="dict.type.ACTIVITY_QSV_CATEGORY" :value="row.category"></dict-tag>
<el-tag size="mini" :type="getCategoryTagType(row.category)">
{{getCategoryText(row.category)}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="开始时间" prop="startTime" sortable></el-table-column>
<el-table-column label="结束时间" prop="endTime" sortable></el-table-column>
<el-table-column label="操作" fixed="right" width="450px">
<el-table-column label="状态" width="100">
<template scope="{row}">
<el-tag v-if="row.enabled !== false" type="success">开启</el-tag>
<el-tag v-else type="info">关闭</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="580px">
<template scope="{row}">
<el-button size="mini" type="primary" @click="openQrCode(row)">二维码</el-button>
<el-button size="mini" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="mini" type="primary" @click="openSubject(row.id)">题目设置</el-button>
<el-button
v-if="row.category === 'SURVEY'"
size="mini"
type="primary"
@click="openLogic(row.id)">逻辑</el-button>
<el-button
v-if="row.enabled === false"
size="mini"
type="success"
@click="updateEnabled(row, true)">开启</el-button>
<el-button
v-else
size="mini"
type="warning"
@click="updateEnabled(row, false)">关闭</el-button>
<el-button size="mini" type="danger" @click="onDelete(row.id)">删除</el-button>
</template>
</el-table-column>
@@ -48,22 +446,22 @@ layout("/layouts/platform.html"){
</template>
</guava>
<basic-form ref="basicFormRef" @refresh="pageData"></basic-form>
<logic-form ref="logicFormRef" @refresh="pageData"></logic-form>
<qr-code-plus :url="signatureAddress" ref="qrCodePlusRef"></qr-code-plus>
</div>
<script nonce="${cspNonce!}">
<!--#include('basicForm.js'){}#-->
<!--#include('subjectForm.js'){}#-->
<!--#include('logicForm.js'){}#-->
new Vue({
el: "#app",
dicts: ["ACTIVITY_QSV_CATEGORY"],
mixins: [initTableMixins],
components: {
"basic-form": basicForm,
"subject-form": subjectForm
"subject-form": subjectForm,
"logic-form": logicForm
},
data() {
return {
@@ -71,9 +469,24 @@ layout("/layouts/platform.html"){
}
},
methods: {
getCategoryText(category) {
const options = this.dict && this.dict.type ? (this.dict.type.ACTIVITY_QSV_CATEGORY || []) : []
const item = options.find(item => item.code === category)
return item ? item.name : category
},
getCategoryTagType(category) {
const map = {
SURVEY: "success",
QUIZ: "primary",
VOTE: "danger"
}
return map[category] || "info"
},
// 编辑
openEdit(row) {
this.$refs.basicFormRef.onOpen(row.id)
location.href = "/platform/qsv/new?id=" + row.id
},
// 打开二维码
@@ -94,9 +507,34 @@ layout("/layouts/platform.html"){
})
},
// 调查类型独立维护题目之间的显隐逻辑
openLogic(id) {
this.$refs.logicFormRef.onOpen(id)
},
// 开启或关闭活动,关闭后移动端首页不展示,直达手机端问卷也不能答题
updateEnabled(row, enabled) {
const actionText = enabled ? "开启" : "关闭"
this.$confirm("您确认" + actionText + "该活动吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
$.post("/platform/qsv/activity/updateEnabled", {
id: row.id,
enabled
}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.pageData()
}
})
})
},
// 删除
onDelete(id) {
this.$confirm("您确认删除吗, 是否继续?", "提示", {
this.$confirm("删除后将同步删除该任务的题目、选项、用户答题记录、统计分析和成绩数据,且不可恢复。确定删除吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
File diff suppressed because one or more lines are too long
@@ -0,0 +1,491 @@
<!--#
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>
<template>
<custom-card>
<h2 slot="header" >{{pageTitle}}</h2>
<template>
<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>
<template slot="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>
</template>
</template>
</custom-card>
</template>
<drawer-user-scope
@group_change="getActivityGroup"
ref="drawerUserScope"
:group_id.sync="formData.groupId"
></drawer-user-scope>
</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>
<!--#
}
#-->
@@ -1,25 +1,5 @@
const optionImg = {
template: /*language=HTML*/ `
<el-dialog :visible.sync="visible" title="设置图片" append-to-body width="700px">
<div style="background: #f7f8f9;text-align: center;border: solid 1px #d7d8d9;border-radius: 4px;">
<el-upload
action="/platform/sys/file/uploadDynamicReturnUrl"
:show-file-list="false"
:on-success="handleSuccess"
:before-upload="beforeUpload">
<img v-if="img" :src="img" class="avatar">
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</div>
<div style="padding: 10px 0">
请上传图片
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="visible = false">取 消</el-button>
<el-button type="primary" @click="onConfirm">确 定</el-button>
</div>
</el-dialog>
`,
template: /*language=HTML*/ "\n <el-dialog :visible.sync=\"visible\" title=\"设置图片\" append-to-body width=\"700px\">\n <div style=\"background: #f7f8f9;text-align: center;border: solid 1px #d7d8d9;border-radius: 4px;\">\n <el-upload\n action=\"/platform/sys/file/uploadDynamicReturnUrl\"\n :show-file-list=\"false\"\n :on-success=\"handleSuccess\"\n :before-upload=\"beforeUpload\">\n <img v-if=\"img\" :src=\"img\" class=\"avatar\">\n <i v-else class=\"el-icon-plus avatar-uploader-icon\"></i>\n </el-upload>\n </div>\n <div style=\"padding: 10px 0\">\n 请上传图片\n </div>\n <div slot=\"footer\" class=\"dialog-footer\">\n <el-button @click=\"visible = false\">取 消</el-button>\n <el-button type=\"primary\" @click=\"onConfirm\">确 定</el-button>\n </div>\n </el-dialog>\n ",
data() {
return {
visible: false,
@@ -52,32 +32,5 @@ const optionImg = {
this.visible = false
}
},
style: /*language=CSS*/ `
.avatar-uploader .el-upload {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
}
.avatar-uploader .el-upload:hover {
border-color: #409EFF;
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 178px;
height: 178px;
line-height: 178px;
text-align: center;
}
.avatar {
width: 178px;
height: 178px;
display: block;
}
`
style: /*language=CSS*/ "\n .avatar-uploader .el-upload {\n border: 1px dashed #d9d9d9;\n border-radius: 6px;\n cursor: pointer;\n position: relative;\n overflow: hidden;\n }\n\n .avatar-uploader .el-upload:hover {\n border-color: #409EFF;\n }\n\n .avatar-uploader-icon {\n font-size: 28px;\n color: #8c939d;\n width: 178px;\n height: 178px;\n line-height: 178px;\n text-align: center;\n }\n\n .avatar {\n width: 178px;\n height: 178px;\n display: block;\n }\n "
}
@@ -1,20 +1,5 @@
const setting = {
template:/*language=HTML*/`
<el-dialog :title="title" :visible.sync="visible" append-to-body width="50%">
<el-form :model="formData" label-width="80px">
<el-form-item label="链接" prop="link">
<el-input maxlength="255" placeholder="" v-model="formData.link"></el-input>
</el-form-item>
<el-form-item label="详情" prop="description">
<text-editor v-model="formData.description"></text-editor>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="visible = false">取 消</el-button>
<el-button type="primary" @click="onConfirm">确 定</el-button>
</div>
</el-dialog>
`,
template:/*language=HTML*/"\n <el-dialog :title=\"title\" :visible.sync=\"visible\" append-to-body width=\"50%\">\n <el-form :model=\"formData\" label-width=\"80px\">\n <el-form-item label=\"链接\" prop=\"link\">\n <el-input maxlength=\"255\" placeholder=\"\" v-model=\"formData.link\"></el-input>\n </el-form-item>\n <el-form-item label=\"详情\" prop=\"description\">\n <text-editor v-model=\"formData.description\"></text-editor>\n </el-form-item>\n <el-form-item label=\"补充填写\" prop=\"fillRequired\">\n <el-switch\n v-model=\"formData.fillRequired\"\n active-text=\"需要填写\"\n inactive-text=\"无需填写\">\n </el-switch>\n </el-form-item>\n <el-form-item label=\"填写提示\" prop=\"fillPlaceholder\" v-if=\"formData.fillRequired\">\n <el-input\n maxlength=\"255\"\n placeholder=\"例如:请填写原因\"\n v-model=\"formData.fillPlaceholder\">\n </el-input>\n </el-form-item>\n </el-form>\n <div slot=\"footer\" class=\"dialog-footer\">\n <el-button @click=\"visible = false\">取 消</el-button>\n <el-button type=\"primary\" @click=\"onConfirm\">确 定</el-button>\n </div>\n </el-dialog> \n ",
data(){
return{
title: '设置',
@@ -26,7 +11,11 @@ const setting = {
methods:{
onOpen(option,ext){
console.log(option)
this.formData = { ...option }
this.formData = {
...option,
fillRequired: !!option.fillRequired,
fillPlaceholder: option.fillPlaceholder || ""
}
this.ext = ext
this.visible = true
},
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,40 +1,12 @@
const basicForm = {
template: /*language=HTML*/ `
<el-dialog :visible.sync="dialogVisible" title="基础设置" width="70%">
<div style="overflow-y: auto">
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="150px">
<el-form-item label="类型" prop="category">
<el-radio-group v-model="formData.category" size="small">
<el-radio v-for="item in dict.type.ACTIVITY_QSV_CATEGORY" :label="item.code"
:key="item.code"
border>
{{item.label}}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" maxlength="50"
show-word-limit></el-input>
</el-form-item>
<el-form-item label="说明" prop="description">
<text-editor v-model="formData.description" :height="150"></text-editor>
</el-form-item>
</el-form>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" @click="onSubmit">确 定</el-button>
</div>
</el-dialog>
`,
template: /*language=HTML*/ "\n <el-drawer\n :title=\"formData.id ? '编辑题库' : '新增题库'\"\n :visible.sync=\"dialogVisible\"\n direction=\"rtl\"\n size=\"1024px\"\n custom-class=\"qsv-bank-basic-drawer\"\n append-to-body\n :wrapper-closable=\"false\"\n :close-on-press-escape=\"false\">\n <div class=\"qsv-bank-basic-drawer-page\" v-loading=\"saveLoading\">\n <el-card shadow=\"never\" class=\"qsv-bank-basic-drawer-card\">\n <h3 class=\"qsv-bank-sub-title\">基础信息</h3>\n <el-form :model=\"formData\" ref=\"formRef\" :rules=\"formRules\" label-width=\"96px\">\n <el-form-item label=\"类型\" prop=\"category\">\n <el-radio-group v-model=\"formData.category\" size=\"small\">\n <el-radio v-for=\"item in dict.type.ACTIVITY_QSV_CATEGORY\" :label=\"item.code\"\n :key=\"item.code\"\n border>\n {{item.label || item.name}}\n </el-radio>\n </el-radio-group>\n </el-form-item>\n\n <el-form-item label=\"标题\" prop=\"title\">\n <el-input v-model=\"formData.title\" placeholder=\"请输入标题\" maxlength=\"50\"\n show-word-limit></el-input>\n </el-form-item>\n\n <el-form-item label=\"说明\" prop=\"description\">\n <text-editor v-model=\"formData.description\" :height=\"180\"></text-editor>\n </el-form-item>\n </el-form>\n </el-card>\n </div>\n <div class=\"qsv-bank-basic-drawer-footer\">\n <el-button @click=\"dialogVisible = false\">取消</el-button>\n <el-button type=\"primary\" :loading=\"saveLoading\" @click=\"onSubmit\">保存</el-button>\n </div>\n </el-drawer>\n ",
dicts: ["ACTIVITY_QSV_CATEGORY", "ACTIVITY_QSV_MODE", "ACTIVITY_QSV_REPEAT_MODE", "ACTIVITY_QSV_SCORE_MODE"],
data() {
return {
dialogVisible: false,
saveLoading: false,
formData: {
category: ''
category: 'QUIZ'
},
formRules: {
category: [{required: true, message: "请选择类型", trigger: "change"}],
@@ -45,27 +17,42 @@ const basicForm = {
methods: {
onOpen(id) {
this.dialogVisible = true
this.saveLoading = false
if (id) {
$.post("/platform/qsv/bank/findOne", {id}).then((res) => {
if (res.code === 0) {
this.formData = res.data
this.formData = res.data || {category: 'QUIZ'}
}
})
} else {
this.formData = {}
this.formData = {
category: 'QUIZ'
}
this.$nextTick(() => {
if (this.$refs.formRef) {
this.$refs.formRef.clearValidate()
}
})
}
},
onSubmit() {
this.$refs.formRef.validate((valid) => {
if (valid) {
$.post("/platform/qsv/bank/" + (this.formData.id ? "update" : "save"), {qsvBank: JSON.stringify(this.formData)}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.dialogVisible = false
this.$emit("refresh", null)
}
})
if (!valid) {
return
}
this.saveLoading = true
$.post("/platform/qsv/bank/" + (this.formData.id ? "update" : "save"), {
qsvBank: JSON.stringify(this.formData)
}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg || "保存成功")
this.dialogVisible = false
this.$emit("refresh", null)
}
this.saveLoading = false
}).fail(() => {
this.saveLoading = false
})
})
}
}
@@ -2,30 +2,152 @@
layout("/layouts/platform.html"){
#-->
<style>
.el-drawer.qsv-bank-drawer {
top: 64px !important;
right: 0 !important;
bottom: auto !important;
width: calc(100% - 250px) !important;
height: calc(100vh - 64px) !important;
display: flex;
flex-direction: column;
}
.qsv-bank-drawer .el-drawer__header {
margin-bottom: 0;
padding: 18px 24px;
border-bottom: 1px solid #ebeef5;
color: #303133;
font-weight: 600;
}
.qsv-bank-drawer .el-drawer__body {
flex: 1;
min-height: 0;
padding: 0;
background: #f0f2f5;
overflow: hidden;
}
.qsv-bank-drawer-page {
height: 100%;
padding: 16px 24px 72px;
box-sizing: border-box;
overflow-y: auto;
}
.qsv-bank-drawer-card {
min-height: 100%;
}
.qsv-bank-sub-title {
margin: 0 0 18px;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.qsv-bank-drawer-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
padding: 12px 24px;
border-top: 1px solid #ebeef5;
background: #ffffff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);
text-align: right;
box-sizing: border-box;
}
.el-drawer.qsv-bank-basic-drawer {
top: 64px !important;
right: 0 !important;
bottom: auto !important;
width: 1024px !important;
height: calc(100vh - 64px) !important;
display: flex;
flex-direction: column;
}
.qsv-bank-basic-drawer .el-drawer__header {
margin-bottom: 0;
padding: 18px 24px;
border-bottom: 1px solid #ebeef5;
color: #303133;
font-weight: 600;
}
.qsv-bank-basic-drawer .el-drawer__body {
flex: 1;
min-height: 0;
padding: 0;
background: #f0f2f5;
overflow: hidden;
}
.qsv-bank-basic-drawer-page {
height: 100%;
padding: 16px 16px 72px;
box-sizing: border-box;
overflow-y: auto;
}
.qsv-bank-basic-drawer-card {
min-height: 100%;
}
.qsv-bank-basic-drawer-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
padding: 12px 24px;
border-top: 1px solid #ebeef5;
background: #ffffff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);
text-align: right;
box-sizing: border-box;
}
@media (max-width: 768px) {
.el-drawer.qsv-bank-drawer,
.el-drawer.qsv-bank-basic-drawer {
top: 0 !important;
width: 100% !important;
height: 100vh !important;
}
}
</style>
<div id="app">
<guava ref="guava">
<el-card shadow="never">
<search @search="doSearch">
<search-item label="标题">
<el-input placeholder="请输入标题" v-model="pageForm.title" clearable></el-input>
<search-item label="标题名称">
<el-input placeholder="请输入标题名称" v-model="pageForm.title" clearable></el-input>
</search-item>
</search>
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="管理列表">
<table-tool label="题库列表">
<el-button @click="$refs.basicFormRef.onOpen()" size="small" type="primary" class="mr5">新增</el-button>
</table-tool>
<el-table :data="tableData" ref="tableRef" @sort-change="pageOrder">
<el-table-column label="序号" width="50" type="index" :index="indexMethod"></el-table-column>
<el-table-column label="标题" prop="title" sortable></el-table-column>
<el-table-column label="标题名称" prop="title" sortable></el-table-column>
<el-table-column label="类型" prop="category" sortable width="200">
<template scope="{row}">
<dict-tag :options="dict.type.ACTIVITY_QSV_CATEGORY" :value="row.category"></dict-tag>
<el-tag size="mini" :type="getCategoryTagType(row.category)">
{{getCategoryText(row.category)}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="450px">
<el-table-column label="操作" width="450px">
<template scope="{row}">
<el-button size="mini" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="mini" type="primary" @click="openSubject(row.id)">题目设置</el-button>
@@ -61,6 +183,21 @@ layout("/layouts/platform.html"){
}
},
methods: {
getCategoryText(category) {
const options = this.dict && this.dict.type ? (this.dict.type.ACTIVITY_QSV_CATEGORY || []) : []
const item = options.find(item => item.code === category)
return item ? item.name : category
},
getCategoryTagType(category) {
const map = {
SURVEY: "success",
QUIZ: "primary",
VOTE: "danger"
}
return map[category] || "info"
},
// 编辑
openEdit(row) {
this.$refs.basicFormRef.onOpen(row.id)
@@ -80,7 +217,7 @@ layout("/layouts/platform.html"){
cancelButtonText: "取消",
type: "warning"
}).then(() => {
$.post("/platform/qsv/activity/delete", {id}).then((res) => {
$.post("/platform/qsv/bank/delete", {id}).then((res) => {
if (res.code === 0) {
this.$message.success("删除成功")
this.pageData()
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -54,7 +54,7 @@ layout("/layouts/platform.html"){
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="管理列表">
<table-tool label="统计列表">
<el-button @click="exportXlsx" icon="el-icon-download" size="small" type="primary" class="mr5">
导出xlsx
</el-button>
@@ -69,6 +69,11 @@ layout("/layouts/platform.html"){
<el-table-column label="联系方式" prop="mobile" sortable></el-table-column>
<el-table-column label="答题日期" prop="attemptDate" sortable></el-table-column>
<el-table-column label="得分" prop="totalScore" sortable></el-table-column>
<el-table-column label="操作" fixed="right" width="100px" v-if="$auth.hasPermission('qsv.quiz.rank.delete')">
<template slot-scope="{row}">
<el-button @click="deleteAnswerRecord(row.id)" size="mini" type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
@@ -106,6 +111,20 @@ layout("/layouts/platform.html"){
exportXlsx() {
this.$downLoad("/platform/qsv/quizRank/exportXlsx", this.pageForm)
},
deleteAnswerRecord(id) {
this.$confirm("您确定要删除该答题记录吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
this.$axios.post("/platform/qsv/quizRank/deleteAnswerRecord", {id}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.pageData()
}
})
})
},
async flushUnits() {
this.$set(this.pageForm, "unitId", null)
this.unitList = []
@@ -1,22 +1,6 @@
const answer = {
/*language=HTML*/
template: `
<el-dialog title="查看答卷" :visible.sync="dialogVisible" width="80%">
<el-row type="flex" justify="end" class="mb10">
<el-button type="primary" size="small" icon="el-icon-download" @click="exportUserAnswerXlsx">导出xlsx</el-button>
</el-row>
<el-table :data="tableData">
<el-table-column label="序号" type="index" width="70"></el-table-column>
<el-table-column v-for="item in tableColumns" :key="item.prop" :label="item.label" :prop="item.prop"></el-table-column>
<el-table-column label="操作" fixed="right" width="100px">
<template slot-scope="scope">
<el-button type="danger" size="mini" icon="el-icon-delete" @click="onDelete(scope.row.id)"></el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-dialog>
`,
template: "\n <el-dialog title=\"查看答卷\" :visible.sync=\"dialogVisible\" width=\"80%\">\n <el-row type=\"flex\" justify=\"end\" class=\"mb10\">\n <el-button type=\"primary\" size=\"small\" icon=\"el-icon-download\" @click=\"exportUserAnswerXlsx\">导出xlsx</el-button>\n </el-row>\n <el-table :data=\"tableData\" v-loading=\"tableLoading\">\n <el-table-column label=\"序号\" type=\"index\" width=\"70\"></el-table-column>\n <el-table-column v-for=\"item in tableColumns\" :key=\"item.prop\" :label=\"item.label\" :prop=\"item.prop\"></el-table-column>\n <el-table-column label=\"操作\" fixed=\"right\" width=\"100px\">\n <template slot-scope=\"scope\">\n <el-button type=\"danger\" size=\"mini\" icon=\"el-icon-delete\" @click=\"onDelete(scope.row.id)\"></el-button>\n </template>\n </el-table-column>\n </el-table>\n <el-row class=\"el-pagination-container\" style=\"margin-top: 20px\">\n <el-pagination\n @size-change=\"pageSizeChange\"\n @current-change=\"pageNumberChange\"\n :current-page=\"pageForm.pageNumber\"\n :page-sizes=\"[5,10, 20, 30, 50]\"\n :page-size=\"pageForm.pageSize\"\n layout=\"total, sizes, prev, pager, next\"\n :total=\"pageForm.totalCount\"\n ></el-pagination>\n</el-row>\n </el-dialog>\n ",
mixins: [initTableMixins],
data() {
return {
@@ -30,17 +14,24 @@ const answer = {
onOpen(activityId) {
this.activityId = activityId
this.dialogVisible = true
this.tableColumns = []
this.tableData = []
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.pageData()
},
pageData() {
this.pageForm.activityId = this.activityId
this.tableLoading = true
$.post("/platform/qsv/survey/userAnswer", this.pageForm).then((res) => {
if (res.code === 0) {
this.tableColumns = res.data.tableColumns
this.tableData = res.data.tableData.list
this.pageForm.totalCount = res.data.tableData.totalCount
}
}).always(() => {
this.tableLoading = false
})
},
@@ -53,7 +44,7 @@ const answer = {
$.post("/platform/qsv/survey/deleteUserAnswer", { id }).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.list()
this.pageData()
}
})
})
@@ -2,6 +2,42 @@
layout("/layouts/platform.html"){
#-->
<style>
.survey-report-dialog {
margin-top: 40px !important;
height: calc(100vh - 80px);
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.survey-report-dialog .el-dialog__header {
flex: 0 0 auto;
}
.survey-report-dialog .el-dialog__body {
flex: 1;
min-height: 0;
overflow: hidden;
padding-top: 10px;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.survey-report-toolbar {
flex: 0 0 auto;
}
.survey-report-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 8px;
box-sizing: border-box;
}
</style>
<div id="app" v-cloak>
<guava>
<el-card shadow="never">
@@ -24,17 +60,19 @@ layout("/layouts/platform.html"){
</table-tool>
<el-table :data="tableData" ref="tableRef" @sort-change="pageOrder">
<el-table-column label="序号" width="50" type="index" :index="indexMethod"></el-table-column>
<el-table-column label="标题" prop="title" width="320" sortable></el-table-column>
<el-table-column label="标题名称" prop="title" width="320" sortable></el-table-column>
<el-table-column label="类型" prop="category" sortable>
<template scope="{row}">
<dict-tag :options="dict.type.ACTIVITY_QSV_CATEGORY" :value="row.category"></dict-tag>
<el-tag size="mini" :type="getCategoryTagType(row.category)">
{{getCategoryText(row.category)}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="开始时间" prop="startTime" sortable></el-table-column>
<el-table-column label="结束时间" prop="endTime" sortable></el-table-column>
<el-table-column label="操作" fixed="right" width="200px">
<template scope="{row}">
<el-button size="mini" type="primary" @click="$refs.reportRef.onOpen(row.id)">查看分析
<el-button size="mini" type="primary" @click="openReportPage(row.id)">查看分析
</el-button>
<el-button size="mini" type="primary" @click="$refs.answerRef.onOpen(row.id)">查看答卷
</el-button>
@@ -44,22 +82,38 @@ layout("/layouts/platform.html"){
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
<report ref="reportRef"></report>
<answer ref="answerRef"></answer>
</div>
<script nonce="${cspNonce!}">
<!--#include('report.js'){}#-->
<!--#include('answer.js'){}#-->
new Vue({
el: "#app",
dicts: ["ACTIVITY_QSV_CATEGORY"],
components: {report, answer},
components: {answer},
mixins: [initTableMixins],
data() {
return {}
},
methods: {
getCategoryText(category) {
const options = this.dict && this.dict.type ? (this.dict.type.ACTIVITY_QSV_CATEGORY || []) : []
const item = options.find(item => item.code === category)
return item ? item.name : category
},
getCategoryTagType(category) {
const map = {
SURVEY: "success",
QUIZ: "primary",
VOTE: "danger"
}
return map[category] || "info"
},
openReportPage(activityId) {
window.location.href = "/platform/qsv/survey/reportPage?activityId=" + encodeURIComponent(activityId)
},
exportXlsx() {
this.$downLoad("/platform/qsv/survey/exportXlsx", this.pageForm)
}
@@ -1,56 +1,6 @@
const report = {
/*language=HTML*/
template: `
<el-dialog title="分析报告" :visible.sync="dialogVisible" width="50%">
<el-row type="flex" justify="end" class="mb10">
<el-button type="primary" size="small" icon="el-icon-download" @click="exportReportXlsx">导出xlsx</el-button>
</el-row>
<div v-for="(subject,subjectIndex) in subjects" class="subject">
<div>
<div class="title">
第{{subjectIndex+1}}题: {{subject.title}}
<span class="type" v-if="subject.type==='text'">[填空题]</span>
<span class="type" v-else-if="subject.type==='radio'">[单选题]</span>
<span class="type" v-else-if="subject.type==='text'">[多选题]</span>
</div>
<div v-if="subject.type==='text'">
{{subject.texts.join('、')}}
</div>
<div v-else-if="subject.type==='radio' || subject.type==='checkbox'">
<el-table :data="subject.options" size="small">
<el-table-column label="选项" prop="text"></el-table-column>
<el-table-column label="小计" prop="selectCount" width="100"></el-table-column>
<el-table-column label="比例" width="500">
<template slot-scope="scope">
<el-progress
:percentage="Math.round(scope.row.selectCount / subject.selectTotal * 100)"></el-progress>
</template>
</el-table-column>
<el-table-column label="详情" width="100px">
<template slot-scope="{row}">
<el-button type="text" @click="openDetail(subject.id,row.id)">详情</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
<el-dialog title="选择详情" :visible.sync="optionVisible" width="50%" append-to-body>
<el-table :data="optionUsers" size="small">
<el-table-column label="姓名" prop="userName"></el-table-column>
<el-table-column label="工号" prop="loginName"></el-table-column>
<el-table-column label="所属工会" prop="unionName"></el-table-column>
<el-table-column label="所属单位" prop="unitName"></el-table-column>
<el-table-column label="选择时间" prop="attemptDate"></el-table-column>
</el-table>
</el-dialog>
</el-dialog>
`,
template: "\n <el-dialog title=\"分析报告\" :visible.sync=\"dialogVisible\" width=\"50%\" custom-class=\"survey-report-dialog\">\n <div class=\"survey-report-toolbar\">\n <el-row type=\"flex\" justify=\"end\" class=\"mb10\">\n <el-button type=\"primary\" size=\"small\" icon=\"el-icon-download\" @click=\"exportReportXlsx\">导出xlsx</el-button>\n </el-row>\n </div>\n <div class=\"survey-report-body\">\n <div v-for=\"(subject,subjectIndex) in subjects\" class=\"subject\">\n <div>\n <div class=\"title\">\n 第{{subjectIndex+1}}题: {{subject.title}}\n <span class=\"type\" v-if=\"subject.type==='text'\">[填空题]</span>\n <span class=\"type\" v-else-if=\"subject.type==='radio'\">[单选题]</span>\n <span class=\"type\" v-else-if=\"subject.type==='checkbox'\">[多选题]</span>\n </div>\n\n <div v-if=\"subject.type==='text'\">\n <el-button type=\"text\" @click=\"openTextDetail(subject)\">查看填写内容</el-button>\n </div>\n\n <div v-else-if=\"subject.type==='radio' || subject.type==='checkbox'\">\n <el-table :data=\"subject.options\" size=\"small\">\n <el-table-column label=\"选项\" prop=\"text\"></el-table-column>\n <el-table-column label=\"小计\" prop=\"selectCount\" width=\"100\"></el-table-column>\n <el-table-column label=\"比例\" width=\"500\">\n <template slot-scope=\"scope\">\n <el-progress\n :percentage=\"Math.round(scope.row.selectCount / subject.selectTotal * 100)\"></el-progress>\n </template>\n </el-table-column>\n <el-table-column label=\"详情\" width=\"100px\">\n <template slot-scope=\"{row}\">\n <el-button type=\"text\" @click=\"openDetail(subject.id,row.id)\">详情</el-button>\n </template>\n </el-table-column>\n </el-table>\n </div>\n </div>\n </div>\n </div>\n \n <el-dialog title=\"选择详情\" :visible.sync=\"optionVisible\" width=\"50%\" append-to-body>\n <el-table :data=\"optionUsers\" size=\"small\">\n <el-table-column label=\"姓名\" prop=\"userName\"></el-table-column>\n <el-table-column label=\"工号\" prop=\"loginName\"></el-table-column>\n <el-table-column label=\"所属工会\" prop=\"unionName\"></el-table-column>\n <el-table-column label=\"所属单位\" prop=\"unitName\"></el-table-column>\n <el-table-column label=\"补充理由\" prop=\"fillContent\" show-overflow-tooltip></el-table-column>\n <el-table-column label=\"选择时间\" prop=\"attemptDate\"></el-table-column>\n </el-table>\n </el-dialog>\n\n <el-dialog :title=\"textSubject.title || '填写内容'\" :visible.sync=\"textVisible\" width=\"50%\" append-to-body>\n <el-table :data=\"textAnswers\" size=\"small\">\n <el-table-column label=\"序号\" type=\"index\" width=\"70\"></el-table-column>\n <el-table-column label=\"姓名\" prop=\"userName\" width=\"100\"></el-table-column>\n <el-table-column label=\"工号\" prop=\"loginName\" width=\"120\"></el-table-column>\n <el-table-column label=\"所属工会\" prop=\"unionName\" width=\"140\"></el-table-column>\n <el-table-column label=\"所属单位\" prop=\"unitName\" width=\"180\" show-overflow-tooltip></el-table-column>\n <el-table-column label=\"填写内容\" prop=\"text\" show-overflow-tooltip></el-table-column>\n <el-table-column label=\"提交时间\" prop=\"submitTime\" width=\"160\"></el-table-column>\n </el-table>\n </el-dialog>\n \n </el-dialog>\n ",
dicts: ["ACTIVITY_QSV_CATEGORY"],
data() {
return {
@@ -59,7 +9,10 @@ const report = {
report: null,
subjects: [],
optionVisible: false,
optionUsers: []
optionUsers: [],
textVisible: false,
textSubject: {},
textAnswers: []
}
},
methods: {
@@ -73,8 +26,6 @@ const report = {
})
},
openDetail(subjectId, optionId) {
console.log(subjectId)
console.log(optionId)
$.post("/platform/qsv/survey/selectOptionUsers", {
activityId: this.activityId,
subjectId,
@@ -87,20 +38,16 @@ const report = {
}
})
},
openTextDetail(subject) {
this.textSubject = subject
this.textAnswers = subject.textAnswers || (subject.texts || []).map((text) => {
return { text }
})
this.textVisible = true
},
exportReportXlsx(){
this.$downLoad("/platform/qsv/survey/exportReportXlsx", { activityId: this.activityId })
}
},
style: /*language=CSS*/ `
.subject{
margin-bottom: 20px;
}
.subject .title{
margin-bottom: 5px;
}
.subject .type{
color: #a6a6a6;
margin-left: 10px;
}
`
style: /*language=CSS*/ "\n .subject{\n margin-bottom: 20px;\n }\n .subject .title{\n margin-bottom: 5px;\n }\n .subject .type{\n color: #a6a6a6;\n margin-left: 10px;\n }\n "
}
File diff suppressed because it is too large Load Diff
@@ -2,30 +2,71 @@
layout("/layouts/platform_h5.html"){
#-->
<div id="app" v-cloak>
<van-nav-bar @click-left="historyBack" left-arrow left-text="返回" title="问卷列表" placeholder fixed></van-nav-bar>
<div id="app" v-cloak class="qsv-h5-page">
<div class="qsv-h5-header">
<div class="qsv-h5-nav">
<button class="qsv-h5-back-btn" @click="historyBack">
<i class="fa fa-angle-left"></i>
</button>
<div class="qsv-h5-nav-title">问卷服务</div>
<div class="qsv-h5-nav-placeholder"></div>
</div>
<div class="qsv-h5-search-row">
<van-search
v-model="pageForm.title"
shape="round"
background="transparent"
placeholder="搜索标题名称"
clearable
@search="onRefresh"
@clear="onRefresh">
</van-search>
<button class="qsv-h5-search-btn" @click="onRefresh">
<i class="fa fa-search"></i>
</button>
</div>
<div class="qsv-h5-filter-row">
<button
v-for="item in categoryOptions"
:key="item.value"
:class="['qsv-h5-filter-btn', pageForm.category === item.value ? 'active' : '']"
@click="changeCategory(item.value)">
<i :class="pageForm.category === item.value ? 'fa fa-check-circle' : 'fa fa-circle-o'"></i>
<span>{{item.label}}</span>
</button>
</div>
</div>
<van-sticky offset-top="46px">
<van-tabs v-model="pageForm.category" @change="doSearch">
<van-tab v-for="item in dict.type.ACTIVITY_QSV_CATEGORY" :name="item.code" :title="item.label"
:key="item.code"></van-tab>
</van-tabs>
</van-sticky>
<table-list api="/platform/h5/qsv/pageData" :page_form.sync="pageForm" ref="tableListRef" title="title"
@ready="doSearch">
<!-- <template #header="{index,row}"></template>-->
<template v-slot="{index,row}">
<table-column label="开始时间">{{row.startTime}}</table-column>
<table-column label="结束时间">{{row.endTime}}</table-column>
</template>
<template #actions="{index,row}">
<div class="action-btn" @click="onEnter(row)">
<i class="fa fa-eye"></i>
<span>查看</span>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh" class="qsv-h5-list-wrap">
<van-list v-model="loading" :finished="finished" finished-text="暂无更多内容" @load="onLoad">
<div
v-for="row in list"
:key="row.id"
class="qsv-h5-card"
@click="onEnter(row)">
<div class="qsv-h5-cover">
<img :src="row.cover || defaultCover" alt="">
</div>
<div class="qsv-h5-card-body">
<div class="qsv-h5-title-row">
<div class="qsv-h5-title">{{row.title}}</div>
<div class="qsv-h5-answered" v-if="row.answeredText || row.isAnswered">{{row.answeredText || getAnsweredText(row.category)}}</div>
</div>
<div class="qsv-h5-meta-row">
<div class="qsv-h5-type">
<i :class="getCategoryIcon(row.category)"></i>
<span>{{getCategoryText(row.category)}}</span>
</div>
</div>
<div class="qsv-h5-time">
<div>开始时间:{{formatDateTime(row.startTime)}}</div>
<div>结束时间:{{formatDateTime(row.endTime)}}</div>
</div>
</div>
</div>
</template>
</table-list>
<van-empty v-if="!loading && list.length === 0" description="暂无内容"></van-empty>
</van-list>
</van-pull-refresh>
</div>
<script nonce="${cspNonce!}">
@@ -35,12 +76,26 @@ layout("/layouts/platform_h5.html"){
store,
data() {
return {
loading: false,
requesting: false,
finished: false,
refreshing: false,
cacheRestored: false,
list: [],
defaultCover: "/assets/mobile/svg/qsv/icon.svg",
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
category: "QUIZ"
title: "",
category: ""
},
categoryOptions: [
{label: "全部", value: ""},
{label: "答题", value: "QUIZ"},
{label: "问卷", value: "SURVEY"},
{label: "投票", value: "VOTE"}
],
categoryPaths: {
QUIZ: "/platform/h5/qsv/quiz",
SURVEY: "/platform/h5/qsv/survey",
@@ -50,31 +105,114 @@ layout("/layouts/platform_h5.html"){
},
methods: {
historyBack() {
this.$pjaxReplace('/platform/h5/home')
this.$pjaxReplace('/platform/h5/home')
},
doSearch() {
this.$nextTick(() => {
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.$refs.tableListRef.doSearch()
changeCategory(category) {
if (this.pageForm.category === category) {
return
}
this.pageForm.category = category
this.onRefresh()
},
onRefresh() {
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.list = []
this.finished = false
this.clearListCache()
this.onLoad()
},
onLoad() {
if (this.requesting || this.finished) {
return
}
this.requesting = true
this.loading = true
this.$axios.post("/platform/h5/qsv/pageData", this.pageForm).then((res) => {
if (res.code === 0) {
const pageData = res.data || {}
const rows = pageData.list || []
this.list = this.list.concat(rows)
this.pageForm.totalCount = pageData.totalCount || 0
if (this.list.length >= this.pageForm.totalCount || rows.length === 0) {
this.finished = true
}
this.pageForm.pageNumber++
this.saveListCache()
}
}).finally(() => {
this.requesting = false
this.loading = false
this.refreshing = false
})
},
async onEnter(item) {
debugger
if (item.groupId) {
const {
code,
data
} = await $.get('/platform/activity/basic/scope/getScopeUser', {activityGroupId: item.groupId})
if (code === 0 && data === 0) {
this.$toast.fail("您没有权限参与")
return
}
refreshListSilently() {
if (this.requesting) {
return
}
const requestForm = Object.assign({}, this.pageForm, {
pageNumber: 1
})
this.requesting = true
this.$axios.post("/platform/h5/qsv/pageData", requestForm).then((res) => {
if (res.code === 0) {
const pageData = res.data || {}
const rows = pageData.list || []
this.list = rows
this.pageForm = Object.assign({}, this.pageForm, {
pageNumber: 2,
totalCount: pageData.totalCount || 0
})
this.finished = rows.length >= this.pageForm.totalCount || rows.length === 0
this.saveListCache()
}
}).finally(() => {
this.requesting = false
this.loading = false
this.refreshing = false
})
},
const {startTime, endTime} = item
getCategoryText(category) {
const map = {
QUIZ: "答题",
SURVEY: "问卷",
VOTE: "投票"
}
return map[category] || "活动"
},
getCategoryIcon(category) {
const map = {
QUIZ: "fa fa-check-square-o",
SURVEY: "fa fa-list-alt",
VOTE: "fa fa-bar-chart"
}
return map[category] || "fa fa-tags"
},
getAnsweredText(category) {
const map = {
QUIZ: "已答题",
SURVEY: "已填写",
VOTE: "已投票"
}
return map[category] || "已完成"
},
formatDateTime(value) {
if (!value) {
return "-"
}
return this.$moment(value).format("YYYY-MM-DD HH:mm")
},
onEnter(item) {
const {startTime} = item
if (this.$moment(startTime).unix() > this.$moment().unix()) {
this.$toast("未开始")
return
@@ -82,13 +220,315 @@ layout("/layouts/platform_h5.html"){
const path = this.categoryPaths[item.category]
if (path) {
this.saveListCache()
pjaxReplace(path + "?id=" + item.id)
}
},
getCacheKey() {
return "qsv:h5:list"
},
saveListCache() {
try {
sessionStorage.setItem(this.getCacheKey(), JSON.stringify({
time: Date.now(),
list: this.list,
pageForm: this.pageForm,
finished: this.finished
}))
} catch (e) {
}
},
restoreListCache() {
try {
const cacheText = sessionStorage.getItem(this.getCacheKey())
if (!cacheText) {
return
}
const cache = JSON.parse(cacheText)
if (!cache || Date.now() - cache.time > 5 * 60 * 1000) {
this.clearListCache()
return
}
this.list = cache.list || []
this.pageForm = Object.assign({}, this.pageForm, cache.pageForm || {})
this.finished = !!cache.finished
this.cacheRestored = this.list.length > 0
} catch (e) {
this.clearListCache()
}
},
clearListCache() {
try {
sessionStorage.removeItem(this.getCacheKey())
} catch (e) {
}
}
},
created() {
this.restoreListCache()
},
mounted() {
if (this.cacheRestored) {
setTimeout(() => {
this.refreshListSilently()
}, 200)
}
}
})
</script>
<style>
.qsv-h5-page {
min-height: 100vh;
background: #f4f8ff;
padding-bottom: 18px;
box-sizing: border-box;
}
.qsv-h5-header {
padding: 10px 14px 16px;
background: linear-gradient(180deg, #1688f8 0%, #0f83ee 100%);
box-sizing: border-box;
}
.qsv-h5-nav {
height: 38px;
display: flex;
align-items: center;
margin-bottom: 8px;
color: #ffffff;
}
.qsv-h5-back-btn,
.qsv-h5-nav-placeholder {
width: 36px;
height: 36px;
flex-shrink: 0;
}
.qsv-h5-back-btn {
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, .16);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
padding: 0;
}
.qsv-h5-nav-title {
flex: 1;
min-width: 0;
text-align: center;
font-size: 18px;
font-weight: 700;
line-height: 36px;
}
.qsv-h5-search-row {
display: flex;
align-items: center;
gap: 10px;
}
.qsv-h5-header .van-search {
flex: 1;
min-width: 0;
padding: 0;
}
.qsv-h5-header .van-search__content {
background: rgba(255, 255, 255, .92);
}
.qsv-h5-search-btn {
width: 42px;
height: 42px;
border: 1px solid rgba(255, 255, 255, .45);
border-radius: 50%;
background: rgba(255, 255, 255, .18);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 18px;
box-sizing: border-box;
}
.qsv-h5-filter-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
padding-bottom: 1px;
}
.qsv-h5-filter-row::-webkit-scrollbar {
display: none;
}
.qsv-h5-filter-btn {
height: 32px;
width: 100%;
padding: 0 6px;
border: 0;
border-radius: 17px;
background: rgba(255, 255, 255, .92);
color: #1688f8;
font-size: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
}
.qsv-h5-filter-btn.active {
background: #ffffff;
font-weight: 600;
}
.qsv-h5-filter-btn i {
font-size: 15px;
}
.qsv-h5-list-wrap {
min-height: calc(100vh - 170px);
padding: 14px;
box-sizing: border-box;
}
.qsv-h5-card {
display: flex;
gap: 14px;
padding: 14px;
margin-bottom: 14px;
background: #ffffff;
border-radius: 13px;
box-shadow: 0 8px 24px rgba(35, 102, 168, .1);
box-sizing: border-box;
}
.qsv-h5-cover {
width: 116px;
height: 87px;
aspect-ratio: 4 / 3;
border-radius: 8px;
overflow: hidden;
background: #eaf2ff;
flex-shrink: 0;
}
.qsv-h5-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.qsv-h5-card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
padding: 2px 0;
}
.qsv-h5-title-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.qsv-h5-title {
flex: 1;
min-width: 0;
color: #1f2f46;
font-size: 16px;
font-weight: 700;
line-height: 1.35;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.qsv-h5-answered {
height: 22px;
min-width: 50px;
padding: 0 8px;
border: 1px solid #19a05f;
border-radius: 11px;
background: #f0fff8;
color: #149356;
font-size: 12px;
font-weight: 700;
line-height: 20px;
text-align: center;
white-space: nowrap;
flex-shrink: 0;
box-sizing: border-box;
}
.qsv-h5-meta-row {
display: flex;
align-items: center;
min-height: 20px;
}
.qsv-h5-type {
display: inline-flex;
align-items: center;
gap: 4px;
height: 20px;
padding: 0 6px;
border: 1px solid #ff9f43;
border-radius: 4px;
background: #fff7ec;
color: #f28b18;
font-size: 12px;
font-weight: 600;
line-height: 18px;
flex-shrink: 0;
box-sizing: border-box;
}
.qsv-h5-type i {
color: #f28b18;
font-size: 12px;
}
.qsv-h5-time {
color: #71829b;
font-size: 12px;
line-height: 1.75;
}
@media (max-width: 360px) {
.qsv-h5-card {
gap: 10px;
padding: 12px;
}
.qsv-h5-cover {
width: 96px;
height: 72px;
}
.qsv-h5-title {
font-size: 16px;
}
}
</style>
<!--#
}
@@ -3,17 +3,19 @@ layout("/layouts/platform_h5.html"){
#-->
<div id="app" v-cloak>
<van-nav-bar @click-left="historyBack" left-arrow left-text="返回" title="问卷列表" placeholder fixed></van-nav-bar>
<van-nav-bar @click-left="historyBack" left-arrow left-text="返回" :title="pageTitle" placeholder fixed></van-nav-bar>
<div class="container" :style="{'padding-bottom': answerRecord.isFinish ? 0 : '84px'}">
<van-sticky offset-top="46px">
<!--开启计时 未完成 活动未结束-->
<div class="timer" v-if="activity.timeLimit > 0 && !answerRecord.isFinish && !isEnd">⏰{{ remainingTime }}s
<div class="quiz-hero" :style="heroStyle">
<div class="quiz-hero-mask">
<div class="timer" v-if="activity.timeLimit > 0 && !answerRecord.isFinish && !isEnd">
⏰ {{ remainingTime }}s
</div>
<h2 class="quiz-title">{{ activity.title }}</h2>
</div>
<h2 class="title">{{ activity.title }}</h2>
</van-sticky>
</div>
<div v-if="!answerRecord.isFinish">
<div class="quiz-card-scroll" v-if="!answerRecord.isFinish">
<div v-for="(subject, index) in subjects" :key="index" class="subject">
<p>{{index+1}}、{{ subject.title }}</p>
<div class="tag">
@@ -84,9 +86,28 @@ layout("/layouts/platform_h5.html"){
return this.activity.timeLimit * 60 - this.answerTime
}
return 0
},
pageTitle() {
return this.getCategoryPageTitle(this.activity && this.activity.category, "在线答题")
},
heroStyle() {
if (this.activity && this.activity.cover) {
return {
backgroundImage: "url(" + this.activity.cover + ")"
}
}
return {}
}
},
methods: {
getCategoryPageTitle(category, defaultTitle) {
const titleMap = {
QUIZ: "在线答题",
SURVEY: "问卷调查",
VOTE: "投票"
}
return titleMap[category] || defaultTitle
},
//获取活动、题目
listSubjects() {
this.$axios.post("/platform/h5/qsv/quiz/subjects", {activityId: this.id}).then((res) => {
@@ -303,24 +324,6 @@ layout("/layouts/platform_h5.html"){
})
},
// async checkGroupPermission() {
// const groupId = this.activity.groupId
// if (groupId) {
// const { code, data } = await this.$axios.post("/open/common/checkGroupPermission", { groupId })
// if (code === 0) {
// if (!data) {
// this.$dialog
// .alert({
// title: "提示",
// message: "您没有权限参与"
// })
// .then(() => {
// location.back()
// })
// }
// }
// }
// }
},
created() {
if (this.timerInterval) {
@@ -330,73 +333,7 @@ layout("/layouts/platform_h5.html"){
this.listSubjects()
}
},
style: /*language=CSS*/ `
.container {
padding: 0;
}
/deep/ .container .title {
text-align: center;
padding: 20px;
background: #ffffff;
margin: 0 0 10px 0 !important;
color: var(--color-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.container .card {
padding: 10px;
background: #ffffff;
}
.subject {
background: #fff;
padding: 15px;
margin-bottom: 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: relative;
}
.subject p {
font-size: 16px;
margin: 20px 0 10px 0;
font-weight: bold;
}
.subject .tag {
position: absolute;
top: 0;
}
/deep/ .timer {
text-align: center;
font-size: 18px;
background: #fff;
padding: 10px 0;
}
.submit-button {
width: 100%;
margin-top: 20px;
}
.result {
text-align: center;
font-size: 20px;
margin-top: 20px;
}
.button-control {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20px;
display: flex;
column-gap: 20px;
background: #fff;
}
`
style: /*language=CSS*/ "\n .container {\n padding: 0;\n background: #f4f8ff;\n height: calc(100vh - 46px);\n overflow: hidden;\n display: flex;\n flex-direction: column;\n box-sizing: border-box;\n }\n\n /deep/ .quiz-hero {\n flex: 0 0 auto;\n min-height: 170px;\n background: linear-gradient(135deg, #1688f8, #0a55c5);\n background-size: cover;\n background-position: center;\n position: relative;\n overflow: hidden;\n margin: 12px 12px 14px;\n border-radius: 16px;\n box-shadow: 0 8px 24px rgba(35, 102, 168, .14);\n }\n\n /deep/ .quiz-hero-mask {\n min-height: 170px;\n padding: 24px 20px 22px;\n display: flex;\n flex-direction: column;\n justify-content: flex-end;\n box-sizing: border-box;\n background: linear-gradient(180deg, rgba(0, 0, 0, .16), rgba(0, 0, 0, .56));\n }\n\n /deep/ .quiz-title {\n margin: 0;\n color: #fff;\n font-size: 22px;\n font-weight: 700;\n line-height: 1.35;\n text-shadow: 0 2px 8px rgba(0, 0, 0, .28);\n }\n\n .quiz-card-scroll {\n flex: 1;\n min-height: 0;\n overflow-y: auto;\n -webkit-overflow-scrolling: touch;\n padding: 0 12px;\n box-sizing: border-box;\n }\n\n .subject {\n background: #fff;\n padding: 15px;\n margin-bottom: 15px;\n box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n position: relative;\n }\n\n .subject p {\n font-size: 16px;\n margin: 20px 0 10px 0;\n font-weight: bold;\n }\n\n .subject .tag {\n position: absolute;\n top: 0;\n }\n\n /deep/ .timer {\n align-self: flex-start;\n margin-bottom: 12px;\n padding: 5px 10px;\n border-radius: 14px;\n background: rgba(255, 255, 255, .22);\n color: #fff;\n font-size: 14px;\n font-weight: 600;\n backdrop-filter: blur(6px);\n }\n\n .submit-button {\n width: 100%;\n margin-top: 20px;\n }\n\n .result {\n text-align: center;\n font-size: 20px;\n margin-top: 20px;\n }\n\n .button-control {\n position: fixed;\n bottom: 0;\n left: 0;\n right: 0;\n padding: 20px;\n display: flex;\n column-gap: 20px;\n background: #fff;\n }\n "
})
</script>
@@ -6,15 +6,15 @@ layout("/layouts/platform_h5.html"){
<van-nav-bar @click-left="historyBack" left-arrow left-text="返回" title="问卷列表" placeholder fixed></van-nav-bar>
<div class="container" :style="{'padding-bottom': answerRecord.isFinish ? 0 : '84px'}">
<van-sticky offset-top="46px">
<h2 class="title">{{ activity.title }}</h2>
<div class="score-form">
<div class="score-form-total">
<div class="score-font-style">{{totalScore}}</div>
<i class="score-underline"></i>
<div class="quiz-hero" :style="heroStyle">
<div class="quiz-hero-mask">
<h2 class="quiz-title">{{ activity.title }}</h2>
<div class="quiz-score">
<span>{{totalScore}}</span>
<em>分</em>
</div>
</div>
</van-sticky>
</div>
<div>
<div v-for="(subject, index) in subjects" :key="index" class="subject">
@@ -111,6 +111,14 @@ layout("/layouts/platform_h5.html"){
}
}
return totalScore
},
heroStyle() {
if (this.activity && this.activity.cover) {
return {
backgroundImage: "url(" + this.activity.cover + ")"
}
}
return {}
}
},
filters: {
@@ -196,159 +204,7 @@ layout("/layouts/platform_h5.html"){
created() {
this.getAnswerResult()
},
style: /*language=CSS*/ `
.container {
padding: 0;
}
/deep/ .container .title {
text-align: center;
padding: 20px;
margin: 0 !important;
background: #ffffff;
color: var(--color-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.container .score {
}
.container .card {
padding: 10px;
background: #ffffff;
}
.subject {
background: #fff;
padding: 15px;
margin-bottom: 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: relative;
}
.subject p {
font-size: 16px;
margin: 20px 0 10px 0;
font-weight: bold;
}
.subject .tag {
position: absolute;
top: 0;
}
/deep/ .score-form {
background: #ffffff;
display: flex;
margin-bottom: 10px;
border-top: 1px solid #f1f1f1;
align-items: center;
}
/deep/ .score-form-total {
flex: 1;
background: #fff;
display: flex;
padding: 10px 20px 10px 0;
flex-direction: column;
align-items: flex-end;
}
/deep/ .score-text-news {
flex: 1;
background: #fff;
}
/deep/ .score-font-style {
font-size: 32px;
color: #ff6a00;
word-break: keep-all;
line-height: 38px;
min-width: 47px;
text-align: center;
}
/deep/ .score-underline {
background: url(//image.wjx.cn/images/newimg/score-form/score-underline@2x.png) no-repeat center;
background-size: 47px 16px;
display: inline-block;
height: 16px;
width: 47px;
margin-top: 7px;
}
.van-checkbox__icon--checked .van-icon {
color: #fff !important;
background-color: var(--color-primary) !important;
border-color: var(--color-primary) !important;
}
.van-checkbox__icon--disabled .van-icon {
background-color: #fff;
}
.answer-container {
padding: 16px;
}
.correct-answer {
color: green;
}
.incorrect-answer {
color: red;
}
.button-control {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20px;
display: flex;
column-gap: 20px;
background: #fff;
}
/deep/ .history-list {
background: #f1f1f1;
padding: 10px;
}
/deep/ .history-item {
background: #ffffff;
border-radius: 12px;
padding: 16px;
margin: 20px 10px;
box-shadow:
8px 8px 16px #d9d9d9,
-8px -8px 16px #ffffff;
transition: transform 0.2s ease-in-out;
}
/deep/ .history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
/deep/ .history-header span {
font-size: 14px;
font-weight: 600;
color: #333;
}
/deep/ .history-header .tag {
margin-left: 8px;
color: #fff;
padding: 2px 5px;
}
/deep/ .history-info {
font-size: 14px;
color: #555;
margin-bottom: 4px;
}
`
style: /*language=CSS*/ "\n .container {\n padding: 0;\n background: #f4f8ff;\n min-height: 100vh;\n }\n\n /deep/ .quiz-hero {\n min-height: 190px;\n background: linear-gradient(135deg, #1688f8, #0a55c5);\n background-size: cover;\n background-position: center;\n position: relative;\n overflow: hidden;\n margin: 12px 12px 14px;\n border-radius: 16px;\n box-shadow: 0 8px 24px rgba(35, 102, 168, .14);\n }\n\n /deep/ .quiz-hero-mask {\n min-height: 190px;\n padding: 28px 20px 24px;\n display: flex;\n flex-direction: column;\n justify-content: flex-end;\n box-sizing: border-box;\n background: linear-gradient(180deg, rgba(0, 0, 0, .18), rgba(0, 0, 0, .58));\n }\n\n /deep/ .quiz-title {\n margin: 0 0 14px;\n color: #fff;\n font-size: 22px;\n font-weight: 700;\n line-height: 1.35;\n text-shadow: 0 2px 8px rgba(0, 0, 0, .28);\n }\n\n /deep/ .quiz-score {\n display: flex;\n align-items: flex-end;\n color: #fff;\n text-shadow: 0 2px 10px rgba(0, 0, 0, .3);\n }\n\n /deep/ .quiz-score span {\n font-size: 48px;\n font-weight: 800;\n line-height: 1;\n }\n\n /deep/ .quiz-score em {\n margin-left: 6px;\n margin-bottom: 5px;\n font-style: normal;\n font-size: 18px;\n font-weight: 600;\n }\n\n .subject {\n background: #fff;\n padding: 15px;\n margin-bottom: 15px;\n box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n position: relative;\n }\n\n .subject p {\n font-size: 16px;\n margin: 20px 0 10px 0;\n font-weight: bold;\n }\n\n .subject .tag {\n position: absolute;\n top: 0;\n }\n\n .van-checkbox__icon--checked .van-icon {\n color: #fff !important;\n background-color: var(--color-primary) !important;\n border-color: var(--color-primary) !important;\n }\n\n .van-checkbox__icon--disabled .van-icon {\n background-color: #fff;\n }\n\n .answer-container {\n padding: 16px;\n }\n .correct-answer {\n color: green;\n }\n .incorrect-answer {\n color: red;\n }\n\n .button-control {\n position: fixed;\n bottom: 0;\n left: 0;\n right: 0;\n padding: 20px;\n display: flex;\n column-gap: 20px;\n background: #fff;\n }\n\n /deep/ .history-list {\n background: #f1f1f1;\n padding: 10px;\n }\n\n /deep/ .history-item {\n background: #ffffff;\n border-radius: 12px;\n padding: 16px;\n margin: 20px 10px;\n box-shadow:\n 8px 8px 16px #d9d9d9,\n -8px -8px 16px #ffffff;\n transition: transform 0.2s ease-in-out;\n }\n\n /deep/ .history-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 12px;\n }\n\n /deep/ .history-header span {\n font-size: 14px;\n font-weight: 600;\n color: #333;\n }\n\n /deep/ .history-header .tag {\n margin-left: 8px;\n color: #fff;\n padding: 2px 5px;\n }\n\n /deep/ .history-info {\n font-size: 14px;\n color: #555;\n margin-bottom: 4px;\n }\n "
})
</script>
@@ -5,18 +5,53 @@ layout("/layouts/platform_h5.html"){
<style>
.container {
padding: 0;
background: #f4f8ff;
height: calc(100vh - 46px);
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.container .title {
text-align: center;
padding: 20px;
background: #ffffff;
margin: 0 0 10px 0;
.quiz-hero {
flex: 0 0 auto;
min-height: 170px;
background: linear-gradient(135deg, #1688f8, #0a55c5);
background-size: cover;
background-position: center;
position: relative;
overflow: hidden;
margin: 12px 12px 14px;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(35, 102, 168, .14);
}
.container .card {
padding: 10px;
background: #ffffff;
.quiz-hero-mask {
min-height: 170px;
padding: 24px 20px 22px;
display: flex;
flex-direction: column;
justify-content: flex-end;
box-sizing: border-box;
background: linear-gradient(180deg, rgba(0, 0, 0, .16), rgba(0, 0, 0, .56));
}
.quiz-title {
margin: 0;
color: #fff;
font-size: 22px;
font-weight: 700;
line-height: 1.35;
text-shadow: 0 2px 8px rgba(0, 0, 0, .28);
}
.quiz-card-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0 12px;
box-sizing: border-box;
}
.subject {
@@ -49,16 +84,30 @@ layout("/layouts/platform_h5.html"){
background: #fff;
}
.van-checkbox__icon--checked .van-icon {
.van-checkbox__icon--checked .van-icon,
.van-radio__icon--checked .van-icon {
color: #fff !important;
background-color: var(--color-primary) !important;
border-color: var(--color-primary) !important;
}
.van-checkbox__icon--disabled .van-icon {
.van-checkbox__icon--disabled .van-icon,
.van-radio__icon--disabled .van-icon {
background-color: #fff;
}
.survey-option-finished-selected .survey-option-text {
color: #00a000;
font-weight: 600;
}
.survey-option-finished-selected .van-radio__icon--disabled .van-icon,
.survey-option-finished-selected .van-checkbox__icon--disabled .van-icon {
color: #fff !important;
background-color: #d8d8d8 !important;
border-color: #d8d8d8 !important;
}
.ui-input-box {
border: 1px solid #e3e3e3;
margin: 5px 0;
@@ -80,57 +129,145 @@ layout("/layouts/platform_h5.html"){
resize: none;
width: 100%;
}
.option-fill-box {
border: 1px solid #e3e3e3;
margin: 0 16px 8px 46px;
background-color: #fff;
padding: 0;
display: flex;
}
.option-fill-box input {
background-color: #fff;
border: none !important;
padding: 10px;
font-size: 14px;
line-height: 18px;
display: inline-block;
margin: 0;
-webkit-appearance: none;
resize: none;
width: 100%;
}
.required-star {
color: #ee0a24;
margin-left: 4px;
font-weight: bold;
}
</style>
<div id="app" v-cloak>
<van-nav-bar title="问卷调查" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
<div class="container" :style="{'padding-bottom': answerRecord.isFinish ? 0 : '84px'}">
<h2 class="title">{{ activity.title }}</h2>
<div v-if="!isFinished">
<div v-for="(subject, index) in subjects" :key="index" class="subject">
<p>{{index+1}}、{{ subject.title }}</p>
<van-nav-bar :title="pageTitle" left-arrow left-text="返回" @click-left="historyBack" placeholder fixed></van-nav-bar>
<div class="container" :style="{'padding-bottom': '84px'}">
<div class="quiz-hero" :style="heroStyle">
<div class="quiz-hero-mask">
<h2 class="quiz-title">{{ activity.title }}</h2>
</div>
</div>
<div class="quiz-card-scroll" v-if="!isFinished">
<div v-for="(subject, index) in visibleSubjects" :key="subject.id" class="subject">
<p>{{index+1}}、{{ subject.title }}<span class="required-star">*</span></p>
<div class="tag">
<van-tag v-if="subject.type === 'checkbox'" type="primary" size="large">多选</van-tag>
<van-tag v-if="subject.type === 'radio'" type="primary" size="large">单选</van-tag>
<van-tag v-if="subject.score" type="primary" size="large">{{subject.score}}分</van-tag>
</div>
<!--单选、多选-->
<van-checkbox-group
v-model="subject.userSelectOptionIds"
:ref="'subject'+subject.id"
v-if="['radio','checkbox'].includes(subject.type)"
<!--单选-->
<van-radio-group
:value="getRadioValue(subject)"
v-if="subject.type === 'radio'"
>
<van-cell-group>
<van-cell
v-for="(option,index) in subject.options"
clickable
:key="option.id"
:title="option.text"
@click="cellToggle(subject,option.id)"
>
<template #icon>
<van-checkbox :name="option.id" :ref="'option'+option.id" style="margin-right: 10px"></van-checkbox>
</template>
<img
slot="right-icon"
v-if="option.imgUrl"
:src="option.imgUrl"
alt=""
style="width: 40px; height: 40px"
@click.stop="previewOptionImg(option.imgUrl)"
/>
</van-cell>
<template v-for="(option,index) in subject.options">
<van-cell
clickable
:key="option.id"
:title="option.text"
:class="getFinishedOptionClass(subject, option)"
@click="cellToggle(subject,option.id)"
>
<template #title>
<span class="survey-option-text">{{option.text}}</span>
</template>
<template #icon>
<van-radio :name="option.id" :disabled="answerRecord.isFinish" style="margin-right: 10px"></van-radio>
</template>
<img
slot="right-icon"
v-if="option.imgUrl"
:src="option.imgUrl"
alt=""
style="width: 40px; height: 40px"
@click.stop="previewOptionImg(option.imgUrl)"
/>
</van-cell>
<div
class="option-fill-box"
v-if="isOptionFillVisible(subject, option)"
:key="option.id + '-fill'">
<input
v-model="subject.optionFillContents[option.id]"
:placeholder="option.fillPlaceholder || '请输入补充内容'">
</div>
</template>
</van-cell-group>
</van-radio-group>
<!--多选-->
<van-checkbox-group
v-model="subject.userSelectOptionIds"
v-if="subject.type === 'checkbox'"
>
<van-cell-group>
<template v-for="(option,index) in subject.options">
<van-cell
clickable
:key="option.id"
:title="option.text"
:class="getFinishedOptionClass(subject, option)"
@click="cellToggle(subject,option.id)"
>
<template #title>
<span class="survey-option-text">{{option.text}}</span>
</template>
<template #icon>
<van-checkbox :name="option.id" :ref="'option'+option.id" :disabled="answerRecord.isFinish" style="margin-right: 10px"></van-checkbox>
</template>
<img
slot="right-icon"
v-if="option.imgUrl"
:src="option.imgUrl"
alt=""
style="width: 40px; height: 40px"
@click.stop="previewOptionImg(option.imgUrl)"
/>
</van-cell>
<div
class="option-fill-box"
:key="option.id + '-fill'"
v-if="isOptionFillVisible(subject, option)">
<input
type="text"
v-model="subject.optionFillContents[option.id]"
:placeholder="option.fillPlaceholder || '请填写补充内容'"
:readonly="answerRecord.isFinish"
@click.stop />
</div>
</template>
</van-cell-group>
</van-checkbox-group>
<!--填空题-->
<div class="ui-input-box" v-if="subject.type==='text'">
<input type="text" v-model="subject.userFillContent" :readonly="answerRecord.isFinish" />
<input type="text" v-model="subject.userFillContent" :placeholder="'请输入' + subject.title" :readonly="answerRecord.isFinish" />
</div>
</div>
<div class="button-control" v-if="!answerRecord.isFinish">
<van-button type="primary" @click="onSubmit" block :disabled="isEnd">{{isEnd ? '已结束' : '提交'}}</van-button>
<div class="button-control">
<van-button v-if="!answerRecord.isFinish" type="primary" @click="onSubmit" block :disabled="isEnd">{{isEnd ? '已结束' : '提交'}}</van-button>
<van-button v-else type="primary" @click="historyBack" block>返回</van-button>
</div>
</div>
</div>
@@ -158,10 +295,34 @@ layout("/layouts/platform_h5.html"){
return this.$moment().unix() > this.$moment(this.activity.endTime).unix()
}
return true
},
visibleSubjects() {
return this.subjects.filter((subject) => {
return this.isSubjectVisible(subject)
})
},
pageTitle() {
return this.getCategoryPageTitle(this.activity && this.activity.category, "问卷调查")
},
heroStyle() {
if (this.activity && this.activity.cover) {
return {
backgroundImage: "url(" + this.activity.cover + ")"
}
}
return {}
}
},
methods: {
getCategoryPageTitle(category, defaultTitle) {
const titleMap = {
QUIZ: "在线答题",
SURVEY: "问卷调查",
VOTE: "投票"
}
return titleMap[category] || defaultTitle
},
//获取活动、题目
listSubjects() {
this.$axios.post("/platform/h5/qsv/survey/subjects", { activityId: this.id }).then((res) => {
@@ -193,14 +354,214 @@ layout("/layouts/platform_h5.html"){
const answer = this.answerRecord.extJson[subject.id]
if (["radio", "checkbox"].includes(subject.type)) {
this.$refs["subject" + subject.id][0].toggleAll(false)
answer?.optionIds.forEach((optionId) => {
this.$nextTick(() => {
this.$refs["option" + optionId][0].toggle()
})
})
const optionIds = answer?.optionIds || []
this.$set(subject, "userSelectOptionIds", subject.type === "radio" ? optionIds.slice(0, 1) : optionIds)
this.$set(subject, "optionFillContents", answer?.optionFillContents || {})
} else if (subject.type === "text") {
subject.userFillContent = answer?.text
this.$set(subject, "userFillContent", answer?.text)
}
})
if (!this.answerRecord.isFinish) {
this.clearHiddenSubjectAnswers()
}
},
//判断题目是否显示,未配置隐显逻辑的题目默认显示;已配置的题目按条件树递归计算后再执行显示或隐藏动作
isSubjectVisible(subject) {
if (!subject.visibleRuleEnabled) {
return true
}
const rootGroup = this.getVisibleRuleRootGroup(subject)
if (!rootGroup) {
return true
}
const matched = this.evaluateVisibleRuleGroup(rootGroup)
if (subject.visibleRuleAction === "hide") {
return !matched
}
return matched
},
//读取隐显逻辑根分组,兼容旧版 subjectId/optionId 平铺条件
getVisibleRuleRootGroup(subject) {
let conditions = subject.visibleRuleConditions || []
if (typeof conditions === "string") {
try {
conditions = JSON.parse(conditions)
} catch (e) {
conditions = []
}
}
if (!Array.isArray(conditions) || conditions.length === 0) {
return null
}
if (conditions.length === 1 && conditions[0].type === "group") {
return this.normalizeVisibleRuleGroup(conditions[0])
}
const children = conditions.filter((condition) => {
return condition.subjectId && condition.optionId
}).map((condition) => {
return {
type: "item",
field: condition.subjectId,
operator: "contains",
value: condition.optionId
}
})
if (children.length === 0) {
return null
}
return {
type: "group",
logic: subject.visibleRuleLogic === "OR" ? "or" : "and",
children
}
},
//规整条件分组,避免历史数据或空字段影响递归判断
normalizeVisibleRuleGroup(group) {
return {
type: "group",
logic: (group.logic || "and").toLowerCase() === "or" ? "or" : "and",
children: Array.isArray(group.children) ? group.children.map((child) => {
if (child.type === "group") {
return this.normalizeVisibleRuleGroup(child)
}
return {
type: "item",
field: child.field || child.subjectId || "",
operator: child.operator || (child.optionId ? "contains" : "=="),
value: child.value !== undefined && child.value !== null ? child.value : (child.optionId || "")
}
}) : []
}
},
//递归计算条件分组,支持并且、或者和嵌套分组
evaluateVisibleRuleGroup(group) {
const children = (group.children || []).filter((child) => {
return child.type === "group" || (child.field && child.operator)
})
if (children.length === 0) {
return true
}
const results = children.map((child) => {
if (child.type === "group") {
return this.evaluateVisibleRuleGroup(child)
}
return this.evaluateVisibleRuleItem(child)
})
return group.logic === "or"
? results.some((item) => {
return item
})
: results.every((item) => {
return item
})
},
//计算单条条件,选择题按选项ID判断,填空题支持文本包含和数字大小比较
evaluateVisibleRuleItem(condition) {
const triggerSubject = this.subjects.find((item) => {
return item.id === condition.field
})
if (!triggerSubject) {
return false
}
const answer = ["radio", "checkbox"].includes(triggerSubject.type)
? (triggerSubject.userSelectOptionIds || [])
: triggerSubject.userFillContent
return this.compareVisibleRuleValue(answer, condition.operator, condition.value)
},
//比较条件值,和后台逻辑抽屉实时预览保持同一套语义
compareVisibleRuleValue(answer, operator, value) {
if (Array.isArray(answer)) {
if (operator === "!=") {
return !answer.includes(value)
}
return answer.includes(value)
}
if ([">", "<", ">=", "<="].includes(operator)) {
const answerNumber = Number(answer)
const valueNumber = Number(value)
if (isNaN(answerNumber) || isNaN(valueNumber)) {
return false
}
if (operator === ">") {
return answerNumber > valueNumber
}
if (operator === "<") {
return answerNumber < valueNumber
}
if (operator === ">=") {
return answerNumber >= valueNumber
}
return answerNumber <= valueNumber
}
const answerText = answer === undefined || answer === null ? "" : String(answer)
const valueText = value === undefined || value === null ? "" : String(value)
if (operator === "!=") {
return answerText !== valueText
}
if (operator === "contains") {
return answerText.indexOf(valueText) !== -1
}
return answerText === valueText
},
//清空当前隐藏题目的答案,避免隐藏题残留旧选项或填空内容参与后续提交
clearHiddenSubjectAnswers() {
this.subjects.forEach((subject) => {
if (this.isSubjectVisible(subject)) {
return
}
if (["radio", "checkbox"].includes(subject.type)) {
this.$set(subject, "userSelectOptionIds", [])
this.$set(subject, "optionFillContents", {})
} else if (subject.type === "text") {
this.$set(subject, "userFillContent", null)
}
})
},
//判断选项是否已被选择,用于控制选项补充填写框显示和提交校验
isOptionSelected(subject, optionId) {
return subject.userSelectOptionIds && subject.userSelectOptionIds.includes(optionId)
},
getFinishedOptionClass(subject, option) {
return this.answerRecord.isFinish && this.isOptionSelected(subject, option.id)
? "survey-option-finished-selected"
: ""
},
//单选题仍按数组保存,这里只取第一个值用于 radio 组件展示
getRadioValue(subject) {
return subject.userSelectOptionIds && subject.userSelectOptionIds.length > 0
? subject.userSelectOptionIds[0]
: ""
},
//选中需要补充填写的选项后显示输入框,例如选择“否”后填写原因
isOptionFillVisible(subject, option) {
return option.fillRequired && this.isOptionSelected(subject, option.id)
},
//清空未选中选项的补充填写内容,避免取消选择后残留旧内容
clearUnselectedOptionFillContents(subject) {
if (!subject.optionFillContents) {
this.$set(subject, "optionFillContents", {})
}
if (!subject.options) {
return
}
subject.options.forEach((option) => {
if (!this.isOptionSelected(subject, option.id)) {
this.$delete(subject.optionFillContents, option.id)
}
})
},
@@ -212,9 +573,14 @@ layout("/layouts/platform_h5.html"){
}
if (subject.type === "radio") {
this.$refs["subject" + subject.id][0].toggleAll(false)
this.$set(subject, "userSelectOptionIds", [optionId])
} else {
this.$refs["option" + optionId][0].toggle()
}
this.$refs["option" + optionId][0].toggle()
this.$nextTick(() => {
this.clearUnselectedOptionFillContents(subject)
this.clearHiddenSubjectAnswers()
})
},
//预览图片
@@ -231,13 +597,20 @@ layout("/layouts/platform_h5.html"){
}
//提示那些题没有作答
for (let i = 0; i < this.subjects.length; i++) {
const subject = this.subjects[i]
for (let i = 0; i < this.visibleSubjects.length; i++) {
const subject = this.visibleSubjects[i]
if (["radio", "checkbox"].includes(subject.type)) {
if (!subject.userSelectOptionIds || subject.userSelectOptionIds.length === 0) {
this.$toast.fail("第" + (i + 1) + "题未做答")
return
}
for (let j = 0; j < subject.options.length; j++) {
const option = subject.options[j]
if (this.isOptionFillVisible(subject, option) && !subject.optionFillContents[option.id]) {
this.$toast.fail("请填写第" + (i + 1) + "题的补充内容")
return
}
}
} else if ("text" === subject.type) {
if (!subject.userFillContent) {
this.$toast.fail("第" + (i + 1) + "题未作答")
@@ -255,11 +628,12 @@ layout("/layouts/platform_h5.html"){
answer: JSON.stringify({
activityId: this.id,
answerRecordId: this.answerRecordId,
subjects: this.subjects.map((subject) => {
subjects: this.visibleSubjects.map((subject) => {
return {
id: subject.id,
userSelectOptionIds: ["checkbox", "radio"].includes(subject.type) ? subject.userSelectOptionIds : [],
userFillContent: subject.type === "text" ? subject.userFillContent : null
userFillContent: subject.type === "text" ? subject.userFillContent : null,
optionFillContents: ["checkbox", "radio"].includes(subject.type) ? subject.optionFillContents : {}
}
})
})
@@ -0,0 +1,872 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<div id="app" v-cloak class="vote-page">
<van-nav-bar :title="pageTitle" left-arrow left-text="返回" @click-left="historyBack" placeholder fixed></van-nav-bar>
<div class="vote-header">
<h1>{{activity.title}}</h1>
<div class="vote-stat-box">
<div class="vote-stat-item">
<strong>{{voteStats.optionCount || optionList.length}}</strong>
<span>参与选项</span>
</div>
<div class="vote-stat-item">
<strong>{{voteStats.totalParticipants || 0}}</strong>
<span>投票总人数</span>
</div>
<button class="vote-rank-btn" @click="rankShow = true">
<i class="fa fa-bar-chart"></i>
<span>排行榜</span>
<van-icon name="arrow"></van-icon>
</button>
</div>
</div>
<div class="vote-countdown" v-if="!isEnd">
距离投票结束还有:
<b>{{countdown.days}}</b> 天
<b>{{countdown.hours}}</b> 时
<b>{{countdown.minutes}}</b> 分
<b>{{countdown.seconds}}</b> 秒
</div>
<div class="vote-countdown" v-else>投票已结束</div>
<div class="vote-search">
<van-search
v-model="keyword"
placeholder="请输入选项编号/名称"
clearable
shape="square"
background="transparent">
</van-search>
<button @click="noop">搜索</button>
</div>
<div class="vote-subject" v-if="currentSubject">
<div class="vote-subject-title">
<span>*</span>
{{currentSubject.title}}
<em v-if="currentSubject.type === 'checkbox' && currentSubject.maxMulti">
【请选择1-{{currentSubject.maxMulti}}项,已选择{{selectedOptionIds.length}}项】
</em>
<em v-else>【请选择{{currentSubject.type === 'radio' ? '1' : '至少1'}}项】</em>
</div>
<div :class="getVoteOptionListClass(currentSubject)">
<div
v-for="(option, index) in filteredOptions"
:key="option.id"
:class="['vote-option-card', isOptionSelected(option.id) ? 'active' : '']"
@click="toggleOption(option)">
<div class="vote-option-img" v-if="option.imgUrl">
<img :src="option.imgUrl" alt="">
</div>
<div class="vote-option-img vote-option-placeholder" v-else>
<span>{{index + 1}}</span>
</div>
<div class="vote-option-info">
<input
class="vote-option-control"
:type="currentSubject.type === 'radio' ? 'radio' : 'checkbox'"
:checked="isOptionSelected(option.id)"
:disabled="answerRecord.isFinish || isEnd"
@click.stop.prevent="toggleOption(option)">
<div class="vote-option-text">
<strong>{{getOptionVotes(option.id)}}票({{getOptionPercent(option.id)}}%)</strong>
<span>{{option.text}}</span>
</div>
<button
class="vote-option-detail-btn"
v-if="hasOptionDetail(option)"
@click.stop="openOptionDetail(option)">
详情
</button>
</div>
</div>
</div>
</div>
<van-empty v-if="!currentSubject" description="暂无投票题目"></van-empty>
<div class="vote-bottom">
<van-button
type="primary"
block
color="#ff4d00"
:disabled="answerRecord.isFinish || isEnd"
@click="onSubmit">
{{answerRecord.isFinish ? '已投票' : '投票'}}
</van-button>
</div>
<van-action-sheet v-model="rankShow" title="排行榜">
<div class="vote-rank-list">
<div v-for="(item, index) in voteStats.rankList || []" :key="item.optionId" class="vote-rank-item">
<span class="vote-rank-index">{{index + 1}}</span>
<div class="vote-rank-name">{{item.text}}</div>
<div class="vote-rank-votes">{{item.votes}}票</div>
</div>
</div>
</van-action-sheet>
<div class="vote-detail-page" v-if="detailVisible && detailOption">
<div class="vote-detail-hero">
<h2>{{activity.title}}</h2>
<h3>》 {{detailOption.text}} 《</h3>
<div class="vote-detail-stats">
<span><i class="fa fa-th-large"></i> {{getOptionVotes(detailOption.id)}}票</span>
<span><i class="fa fa-institution"></i> 第{{getOptionRank(detailOption.id)}}名</span>
</div>
</div>
<div class="vote-countdown vote-detail-countdown" v-if="!isEnd">
距离投票结束还有:
<b>{{countdown.days}}</b> 天
<b>{{countdown.hours}}</b> 时
<b>{{countdown.minutes}}</b> 分
<b>{{countdown.seconds}}</b> 秒
</div>
<div class="vote-countdown vote-detail-countdown" v-else>投票已结束</div>
<div class="vote-detail-section">
<h4>投票规则</h4>
<p><i class="fa fa-play-circle"></i> 开始时间:{{activity.startTime || '-'}}</p>
<p><i class="fa fa-stop-circle"></i> 结束时间:{{activity.endTime || '-'}}</p>
<a v-if="detailOption.link" :href="detailOption.link" target="_blank">查看规则详情</a>
</div>
<div class="vote-detail-section">
<h4>详情介绍</h4>
<div class="vote-detail-content" v-if="detailOption.description" v-html="detailOption.description"></div>
<div class="vote-detail-empty" v-else>暂无详情</div>
</div>
<div class="vote-detail-bottom">
<button @click="closeOptionDetail">返回</button>
<button
class="primary"
:disabled="answerRecord.isFinish || isEnd"
@click="voteFromDetail">
{{answerRecord.isFinish ? '已投票' : '投票'}}
</button>
</div>
</div>
</div>
<script nonce="${cspNonce!}">
const vue = new Vue({
el: "#app",
data() {
return {
id: GetQueryString("id"),
activity: {},
subjects: [],
answerRecordId: null,
answerRecord: {},
voteStats: {},
keyword: "",
selectedOptionIds: [],
countdown: {
days: 0,
hours: 0,
minutes: 0,
seconds: 0
},
countdownTimer: null,
rankShow: false,
detailVisible: false,
detailOption: null
}
},
computed: {
currentSubject() {
return this.subjects.length > 0 ? this.subjects[0] : null
},
optionList() {
return this.currentSubject && this.currentSubject.options ? this.currentSubject.options : []
},
filteredOptions() {
const keyword = (this.keyword || "").trim()
if (!keyword) {
return this.optionList
}
return this.optionList.filter((option, index) => {
return String(index + 1).indexOf(keyword) > -1 || (option.text || "").indexOf(keyword) > -1
})
},
pageTitle() {
return this.getCategoryPageTitle(this.activity && this.activity.category, "投票")
},
isEnd() {
if (!this.activity || !this.activity.endTime) {
return false
}
return this.$moment().unix() > this.$moment(this.activity.endTime).unix()
}
},
methods: {
getCategoryPageTitle(category, defaultTitle) {
const titleMap = {
QUIZ: "在线答题",
SURVEY: "问卷调查",
VOTE: "投票"
}
return titleMap[category] || defaultTitle
},
noop() {
},
getVoteOptionColumns(subject) {
if (!subject || subject.optionLayout !== "HORIZONTAL") {
return 1
}
return Number(subject.optionColumns) === 3 ? 3 : 2
},
getVoteOptionListClass(subject) {
return "vote-option-list vote-option-list-" + this.getVoteOptionColumns(subject)
},
hasOptionDetail(option) {
return !!(option && ((option.description && option.description.trim()) || option.link))
},
openOptionDetail(option) {
this.detailOption = option
this.detailVisible = true
},
closeOptionDetail() {
this.detailVisible = false
this.detailOption = null
},
getOptionRank(optionId) {
const list = this.voteStats.rankList || []
const index = list.findIndex((item) => String(item.optionId) === String(optionId))
return index > -1 ? index + 1 : "-"
},
voteFromDetail() {
if (!this.detailOption || this.answerRecord.isFinish || this.isEnd) {
return
}
if (!this.isOptionSelected(this.detailOption.id)) {
this.toggleOption(this.detailOption)
}
if (this.isOptionSelected(this.detailOption.id)) {
this.onSubmit()
}
},
listSubjects() {
this.$axios.post("/platform/h5/qsv/vote/subjects", {activityId: this.id}).then((res) => {
if (res.code === 0) {
this.activity = res.data.activity || {}
this.subjects = res.data.subjects || []
this.answerRecordId = res.data.answerRecordId
this.answerRecord = res.data.answerRecord || {}
this.voteStats = res.data.voteStats || {}
this.initAnswer()
this.startCountdown()
} else {
this.$dialog.alert({
title: "提示",
message: res.msg
}).then(() => {
pjaxReplace("/platform/h5/qsv")
})
}
})
},
initAnswer() {
if (!this.currentSubject || !this.answerRecord || !this.answerRecord.extJson) {
this.selectedOptionIds = []
return
}
const answer = this.answerRecord.extJson[this.currentSubject.id]
this.selectedOptionIds = answer && answer.optionIds ? answer.optionIds.map(String) : []
},
startCountdown() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
}
this.updateCountdown()
this.countdownTimer = setInterval(() => {
this.updateCountdown()
}, 1000)
},
updateCountdown() {
if (!this.activity || !this.activity.endTime) {
return
}
const diff = Math.max(0, this.$moment(this.activity.endTime).unix() - this.$moment().unix())
this.countdown.days = Math.floor(diff / 86400)
this.countdown.hours = Math.floor((diff % 86400) / 3600)
this.countdown.minutes = Math.floor((diff % 3600) / 60)
this.countdown.seconds = diff % 60
},
toggleOption(option) {
if (this.answerRecord.isFinish || this.isEnd) {
return
}
const optionId = String(option.id)
const index = this.selectedOptionIds.indexOf(optionId)
if (this.currentSubject.type === "radio") {
this.selectedOptionIds = index > -1 ? [] : [optionId]
return
}
if (index > -1) {
this.selectedOptionIds.splice(index, 1)
return
}
if (this.currentSubject.maxMulti && this.selectedOptionIds.length >= this.currentSubject.maxMulti) {
this.$toast("最多选择" + this.currentSubject.maxMulti + "项")
return
}
this.selectedOptionIds.push(optionId)
},
isOptionSelected(optionId) {
return this.selectedOptionIds.indexOf(String(optionId)) > -1
},
getOptionStats(optionId) {
return this.voteStats.optionStats && this.voteStats.optionStats[optionId]
? this.voteStats.optionStats[optionId]
: {votes: 0, percent: 0}
},
getOptionVotes(optionId) {
return this.getOptionStats(optionId).votes || 0
},
getOptionPercent(optionId) {
const percent = this.getOptionStats(optionId).percent || 0
return Number(percent).toFixed(2)
},
previewOptionImg(img) {
vant.ImagePreview([img])
},
onSubmit() {
if (!this.currentSubject) {
return
}
if (this.isEnd) {
this.$toast("投票已结束")
return
}
if (this.selectedOptionIds.length === 0) {
this.$toast.fail("请选择投票选项")
return
}
this.$axios.post("/platform/h5/qsv/vote/submitAnswer", {
answer: JSON.stringify({
activityId: this.id,
answerRecordId: this.answerRecordId,
subjects: [
{
id: this.currentSubject.id,
userSelectOptionIds: this.selectedOptionIds
}
]
})
}).then((res) => {
if (res.code === 0) {
this.$toast.success(res.msg)
this.listSubjects()
}
})
},
historyBack() {
pjaxReplace("/platform/h5/qsv")
}
},
created() {
if (this.id) {
this.listSubjects()
}
},
beforeDestroy() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
}
}
})
</script>
<style>
.vote-page {
min-height: 100vh;
background: #fff1e9;
padding-bottom: calc(20px + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.vote-header {
padding: 36px 20px 28px;
background: radial-gradient(circle at right top, #fff 0, #fff7f2 38%, #fffdfb 100%);
}
.vote-header h1 {
margin: 0 0 34px;
color: #ff4d00;
font-size: 22px;
text-align: center;
font-weight: 700;
}
.vote-stat-box {
height: 106px;
border: 1px solid #ff6a2b;
border-radius: 8px;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
position: relative;
background: rgba(255, 255, 255, .62);
}
.vote-stat-box:before,
.vote-stat-box:after {
content: "";
position: absolute;
top: -1px;
width: 14px;
height: 14px;
background: #fff7f2;
}
.vote-stat-box:before {
left: -1px;
border-right: 1px solid #ff6a2b;
border-bottom: 1px solid #ff6a2b;
border-radius: 0 0 8px 0;
}
.vote-stat-box:after {
right: -1px;
border-left: 1px solid #ff6a2b;
border-bottom: 1px solid #ff6a2b;
border-radius: 0 0 0 8px;
}
.vote-stat-item {
text-align: center;
color: #999;
}
.vote-stat-item strong {
display: block;
margin-bottom: 8px;
color: #ff4d00;
font-size: 24px;
font-weight: 500;
}
.vote-stat-item span {
font-size: 13px;
}
.vote-rank-btn {
justify-self: center;
min-width: 82px;
height: 30px;
border: 1px solid #ff6a2b;
border-radius: 4px;
background: #fff;
color: #ff4d00;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 13px;
}
.vote-rank-btn .fa {
margin-right: 8px;
font-size: 16px;
}
.vote-countdown {
padding: 12px 16px;
color: #ff4d00;
font-size: 13px;
background: #fff7f2;
}
.vote-countdown b {
display: inline-block;
min-width: 22px;
height: 20px;
margin: 0 2px;
border-radius: 2px;
background: #ff5a1f;
color: #fff;
text-align: center;
line-height: 20px;
font-size: 14px;
}
.vote-search {
display: flex;
align-items: center;
margin: 8px 4px 0;
background: #fff;
border: 1px solid #f0f0f0;
}
.vote-search .van-search {
flex: 1;
min-width: 0;
padding: 0;
}
.vote-search button {
width: 58px;
height: 44px;
border: 0;
border-left: 1px solid #f0f0f0;
background: #fff;
color: #ff4d00;
}
.vote-subject {
padding: 14px 15px 18px;
}
.vote-subject-title {
margin-bottom: 12px;
color: #333;
font-size: 16px;
line-height: 1.55;
}
.vote-subject-title span {
color: #e61f1f;
}
.vote-subject-title em {
color: #666;
font-style: normal;
}
.vote-option-list {
display: block;
}
.vote-option-list-2,
.vote-option-list-3 {
display: grid;
gap: 12px;
}
.vote-option-list-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.vote-option-list-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.vote-option-card {
margin-bottom: 14px;
background: #fff;
border: 1px solid transparent;
box-sizing: border-box;
transition: border-color .2s, background-color .2s;
}
.vote-option-list-2 .vote-option-card,
.vote-option-list-3 .vote-option-card {
min-width: 0;
margin-bottom: 0;
}
.vote-option-card.active {
border-color: #ff4d00;
background: #fff8f4;
}
.vote-option-img {
position: relative;
width: 100%;
height: 172px;
border-radius: 6px;
overflow: hidden;
background: #f4f4f4;
}
.vote-option-list-2 .vote-option-img {
height: 126px;
}
.vote-option-list-3 .vote-option-img {
height: 92px;
}
.vote-option-img img {
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
display: block;
}
.vote-option-img .van-icon {
position: absolute;
right: 8px;
top: 8px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(255, 255, 255, .78);
color: #5a8ee8;
text-align: center;
line-height: 18px;
}
.vote-option-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: #ff7b3c;
font-size: 42px;
font-weight: 700;
background: linear-gradient(135deg, #fff7f2, #ffe4d7);
}
.vote-option-info {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
min-height: 50px;
box-sizing: border-box;
}
.vote-option-control {
flex-shrink: 0;
width: 20px;
height: 20px;
margin: 0;
accent-color: #ff4d00;
cursor: pointer;
}
.vote-option-control:disabled {
cursor: not-allowed;
}
.vote-option-text {
flex: 1;
min-width: 0;
}
.vote-option-text strong {
display: block;
margin-bottom: 5px;
color: #ff4d00;
font-size: 14px;
font-weight: 600;
}
.vote-option-text span {
display: block;
color: #333;
font-size: 15px;
line-height: 20px;
overflow-wrap: anywhere;
}
.vote-option-detail-btn {
flex-shrink: 0;
align-self: center;
margin: 0;
padding: 4px 0 4px 8px;
border: 0;
background: transparent;
color: #ff4d00;
font-size: 14px;
line-height: 22px;
}
.vote-bottom {
position: relative;
width: auto;
margin: 0 15px;
padding: 14px 19px calc(14px + env(safe-area-inset-bottom));
background: #fff1e9;
z-index: 1;
box-sizing: border-box;
}
.vote-rank-list {
padding: 12px;
background: #f8f8f8;
}
.vote-rank-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
margin-bottom: 8px;
border-radius: 8px;
background: #fff;
}
.vote-rank-index {
width: 24px;
height: 24px;
border-radius: 50%;
background: #ff5a1f;
color: #fff;
text-align: center;
line-height: 24px;
}
.vote-rank-name {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.vote-rank-votes {
color: #ff4d00;
font-weight: 600;
}
.vote-detail-page {
position: fixed;
inset: 0;
z-index: 1000;
overflow-y: auto;
padding-bottom: calc(88px + env(safe-area-inset-bottom));
background: #f5f8fb;
box-sizing: border-box;
-webkit-overflow-scrolling: touch;
}
.vote-detail-hero {
padding: 30px 18px 28px;
min-height: 150px;
color: #fff;
text-align: center;
background: linear-gradient(135deg, #ff4d00 0%, #ff6a1f 48%, #f24a00 100%);
box-sizing: border-box;
}
.vote-detail-hero h2 {
margin: 0 0 24px;
font-size: 21px;
font-weight: 700;
}
.vote-detail-hero h3 {
margin: 0 0 28px;
font-size: 20px;
font-weight: 700;
}
.vote-detail-stats {
display: flex;
justify-content: space-around;
font-size: 15px;
font-weight: 700;
}
.vote-detail-countdown {
margin-bottom: 10px;
}
.vote-detail-section {
margin: 10px 0;
padding: 16px;
background: #fff;
}
.vote-detail-section h4 {
margin: 0 0 14px;
color: #222;
font-size: 16px;
}
.vote-detail-section p {
margin: 10px 0;
color: #333;
font-size: 14px;
}
.vote-detail-section i {
margin-right: 8px;
color: #ff4d00;
}
.vote-detail-section a {
color: #ff4d00;
font-size: 14px;
}
.vote-detail-content {
color: #333;
font-size: 14px;
line-height: 1.7;
overflow-wrap: anywhere;
}
.vote-detail-content img {
max-width: 100%;
border-radius: 6px;
}
.vote-detail-empty {
color: #999;
font-size: 14px;
}
.vote-detail-bottom {
position: fixed;
left: 50%;
bottom: 0;
width: 100%;
max-width: 750px;
transform: translateX(-50%);
display: grid;
grid-template-columns: 1fr 1fr;
min-height: calc(54px + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
border-top: 1px solid #eee;
background: #fff;
z-index: 1001;
box-sizing: border-box;
}
.vote-detail-bottom button {
border: 0;
background: #fff;
color: #ff4d00;
font-size: 16px;
}
.vote-detail-bottom button.primary {
background: #ff4d00;
color: #fff;
}
.vote-detail-bottom button:disabled {
background: #f3f3f3;
color: #bbb;
}
</style>
<!--#
}
#-->
@@ -96,6 +96,35 @@ layout("/layouts/platform_h5.html"){
font-size: 13px;
text-align: center;
}
.tour-signup-toast {
width: 86vw;
max-width: 520px;
min-width: 220px;
box-sizing: border-box;
line-height: 1.6;
text-align: center;
white-space: pre-wrap;
word-break: break-word;
}
.tour-signup-toast /deep/ .van-toast__text {
text-align: center;
}
.tour-signup-notice-dialog {
width: 88vw;
max-width: 520px;
}
.tour-signup-notice-dialog .van-dialog__message {
max-height: 55vh;
overflow-y: auto;
text-align: center;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
</style>
<div id="app">
@@ -277,7 +306,7 @@ layout("/layouts/platform_h5.html"){
methods: {
init() {
if (!this.matterId) {
vant.Dialog.alert({ title: "提示", message: "报名事项参数缺失", confirmButtonColor: "#1867b0" })
this.showSignupNotice("报名事项参数缺失")
this.pageLoading = false
return
}
@@ -318,11 +347,7 @@ layout("/layouts/platform_h5.html"){
noticeChecked: this.noticeChecked
}).then((res) => {
if (res.code !== 0) {
vant.Dialog.alert({
title: "提示",
message: res.msg || "报名信息加载失败",
confirmButtonColor: "#1867b0"
})
this.showSignupNotice(res.msg || "报名信息加载失败")
return
}
const data = res.data || {}
@@ -552,27 +577,63 @@ layout("/layouts/platform_h5.html"){
this.$axios.post("/platform/tour/signup/doSignup", form).then((res) => {
this.submitLoading = false
if (res.code === 0) {
vant.Dialog.alert({
title: "提示",
message: res.msg || "报名成功",
confirmButtonColor: "#1867b0"
}).then(() => {
this.showSignupNotice(res.msg || "报名成功", () => {
window.location.href = "/platform/tour/signup/h5/signup"
})
} else {
vant.Dialog.alert({
title: "提示",
message: res.msg || "报名失败",
confirmButtonColor: "#1867b0"
})
this.showSignupNotice(res.msg || "报名失败")
}
}).catch(() => {
this.submitLoading = false
vant.Toast("报名提交失败")
})
},
initNoticePopupOptions() {
// 统一报名提交页的 Toast 提示样式,长提示按 3 秒展示并放宽内容区域。
if (vant && vant.Toast && vant.Toast.setDefaultOptions) {
vant.Toast.setDefaultOptions("text", {
duration: 3000,
className: "tour-signup-toast"
})
vant.Toast.setDefaultOptions("success", {
duration: 3000,
className: "tour-signup-toast"
})
vant.Toast.setDefaultOptions("fail", {
duration: 3000,
className: "tour-signup-toast"
})
}
},
showSignupNotice(message, callback) {
// 报名提醒内容可能来自后端规则说明,统一加宽弹窗并在 3 秒后自动关闭。
let closed = false
const done = () => {
if (closed) return
closed = true
if (typeof callback === "function") {
callback()
}
}
const timer = setTimeout(() => {
vant.Dialog.close()
done()
}, 3000)
vant.Dialog.alert({
title: "提示",
message: message || "",
className: "tour-signup-notice-dialog",
confirmButtonColor: "#1867b0"
}).then(() => {
clearTimeout(timer)
done()
}).catch(() => {
clearTimeout(timer)
done()
})
}
},
created() {
this.initNoticePopupOptions()
this.init()
}
})
@@ -339,6 +339,10 @@ layout("/layouts/platform_h5.html"){
<span class="tour-signup-people-link" @click.stop="openPeoplePopup">查看</span>
</span>
</div>
<div class="tour-detail-meta-row">
<span class="tour-detail-meta-label"><span>最多报名人数</span><span>:</span></span>
<span class="tour-detail-meta-value">{{ signupDetail.maxSignupPeople || 0 }}人</span>
</div>
<div class="tour-detail-meta-row">
<span class="tour-detail-meta-label"><span>联系人</span><span>:</span></span>
<span class="tour-detail-meta-value">{{ signupDetail.contactName || '暂无' }}</span>
@@ -423,6 +427,7 @@ layout("/layouts/platform_h5.html"){
linePdfObserver: null,
peopleVisible: false,
peopleLoading: false,
peopleRequesting: false,
peopleFinished: true,
peopleList: [],
peopleForm: {
@@ -544,9 +549,11 @@ layout("/layouts/platform_h5.html"){
this.loadPeople()
},
loadPeople() {
if (!this.matterId || this.peopleLoading || this.peopleFinished) {
if (!this.matterId || this.peopleFinished || this.peopleRequesting) {
return
}
// Vant List triggers load after setting peopleLoading=true, so this method must not return just because loading is already true.
this.peopleRequesting = true
this.peopleLoading = true
this.$axios.post("/platform/tour/signup/h5/signupPageData", {
matterId: this.matterId,
@@ -581,6 +588,7 @@ layout("/layouts/platform_h5.html"){
vant.Toast("报名人员加载失败")
this.peopleFinished = true
}).finally(() => {
this.peopleRequesting = false
this.peopleLoading = false
})
},
@@ -593,7 +601,7 @@ layout("/layouts/platform_h5.html"){
return
}
if (this.isSigned()) {
this.onApply(true)
this.onApply()
return
}
this.onApply()
@@ -897,7 +905,7 @@ layout("/layouts/platform_h5.html"){
vant.Toast("线路详情加载失败")
})
},
onApply(skipNotice) {
onApply() {
if (!this.matterId) {
vant.Toast("报名事项信息不完整")
return
@@ -905,49 +913,7 @@ layout("/layouts/platform_h5.html"){
const goApply = () => {
window.location.href = "/platform/tour/signup/h5/apply?matterId=" + encodeURIComponent(this.matterId) + "&noticeChecked=1"
}
if (skipNotice) {
goApply()
return
}
this.applyLoading = true
this.$axios.post("/platform/tour/signup/signupEligibilityNotice", { matterId: this.matterId }).then((res) => {
if (res.code !== 0) {
vant.Dialog.alert({
title: "温馨提醒",
message: res.msg || "当前不能报名",
confirmButtonColor: "#1867b0"
}).then(() => {
this.applyLoading = false
})
return
}
const data = res.data || {}
if (!data.noticeRequired) {
goApply()
return
}
if (data.canApply) {
vant.Dialog.confirm({
title: "温馨提醒",
message: data.message || "当前可报名,是否继续报名?",
confirmButtonText: "继续报名",
cancelButtonText: "稍后报名",
confirmButtonColor: "#1867b0"
}).then(goApply).catch(() => {
this.applyLoading = false
})
} else {
vant.Dialog.alert({
title: "温馨提醒",
message: data.message || "当前不能报名",
confirmButtonColor: "#1867b0"
}).then(() => {
this.applyLoading = false
})
}
}).catch(() => {
this.applyLoading = false
})
goApply()
},
revokeSignup() {
vant.Dialog.confirm({
@@ -334,6 +334,7 @@ layout("/layouts/platform_h5.html"){
.tour-line-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
@@ -463,6 +464,7 @@ layout("/layouts/platform_h5.html"){
<div class="tour-line-info">
<div><span>出行开始时间:</span>{{ row.travelStartTime || '暂无' }}</div>
<div><span>出行结束时间:</span>{{ row.travelEndTime || '暂无' }}</div>
<div><span>最多报名人数:</span>{{ row.maxSignupPeople || 0 }}人</div>
<!-- 临时屏蔽旅行社信息,保留字段和接口便于后续恢复。
<div><span>旅行社:</span>{{ row.travelAgencyName || '暂无' }}</div>
-->
@@ -480,6 +482,15 @@ layout("/layouts/platform_h5.html"){
@click.stop="handleSignupAction(row)">
{{ signupCheckingMatterId === row.matterId ? '进入中...' : signupActionText(row) }}
</button>
<button
v-if="showCancel(row)"
type="button"
class="tour-line-action tour-line-action--danger"
:class="{'tour-line-action--disabled': isTravelEnded(row) || cancelLoadingLedgerId === row.ledgerId}"
:disabled="isTravelEnded(row) || cancelLoadingLedgerId === row.ledgerId"
@click.stop="cancelByRow(row)">
{{ cancelLoadingLedgerId === row.ledgerId ? '取消中...' : '取消报名' }}
</button>
</div>
</div>
</div>
@@ -519,8 +530,8 @@ layout("/layouts/platform_h5.html"){
list: [],
loading: false,
finished: false,
lineChecking: false,
signupCheckingMatterId: "",
cancelLoadingLedgerId: "",
showUnionPopup: false,
unionList: [
{id: "", name: "全部分工会"}
@@ -576,6 +587,20 @@ layout("/layouts/platform_h5.html"){
}
return !this.isFinishedSignup(row) && !this.isRejectedSignup(row) && row.taskKey === "startTask"
},
isTravelEnded(row) {
if (!row || !row.travelEndTime) {
return false
}
const text = String(row.travelEndTime).trim()
const date = new Date((text.length === 10 ? text + " 23:59:59" : text).replace(/-/g, "/"))
return !isNaN(date.getTime()) && new Date().getTime() > date.getTime()
},
showCancel(row) {
if (!row || !this.isSigned(row)) {
return false
}
return this.isInSignupWindow(row) && (!this.isApprovalSignup(row) || this.canModifySignup(row))
},
signupActionText(row) {
if (this.isSigned(row)) {
if (this.canRevokeSignup(row)) {
@@ -589,11 +614,29 @@ layout("/layouts/platform_h5.html"){
return "报名"
},
showSignupAction(row) {
return this.isSigned(row) && this.canModifySignup(row)
return this.isSigned(row) && this.isInSignupWindow(row) && this.canModifySignup(row)
},
signupActionDisabled(row) {
return !!(this.isSigned(row) && this.isApprovalSignup(row) && (this.isRejectedSignup(row) || (!this.canRevokeSignup(row) && !this.canModifySignup(row))))
},
isInSignupWindow(row) {
if (!row || !row.signupStartTime || !row.signupEndTime) {
return false
}
const start = this.parseSignupDate(row.signupStartTime, false)
const end = this.parseSignupDate(row.signupEndTime, true)
const now = new Date().getTime()
return start !== null && end !== null && now >= start && now <= end
},
parseSignupDate(value, endOfDay) {
const text = String(value || "").trim()
if (!text) {
return null
}
const dateText = text.length === 10 ? text + (endOfDay ? " 23:59:59" : " 00:00:00") : text
const date = new Date(dateText.replace(/-/g, "/"))
return isNaN(date.getTime()) ? null : date.getTime()
},
toBoolean(value) {
return value === true || value === 1 || value === "1" || value === "true" || value === "TRUE"
},
@@ -606,12 +649,12 @@ layout("/layouts/platform_h5.html"){
return
}
if (this.isSigned(row)) {
this.goApply(row, true)
this.goApply(row)
return
}
this.goApply(row)
},
goApply(row, skipNotice) {
goApply(row) {
if (!row || !row.matterId) {
vant.Toast("报名事项信息不完整")
return
@@ -619,50 +662,8 @@ layout("/layouts/platform_h5.html"){
const go = () => {
window.location.href = "/platform/tour/signup/h5/apply?matterId=" + encodeURIComponent(row.matterId) + "&noticeChecked=1"
}
if (skipNotice) {
this.signupCheckingMatterId = row.matterId
go()
return
}
this.signupCheckingMatterId = row.matterId
this.$axios.post("/platform/tour/signup/signupEligibilityNotice", { matterId: row.matterId }).then((res) => {
if (res.code !== 0) {
vant.Dialog.alert({
title: "温馨提醒",
message: res.msg || "当前不能报名",
confirmButtonColor: "#1867b0"
}).then(() => {
this.signupCheckingMatterId = ""
})
return
}
const data = res.data || {}
if (!data.noticeRequired) {
go()
return
}
if (data.canApply) {
vant.Dialog.confirm({
title: "温馨提醒",
message: data.message || "当前可报名,是否继续报名?",
confirmButtonText: "继续报名",
cancelButtonText: "稍后报名",
confirmButtonColor: "#1867b0"
}).then(go).catch(() => {
this.signupCheckingMatterId = ""
})
} else {
vant.Dialog.alert({
title: "温馨提醒",
message: data.message || "当前不能报名",
confirmButtonColor: "#1867b0"
}).then(() => {
this.signupCheckingMatterId = ""
})
}
}).catch(() => {
this.signupCheckingMatterId = ""
})
go()
},
revokeSignup(row) {
vant.Dialog.confirm({
@@ -680,6 +681,34 @@ layout("/layouts/platform_h5.html"){
})
}).catch(() => {})
},
cancelByRow(row) {
if (!row || (!row.ledgerId && !row.matterId)) {
vant.Toast("暂无可取消的报名记录")
return
}
vant.Dialog.confirm({
title: "温馨提示",
message: "是否取消您选择的线路,请确认",
confirmButtonText: "确认",
cancelButtonText: "返回",
confirmButtonColor: "#ee2f2f"
}).then(() => {
this.cancelLoadingLedgerId = row.ledgerId || row.matterId
this.$axios.post("/platform/tour/signup/doCancelSignup", {
ledgerId: row.ledgerId || "",
matterId: row.matterId || ""
}).then((res) => {
if (res.code === 0) {
vant.Toast.success(res.msg || "取消报名成功")
this.doSearch()
} else {
vant.Dialog.alert({ title: "提示", message: res.msg || "取消报名失败", confirmButtonColor: "#1867b0" })
}
}).finally(() => {
this.cancelLoadingLedgerId = ""
})
}).catch(() => {})
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
@@ -760,38 +789,10 @@ layout("/layouts/platform_h5.html"){
vant.Toast("线路信息不完整")
return
}
if (this.lineChecking) {
return
}
this.lineChecking = true
axios.post("/platform/tour/signup/signupDetail", $.param({ matterId: row.matterId }), {
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
"x-requested-with": "XMLHttpRequest"
}
}).then((resp) => {
this.lineChecking = false
const res = resp.data || {}
if (res.code !== 0) {
vant.Dialog.alert({
title: "温馨提醒",
message: res.msg || "当前线路暂不能报名",
confirmButtonColor: "#1867b0"
})
return
}
window.location.href = "/platform/tour/signup/h5/lineInfo?matterId="
+ encodeURIComponent(row.matterId)
+ "&lineId="
+ encodeURIComponent(row.lineId)
}).catch((err) => {
this.lineChecking = false
vant.Dialog.alert({
title: "温馨提醒",
message: err && err.msg ? err.msg : "当前线路暂不能报名",
confirmButtonColor: "#1867b0"
})
})
window.location.href = "/platform/tour/signup/h5/lineInfo?matterId="
+ encodeURIComponent(row.matterId)
+ "&lineId="
+ encodeURIComponent(row.lineId)
}
},
created() {