From 2af94299dde45d2d046351c2527a3a47b5fdd1fd Mon Sep 17 00:00:00 2001 From: zhangrui Date: Thu, 2 Jul 2026 15:46:51 +0800 Subject: [PATCH] 1 --- .../budwk/app/base/constant/RoleConstant.java | 1 + .../qsv/controller/QsvActivityController.java | 26 +- .../qsv/controller/QsvBankController.java | 6 + .../qsv/controller/QsvNewController.java | 27 + .../qsv/controller/QsvOnlineController.java | 606 +++++++ .../qsv/controller/QsvQuizRankController.java | 15 + .../qsv/controller/QsvSurveyController.java | 295 +++- .../qsv/h5controller/H5QsvController.java | 72 +- .../qsv/h5controller/H5QsvQuizController.java | 18 +- .../h5controller/H5QsvSurveyController.java | 115 +- .../qsv/h5controller/H5QsvVoteController.java | 334 ++++ .../dayofficework/qsv/models/QsvActivity.java | 41 +- .../dayofficework/qsv/models/QsvOption.java | 10 + .../dayofficework/qsv/models/QsvSubject.java | 78 + .../qsv/param/QsvAnswerParam.java | 2 + .../qsv/service/QsvActivityService.java | 6 + .../qsv/service/QsvSurveyService.java | 8 + .../service/impl/QsvActivityServiceImpl.java | 67 + .../service/impl/QsvQuizRankServiceImpl.java | 17 +- .../service/impl/QsvSurveyServiceImpl.java | 532 ++++++- .../impl/QsvUserAnswerRecordServiceImpl.java | 5 +- .../tour/controller/TourGroupController.java | 164 +- .../tour/controller/TourMatterController.java | 5 + .../controller/TourMySignupController.java | 66 +- .../TourSchoolUnionApprovalController.java | 26 +- .../controller/TourSettingController.java | 9 +- .../tour/controller/TourSignupController.java | 242 ++- .../controller/TourUndertakeController.java | 11 +- .../TourUnionApprovalController.java | 26 +- .../controller/TourUnionLedgerController.java | 26 +- .../dayofficework/tour/models/TourMatter.java | 5 + .../tour/service/TourLedgerService.java | 34 + .../tour/service/TourSettingService.java | 7 + .../service/impl/TourLedgerServiceImpl.java | 130 +- .../service/impl/TourSettingServiceImpl.java | 18 + src/main/resources/db/menu/init_qsv_menu.sql | 137 ++ src/main/resources/db/menu/init_tour_menu.sql | 2 +- src/main/resources/db/tour_matter_create.sql | 14 +- src/main/resources/db/tour_setting_create.sql | 14 +- src/main/resources/db/tour_undertake_menu.sql | 2 +- .../zhgh/dayofficework/Tour/matter/index.html | 20 +- .../zhgh/dayofficework/Tour/route/index.html | 20 +- .../Tour/schoolUnionApproval/index.html | 132 +- .../dayofficework/Tour/setting/index.html | 15 +- .../zhgh/dayofficework/Tour/signup/index.html | 97 +- .../Tour/unionApproval/index.html | 132 +- .../zhgh/dayofficework/qsv/activity/bank.js | 10 +- .../dayofficework/qsv/activity/basicForm.js | 205 +-- .../dayofficework/qsv/activity/index.html | 464 +++++- .../dayofficework/qsv/activity/logicForm.js | 464 ++++++ .../zhgh/dayofficework/qsv/activity/new.html | 491 ++++++ .../dayofficework/qsv/activity/optionImg.js | 51 +- .../dayofficework/qsv/activity/setting.js | 23 +- .../dayofficework/qsv/activity/subjectForm.js | 579 +++---- .../dayofficework/qsv/activity/txtImport.js | 794 +++++---- .../zhgh/dayofficework/qsv/bank/basicForm.js | 69 +- .../zhgh/dayofficework/qsv/bank/index.html | 151 +- .../dayofficework/qsv/bank/subjectForm.js | 375 +---- .../zhgh/dayofficework/qsv/online/index.html | 1413 +++++++++++++++++ .../zhgh/dayofficework/qsv/quiz/rank.html | 21 +- .../zhgh/dayofficework/qsv/survey/answer.js | 27 +- .../zhgh/dayofficework/qsv/survey/index.html | 66 +- .../zhgh/dayofficework/qsv/survey/report.js | 79 +- .../dayofficework/qsv/survey/reportPage.html | 1323 +++++++++++++++ .../zhghh5/dayofficework/qsv/index.html | 522 +++++- .../zhghh5/dayofficework/qsv/quiz/index.html | 121 +- .../zhghh5/dayofficework/qsv/quiz/result.html | 176 +- .../dayofficework/qsv/survey/index.html | 486 +++++- .../zhghh5/dayofficework/qsv/vote/index.html | 872 ++++++++++ .../dayofficework/tour/signup/apply.html | 95 +- .../dayofficework/tour/signup/lineInfo.html | 58 +- .../dayofficework/tour/signup/list.html | 159 +- 72 files changed, 10324 insertions(+), 2405 deletions(-) create mode 100644 src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvNewController.java create mode 100644 src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvOnlineController.java create mode 100644 src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvVoteController.java create mode 100644 src/main/resources/db/menu/init_qsv_menu.sql create mode 100644 src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/logicForm.js create mode 100644 src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/new.html create mode 100644 src/main/resources/views/platform/zhgh/dayofficework/qsv/online/index.html create mode 100644 src/main/resources/views/platform/zhgh/dayofficework/qsv/survey/reportPage.html create mode 100644 src/main/resources/views/platform/zhghh5/dayofficework/qsv/vote/index.html diff --git a/src/main/java/com/budwk/app/base/constant/RoleConstant.java b/src/main/java/com/budwk/app/base/constant/RoleConstant.java index 34ca769..415a9c8 100644 --- a/src/main/java/com/budwk/app/base/constant/RoleConstant.java +++ b/src/main/java/com/budwk/app/base/constant/RoleConstant.java @@ -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("分工会主席"), diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvActivityController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvActivityController.java index 1963338..dccf7d6 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvActivityController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvActivityController.java @@ -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 diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvBankController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvBankController.java index 73fb63c..9cdb9b9 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvBankController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvBankController.java @@ -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); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvNewController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvNewController.java new file mode 100644 index 0000000..8eef7d0 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvNewController.java @@ -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() { + + } +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvOnlineController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvOnlineController.java new file mode 100644 index 0000000..d1fffe5 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvOnlineController.java @@ -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 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 activities = dao.query(QsvActivity.class, cnd); + List activityIds = activities.stream().map(QsvActivity::getId).collect(Collectors.toList()); + // 预加载用户答题记录,后续构建列表状态时避免循环查询数据库。 + Map> recordGroup = dao.query(QsvUserAnswerRecord.class, Cnd.where("userId", "=", SecurityUtil.getUserId()) + .and("activityId", "in", activityIds)) + .stream().collect(Collectors.groupingBy(QsvUserAnswerRecord::getActivityId)); + List 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 activitySubjects = queryActivitySubjectsForSync(activity); + // 后台调整题目后,同步未完成记录的题目范围,保证用户继续作答时题目完整。 + syncAnswerRecordSubjects(answerRecord, activity, activitySubjects); + + List 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 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 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 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 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 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 subjects = dao.query(QsvSubject.class, subjectCnd.asc("sortNum")); + if (subjects.size() < randomCount) { + throw new RuntimeException("题目数不够,无法生成题目"); + } + Collections.shuffle(subjects); + List randomSubjectIds = subjects.subList(0, randomCount).stream().map(QsvSubject::getId).collect(Collectors.toList()); + return qsvUserAnswerRecordService.insertRecord(activity.getId(), randomSubjectIds); + } + + private List 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 activitySubjects) { + if (answerRecord == null || Boolean.TRUE.equals(answerRecord.getIsFinish())) { + return; + } + List currentSubjectIds = activitySubjects.stream().map(QsvSubject::getId).collect(Collectors.toList()); + List 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 currentSubjectIdSet = new LinkedHashSet<>(currentSubjectIds); + List 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 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 querySubjectsByRecordOrder(List subjectIds) { + if (subjectIds == null || subjectIds.isEmpty()) { + return new ArrayList<>(); + } + List subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds)); + Map 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 subjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum")); + dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum")); + + Map 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 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 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 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); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvQuizRankController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvQuizRankController.java index 1aa0e2e..dce19da 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvQuizRankController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvQuizRankController.java @@ -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("删除成功"); + } } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvSurveyController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvSurveyController.java index 2187793..5ba96df 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvSurveyController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvSurveyController.java @@ -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 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 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 wrap(String text, float fontSize, float maxWidth, PDType0Font font) throws IOException { + List 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 answerRecords = dao.query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)); - List 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 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) { diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvController.java index 028b75e..a6f763a 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvController.java @@ -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 rows = pagination.getList(); + if (rows == null || rows.isEmpty()) { + return; + } + List activityIds = rows.stream() + .map(this::getRowActivityId) + .filter(id -> id != null && !id.isBlank()) + .collect(Collectors.toList()); + if (activityIds.isEmpty()) { + return; + } + Set 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 "已完成"; + } } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvQuizController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvQuizController.java index 02e3237..c465775 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvQuizController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvQuizController.java @@ -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 querySubjectsByRecordOrder(List subjectIds) { + if (subjectIds == null || subjectIds.isEmpty()) { + return new ArrayList<>(); + } + List subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds)); + Map 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()); + } + } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvSurveyController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvSurveyController.java index 664aed4..264af86 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvSurveyController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvSurveyController.java @@ -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 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 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 subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", answerRecord2.getSubjectIds()).asc("sortNum")); + List 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 activitySubjects) { + if (answerRecord == null || Boolean.TRUE.equals(answerRecord.getIsFinish())) { + return; + } + + List currentSubjectIds = activitySubjects.stream().map(QsvSubject::getId).collect(Collectors.toList()); + List recordSubjectIds = answerRecord.getSubjectIds(); + if (recordSubjectIds == null) { + recordSubjectIds = new ArrayList<>(); + } + + JSONObject extJson = answerRecord.getExtJson(); + if (extJson == null) { + extJson = new JSONObject(); + } + + LinkedHashSet currentSubjectIdSet = new LinkedHashSet<>(currentSubjectIds); + List 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 querySubjectsByRecordOrder(List subjectIds) { + if (subjectIds == null || subjectIds.isEmpty()) { + return new ArrayList<>(); + } + List subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds)); + Map 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); } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvVoteController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvVoteController.java new file mode 100644 index 0000000..aea49c0 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvVoteController.java @@ -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 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 subjectIds = activitySubjects.stream() + .map(QsvSubject::getId) + .collect(Collectors.toList()); + answerRecord = qsvUserAnswerRecordService.insertRecord(activityId, subjectIds); + } + syncAnswerRecordSubjects(answerRecord, activitySubjects); + + List 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 activitySubjects) { + if (answerRecord == null || Boolean.TRUE.equals(answerRecord.getIsFinish())) { + return; + } + // 后台调整投票题目后,未完成记录需要同步当前有效题目,避免提交旧题目。 + List currentSubjectIds = activitySubjects.stream() + .map(QsvSubject::getId) + .collect(Collectors.toList()); + List recordSubjectIds = answerRecord.getSubjectIds(); + if (recordSubjectIds == null) { + recordSubjectIds = new ArrayList<>(); + } + JSONObject extJson = answerRecord.getExtJson(); + if (extJson == null) { + extJson = new JSONObject(); + } + + LinkedHashSet currentSubjectIdSet = new LinkedHashSet<>(currentSubjectIds); + List 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 querySubjectsByRecordOrder(List subjectIds) { + if (subjectIds == null || subjectIds.isEmpty()) { + return new ArrayList<>(); + } + List subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds)); + Map 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 subjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum")); + dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum")); + + Map 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 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 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 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); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvActivity.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvActivity.java index 3554147..839a68b 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvActivity.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvActivity.java @@ -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(); + } } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvOption.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvOption.java index 9c69355..7d53ae4 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvOption.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvOption.java @@ -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) diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvSubject.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvSubject.java index b72f5bc..47e6d63 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvSubject.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/models/QsvSubject.java @@ -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 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 children; + + } + } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/param/QsvAnswerParam.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/param/QsvAnswerParam.java index c614794..6c7e2e3 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/param/QsvAnswerParam.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/param/QsvAnswerParam.java @@ -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 userSelectOptionIds; private String userFillContent; + private Map optionFillContents; } } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvActivityService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvActivityService.java index 27278a2..f57b880 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvActivityService.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvActivityService.java @@ -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 saveActivity(QsvActivity qsvActivity); + + void deleteActivity(String id); + + void updateEnabled(String id, Boolean enabled); } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvSurveyService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvSurveyService.java index eca20dd..00e9a46 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvSurveyService.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/QsvSurveyService.java @@ -12,6 +12,14 @@ public interface QsvSurveyService extends BaseService { List report(String activityId); + List categoryReport(String activityId, String conditions); + + NutMap crossReport(String activityId, String xSubjectIds, String ySubjectIds, String conditions); + + NutMap compareReport(String activityId, String subjectId, String optionId); + + List selectOptionUsers(String activityId, String subjectId, String optionId, String conditions); + void exportReportXlsx(String activityId, HttpServletResponse response); void exportUserAnswerXlsx(String activityId, HttpServletResponse response); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvActivityServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvActivityServiceImpl.java index 1d77d6b..94f0102 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvActivityServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvActivityServiceImpl.java @@ -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 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 subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", id)); + List 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); + } } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvQuizRankServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvQuizRankServiceImpl.java index 3cc084a..7102663 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvQuizRankServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvQuizRankServiceImpl.java @@ -90,6 +90,7 @@ public class QsvQuizRankServiceImpl extends BaseServiceImpl 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 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); } } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvSurveyServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvSurveyServiceImpl.java index ff21f11..231be8a 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvSurveyServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvSurveyServiceImpl.java @@ -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 i // 处理每个题目 for (NutMap subject : subjects) { // 获取当前题目的选项列表 - List subjectOptions = Lang.collection2list(optionsGroup.get(subject.getString("id")), NutMap.class); + List currentOptions = optionsGroup.get(subject.getString("id")); + List subjectOptions = ObjectUtil.isEmpty(currentOptions) ? new ArrayList<>() : Lang.collection2list(currentOptions, NutMap.class); // 获取题目类型 String subjectType = subject.getString("type"); // 处理文本类型题目 if ("text".equals(subjectType)) { - List 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 textAnswers = buildTextAnswerDetails(answerRecords, subject.getString("id")); + List 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 i } + @Override + public List categoryReport(String activityId, String conditions) { + if (activityId == null || activityId.isEmpty()) { + throw new IllegalArgumentException("activityId不能为空"); + } + try { + List conditionList = parseCategoryConditions(conditions); + List 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 xSubjectIdList = parseSubjectIds(xSubjectIds); + List 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 subjects = querySubjects(activityId); + Map 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 allSubjectIds = new ArrayList<>(); + allSubjectIds.addAll(xSubjectIdList); + allSubjectIds.addAll(ySubjectIdList); + List options = dao().query(QsvOption.class, Cnd.where("subjectId", "in", allSubjectIds).asc("sortNum")); + Map> optionGroup = options.stream().collect(Collectors.groupingBy(QsvOption::getSubjectId)); + List xOptionMaps = buildVariableOptionCombinations(xSubjectIdList, subjectMap, optionGroup); + List conditionList = parseCategoryConditions(conditions); + List answerRecords = dao().query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId) + .and("isFinish", "=", true)); + List filteredRecords = filterAnswerRecords(answerRecords, conditionList); + List reports = new ArrayList<>(); + + for (String ySubjectId : ySubjectIdList) { + NutMap ySubject = subjectMap.get(ySubjectId); + List yOptions = optionGroup.get(ySubjectId); + if (ObjectUtil.isEmpty(yOptions)) { + yOptions = new ArrayList<>(); + } + List yOptionMaps = ObjectUtil.isEmpty(yOptions) ? new ArrayList<>() : Lang.collection2list(yOptions, NutMap.class); + List rows = new ArrayList<>(); + + for (NutMap xOption : xOptionMaps) { + List xConditions = xOption.getList("conditions", NutMap.class); + List xMatchedRecords = filteredRecords.stream() + .filter(record -> isRecordMatched(record, xConditions)) + .toList(); + long rowTotal = xMatchedRecords.size(); + List cells = new ArrayList<>(); + for (QsvOption yOption : yOptions) { + List 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 subjects = querySubjects(activityId); + Map subjectMap = subjects.stream() + .collect(Collectors.toMap(subject -> subject.getString("id"), subject -> subject)); + NutMap compareSubject = subjectMap.get(subjectId); + if (compareSubject == null) { + throw new IllegalArgumentException("对比变量不存在"); + } + + List subjectIds = subjects.stream().map(subject -> subject.getString("id")).toList(); + List options = dao().query(QsvOption.class, Cnd.where("subjectId", "in", subjectIds).asc("sortNum")); + Map> 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 compareCondition = List.of(NutMap.NEW() + .addv("subjectId", subjectId) + .addv("optionId", optionId)); + List answerRecords = dao().query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId) + .and("isFinish", "=", true)); + List filteredRecords = filterAnswerRecords(answerRecords, compareCondition); + List 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 currentOptions = optionGroup.get(currentSubjectId); + if (ObjectUtil.isEmpty(currentOptions)) { + continue; + } + List cells = new ArrayList<>(); + long rowTotal = filteredRecords.size(); + for (QsvOption option : currentOptions) { + List 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 selectOptionUsers(String activityId, String subjectId, String optionId, String conditions) { + List conditionList = parseCategoryConditions(conditions); + List 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 report = report(activityId); @@ -134,10 +357,8 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl i exportEntities.add(new ExcelExportEntity("选择人数", "selectCount", 20)); exportEntities.add(new ExcelExportEntity("选择比例", "selectPercent", 20)); - Map> 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 i List answerExtList = answerRecords.stream().map(QsvUserAnswerRecord::getExtJson).toList(); // 查询活动题目列表 - List subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId)); + List subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum")); // 将题目列表转换为Map Map subjectMap = subjects.stream().collect(Collectors.toMap(QsvSubject::getId, v -> v)); // 获取题目ID列表 @@ -185,7 +406,7 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl i // 构建Excel导出实体 List excelExportEntities = buildExcelExportEntities(subjects); // 构建答题记录列表 - List list = buildAnswerRecords(answerRecords, subjectMap, options); + List list = buildAnswerRecords(answerRecords, subjectMap, optionMap); // 设置导出参数 ExportParams exportParams = new ExportParams(); @@ -223,7 +444,7 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl i // List answerExtList = answerRecords.stream().map(QsvUserAnswerRecord::getExtJson).toList(); // 查询活动题目列表 - List subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId)); + List subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum")); // 将题目列表转换为Map Map subjectMap = subjects.stream().collect(Collectors.toMap(QsvSubject::getId, v -> v)); // 获取题目ID列表 @@ -245,18 +466,15 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl 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 i } } + private List buildReport(String activityId, List answerRecords) { + List answerExtList = answerRecords.stream().map(QsvUserAnswerRecord::getExtJson).toList(); + List subjects = querySubjects(activityId); + List subjectIds = subjects.stream().map(v -> v.getString("id")).toList(); + List options = dao().query(QsvOption.class, Cnd.where("subjectId", "in", subjectIds).asc("sortNum")); + Map> optionsGroup = options.stream().collect(Collectors.groupingBy(QsvOption::getSubjectId)); + + for (NutMap subject : subjects) { + List currentOptions = optionsGroup.get(subject.getString("id")); + List subjectOptions = ObjectUtil.isEmpty(currentOptions) ? new ArrayList<>() : Lang.collection2list(currentOptions, NutMap.class); + String subjectType = subject.getString("type"); + if ("text".equals(subjectType)) { + List textAnswers = buildTextAnswerDetails(answerRecords, subject.getString("id")); + List 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 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 parseSubjectIds(String subjectIds) { + if (ObjectUtil.isEmpty(subjectIds)) { + return new ArrayList<>(); + } + String value = subjectIds.trim(); + List 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 subjectIds, Map 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 buildVariableOptionCombinations(List subjectIds, Map subjectMap, Map> optionGroup) { + List> subjectOptionGroups = new ArrayList<>(); + for (String subjectId : subjectIds) { + NutMap subject = subjectMap.get(subjectId); + List options = optionGroup.get(subjectId); + if (ObjectUtil.isEmpty(options)) { + continue; + } + List 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 result = new ArrayList<>(); + buildVariableOptionCombinations(subjectOptionGroups, 0, new ArrayList<>(), result); + return result; + } + + private void buildVariableOptionCombinations(List> subjectOptionGroups, int index, List current, List 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 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 filterAnswerRecords(List answerRecords, List conditions) { + if (ObjectUtil.isEmpty(conditions)) { + return answerRecords; + } + return answerRecords.stream() + .filter(record -> isRecordMatched(record, conditions)) + .toList(); + } + + private boolean isRecordMatched(QsvUserAnswerRecord record, List 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 querySubjects(String activityId) { Sql subjectSql = Sqls.create(""" SELECT @@ -309,7 +692,37 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl i return excelExportEntities; } - private List buildAnswerRecords(List answerRecords, Map subjectMap, List options) { + /** + * 构建填空题填写详情。分析弹窗需要同时展示填写内容和答题人信息, + * 因此这里从已完成答题记录中提取当前题目的文本答案,并携带姓名、工号、所属工会和单位。 + * + * @param answerRecords 当前活动已完成的答题记录 + * @param subjectId 当前填空题ID,用于从 extJson 中取出该题填写内容 + * @return 填空题填写详情列表,每条包含 loginName、userName、unionName、unitName、submitTime、text + */ + private List buildTextAnswerDetails(List answerRecords, String subjectId) { + List 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 buildAnswerRecords(List answerRecords, Map subjectMap, Map optionMap) { return answerRecords.stream().map(record -> { NutMap map = NutMap.NEW() .addv("id", record.getId()) @@ -318,20 +731,61 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl 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 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 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); + } } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvUserAnswerRecordServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvUserAnswerRecordServiceImpl.java index e22005c..93aa6ea 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvUserAnswerRecordServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/service/impl/QsvUserAnswerRecordServiceImpl.java @@ -121,11 +121,12 @@ public class QsvUserAnswerRecordServiceImpl extends BaseServiceImpl @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 list = listSql.getList(NutMap.class); - fillSignupMobile(list); - flattenSignupFamilies(list); + // 人员列表已在 SQL 层按教职工和家属平铺后分页,这里只补齐教职工手机号,避免分页后再次追加家属行。 + fillSignupMobile(list, maskOtherUnionPeople); Pagination 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 list) { + private void fillSignupMobile(List list, boolean maskOtherUnionPeople) { if (list == null || list.isEmpty()) { return; } List 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"; } } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMatterController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMatterController.java index 7198ebf..18bc0d4 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMatterController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMatterController.java @@ -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"); } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMySignupController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMySignupController.java index be30bc4..dccbe3f 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMySignupController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMySignupController.java @@ -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 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); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSchoolUnionApprovalController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSchoolUnionApprovalController.java index e3977f2..a6d8477 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSchoolUnionApprovalController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSchoolUnionApprovalController.java @@ -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 diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSettingController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSettingController.java index 57fa7cf..1e21e83 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSettingController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSettingController.java @@ -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 diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSignupController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSignupController.java index 1f747ff..0f939f2 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSignupController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSignupController.java @@ -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 rows = querySignupExportRows(pageForm, year, lineName, lineType, unionId); + List 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 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 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 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 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 buildSignupExportEntities() { + List 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"; } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourUndertakeController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourUndertakeController.java index 19e033b..d110258 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourUndertakeController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourUndertakeController.java @@ -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()) diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourUnionApprovalController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourUnionApprovalController.java index d64a39c..86c6cc1 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourUnionApprovalController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourUnionApprovalController.java @@ -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 diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourUnionLedgerController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourUnionLedgerController.java index 068f047..4b5e1ab 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourUnionLedgerController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourUnionLedgerController.java @@ -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 diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/models/TourMatter.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/models/TourMatter.java index 1e0a28b..8e2b184 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/models/TourMatter.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/models/TourMatter.java @@ -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)") diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/TourLedgerService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/TourLedgerService.java index e5ac0d8..d0d49be 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/TourLedgerService.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/TourLedgerService.java @@ -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 { * @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); } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/TourSettingService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/TourSettingService.java index ca1cb4e..195154b 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/TourSettingService.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/TourSettingService.java @@ -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 { + + /** + * 更新疗休养配置,并允许报名开始时间、报名结束时间被清空。 + * + * @param tourSetting 页面提交的疗休养配置 + */ + void updateWithNullableSignupTime(TourSetting tourSetting); } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/impl/TourLedgerServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/impl/TourLedgerServiceImpl.java index 0283631..c9f8e08 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/impl/TourLedgerServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/impl/TourLedgerServiceImpl.java @@ -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 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 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 implement || AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name()); } + /** + * 疗休养领办人需要与领办列表保持一致,可在领办前查询到全部成团数据。 + */ + private boolean hasTourApplicantScope() { + return AuthUtil.hasRole(RoleConstant.TOUR_APPLICANT.name()); + } + /** * 判断当前用户是否具备分工会级别的事项处理范围。 */ diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/impl/TourSettingServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/impl/TourSettingServiceImpl.java index 68f57fc..32428f1 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/impl/TourSettingServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/impl/TourSettingServiceImpl.java @@ -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 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())); + } } diff --git a/src/main/resources/db/menu/init_qsv_menu.sql b/src/main/resources/db/menu/init_qsv_menu.sql new file mode 100644 index 0000000..37f92fc --- /dev/null +++ b/src/main/resources/db/menu/init_qsv_menu.sql @@ -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; diff --git a/src/main/resources/db/menu/init_tour_menu.sql b/src/main/resources/db/menu/init_tour_menu.sql index 02d0287..002d034 100644 --- a/src/main/resources/db/menu/init_tour_menu.sql +++ b/src/main/resources/db/menu/init_tour_menu.sql @@ -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; -- 普惠疗休养移动端菜单。 diff --git a/src/main/resources/db/tour_matter_create.sql b/src/main/resources/db/tour_matter_create.sql index 52667a1..073f6b0 100644 --- a/src/main/resources/db/tour_matter_create.sql +++ b/src/main/resources/db/tour_matter_create.sql @@ -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='普惠疗休养事项'; diff --git a/src/main/resources/db/tour_setting_create.sql b/src/main/resources/db/tour_setting_create.sql index fe4e86a..de38180 100644 --- a/src/main/resources/db/tour_setting_create.sql +++ b/src/main/resources/db/tour_setting_create.sql @@ -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='普惠疗休养事项'; -- 疗休养事项批次表。 diff --git a/src/main/resources/db/tour_undertake_menu.sql b/src/main/resources/db/tour_undertake_menu.sql index 9e7d9c3..c6e494c 100644 --- a/src/main/resources/db/tour_undertake_menu.sql +++ b/src/main/resources/db/tour_undertake_menu.sql @@ -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; diff --git a/src/main/resources/views/platform/zhgh/dayofficework/Tour/matter/index.html b/src/main/resources/views/platform/zhgh/dayofficework/Tour/matter/index.html index 7947698..c2964d9 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/Tour/matter/index.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/Tour/matter/index.html @@ -244,6 +244,13 @@ layout("/layouts/platform.html"){ + + + + + + + 取消 @@ -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"]) } }) }, diff --git a/src/main/resources/views/platform/zhgh/dayofficework/Tour/route/index.html b/src/main/resources/views/platform/zhgh/dayofficework/Tour/route/index.html index 7664c99..7a55b3e 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/Tour/route/index.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/Tour/route/index.html @@ -333,6 +333,13 @@ layout("/layouts/platform.html"){ + + + + + + + 上一步 @@ -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"]) } }) }, diff --git a/src/main/resources/views/platform/zhgh/dayofficework/Tour/schoolUnionApproval/index.html b/src/main/resources/views/platform/zhgh/dayofficework/Tour/schoolUnionApproval/index.html index 6cf64ea..843b9ef 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/Tour/schoolUnionApproval/index.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/Tour/schoolUnionApproval/index.html @@ -113,25 +113,39 @@ layout("/layouts/platform.html"){
{{ formData.taskName }}
- - - - - - - 取消 - 退回到发起人 - 拒绝申请 - 同意申请 - +
+ + + + 同意申请 + 拒绝申请 + 退回发起人 + + + + + + + +
+ 取消 + 提交 +
+
@@ -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; + } + + diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/optionImg.js b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/optionImg.js index 0a9a801..7c34fa3 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/optionImg.js +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/optionImg.js @@ -1,25 +1,5 @@ const optionImg = { - template: /*language=HTML*/ ` - -
- - - - -
-
- 请上传图片 -
- -
- `, + template: /*language=HTML*/ "\n \n
\n \n \n \n \n
\n
\n 请上传图片\n
\n
\n 取 消\n 确 定\n
\n
\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 " } diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/setting.js b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/setting.js index 5367a46..9df4eec 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/setting.js +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/setting.js @@ -1,20 +1,5 @@ const setting = { - template:/*language=HTML*/` - - - - - - - - - - - - `, + template:/*language=HTML*/"\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n 取 消\n 确 定\n
\n
\n ", data(){ return{ 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 }, diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/subjectForm.js b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/subjectForm.js index 1c22aaf..64bc329 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/subjectForm.js +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/subjectForm.js @@ -4,224 +4,19 @@ const subjectForm = { - template: /*language=HTML*/ ` -
- -
- - -
-
-
- - 第 {{subjectIndex + 1}} 题 - - -
- -
-
- 题目类型: - - - 单选题 - - - 多选题 - - - 填空题 - - - - - -
-
- 最大选择数: - -
- - - - - - - -
- 题目分数: - - - -
- -
- 显示日期: - - -
-
-
- - - -
- - - - - - - - - -
-
-
-
- -
-
- -
- - - - - - 从文本导入 - - - 添加题目 - - - 保存题目 - -
- - - - - -
- `, + template: /*language=HTML*/ "\n
\n \n
\n \n \n
\n
\n
\n \n 第 {{subjectIndex + 1}} 题\n \n \n
\n\n
\n
\n 题目类型:\n \n \n 单选题\n \n \n 多选题\n \n \n 填空题\n \n \n \n \n \n
\n
\n 最大选择数:\n \n
\n
\n 排列方式:\n \n \n \n \n
\n
\n 横向列数:\n \n \n \n \n
\n \n \n \n \n \n \n\n
\n 题目分数:\n \n \n \n
\n\n
\n 显示日期:\n \n \n
\n
\n
\n\n \n\n
\n \n \n \n \n \n \n \n \n \n
\n
\n
\n
\n \n
\n
\n\n
\n \n \n \n \n \n 导入题目\n \n \n 添加题目\n \n \n 存到题库\n \n \n 保存题目\n \n
\n\n \n \n \n \n \n \n \n \n \n \n
\n 取消\n 确定\n
\n \n
\n ", components: { 'option-img': optionImg, 'setting': setting, 'txt-import': txtImport, 'bank': subjectBank }, + props: { + hideSaveButton: { + type: Boolean, + default: false + } + }, data() { return { id: null, @@ -230,7 +25,20 @@ const subjectForm = { subjects: [], previewDialogVisible: false, activity: {}, - formRules: {} + formRules: {}, + saveBankDialog: { + visible: false, + loading: false, + formData: { + title: "" + }, + rules: { + title: [ + { required: true, message: "请输入题库标题", trigger: "blur" }, + { max: 50, message: "标题名称不能超过50个字", trigger: "blur" } + ] + } + } } }, methods: { @@ -238,14 +46,19 @@ const subjectForm = { this.visible = true if (id) { this.id = id - $.post("/platform/qsv/activity/findOne", {id}).then((res) => { - if (res.code === 0) { - this.activity = res.data + Promise.all([ + $.post("/platform/qsv/activity/findOne", {id}), + $.post("/platform/qsv/activity/listSubjects", {activityId: id}) + ]).then(([activityRes, subjectsRes]) => { + if (activityRes.code === 0) { + this.activity = activityRes.data } - }) - $.post("/platform/qsv/activity/listSubjects", {activityId: id}).then((res) => { - if (res.code === 0) { - this.subjects = res.data + if (subjectsRes.code === 0) { + this.subjects = subjectsRes.data || [] + this.normalizeVoteSubjectLayouts() + } + if (this.activity.category === "VOTE" && this.subjects.length === 0) { + this.addSubject() } }) } @@ -265,6 +78,8 @@ const subjectForm = { type: "radio", score: 0, displayDate: "", + optionLayout: "VERTICAL", + optionColumns: 1, options: [ { id: this.generateId(), @@ -284,7 +99,40 @@ const subjectForm = { }) }, - //题目类型切换 + // 投票选项排列默认值 + normalizeVoteSubjectLayout(subject) { + if (!subject) { + return + } + if (!subject.optionLayout) { + this.$set(subject, "optionLayout", "VERTICAL") + } + if (subject.optionLayout === "HORIZONTAL") { + const columns = Number(subject.optionColumns) + this.$set(subject, "optionColumns", columns === 3 ? 3 : 2) + } else { + this.$set(subject, "optionLayout", "VERTICAL") + this.$set(subject, "optionColumns", 1) + } + }, + + normalizeVoteSubjectLayouts() { + if (this.activity.category !== "VOTE") { + return + } + this.subjects.forEach((subject) => { + this.normalizeVoteSubjectLayout(subject) + }) + }, + + onVoteLayoutChange(subject) { + if (subject.optionLayout === "HORIZONTAL") { + this.$set(subject, "optionColumns", Number(subject.optionColumns) === 3 ? 3 : 2) + } else { + this.$set(subject, "optionColumns", 1) + } + }, + subjectTypeChange(subject, subjectIndex) { if (subject.type === 'text') { subject.options = [] @@ -351,7 +199,7 @@ const subjectForm = { //选项设置确认 onSettingConfirm({option, ext}) { - this.subjects[ext.subjectIndex].options[ext.optionIndex] = option + this.$set(this.subjects[ext.subjectIndex].options, ext.optionIndex, option) console.log(this.subjects[ext.subjectIndex].options[ext.optionIndex]) // this.$set(this.subjects[ext.subjectIndex].options[ext.optionIndex], "isCorrect", option.isCorrect) @@ -360,7 +208,7 @@ const subjectForm = { //从文本导入 openTxtImport() { - this.$refs.txtImportRef.visible = true; + this.$refs.txtImportRef.onOpen(this.activity.category); }, //处理导入的题目 @@ -375,6 +223,9 @@ const subjectForm = { // 更新导入题目的序号 importedQuestions.forEach((question, index) => { question.sortNum = startSortNum + index; + if (this.activity.category === "VOTE") { + this.normalizeVoteSubjectLayout(question) + } }); // 将导入的题目添加到现有题目列表 @@ -393,8 +244,127 @@ const subjectForm = { }, + // 打开存到题库对话框 + openSaveBankDialog() { + if (!this.subjects || this.subjects.length === 0) { + this.$message.warning("请先添加题目") + return + } + this.saveBankDialog.formData.title = this.activity && this.activity.title ? this.activity.title : "" + this.saveBankDialog.visible = true + this.$nextTick(() => { + if (this.$refs.saveBankFormRef) { + this.$refs.saveBankFormRef.clearValidate() + } + }) + }, + + // 复制题目到题库时重新生成ID,避免与活动题目关联 + buildBankSubjects() { + const subjects = JSON.parse(JSON.stringify(this.subjects || [])) + const subjectIdMap = {} + const optionIdMap = {} + const remapValue = (value, map) => { + if (Array.isArray(value)) { + return value.map((item) => map[item] || item) + } + return map[value] || value + } + const remapVisibleConditions = (conditions) => { + if (!Array.isArray(conditions)) { + return conditions + } + return conditions.map((condition) => { + if (condition.subjectId) { + condition.subjectId = subjectIdMap[condition.subjectId] || condition.subjectId + } + if (condition.field) { + condition.field = subjectIdMap[condition.field] || condition.field + } + if (condition.optionId) { + condition.optionId = optionIdMap[condition.optionId] || condition.optionId + } + if (Object.prototype.hasOwnProperty.call(condition, "value")) { + condition.value = remapValue(condition.value, optionIdMap) + } + if (Array.isArray(condition.children)) { + condition.children = remapVisibleConditions(condition.children) + } + return condition + }) + } + + subjects.forEach((subject) => { + const oldSubjectId = subject.id + const newSubjectId = this.generateId() + subject._bankNewSubjectId = newSubjectId + if (oldSubjectId) { + subjectIdMap[oldSubjectId] = newSubjectId + } + if (Array.isArray(subject.options)) { + subject.options.forEach((option) => { + const oldOptionId = option.id + const newOptionId = this.generateId() + option._bankNewOptionId = newOptionId + if (oldOptionId) { + optionIdMap[oldOptionId] = newOptionId + } + }) + } + }) + + return subjects.map((subject, index) => { + const newSubjectId = this.generateId() + subject.id = subject._bankNewSubjectId || newSubjectId + delete subject._bankNewSubjectId + subject.activityId = null + subject.sortNum = index + 1 + if (Array.isArray(subject.options)) { + subject.options = subject.options.map((option, optionIndex) => { + option.id = option._bankNewOptionId || this.generateId() + delete option._bankNewOptionId + option.subjectId = subject.id + option.sortNum = optionIndex + 1 + return option + }) + } + if (Array.isArray(subject.correctAnswer)) { + subject.correctAnswer = remapValue(subject.correctAnswer, optionIdMap) + } + if (Array.isArray(subject.visibleRuleConditions)) { + subject.visibleRuleConditions = remapVisibleConditions(subject.visibleRuleConditions) + } + return subject + }) + }, + + // 保存当前全部题目到题库 + saveToBank() { + this.$refs.saveBankFormRef.validate((valid) => { + if (!valid) { + return + } + const qsvBank = { + title: this.saveBankDialog.formData.title, + category: this.activity.category, + subjects: this.buildBankSubjects() + } + this.saveBankDialog.loading = true + $.post("/platform/qsv/bank/save", { qsvBank: JSON.stringify(qsvBank) }).then((res) => { + this.saveBankDialog.loading = false + if (res.code === 0) { + this.$message.success("已存到题库") + this.saveBankDialog.visible = false + } + }).fail(() => { + this.saveBankDialog.loading = false + }) + }) + }, + //保存 saveQuestionnaire() { + this.normalizeVoteSubjectLayouts() $ .post("/platform/qsv/activity/saveSubjects", { activityId: this.id, @@ -409,166 +379,5 @@ const subjectForm = { }) } }, - style: /*language=CSS*/ ` - /*.subject-form-dialog,.subject-form-dialog .el-dialog__body {*/ - /* background: rgb(248, 249, 250);*/ - /*}*/ - - /deep/ .questionnaire-editor { - max-width: 1200px; - margin: 0 auto; - padding: 20px; - } - - /deep/ .editor-header { - background: white; - padding: 20px; - border-radius: 8px; - box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); - margin-bottom: 20px; - } - - /deep/ .subject-item { - background: white; - border-radius: 8px; - padding: 25px; - margin-bottom: 20px; - box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); - transition: all 0.3s ease; - border: 1px dashed #d9d9d9; - } - - /deep/ .subject-item:hover { - box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.1); - /*transform: translateY(-1px);*/ - } - - /deep/ .subject-header { - border-bottom: 1px solid #eee; - padding-bottom: 15px; - margin-bottom: 15px; - } - - /deep/ .subject-toolbar { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 15px; - padding-top: 15px; - border-top: 1px solid #eee; - } - - /deep/ .toolbar { - background: white; - padding: 15px; - border-radius: 8px; - box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); - z-index: 1000; - } - - /deep/ .toolbar-btn { - margin: 5px 0; - width: 100%; - } - - /deep/ .option-item { - background: #f8f9fa; - border-radius: 4px; - padding: 10px; - margin-bottom: 10px; - display: flex; - align-items: center; - } - - /deep/ .option-drag-handle { - cursor: move; - margin-right: 10px; - color: #909399; - } - - /deep/ .option-content { - flex-grow: 1; - margin-right: 10px; - } - - /deep/ .option-tools { - display: flex; - align-items: center; - gap: 10px; - } - - /deep/ .preview-dialog { - max-width: 800px; - margin: 0 auto; - } - - /deep/ .date-visible { - margin-left: 20px; - } - - /deep/ .subject-meta { - display: flex; - flex-wrap: wrap; - gap: 15px; - margin-top: 15px; - background: #f8f9fa; - padding: 10px; - border-radius: 4px; - } - - /deep/ .image-preview { - max-width: 200px; - max-height: 200px; - margin-top: 10px; - border-radius: 4px; - border: 1px solid #dcdfe6; - } - - /deep/ .drag-handler { - cursor: move; - color: #909399; - margin-right: 10px; - } - - /deep/ .meta-info { - color: #909399; - font-size: 12px; - margin-top: 5px; - } - - /deep/ .subject-type-icon { - margin-right: 5px; - } - - - /deep/ .option-img-box { - position: relative; - width: 40px; - height: 40px; - cursor: pointer; - flex-shrink: 0 - } - - /deep/ .option-img-box:hover .el-icon-remove { - display: block; - } - - /deep/ .option-img-box img { - width: 100%; - height: 100%; - } - - /deep/ .option-img-box .el-icon-remove { - position: absolute; - top: -7px; - right: -7px; - color: red; - display: none; - } - - /deep/ .option-img-box .el-icon-remove:hover { - transform: scale(1.1); - } - - ` + style: /*language=CSS*/ "\n /deep/ .qsv-subject-editor {\n padding-bottom: 72px;\n }\n\n /deep/ .qsv-subject-footer {\n position: fixed;\n left: 0;\n width: 100%;\n right: 0;\n bottom: 0;\n z-index: 900;\n padding: 12px 24px;\n border-top: 1px solid #ebeef5;\n background: #ffffff;\n box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);\n text-align: right;\n box-sizing: border-box;\n }\n\n @media (max-width: 768px) {\n /deep/ .qsv-subject-footer {\n left: 0;\n width: 100%;\n }\n }\n\n /deep/ .save-bank-dialog {\n top: 50%;\n margin-top: 0 !important;\n transform: translateY(calc(-50% + 250px));\n }\n\n /*.subject-form-dialog,.subject-form-dialog .el-dialog__body {*/\n /* background: rgb(248, 249, 250);*/\n /*}*/\n\n /deep/ .questionnaire-editor {\n max-width: 1200px;\n margin: 0 auto;\n padding: 20px;\n }\n\n /deep/ .editor-header {\n background: white;\n padding: 20px;\n border-radius: 8px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);\n margin-bottom: 20px;\n }\n\n /deep/ .subject-item {\n background: white;\n border-radius: 8px;\n padding: 25px;\n margin-bottom: 20px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);\n transition: all 0.3s ease;\n border: 1px dashed #d9d9d9;\n }\n\n /deep/ .subject-item:hover {\n box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.1);\n /*transform: translateY(-1px);*/\n }\n\n /deep/ .subject-header {\n border-bottom: 1px solid #eee;\n padding-bottom: 15px;\n margin-bottom: 15px;\n }\n\n /deep/ .subject-title-row {\n display: flex;\n align-items: center;\n }\n\n /deep/ .subject-title-input {\n flex: 1;\n }\n\n /deep/ .subject-toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-top: 15px;\n padding-top: 15px;\n border-top: 1px solid #eee;\n }\n\n /deep/ .toolbar {\n background: white;\n padding: 15px;\n border-radius: 8px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);\n z-index: 1000;\n }\n\n /deep/ .toolbar-btn {\n margin: 5px 0;\n width: 100%;\n }\n\n /deep/ .option-item {\n background: #f8f9fa;\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 10px;\n display: flex;\n align-items: center;\n }\n\n /deep/ .option-drag-handle {\n cursor: move;\n margin-right: 10px;\n color: #909399;\n }\n\n /deep/ .option-content {\n flex-grow: 1;\n margin-right: 10px;\n }\n\n /deep/ .option-tools {\n display: flex;\n align-items: center;\n gap: 10px;\n }\n\n /deep/ .preview-dialog {\n max-width: 800px;\n margin: 0 auto;\n }\n\n /deep/ .date-visible {\n margin-left: 20px;\n }\n\n /deep/ .subject-meta {\n display: flex;\n flex-wrap: wrap;\n gap: 15px;\n margin-top: 15px;\n background: #f8f9fa;\n padding: 10px;\n border-radius: 4px;\n }\n\n /deep/ .image-preview {\n max-width: 200px;\n max-height: 200px;\n margin-top: 10px;\n border-radius: 4px;\n border: 1px solid #dcdfe6;\n }\n\n /deep/ .drag-handler {\n cursor: move;\n color: #909399;\n margin-right: 10px;\n }\n\n /deep/ .meta-info {\n color: #909399;\n font-size: 12px;\n margin-top: 5px;\n }\n\n /deep/ .subject-type-icon {\n margin-right: 5px;\n }\n\n\n /deep/ .option-img-box {\n position: relative;\n width: 40px;\n height: 40px;\n cursor: pointer;\n flex-shrink: 0\n }\n\n /deep/ .option-img-box:hover .el-icon-remove {\n display: block;\n }\n\n /deep/ .option-img-box img {\n width: 100%;\n height: 100%;\n }\n\n /deep/ .option-img-box .el-icon-remove {\n position: absolute;\n top: -7px;\n right: -7px;\n color: red;\n display: none;\n }\n\n /deep/ .option-img-box .el-icon-remove:hover {\n transform: scale(1.1);\n }\n\n " } diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/txtImport.js b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/txtImport.js index 94ecdf8..3aab873 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/txtImport.js +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/txtImport.js @@ -1,254 +1,274 @@ const txtImport = { - template: /*language=HTML*/ ` - -
-
-
-
题目输入区
-
- -
-
-

支持的格式:

-

单选题:

-

1.题目标题?(A)

-

A.选项A

-

B.选项B

-

C.选项C

-

D.选项D

-

多选题:

-

2.题目标题?(ABC)

-

A.选项A

-

B.选项B

-

C.选项C

-

D.选项D

-

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

-
-
- 格式说明 -
-
-
-
- -
-
- 解析题目 - 清空 -
-
-
-
-
解析结果
-
- {{statsText}} - {{parsedQuestions.length}} 题 -
-
-
-
- - - -
-
-
- {{index + 1}} - {{question.title}} - 多选题 - 单选题 -
-
- -
- 未发现选项数据 -
-
-
- 正确答案: {{getCorrectAnswerText(question)}} -
-
-
-
-
- -
- `, + template: /*language=HTML*/ "\n \n
\n
\n
\n
\n \n 文本解析\n 调用题库\n \n
题目输入区
\n \n
\n
\n

支持的格式:

\n

单选题:

\n

1.题目标题?(A)

\n

A.选项A

\n

B.选项B

\n

多选题:

\n

2.题目标题?(ABC)

\n

A.选项A

\n

B.选项B

\n

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

\n
\n
\n

支持的格式:

\n

单选题:

\n

1.题目标题?

\n

A.选项A

\n

B.选项B

\n

多选题:

\n

2.题目标题?(多选)

\n

A.选项A

\n

B.选项B

\n

填空题(仅问卷):

\n

3.请填写建议(填空)

\n
\n
\n 格式说明\n
\n
\n
\n \n \n
\n
\n 解析题目\n 清空\n
\n
\n\n
\n
\n \n 文本解析\n 调用题库\n \n
题库列表
\n 刷新\n
\n
\n \n \n
{{bank.title || '未命名题库'}}
\n {{getCategoryText(bank.category)}}\n {{getBankSubjectCount(bank)}} 题\n
\n
\n
\n\n
\n
\n
{{activeTab === 'text' ? '解析结果' : '题库题目'}}
\n
0\">\n \n 全选\n \n 已选 {{currentSelectedIds.length}} / {{currentQuestions.length}} 题\n
\n
\n
\n \n \n \n
\n \n \n {{index + 1}}\n {{question.title}}\n 多选题\n 填空题\n 单选题\n
\n
\n \n
无选项
\n
\n
0\">\n 正确答案: {{getCorrectAnswerText(question) || '未设置'}}\n
\n
\n
\n
\n \n \n
\n 取 消\n 确 定\n
\n
\n ", data() { return { visible: false, - inputText: '', + activeTab: "text", + category: null, + inputText: "", parsedQuestions: [], - statsText: '', - activityId: "cc7cb3b0b25c472794048229ad910fb1", - parsing: false + parsedSelectedIds: [], + statsText: "", + parsing: false, + bankList: [], + bankLoading: false, + selectedBankId: "", + bankQuestions: [], + bankSelectedIds: [], + excludeBankId: "" + } + }, + computed: { + currentQuestions() { + return this.activeTab === "text" ? this.parsedQuestions : this.bankQuestions + }, + currentSelectedIds() { + return this.activeTab === "text" ? this.parsedSelectedIds : this.bankSelectedIds + }, + isAllSelected() { + return this.currentQuestions.length > 0 && this.currentSelectedIds.length === this.currentQuestions.length + }, + isIndeterminate() { + return this.currentSelectedIds.length > 0 && this.currentSelectedIds.length < this.currentQuestions.length + }, + inputPlaceholder() { + if (this.category === "QUIZ") { + return "请粘贴题目内容,支持格式如:\n\n1.在()中,中国工人阶级第一次以独立的姿态登上政治舞台。(A)\nA.五四运动\nB.五卅运动\nC.二七罢工\nD.安源路矿工人大罢工\n\n2.充分调动广大职工群众的( ),积极投身全面推进强国建设、民族复兴的伟大事业。(ABC)\nA.积极性\nB.主动性\nC.创造性\nD.创新性" + } + if (this.category === "VOTE") { + return "请粘贴投票题目内容,支持格式如:\n\n1.请选择您支持的候选项目(多选)\nA.项目一\nB.项目二\nC.项目三\nD.项目四\n\n2.请选择一个最喜欢的项目\nA.项目一\nB.项目二" + } + return "请粘贴问卷题目内容,支持格式如:\n\n1.您的性别?\nA.男\nB.女\n\n2.您喜欢哪些活动?(多选)\nA.文体活动\nB.培训讲座\nC.福利服务\n\n3.请填写您的建议(填空)" } }, methods: { - onOpen() { - this.visible = true; - this.inputText = ''; - this.parsedQuestions = []; - this.statsText = ''; + onOpen(category, options = {}) { + this.visible = true + this.category = category || null + this.excludeBankId = options.excludeBankId || "" + this.activeTab = "text" + this.inputText = "" + this.parsedQuestions = [] + this.parsedSelectedIds = [] + this.statsText = "" + this.selectedBankId = "" + this.bankQuestions = [] + this.bankSelectedIds = [] + this.listBank() + }, + + onTabChange(tab) { + if (tab === "bank" && this.bankList.length === 0) { + this.listBank() + } }, clearInput() { - this.$confirm('确定要清空当前输入内容吗?', '提示', { - confirmButtonText: '确定', - cancelButtonText: '取消', - type: 'warning' + this.$confirm("确定要清空当前输入内容吗?", "提示", { + confirmButtonText: "确定", + cancelButtonText: "取消", + type: "warning" }).then(() => { - this.inputText = ''; - this.$message.success('已清空输入内容'); - }).catch(() => {}); + this.inputText = "" + this.parsedQuestions = [] + this.parsedSelectedIds = [] + this.statsText = "" + this.$message.success("已清空输入内容") + }).catch(() => {}) }, parseQuestions() { - const input = this.inputText.trim(); + const input = this.inputText.trim() if (!input) { - this.$message.error('请先输入题目内容'); - return; + this.$message.error("请先输入题目内容") + return } - this.parsing = true; + this.parsing = true setTimeout(() => { try { - const questionsGroups = this.splitIntoQuestions(input); - + const questionsGroups = this.splitIntoQuestions(input) if (questionsGroups.length === 0) { - throw new Error('未识别到有效题目,请检查格式'); + throw new Error("未识别到有效题目,请检查格式") } - const parsedQuestions = []; + const parsedQuestions = [] questionsGroups.forEach((group, index) => { - const result = this.parseOneQuestion(group, index + 1); + const result = this.parseOneQuestion(group, index + 1) if (result) { - parsedQuestions.push(result); + parsedQuestions.push(result) } - }); + }) - this.parsedQuestions = parsedQuestions; - this.updateStats(); + this.parsedQuestions = parsedQuestions + this.parsedSelectedIds = parsedQuestions.map((question) => question.id) + this.updateStats() if (parsedQuestions.length === 0) { - this.$message.warning('没有成功解析任何题目,请检查格式'); + this.$message.warning("没有成功解析任何题目,请检查格式") } else if (parsedQuestions.length < questionsGroups.length) { - this.$message.warning('共' + questionsGroups.length + '道题目,成功解析' + parsedQuestions.length + '道'); + this.$message.warning("共" + questionsGroups.length + "道题目,成功解析" + parsedQuestions.length + "道") } else { - this.$message.success('成功解析' + parsedQuestions.length + '道题目'); + this.$message.success("成功解析" + parsedQuestions.length + "道题目") } } catch (error) { - this.$message.error('解析失败,请检查题目格式:' + error.message); + this.$message.error("解析失败,请检查题目格式:" + error.message) } finally { - this.parsing = false; + this.parsing = false } - }, 300); + }, 300) }, splitIntoQuestions(text) { - const lines = text.split('\n').map(line => line.trim()).filter(line => line); - const questions = []; - let currentQuestion = []; + const lines = text.split(/\r?\n/).map(line => line.trim()).filter(line => line) + const questions = [] + let currentQuestion = [] lines.forEach(line => { - if (/^\d+\./.test(line)) { + if (/^\d+[..、]/.test(line)) { if (currentQuestion.length > 0) { - questions.push(currentQuestion.join('\n')); - currentQuestion = []; + questions.push(currentQuestion.join("\n")) + currentQuestion = [] } } - currentQuestion.push(line); - }); + currentQuestion.push(line) + }) if (currentQuestion.length > 0) { - questions.push(currentQuestion.join('\n')); + questions.push(currentQuestion.join("\n")) } - return questions; + return questions }, parseOneQuestion(questionText, sortNum) { - const lines = questionText.split('\n').map(line => line.trim()).filter(line => line); + if (this.category === "QUIZ") { + return this.parseQuizQuestion(questionText, sortNum) + } + return this.parsePlainQuestion(questionText, sortNum) + }, - const titleLine = lines[0]; - const titleMatch = titleLine.match(/^(\d+)\.(.+?)[((]([A-Z]+)[))]$/); + parseQuizQuestion(questionText, sortNum) { + const lines = questionText.split(/\r?\n/).map(line => line.trim()).filter(line => line) + const titleLine = lines[0] + const titleMatch = titleLine.match(/^(\d+)[..、](.+?)[((]([A-Z]+)[))]$/) if (!titleMatch) { - return null; + return null } - const questionNumber = titleMatch[1]; - const title = titleMatch[2].trim(); - const correctAnswerLetters = titleMatch[3].split(''); + const title = titleMatch[2].trim() + const correctAnswerLetters = titleMatch[3].split("") + const options = [] - const options = []; for (let i = 1; i < lines.length; i++) { - const line = lines[i]; - const optionMatch = line.match(/^([A-Z])[..、\s](.+)$/); - + const optionMatch = lines[i].match(/^([A-Z])[..、\s](.+)$/) if (optionMatch) { - const optionLetter = optionMatch[1]; - const optionText = optionMatch[2].trim(); - + const optionLetter = optionMatch[1] + const optionText = optionMatch[2].trim() options.push({ letter: optionLetter, - text: optionLetter + '. ' + optionText, + text: optionLetter + ". " + optionText, isCorrect: correctAnswerLetters.includes(optionLetter) - }); + }) } } if (options.length === 0) { - return null; + return null } return this.createQuestionObject({ - questionNumber, title, correctAnswerLetters, options, sortNum, - isMultiple: correctAnswerLetters.length > 1 - }); + type: correctAnswerLetters.length > 1 ? "checkbox" : "radio", + score: 10, + maxMulti: correctAnswerLetters.length > 1 ? correctAnswerLetters.length : null + }) + }, + + parsePlainQuestion(questionText, sortNum) { + const lines = questionText.split(/\r?\n/).map(line => line.trim()).filter(line => line) + const titleLine = lines[0] + const titleMatch = titleLine.match(/^(\d+)[..、](.+)$/) + + if (!titleMatch) { + return null + } + + const titleMeta = this.parsePlainTitle(titleMatch[2].trim()) + const options = [] + + for (let i = 1; i < lines.length; i++) { + const optionMatch = lines[i].match(/^([A-Z])[..、\s](.+)$/) + if (optionMatch) { + const optionLetter = optionMatch[1] + const optionText = optionMatch[2].trim() + options.push({ + letter: optionLetter, + text: optionLetter + ". " + optionText, + isCorrect: false + }) + } + } + + let questionType = titleMeta.type + if (!questionType) { + questionType = options.length === 0 && this.category === "SURVEY" ? "text" : "radio" + } + + if (this.category === "VOTE" && questionType === "text") { + questionType = "radio" + } + + if (questionType !== "text" && options.length === 0) { + return null + } + + return this.createQuestionObject({ + title: titleMeta.title, + correctAnswerLetters: [], + options, + sortNum, + type: questionType, + score: 0, + maxMulti: questionType === "checkbox" ? options.length : null + }) + }, + + parsePlainTitle(title) { + const markers = [ + { pattern: /[((【\[]\s*多选\s*[))】\]]/g, type: "checkbox" }, + { pattern: /[((【\[]\s*单选\s*[))】\]]/g, type: "radio" }, + { pattern: /[((【\[]\s*填空\s*[))】\]]/g, type: "text" } + ] + let type = null + let cleanTitle = title + + markers.forEach((marker) => { + if (marker.pattern.test(cleanTitle)) { + type = marker.type + cleanTitle = cleanTitle.replace(marker.pattern, "") + } + }) + + return { + title: cleanTitle.trim(), + type + } }, createQuestionObject(data) { - const questionId = this.generateId(); - const formattedOptions = []; - const correctOptionIds = []; + const questionId = this.generateId() + const formattedOptions = [] + const correctOptionIds = [] data.options.forEach((option, index) => { - const optionId = this.generateId(); + const optionId = this.generateId() if (option.isCorrect) { - correctOptionIds.push(optionId); + correctOptionIds.push(optionId) } formattedOptions.push({ @@ -260,269 +280,229 @@ E.先进性" link: null, description: null, sortNum: index + 1 - }); - }); + }) + }) return { id: questionId, - activityId: this.activityId, + activityId: null, title: data.title, - type: data.isMultiple ? "checkbox" : "radio", + type: data.type, correctAnswer: correctOptionIds, - score: 10, + score: data.score || 0, displayDate: null, - maxMulti: data.isMultiple ? correctOptionIds.length : null, + maxMulti: data.maxMulti || null, + optionLayout: "VERTICAL", + optionColumns: 1, sortNum: data.sortNum, options: formattedOptions, + visibleRuleEnabled: false, + visibleRuleAction: "show", + visibleRuleLogic: "AND", + visibleRuleConditions: [], userSelectOptionIds: null, userFillContent: null - }; + } + }, + + listBank() { + this.bankLoading = true + $.post("/platform/qsv/activity/listBank", {category: this.category}).then((res) => { + this.bankLoading = false + if (res.code === 0) { + const bankList = res.data || [] + this.bankList = this.excludeBankId + ? bankList.filter((bank) => bank.id !== this.excludeBankId) + : bankList + } + }).fail(() => { + this.bankLoading = false + }) + }, + + selectBank(bank) { + this.selectedBankId = bank.id + this.bankQuestions = this.normalizeSubjects(bank.subjects) + this.bankSelectedIds = this.bankQuestions.map((question) => question.id) + }, + + getBankSubjectCount(bank) { + return this.normalizeSubjects(bank && bank.subjects).length + }, + + normalizeSubjects(subjects) { + if (typeof subjects === "string") { + try { + return JSON.parse(subjects) || [] + } catch (e) { + return [] + } + } + return JSON.parse(JSON.stringify(subjects || [])) + }, + + getCategoryText(category) { + const map = { + QUIZ: "答题", + SURVEY: "问卷", + VOTE: "投票", + quiz: "答题", + survey: "问卷", + vote: "投票" + } + return map[category] || category || "未分类" + }, + + isQuestionSelected(question) { + return this.currentSelectedIds.includes(question.id) + }, + + toggleQuestion(question) { + const selectedIds = this.activeTab === "text" ? this.parsedSelectedIds : this.bankSelectedIds + const index = selectedIds.indexOf(question.id) + if (index > -1) { + selectedIds.splice(index, 1) + } else { + selectedIds.push(question.id) + } + }, + + toggleSelectAll(checked) { + const selectedIds = checked ? this.currentQuestions.map((question) => question.id) : [] + if (this.activeTab === "text") { + this.parsedSelectedIds = selectedIds + } else { + this.bankSelectedIds = selectedIds + } }, generateId() { - return 'xxxxxxxx'.replace(/[x]/g, () => { - return (Math.random() * 16 | 0).toString(16); - }); + return Date.now() + Math.random().toString(36).substr(2, 9) }, getCorrectAnswerText(question) { if (!question || !question.options || !Array.isArray(question.options)) { - return ''; + return "" } - const correctOptions = question.options.filter(opt => opt.isCorrect); + const correctOptions = question.options.filter(opt => opt.isCorrect) if (correctOptions.length === 0) { - return ''; + return "" } - return correctOptions.map(opt => opt.text).join('、'); + return correctOptions.map(opt => opt.text).join("、") }, updateStats() { - this.statsText = '已解析 ' + this.parsedQuestions.length + ' 道题目 | ' + - '总分值 ' + (this.parsedQuestions.length * 10) + ' 分 | '; + this.statsText = "已解析 " + this.parsedQuestions.length + " 道题目 | 总分值 " + (this.parsedQuestions.length * 10) + " 分 | " + }, + + cloneQuestionsForImport(questions) { + const clonedQuestions = JSON.parse(JSON.stringify(questions || [])) + const subjectIdMap = {} + const optionIdMap = {} + + clonedQuestions.forEach((question) => { + const oldQuestionId = question.id + const newQuestionId = this.generateId() + question._newQuestionId = newQuestionId + if (oldQuestionId) { + subjectIdMap[oldQuestionId] = newQuestionId + } + if (Array.isArray(question.options)) { + question.options.forEach((option) => { + const oldOptionId = option.id + const newOptionId = this.generateId() + option._newOptionId = newOptionId + if (oldOptionId) { + optionIdMap[oldOptionId] = newOptionId + } + }) + } + }) + + const remapValue = (value) => { + if (Array.isArray(value)) { + return value.map((item) => optionIdMap[item] || item) + } + return optionIdMap[value] || value + } + + const remapVisibleConditions = (conditions) => { + if (!Array.isArray(conditions)) { + return [] + } + return conditions.map((condition) => { + if (condition.subjectId) { + condition.subjectId = subjectIdMap[condition.subjectId] || condition.subjectId + } + if (condition.field) { + condition.field = subjectIdMap[condition.field] || condition.field + } + if (condition.optionId) { + condition.optionId = optionIdMap[condition.optionId] || condition.optionId + } + if (Object.prototype.hasOwnProperty.call(condition, "value")) { + condition.value = remapValue(condition.value) + } + if (Array.isArray(condition.children)) { + condition.children = remapVisibleConditions(condition.children) + } + return condition + }) + } + + return clonedQuestions.map((question, index) => { + const newQuestionId = question._newQuestionId || this.generateId() + question.id = newQuestionId + delete question._newQuestionId + question.activityId = null + question.sortNum = index + 1 + question.userSelectOptionIds = null + question.userFillContent = null + + if (Array.isArray(question.options)) { + question.options = question.options.map((option, optionIndex) => { + option.id = option._newOptionId || this.generateId() + delete option._newOptionId + option.subjectId = newQuestionId + option.sortNum = optionIndex + 1 + return option + }) + } else { + question.options = [] + } + + if (Array.isArray(question.correctAnswer)) { + question.correctAnswer = question.correctAnswer.map((optionId) => optionIdMap[optionId] || optionId) + } else { + question.correctAnswer = [] + } + + if (Array.isArray(question.visibleRuleConditions)) { + question.visibleRuleConditions = remapVisibleConditions(question.visibleRuleConditions) + } else { + question.visibleRuleConditions = [] + } + + if (question.type !== "checkbox") { + question.maxMulti = null + } + + return question + }) }, confirmAdd() { - if (this.parsedQuestions.length === 0) { - this.$message.warning('请先解析题目'); - return; + if (this.currentSelectedIds.length === 0) { + this.$message.warning("请选择要导入的题目") + return } - console.log(this.parsedQuestions) - this.$emit('confirm', this.parsedQuestions); - this.visible = false; + const selectedIdSet = new Set(this.currentSelectedIds) + const selectedQuestions = this.currentQuestions.filter((question) => selectedIdSet.has(question.id)) + this.$emit("confirm", this.cloneQuestionsForImport(selectedQuestions)) + this.visible = false } }, - style: /*language=CSS*/ ` - /deep/ .txt-import-dialog .el-dialog__body { - padding: 10px 20px; - } - - /deep/ .txt-import-container { - display: flex; - height: 80vh; - border-radius: 4px; - overflow: hidden; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); - background-color: #fff; - } - - /deep/ .txt-import-panel-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 15px; - padding-bottom: 10px; - border-bottom: 1px solid #ebeef5; - height: 32px; /* 统一高度 */ - } - - /deep/ .txt-import-left-panel { - width: 50%; - padding: 20px; - border-right: 1px solid #ebeef5; - display: flex; - flex-direction: column; - } - - /deep/ .txt-import-right-panel { - width: 50%; - padding: 20px; - background-color: #fff; /* 改为白色背景 */ - overflow-y: auto; - display: flex; - flex-direction: column; - } - - /deep/ .txt-import-title { - font-size: 16px; - font-weight: bold; - color: #303133; - } - - /deep/ .txt-import-format-tip { - display: flex; - align-items: center; - } - - /deep/ .txt-import-format-tooltip { - margin-left: 10px; - } - - /deep/ .txt-import-input-area { - flex: 1; - position: relative; - margin-bottom: 15px; - background-color: #f9fafc; - border-radius: 4px; - border: 1px solid #ebeef5; - padding: 2px; - } - - /deep/ .txt-import-textarea { - width: 100%; - height: 100%; - padding: 15px; - border: none; - border-radius: 4px; - font-size: 14px; - line-height: 1.6; - resize: none; - outline: none; - font-family: "Consolas", "微软雅黑", monospace; - transition: all 0.3s; - background-color: transparent; - } - - /deep/ .txt-import-textarea:focus { - box-shadow: 0 0 0 2px rgba(24,103,176,0.2); - } - - /deep/ .txt-import-action-buttons { - display: flex; - justify-content: flex-end; - margin-top: 10px; - gap: 10px; - } - - /deep/ .txt-import-stats { - color: #1867b0; - font-size: 13px; - display: flex; - align-items: center; - } - - /deep/ .txt-import-result-display { - flex: 1; - overflow-y: auto; - background-color: #f9fafc; /* 只在结果显示区域使用浅灰色背景 */ - border-radius: 4px; - padding: 15px; - border: 1px solid #ebeef5; - } - - /deep/ .txt-import-no-questions { - height: 100%; - display: flex; - align-items: center; - justify-content: center; - color: #909399; - font-style: italic; - padding: 20px; - } - - /deep/ .txt-import-question-item { - background-color: #fff; - margin-bottom: 15px; - padding: 15px; - border-radius: 4px; - box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); - transition: all 0.3s; - border-left: 3px solid #67c23a; - } - - /deep/ .txt-import-question-item:hover { - box-shadow: 0 4px 12px 0 rgba(0,0,0,0.1); - transform: translateY(-2px); - } - - /deep/ .txt-import-question-title { - font-size: 15px; - font-weight: bold; - margin-bottom: 12px; - color: #303133; - line-height: 1.5; - } - - /deep/ .txt-import-question-number { - display: inline-block; - width: 25px; - height: 25px; - line-height: 25px; - text-align: center; - background-color: #1867b0; - color: white; - border-radius: 50%; - margin-right: 8px; - font-size: 13px; - } - - /deep/ .txt-import-options { - margin-bottom: 12px; - } - - /deep/ .txt-import-option-item { - margin-bottom: 8px; - padding: 8px 12px; - background-color: #f5f7fa; - border-radius: 4px; - font-size: 14px; - display: flex; - justify-content: space-between; - align-items: center; - transition: all 0.2s; - } - - /deep/ .txt-import-option-item:hover { - background-color: #ebeef5; - } - - /deep/ .txt-import-option-item.txt-import-correct { - background-color: #f0f9eb; - color: #67c23a; - border-left: 3px solid #67c23a; - } - - /deep/ .txt-import-option-text { - flex: 1; - } - - /deep/ .txt-import-correct-mark { - color: #67c23a; - font-weight: bold; - font-size: 16px; - } - - /deep/ .txt-import-question-meta { - margin-top: 12px; - padding-top: 12px; - border-top: 1px dashed #ebeef5; - font-size: 12px; - color: #909399; - } - - /deep/ .txt-import-dialog-footer { - display: flex; - justify-content: flex-end; - } - - /deep/ .txt-import-no-options { - padding: 10px; - color: #f56c6c; - font-style: italic; - background-color: #fef0f0; - border-radius: 4px; - margin-bottom: 10px; - } - ` + style: /*language=CSS*/ "\n /deep/ .txt-import-dialog .el-dialog__body {\n padding: 10px 20px;\n }\n\n /deep/ .txt-import-shell {\n display: flex;\n flex-direction: column;\n }\n\n /deep/ .txt-import-tabs {\n flex: 0 0 auto;\n }\n\n /deep/ .txt-import-container {\n display: flex;\n height: 78vh;\n border-radius: 4px;\n overflow: hidden;\n box-shadow: 0 1px 3px rgba(0, 0, 0, .1);\n background-color: #fff;\n }\n\n /deep/ .txt-import-panel-header {\n display: flex;\n align-items: center;\n position: relative;\n gap: 12px;\n margin-bottom: 15px;\n padding-bottom: 10px;\n border-bottom: 1px solid #ebeef5;\n min-height: 32px;\n }\n\n /deep/ .txt-import-left-panel {\n width: 50%;\n padding: 20px;\n border-right: 1px solid #ebeef5;\n display: flex;\n flex-direction: column;\n box-sizing: border-box;\n }\n\n /deep/ .txt-import-right-panel {\n width: 50%;\n padding: 20px;\n background-color: #fff;\n display: flex;\n flex-direction: column;\n box-sizing: border-box;\n }\n\n /deep/ .txt-import-title {\n font-size: 16px;\n font-weight: bold;\n color: #303133;\n }\n\n /deep/ .txt-import-center-title {\n position: absolute;\n left: 50%;\n transform: translateX(-50%);\n white-space: nowrap;\n }\n\n /deep/ .txt-import-format-tooltip,\n /deep/ .txt-import-panel-header > .el-button {\n margin-left: auto;\n }\n\n /deep/ .txt-import-input-area {\n flex: 1;\n position: relative;\n margin-bottom: 15px;\n background-color: #f9fafc;\n border-radius: 4px;\n border: 1px solid #ebeef5;\n padding: 2px;\n }\n\n /deep/ .txt-import-textarea {\n width: 100%;\n height: 100%;\n padding: 15px;\n border: none;\n border-radius: 4px;\n font-size: 14px;\n line-height: 1.6;\n resize: none;\n outline: none;\n font-family: Consolas, \"Microsoft YaHei\", monospace;\n transition: all .3s;\n background-color: transparent;\n box-sizing: border-box;\n }\n\n /deep/ .txt-import-textarea:focus {\n box-shadow: 0 0 0 2px rgba(24, 103, 176, .2);\n }\n\n /deep/ .txt-import-action-buttons {\n display: flex;\n justify-content: flex-end;\n margin-top: 10px;\n gap: 10px;\n }\n\n /deep/ .txt-import-bank-list {\n flex: 1;\n overflow-y: auto;\n border: 1px solid #ebeef5;\n border-radius: 4px;\n background: #f9fafc;\n padding: 10px;\n }\n\n /deep/ .txt-import-bank-item {\n min-height: 58px;\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 0 14px;\n margin-bottom: 10px;\n border: 1px solid #dcdfe6;\n border-radius: 4px;\n background: #fff;\n cursor: pointer;\n transition: all .2s;\n box-sizing: border-box;\n }\n\n /deep/ .txt-import-bank-item:hover,\n /deep/ .txt-import-bank-item.active {\n border-color: #0074c8;\n box-shadow: 0 2px 8px rgba(0, 116, 200, .12);\n }\n\n /deep/ .txt-import-bank-name {\n flex: 1;\n min-width: 0;\n font-weight: 600;\n color: #303133;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n /deep/ .txt-import-bank-count {\n color: #909399;\n font-size: 12px;\n }\n\n /deep/ .txt-import-stats {\n color: #1867b0;\n font-size: 13px;\n display: flex;\n align-items: center;\n gap: 12px;\n }\n\n /deep/ .txt-import-selected-text {\n color: #606266;\n }\n\n /deep/ .txt-import-result-display {\n flex: 1;\n overflow-y: auto;\n background-color: #f9fafc;\n border-radius: 4px;\n padding: 15px;\n border: 1px solid #ebeef5;\n }\n\n /deep/ .txt-import-question-item {\n background-color: #fff;\n margin-bottom: 15px;\n padding: 15px;\n border-radius: 4px;\n box-shadow: 0 2px 12px 0 rgba(0, 0, 0, .05);\n transition: all .2s;\n border-left: 3px solid #dcdfe6;\n cursor: pointer;\n }\n\n /deep/ .txt-import-question-item:hover,\n /deep/ .txt-import-question-item.selected {\n border-left-color: #67c23a;\n box-shadow: 0 4px 12px 0 rgba(0, 0, 0, .1);\n }\n\n /deep/ .txt-import-question-title {\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 15px;\n font-weight: bold;\n margin-bottom: 12px;\n color: #303133;\n line-height: 1.5;\n }\n\n /deep/ .txt-import-question-number {\n display: inline-block;\n width: 25px;\n height: 25px;\n line-height: 25px;\n text-align: center;\n background-color: #1867b0;\n color: white;\n border-radius: 50%;\n font-size: 13px;\n flex-shrink: 0;\n }\n\n /deep/ .txt-import-question-text {\n flex: 1;\n min-width: 0;\n }\n\n /deep/ .txt-import-options {\n margin-bottom: 12px;\n }\n\n /deep/ .txt-import-option-item {\n margin-bottom: 8px;\n padding: 8px 12px;\n background-color: #f5f7fa;\n border-radius: 4px;\n font-size: 14px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n transition: all .2s;\n }\n\n /deep/ .txt-import-option-item.txt-import-correct {\n background-color: #f0f9eb;\n color: #67c23a;\n border-left: 3px solid #67c23a;\n }\n\n /deep/ .txt-import-option-text {\n flex: 1;\n }\n\n /deep/ .txt-import-correct-mark {\n color: #67c23a;\n font-weight: bold;\n font-size: 16px;\n }\n\n /deep/ .txt-import-question-meta {\n margin-top: 12px;\n padding-top: 12px;\n border-top: 1px dashed #ebeef5;\n font-size: 12px;\n color: #909399;\n }\n\n /deep/ .txt-import-dialog-footer {\n display: flex;\n justify-content: flex-end;\n }\n\n /deep/ .txt-import-no-options {\n padding: 10px;\n color: #909399;\n background-color: #f5f7fa;\n border-radius: 4px;\n margin-bottom: 10px;\n }\n " }; diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/bank/basicForm.js b/src/main/resources/views/platform/zhgh/dayofficework/qsv/bank/basicForm.js index 37dfec2..8ef0c4c 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/bank/basicForm.js +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/bank/basicForm.js @@ -1,40 +1,12 @@ const basicForm = { - template: /*language=HTML*/ ` - -
- - - - - {{item.label}} - - - - - - - - - - - - -
- -
- `, + template: /*language=HTML*/ "\n \n
\n \n

基础信息

\n \n \n \n \n {{item.label || item.name}}\n \n \n \n\n \n \n \n\n \n \n \n \n
\n
\n
\n 取消\n 保存\n
\n \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 + }) }) } } diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/bank/index.html b/src/main/resources/views/platform/zhgh/dayofficework/qsv/bank/index.html index 7b4a992..14a0169 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/qsv/bank/index.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/bank/index.html @@ -2,30 +2,152 @@ layout("/layouts/platform.html"){ #--> + +
- - + + - + 新增 - + - +