From 449428b76231525ff14d1a92f8c15e564a18be74 Mon Sep 17 00:00:00 2001 From: zhouhefeng Date: Tue, 2 Jun 2026 08:17:07 +0800 Subject: [PATCH 1/4] commit --- .../info/MemberInfoGroupController.java | 56 +++ .../member/info/common/commonQuery.js | 13 +- .../staffmanage/member/info/group/index.html | 422 ++++++++++++++---- 3 files changed, 409 insertions(+), 82 deletions(-) diff --git a/src/main/java/com/budwk/app/zhgh/staffmanage/member/controller/info/MemberInfoGroupController.java b/src/main/java/com/budwk/app/zhgh/staffmanage/member/controller/info/MemberInfoGroupController.java index 3ff2d154..211b3c3d 100644 --- a/src/main/java/com/budwk/app/zhgh/staffmanage/member/controller/info/MemberInfoGroupController.java +++ b/src/main/java/com/budwk/app/zhgh/staffmanage/member/controller/info/MemberInfoGroupController.java @@ -7,6 +7,8 @@ import com.alibaba.excel.EasyExcel; import com.budwk.app.base.annotation.SLog; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.result.Result; +import com.budwk.app.sys.models.Sys_union; +import com.budwk.app.sys.models.Sys_union_cadre; import com.budwk.app.zhgh.staffmanage.member.models.MemberHistory; import com.budwk.app.zhgh.staffmanage.member.param.pageform.MemberInfoPageForm; import com.budwk.app.zhgh.staffmanage.member.service.MemberInfoService; @@ -14,6 +16,7 @@ import com.budwk.app.zhgh.staffmanage.member.vo.MemberImportVo; import lombok.extern.slf4j.Slf4j; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; +import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; @@ -30,6 +33,7 @@ import javax.servlet.http.HttpServletResponse; import javax.validation.Valid; import java.net.URLEncoder; import java.util.ArrayList; +import java.util.stream.Collectors; /** * @version 1.0 @@ -72,6 +76,58 @@ public class MemberInfoGroupController { return Result.success(pagination); } + @At + @SaCheckPermission("member.info.group") + public Result unionOverview(String unionId) { + Sys_union union = memberInfoService.dao().fetch(Sys_union.class, unionId); + if (union == null) { + return Result.success(NutMap.NEW()); + } + + int staffTotal = memberInfoService.count(Sqls.create(""" + select count(1) + from vw_user u + where u.unionId = @unionId + and u.personType in ( + select dict.code + from sys_dict dict + left join sys_dict parent on parent.id = dict.parentId + where parent.code = 'USER_PERSON_TYPE' + and dict.name = '教职工' + ) + """) + .setParam("unionId", unionId)); + int memberTotal = memberInfoService.count(Sqls.create("select count(1) from vw_user where unionId = @unionId and member = 1") + .setParam("unionId", unionId)); + int maleTotal = memberInfoService.count(Sqls.create("select count(1) from vw_user where unionId = @unionId and member = 1 and (sex = '男' or sex = '男性')") + .setParam("unionId", unionId)); + int femaleTotal = memberInfoService.count(Sqls.create("select count(1) from vw_user where unionId = @unionId and member = 1 and (sex = '女' or sex = '女性')") + .setParam("unionId", unionId)); + + java.util.List chairmen = memberInfoService.dao().query(Sys_union_cadre.class, + Cnd.where("unionId", "=", unionId) + .and("roleCode", "=", "BRANCH_UNION_CHAIRMAN") + .and(Cnd.exps("isServing", "=", true).or("isServing", "is", null))); + String chairman = chairmen.stream() + .map(item -> item.getUserName() + "(" + item.getLoginName() + ")") + .collect(Collectors.joining("、")); + String chairmanMobile = chairmen.stream() + .map(Sys_union_cadre::getMobile) + .filter(mobile -> mobile != null && !mobile.isBlank()) + .collect(Collectors.joining("、")); + + return Result.success(NutMap.NEW() + .addv("unionName", union.getName()) + .addv("unionCode", union.getUnionCode()) + .addv("chairman", chairman) + .addv("telephone", union.getTelephone()) + .addv("chairmanMobile", chairmanMobile) + .addv("memberTotal", memberTotal) + .addv("staffTotal", staffTotal) + .addv("maleTotal", maleTotal) + .addv("femaleTotal", femaleTotal)); + } + @At @SaCheckPermission("member.info.group") diff --git a/src/main/resources/views/platform/zhgh/staffmanage/member/info/common/commonQuery.js b/src/main/resources/views/platform/zhgh/staffmanage/member/info/common/commonQuery.js index 04d2d688..093e1245 100644 --- a/src/main/resources/views/platform/zhgh/staffmanage/member/info/common/commonQuery.js +++ b/src/main/resources/views/platform/zhgh/staffmanage/member/info/common/commonQuery.js @@ -20,7 +20,7 @@ - @@ -94,6 +94,17 @@ pageForm.preparedBys = JSON.stringify(this.pageForm.preparedBys) this.$emit('search', pageForm) }, + handleUnionChange() { + this.flushUnits() + this.$emit("union-change", this.pageForm.unionId || "") + }, + handleUnionClear() { + this.handleUnionChange() + }, + setUnionFromTree(unionId) { + this.$set(this.pageForm, "unionId", unionId || "") + this.flushUnits() + }, async initData(){ if (this.$auth.hasRoleOr("SYSADMIN, SCHOOL_UNION_ADMIN, SCHOOL_UNION_MEMBER_ADMIN")) { this.$businessTool.listUnion(this.pageForm.unionId).then((data) => { diff --git a/src/main/resources/views/platform/zhgh/staffmanage/member/info/group/index.html b/src/main/resources/views/platform/zhgh/staffmanage/member/info/group/index.html index 2c415fde..77518eb9 100644 --- a/src/main/resources/views/platform/zhgh/staffmanage/member/info/group/index.html +++ b/src/main/resources/views/platform/zhgh/staffmanage/member/info/group/index.html @@ -4,88 +4,187 @@ layout("/layouts/platform.html"){
- +
+
- +
- - -
⏰{{ remainingTime }}s +
+
+
+ ⏰ {{ remainingTime }}s +
+

{{ activity.title }}

-

{{ activity.title }}

- +
-
+

{{index+1}}、{{ subject.title }}

@@ -84,9 +86,28 @@ layout("/layouts/platform_h5.html"){ return this.activity.timeLimit * 60 - this.answerTime } return 0 + }, + pageTitle() { + return this.getCategoryPageTitle(this.activity && this.activity.category, "在线答题") + }, + heroStyle() { + if (this.activity && this.activity.cover) { + return { + backgroundImage: "url(" + this.activity.cover + ")" + } + } + return {} } }, methods: { + getCategoryPageTitle(category, defaultTitle) { + const titleMap = { + QUIZ: "在线答题", + SURVEY: "问卷调查", + VOTE: "投票" + } + return titleMap[category] || defaultTitle + }, //获取活动、题目 listSubjects() { this.$axios.post("/platform/h5/qsv/quiz/subjects", {activityId: this.id}).then((res) => { @@ -245,14 +266,6 @@ layout("/layouts/platform_h5.html"){ return } - const selectedOptionIds = subject.userSelectOptionIds || [] - const hasSelected = selectedOptionIds.includes(optionId) - // 多选题达到最大可选数量后禁止继续勾选,但允许取消已选项。 - if (subject.type === "checkbox" && !hasSelected && subject.maxMulti && subject.maxMulti > 0 && selectedOptionIds.length >= subject.maxMulti) { - this.$toast("本题最多选择" + subject.maxMulti + "项") - return - } - if (subject.type === "radio") { this.$refs["subject" + subject.id][0].toggleAll(false) } @@ -341,20 +354,53 @@ layout("/layouts/platform_h5.html"){ style: /*language=CSS*/ ` .container { padding: 0; + background: #f4f8ff; + height: calc(100vh - 46px); + overflow: hidden; + display: flex; + flex-direction: column; + box-sizing: border-box; } - /deep/ .container .title { - text-align: center; - padding: 20px; - background: #ffffff; - margin: 0 0 10px 0 !important; - color: var(--color-primary); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + /deep/ .quiz-hero { + flex: 0 0 auto; + min-height: 170px; + background: linear-gradient(135deg, #1688f8, #0a55c5); + background-size: cover; + background-position: center; + position: relative; + overflow: hidden; + margin: 12px 12px 14px; + border-radius: 16px; + box-shadow: 0 8px 24px rgba(35, 102, 168, .14); } - .container .card { - padding: 10px; - background: #ffffff; + /deep/ .quiz-hero-mask { + min-height: 170px; + padding: 24px 20px 22px; + display: flex; + flex-direction: column; + justify-content: flex-end; + box-sizing: border-box; + background: linear-gradient(180deg, rgba(0, 0, 0, .16), rgba(0, 0, 0, .56)); + } + + /deep/ .quiz-title { + margin: 0; + color: #fff; + font-size: 22px; + font-weight: 700; + line-height: 1.35; + text-shadow: 0 2px 8px rgba(0, 0, 0, .28); + } + + .quiz-card-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding: 0 12px; + box-sizing: border-box; } .subject { @@ -377,10 +423,15 @@ layout("/layouts/platform_h5.html"){ } /deep/ .timer { - text-align: center; - font-size: 18px; - background: #fff; - padding: 10px 0; + align-self: flex-start; + margin-bottom: 12px; + padding: 5px 10px; + border-radius: 14px; + background: rgba(255, 255, 255, .22); + color: #fff; + font-size: 14px; + font-weight: 600; + backdrop-filter: blur(6px); } .submit-button { diff --git a/src/main/resources/views/platform/zhghh5/dayofficework/qsv/quiz/result.html b/src/main/resources/views/platform/zhghh5/dayofficework/qsv/quiz/result.html index d85a9408..d86899b4 100644 --- a/src/main/resources/views/platform/zhghh5/dayofficework/qsv/quiz/result.html +++ b/src/main/resources/views/platform/zhghh5/dayofficework/qsv/quiz/result.html @@ -6,15 +6,15 @@ layout("/layouts/platform_h5.html"){
- -

{{ activity.title }}

-
-
-
{{totalScore}}
- +
+
+

{{ activity.title }}

+
+ {{totalScore}} +
- +
@@ -111,6 +111,14 @@ layout("/layouts/platform_h5.html"){ } } return totalScore + }, + heroStyle() { + if (this.activity && this.activity.cover) { + return { + backgroundImage: "url(" + this.activity.cover + ")" + } + } + return {} } }, filters: { @@ -199,23 +207,60 @@ layout("/layouts/platform_h5.html"){ style: /*language=CSS*/ ` .container { padding: 0; + background: #f4f8ff; + min-height: 100vh; } - /deep/ .container .title { - text-align: center; - padding: 20px; - margin: 0 !important; - background: #ffffff; - color: var(--color-primary); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + /deep/ .quiz-hero { + min-height: 190px; + background: linear-gradient(135deg, #1688f8, #0a55c5); + background-size: cover; + background-position: center; + position: relative; + overflow: hidden; + margin: 12px 12px 14px; + border-radius: 16px; + box-shadow: 0 8px 24px rgba(35, 102, 168, .14); } - .container .score { + /deep/ .quiz-hero-mask { + min-height: 190px; + padding: 28px 20px 24px; + display: flex; + flex-direction: column; + justify-content: flex-end; + box-sizing: border-box; + background: linear-gradient(180deg, rgba(0, 0, 0, .18), rgba(0, 0, 0, .58)); } - .container .card { - padding: 10px; - background: #ffffff; + /deep/ .quiz-title { + margin: 0 0 14px; + color: #fff; + font-size: 22px; + font-weight: 700; + line-height: 1.35; + text-shadow: 0 2px 8px rgba(0, 0, 0, .28); + } + + /deep/ .quiz-score { + display: flex; + align-items: flex-end; + color: #fff; + text-shadow: 0 2px 10px rgba(0, 0, 0, .3); + } + + /deep/ .quiz-score span { + font-size: 48px; + font-weight: 800; + line-height: 1; + } + + /deep/ .quiz-score em { + margin-left: 6px; + margin-bottom: 5px; + font-style: normal; + font-size: 18px; + font-weight: 600; } .subject { @@ -237,46 +282,6 @@ layout("/layouts/platform_h5.html"){ top: 0; } - /deep/ .score-form { - background: #ffffff; - display: flex; - margin-bottom: 10px; - border-top: 1px solid #f1f1f1; - align-items: center; - } - - /deep/ .score-form-total { - flex: 1; - background: #fff; - display: flex; - padding: 10px 20px 10px 0; - flex-direction: column; - align-items: flex-end; - } - - /deep/ .score-text-news { - flex: 1; - background: #fff; - } - - /deep/ .score-font-style { - font-size: 32px; - color: #ff6a00; - word-break: keep-all; - line-height: 38px; - min-width: 47px; - text-align: center; - } - - /deep/ .score-underline { - background: url(//image.wjx.cn/images/newimg/score-form/score-underline@2x.png) no-repeat center; - background-size: 47px 16px; - display: inline-block; - height: 16px; - width: 47px; - margin-top: 7px; - } - .van-checkbox__icon--checked .van-icon { color: #fff !important; background-color: var(--color-primary) !important; diff --git a/src/main/resources/views/platform/zhghh5/dayofficework/qsv/survey/index.html b/src/main/resources/views/platform/zhghh5/dayofficework/qsv/survey/index.html index 156996bb..98ac259f 100644 --- a/src/main/resources/views/platform/zhghh5/dayofficework/qsv/survey/index.html +++ b/src/main/resources/views/platform/zhghh5/dayofficework/qsv/survey/index.html @@ -5,18 +5,53 @@ layout("/layouts/platform_h5.html"){
- -
-

{{ activity.title }}

-
-
-

{{index+1}}、{{ subject.title }}

+ +
+
+
+

{{ activity.title }}

+
+
+
+
+

{{index+1}}、{{ subject.title }}*

多选 单选 {{subject.score}}分
- - + - - - - + + + + + + + +
- +
-
- {{isEnd ? '已结束' : '提交'}} +
+ {{isEnd ? '已结束' : '提交'}} + 返回
@@ -158,10 +295,34 @@ layout("/layouts/platform_h5.html"){ return this.$moment().unix() > this.$moment(this.activity.endTime).unix() } return true + }, + visibleSubjects() { + return this.subjects.filter((subject) => { + return this.isSubjectVisible(subject) + }) + }, + pageTitle() { + return this.getCategoryPageTitle(this.activity && this.activity.category, "问卷调查") + }, + heroStyle() { + if (this.activity && this.activity.cover) { + return { + backgroundImage: "url(" + this.activity.cover + ")" + } + } + return {} } }, methods: { + getCategoryPageTitle(category, defaultTitle) { + const titleMap = { + QUIZ: "在线答题", + SURVEY: "问卷调查", + VOTE: "投票" + } + return titleMap[category] || defaultTitle + }, //获取活动、题目 listSubjects() { this.$axios.post("/platform/h5/qsv/survey/subjects", { activityId: this.id }).then((res) => { @@ -193,14 +354,214 @@ layout("/layouts/platform_h5.html"){ const answer = this.answerRecord.extJson[subject.id] if (["radio", "checkbox"].includes(subject.type)) { - this.$refs["subject" + subject.id][0].toggleAll(false) - answer?.optionIds.forEach((optionId) => { - this.$nextTick(() => { - this.$refs["option" + optionId][0].toggle() - }) - }) + const optionIds = answer?.optionIds || [] + this.$set(subject, "userSelectOptionIds", subject.type === "radio" ? optionIds.slice(0, 1) : optionIds) + this.$set(subject, "optionFillContents", answer?.optionFillContents || {}) } else if (subject.type === "text") { - subject.userFillContent = answer?.text + this.$set(subject, "userFillContent", answer?.text) + } + }) + if (!this.answerRecord.isFinish) { + this.clearHiddenSubjectAnswers() + } + }, + + //判断题目是否显示,未配置隐显逻辑的题目默认显示;已配置的题目按条件树递归计算后再执行显示或隐藏动作 + isSubjectVisible(subject) { + if (!subject.visibleRuleEnabled) { + return true + } + + const rootGroup = this.getVisibleRuleRootGroup(subject) + if (!rootGroup) { + return true + } + + const matched = this.evaluateVisibleRuleGroup(rootGroup) + + if (subject.visibleRuleAction === "hide") { + return !matched + } + return matched + }, + + //读取隐显逻辑根分组,兼容旧版 subjectId/optionId 平铺条件 + getVisibleRuleRootGroup(subject) { + let conditions = subject.visibleRuleConditions || [] + if (typeof conditions === "string") { + try { + conditions = JSON.parse(conditions) + } catch (e) { + conditions = [] + } + } + if (!Array.isArray(conditions) || conditions.length === 0) { + return null + } + if (conditions.length === 1 && conditions[0].type === "group") { + return this.normalizeVisibleRuleGroup(conditions[0]) + } + const children = conditions.filter((condition) => { + return condition.subjectId && condition.optionId + }).map((condition) => { + return { + type: "item", + field: condition.subjectId, + operator: "contains", + value: condition.optionId + } + }) + if (children.length === 0) { + return null + } + return { + type: "group", + logic: subject.visibleRuleLogic === "OR" ? "or" : "and", + children + } + }, + + //规整条件分组,避免历史数据或空字段影响递归判断 + normalizeVisibleRuleGroup(group) { + return { + type: "group", + logic: (group.logic || "and").toLowerCase() === "or" ? "or" : "and", + children: Array.isArray(group.children) ? group.children.map((child) => { + if (child.type === "group") { + return this.normalizeVisibleRuleGroup(child) + } + return { + type: "item", + field: child.field || child.subjectId || "", + operator: child.operator || (child.optionId ? "contains" : "=="), + value: child.value !== undefined && child.value !== null ? child.value : (child.optionId || "") + } + }) : [] + } + }, + + //递归计算条件分组,支持并且、或者和嵌套分组 + evaluateVisibleRuleGroup(group) { + const children = (group.children || []).filter((child) => { + return child.type === "group" || (child.field && child.operator) + }) + if (children.length === 0) { + return true + } + const results = children.map((child) => { + if (child.type === "group") { + return this.evaluateVisibleRuleGroup(child) + } + return this.evaluateVisibleRuleItem(child) + }) + return group.logic === "or" + ? results.some((item) => { + return item + }) + : results.every((item) => { + return item + }) + }, + + //计算单条条件,选择题按选项ID判断,填空题支持文本包含和数字大小比较 + evaluateVisibleRuleItem(condition) { + const triggerSubject = this.subjects.find((item) => { + return item.id === condition.field + }) + if (!triggerSubject) { + return false + } + const answer = ["radio", "checkbox"].includes(triggerSubject.type) + ? (triggerSubject.userSelectOptionIds || []) + : triggerSubject.userFillContent + return this.compareVisibleRuleValue(answer, condition.operator, condition.value) + }, + + //比较条件值,和后台逻辑抽屉实时预览保持同一套语义 + compareVisibleRuleValue(answer, operator, value) { + if (Array.isArray(answer)) { + if (operator === "!=") { + return !answer.includes(value) + } + return answer.includes(value) + } + if ([">", "<", ">=", "<="].includes(operator)) { + const answerNumber = Number(answer) + const valueNumber = Number(value) + if (isNaN(answerNumber) || isNaN(valueNumber)) { + return false + } + if (operator === ">") { + return answerNumber > valueNumber + } + if (operator === "<") { + return answerNumber < valueNumber + } + if (operator === ">=") { + return answerNumber >= valueNumber + } + return answerNumber <= valueNumber + } + const answerText = answer === undefined || answer === null ? "" : String(answer) + const valueText = value === undefined || value === null ? "" : String(value) + if (operator === "!=") { + return answerText !== valueText + } + if (operator === "contains") { + return answerText.indexOf(valueText) !== -1 + } + return answerText === valueText + }, + + //清空当前隐藏题目的答案,避免隐藏题残留旧选项或填空内容参与后续提交 + clearHiddenSubjectAnswers() { + this.subjects.forEach((subject) => { + if (this.isSubjectVisible(subject)) { + return + } + if (["radio", "checkbox"].includes(subject.type)) { + this.$set(subject, "userSelectOptionIds", []) + this.$set(subject, "optionFillContents", {}) + } else if (subject.type === "text") { + this.$set(subject, "userFillContent", null) + } + }) + }, + + //判断选项是否已被选择,用于控制选项补充填写框显示和提交校验 + isOptionSelected(subject, optionId) { + return subject.userSelectOptionIds && subject.userSelectOptionIds.includes(optionId) + }, + + getFinishedOptionClass(subject, option) { + return this.answerRecord.isFinish && this.isOptionSelected(subject, option.id) + ? "survey-option-finished-selected" + : "" + }, + + //单选题仍按数组保存,这里只取第一个值用于 radio 组件展示 + getRadioValue(subject) { + return subject.userSelectOptionIds && subject.userSelectOptionIds.length > 0 + ? subject.userSelectOptionIds[0] + : "" + }, + + //选中需要补充填写的选项后显示输入框,例如选择“否”后填写原因 + isOptionFillVisible(subject, option) { + return option.fillRequired && this.isOptionSelected(subject, option.id) + }, + + //清空未选中选项的补充填写内容,避免取消选择后残留旧内容 + clearUnselectedOptionFillContents(subject) { + if (!subject.optionFillContents) { + this.$set(subject, "optionFillContents", {}) + } + if (!subject.options) { + return + } + subject.options.forEach((option) => { + if (!this.isOptionSelected(subject, option.id)) { + this.$delete(subject.optionFillContents, option.id) } }) }, @@ -212,9 +573,14 @@ layout("/layouts/platform_h5.html"){ } if (subject.type === "radio") { - this.$refs["subject" + subject.id][0].toggleAll(false) + this.$set(subject, "userSelectOptionIds", [optionId]) + } else { + this.$refs["option" + optionId][0].toggle() } - this.$refs["option" + optionId][0].toggle() + this.$nextTick(() => { + this.clearUnselectedOptionFillContents(subject) + this.clearHiddenSubjectAnswers() + }) }, //预览图片 @@ -231,13 +597,20 @@ layout("/layouts/platform_h5.html"){ } //提示那些题没有作答 - for (let i = 0; i < this.subjects.length; i++) { - const subject = this.subjects[i] + for (let i = 0; i < this.visibleSubjects.length; i++) { + const subject = this.visibleSubjects[i] if (["radio", "checkbox"].includes(subject.type)) { if (!subject.userSelectOptionIds || subject.userSelectOptionIds.length === 0) { this.$toast.fail("第" + (i + 1) + "题未做答") return } + for (let j = 0; j < subject.options.length; j++) { + const option = subject.options[j] + if (this.isOptionFillVisible(subject, option) && !subject.optionFillContents[option.id]) { + this.$toast.fail("请填写第" + (i + 1) + "题的补充内容") + return + } + } } else if ("text" === subject.type) { if (!subject.userFillContent) { this.$toast.fail("第" + (i + 1) + "题未作答") @@ -255,11 +628,12 @@ layout("/layouts/platform_h5.html"){ answer: JSON.stringify({ activityId: this.id, answerRecordId: this.answerRecordId, - subjects: this.subjects.map((subject) => { + subjects: this.visibleSubjects.map((subject) => { return { id: subject.id, userSelectOptionIds: ["checkbox", "radio"].includes(subject.type) ? subject.userSelectOptionIds : [], - userFillContent: subject.type === "text" ? subject.userFillContent : null + userFillContent: subject.type === "text" ? subject.userFillContent : null, + optionFillContents: ["checkbox", "radio"].includes(subject.type) ? subject.optionFillContents : {} } }) }) From 601e1ac039dcf50f25ddf0348cbcf7aa5d5f8006 Mon Sep 17 00:00:00 2001 From: zhouhefeng Date: Mon, 15 Jun 2026 14:20:03 +0800 Subject: [PATCH 3/4] Changes --- .../qsv/controller/QsvNewController.java | 21 + .../qsv/controller/QsvOnlineController.java | 562 +++++++ .../qsv/h5controller/H5QsvVoteController.java | 301 ++++ .../db/qsv_subject_add_vote_option_layout.sql | 3 + .../dayofficework/qsv/activity/logicForm.js | 744 +++++++++ .../zhgh/dayofficework/qsv/activity/new.html | 496 ++++++ .../zhgh/dayofficework/qsv/online/index.html | 1165 +++++++++++++++ .../dayofficework/qsv/survey/reportPage.html | 1323 +++++++++++++++++ .../zhghh5/dayofficework/qsv/vote/index.html | 872 +++++++++++ 9 files changed, 5487 insertions(+) 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/qsv_subject_add_vote_option_layout.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/zhgh/dayofficework/qsv/controller/QsvNewController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvNewController.java new file mode 100644 index 00000000..e1a5855e --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvNewController.java @@ -0,0 +1,21 @@ +package com.budwk.app.zhgh.dayofficework.qsv.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.annotations.ApiOperation; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; + +@IocBean +@At("/platform/qsv") +@Ok("json:full") +@ApiOperation("问卷调查新建") +public class QsvNewController { + + @At("/new") + @Ok("beetl:/platform/zhgh/dayofficework/qsv/activity/new.html") + @SaCheckPermission("qsv.activity") + public void newPage() { + + } +} 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 00000000..0d1a4394 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/controller/QsvOnlineController.java @@ -0,0 +1,562 @@ +package com.budwk.app.zhgh.dayofficework.qsv.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.lang.Dict; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.json.JSONObject; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.result.Result; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope; +import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity; +import com.budwk.app.zhgh.dayofficework.qsv.models.QsvOption; +import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject; +import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord; +import com.budwk.app.zhgh.dayofficework.qsv.param.QsvAnswerParam; +import com.budwk.app.zhgh.dayofficework.qsv.dto.QsvCheckAnswerResult; +import com.budwk.app.zhgh.dayofficework.qsv.service.QsvQuizService; +import com.budwk.app.zhgh.dayofficework.qsv.service.QsvUserAnswerRecordService; +import io.swagger.annotations.ApiOperation; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.util.NutMap; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; + +import javax.validation.Valid; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +@IocBean +@At("/platform/qsv/online") +@Ok("json:full") +@ApiOperation("在线答题") +public class QsvOnlineController { + + @Inject + private Dao dao; + + @Inject + private QsvUserAnswerRecordService qsvUserAnswerRecordService; + @Inject + private QsvQuizService qsvQuizService; + + @At("") + @Ok("beetl:/platform/zhgh/dayofficework/qsv/online/index.html") + @SaCheckPermission("qsv.online") + public void index() { + + } + + @At + @SaCheckPermission("qsv.online") + @ApiOperation("在线答题任务分页") + public Result pageData(@Valid PageForm pageForm, Integer year, String title, Boolean isAnswered) { + List 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("在线答题题目") + 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("在线答题记录") + public Result answerRecord(@Valid String answerRecordId) { + QsvUserAnswerRecord record = dao.fetch(QsvUserAnswerRecord.class, answerRecordId); + return Result.success(record); + } + + @At + @SaCheckPermission("qsv.online") + @ApiOperation("在线投票统计") + public Result voteStats(@Valid String activityId) { + QsvActivity activity = dao.fetch(QsvActivity.class, activityId); + if (activity == null || !"VOTE".equals(activity.getCategory())) { + return Result.error("投票不存在"); + } + Result scopeResult = checkUserScope(activity); + if (scopeResult != null) { + return scopeResult; + } + return Result.success(buildVoteStats(activityId)); + } + + @At + @SaCheckPermission("qsv.online") + @Aop(TransAop.READ_COMMITTED) + @ApiOperation("提交在线答题") + public Result submitAnswer(@Valid @Param("answer") QsvAnswerParam qsvAnswerParam) { + QsvActivity activity = dao.fetch(QsvActivity.class, qsvAnswerParam.getActivityId()); + Result activityStatusResult = checkActivityStatus(activity); + if (activityStatusResult != null) { + return activityStatusResult; + } + Result scopeResult = checkUserScope(activity); + if (scopeResult != null) { + return scopeResult; + } + + QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId()); + if (answerRecord == null || !SecurityUtil.getUserId().equals(answerRecord.getUserId())) { + return Result.error("答题记录不存在"); + } + if (Boolean.TRUE.equals(answerRecord.getIsFinish())) { + return Result.error("您已完成该调查"); + } + + JSONObject extJson = answerRecord.getExtJson(); + if (extJson == null) { + extJson = new JSONObject(); + } + float totalScore = 0; + boolean isQuiz = "QUIZ".equals(activity.getCategory()); + for (QsvAnswerParam.Subject subject : qsvAnswerParam.getSubjects()) { + JSONObject entries = extJson.get(subject.getId(), JSONObject.class); + if (ObjectUtil.isEmpty(entries)) { + entries = new JSONObject(); + } + entries.set("optionIds", subject.getUserSelectOptionIds()); + entries.set("text", subject.getUserFillContent()); + entries.set("optionFillContents", subject.getOptionFillContents()); + if (isQuiz) { + QsvCheckAnswerResult answerResult = qsvQuizService.calcScore(subject.getId(), subject.getUserSelectOptionIds()); + entries.set("score", answerResult.getScore()); + entries.set("isCorrect", answerResult.isCorrect()); + totalScore += answerResult.getScore(); + } + extJson.set(subject.getId(), entries); + } + + answerRecord.setExtJson(extJson); + if (isQuiz) { + answerRecord.setTotalScore(totalScore); + answerRecord.setAnswerTime(qsvAnswerParam.getAnswerTime()); + } + answerRecord.setAttemptDate(new Date()); + answerRecord.setSubmitTime(new Date()); + answerRecord.setIsFinish(true); + dao.update(answerRecord); + if (isQuiz) { + qsvUserAnswerRecordService.calcByScoreMode(qsvAnswerParam.getActivityId(), SecurityUtil.getUserId()); + } + return Result.success("提交成功"); + } + + private Result checkActivityStatus(QsvActivity activity) { + if (activity == null) { + return Result.error("调查不存在"); + } + if (Boolean.FALSE.equals(activity.getEnabled())) { + return Result.error("活动已关闭,暂不能参与"); + } + Date now = new Date(); + if (activity.getStartTime() != null && activity.getStartTime().after(now)) { + return Result.error("调查尚未开始,请在开始后再参与"); + } + if (activity.getEndTime() != null && activity.getEndTime().before(now)) { + return Result.error("调查已结束"); + } + return null; + } + + private Result checkUserScope(QsvActivity activity) { + int count = dao.count(ActivityUserScope.class, Cnd.where("groupId", "=", activity.getGroupId()) + .and("userId", "=", SecurityUtil.getUserId())); + if (count != 1) { + return Result.error("您无需参加此次调查,感谢您的关注!"); + } + return null; + } + + private NutMap buildOnlineRow(QsvActivity activity, List 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/h5controller/H5QsvVoteController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvVoteController.java new file mode 100644 index 00000000..d1498afc --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/qsv/h5controller/H5QsvVoteController.java @@ -0,0 +1,301 @@ +package com.budwk.app.zhgh.dayofficework.qsv.h5controller; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.json.JSONObject; +import com.budwk.app.base.result.Result; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope; +import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity; +import com.budwk.app.zhgh.dayofficework.qsv.models.QsvOption; +import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject; +import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord; +import com.budwk.app.zhgh.dayofficework.qsv.param.QsvAnswerParam; +import com.budwk.app.zhgh.dayofficework.qsv.service.QsvUserAnswerRecordService; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.util.NutMap; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; + +import javax.validation.Valid; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@IocBean +@At("/platform/h5/qsv/vote") +@Ok("json:full") +public class H5QsvVoteController { + + @Inject + private Dao dao; + @Inject + private QsvUserAnswerRecordService qsvUserAnswerRecordService; + + @At("") + @Ok("beetl:/platform/zhghh5/dayofficework/qsv/vote/index.html") + public void index() { + } + + @At + @Aop(TransAop.READ_COMMITTED) + public Result subjects(String activityId) { + QsvActivity activity = dao.fetch(QsvActivity.class, activityId); + Result activityStatusResult = checkActivityReadable(activity); + if (activityStatusResult != null) { + return activityStatusResult; + } + + Result scopeResult = checkScope(activity); + if (scopeResult != null) { + return scopeResult; + } + + List 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 + public Result answerRecord(@Valid String answerRecordId) { + QsvUserAnswerRecord record = dao.fetch(QsvUserAnswerRecord.class, answerRecordId); + return Result.success(record); + } + + @At + public Result rank(@Valid String activityId) { + return Result.success(buildVoteStats(activityId)); + } + + @At + @Aop(TransAop.READ_COMMITTED) + public Result submitAnswer(@Valid @Param("answer") QsvAnswerParam qsvAnswerParam) { + QsvActivity activity = dao.fetch(QsvActivity.class, qsvAnswerParam.getActivityId()); + Result activityStatusResult = checkActivityWritable(activity); + if (activityStatusResult != null) { + return activityStatusResult; + } + + QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId()); + if (answerRecord == null || !SecurityUtil.getUserId().equals(answerRecord.getUserId())) { + return Result.error("\u6295\u7968\u8bb0\u5f55\u4e0d\u5b58\u5728"); + } + if (Boolean.TRUE.equals(answerRecord.getIsFinish())) { + return Result.error("\u60a8\u5df2\u5b8c\u6210\u6295\u7968"); + } + + JSONObject extJson = answerRecord.getExtJson(); + if (extJson == null) { + extJson = new JSONObject(); + } + + for (QsvAnswerParam.Subject subject : qsvAnswerParam.getSubjects()) { + JSONObject entries = extJson.get(subject.getId(), JSONObject.class); + if (entries == null) { + entries = new JSONObject(); + } + entries.set("optionIds", subject.getUserSelectOptionIds()); + entries.set("text", subject.getUserFillContent()); + entries.set("optionFillContents", subject.getOptionFillContents()); + extJson.set(subject.getId(), entries); + } + + answerRecord.setExtJson(extJson); + answerRecord.setAttemptDate(new Date()); + answerRecord.setSubmitTime(new Date()); + answerRecord.setIsFinish(true); + dao.update(answerRecord); + + return Result.success("\u6295\u7968\u6210\u529f"); + } + + private Result checkActivityReadable(QsvActivity activity) { + if (activity == null) { + return Result.error("\u6295\u7968\u4e0d\u5b58\u5728"); + } + if (Boolean.FALSE.equals(activity.getEnabled())) { + return Result.error("\u6d3b\u52a8\u5df2\u5173\u95ed\uff0c\u6682\u4e0d\u80fd\u53c2\u4e0e"); + } + Date now = new Date(); + if (activity.getStartTime() != null && activity.getStartTime().after(now)) { + return Result.error("\u6295\u7968\u5c1a\u672a\u5f00\u59cb\uff0c\u8bf7\u5728\u5f00\u59cb\u540e\u518d\u53c2\u4e0e"); + } + return null; + } + + private Result checkActivityWritable(QsvActivity activity) { + Result readableResult = checkActivityReadable(activity); + if (readableResult != null) { + return readableResult; + } + Date now = new Date(); + if (activity.getEndTime() != null && activity.getEndTime().before(now)) { + return Result.error("\u6295\u7968\u5df2\u7ed3\u675f"); + } + return null; + } + + private Result checkScope(QsvActivity activity) { + if (activity.getGroupId() == null) { + return null; + } + int count = dao.count(ActivityUserScope.class, Cnd.where("groupId", "=", activity.getGroupId()) + .and("userId", "=", SecurityUtil.getUserId())); + if (count != 1) { + return Result.error("\u60a8\u65e0\u9700\u53c2\u52a0\u6b64\u6b21\u6295\u7968\uff0c\u611f\u8c22\u60a8\u7684\u5173\u6ce8\uff01"); + } + return null; + } + + private void syncAnswerRecordSubjects(QsvUserAnswerRecord answerRecord, List 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/resources/db/qsv_subject_add_vote_option_layout.sql b/src/main/resources/db/qsv_subject_add_vote_option_layout.sql new file mode 100644 index 00000000..2b07d078 --- /dev/null +++ b/src/main/resources/db/qsv_subject_add_vote_option_layout.sql @@ -0,0 +1,3 @@ +ALTER TABLE qsv_subject + ADD COLUMN option_layout VARCHAR(20) DEFAULT 'VERTICAL' COMMENT '投票选项排列方式', + ADD COLUMN option_columns INT DEFAULT 1 COMMENT '投票选项横向列数'; diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/logicForm.js b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/logicForm.js new file mode 100644 index 00000000..59a747df --- /dev/null +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/logicForm.js @@ -0,0 +1,744 @@ +const conditionGroupEditor = { + name: "condition-group-editor", + props: { + group: { + type: Object, + required: true + }, + questions: { + type: Array, + default() { + return [] + } + }, + isRoot: { + type: Boolean, + default: false + } + }, + template: /*language=HTML*/ ` +
+
条件规则
+
+ + + + +
+ 添加条件 + 添加分组 + + +
+
+ +
+
+ + + +
+ +
+ 暂无条件,请点击上方按钮添加 +
+
+ + +
+ `, + methods: { + addItem() { + this.group.children.push({ + type: "item", + field: "", + operator: "==", + value: "" + }) + this.emitChange() + }, + addGroup() { + this.group.children.push({ + type: "group", + logic: "and", + children: [] + }) + this.emitChange() + }, + removeChild(index) { + this.group.children.splice(index, 1) + this.emitChange() + }, + onFieldChange(condition) { + condition.value = "" + const question = this.getQuestion(condition.field) + if (question && question.type === "checkbox" && condition.operator === "==") { + condition.operator = "contains" + } + this.emitChange() + }, + emitChange() { + this.$emit("change") + }, + getQuestion(questionId) { + return this.questions.find((question) => { + return question.id === questionId + }) + }, + isChoiceQuestion(questionId) { + const question = this.getQuestion(questionId) + return question && ["radio", "checkbox"].includes(question.type) + }, + getQuestionOptions(questionId) { + const question = this.getQuestion(questionId) + return question && question.options ? question.options : [] + }, + isNumericOperator(operator) { + return [">", "<", ">=", "<="].includes(operator) + } + } +} + +const logicForm = { + components: { + "condition-group-editor": conditionGroupEditor + }, + template: /*language=HTML*/ ` + +
+
+
+ 题目列表 + + 保存问卷 + +
+
+
+
+ {{index + 1}} + + {{getSubjectTypeText(subject)}} + + + {{subject.title || '未填写题目'}} + +
+
+ + + + + + + + + + +
+
+ +
+
+ +
+
+ 实时预览 +
+ 显示序号 + 配置即所得 +
+
+
+
+ {{index + 1}}. +
+
{{subject.title || '未填写题目'}}
+ + + + + + + +
+
+ +
+
+
+ + + + + + + + + + + +
+ `, + data() { + return { + visible: false, + loading: false, + saveLoading: false, + id: null, + activity: {}, + subjects: [], + activeSubjectId: null, + previewAnswers: {}, + previewTextAnswers: {}, + showPreviewIndex: true, + subjectVisibleSetting: { + visible: false, + subjectIndex: null, + subjectTitle: "", + formData: { + enabled: false, + action: "hide", + rootGroup: { + type: "group", + logic: "and", + children: [] + } + } + } + } + }, + computed: { + visiblePreviewSubjects() { + return this.subjects.filter((subject) => { + return this.isSubjectVisible(subject) + }) + }, + subjectVisibleRuleSubjects() { + if (this.subjectVisibleSetting.subjectIndex === null) { + return [] + } + return this.subjects.filter((item, index) => { + return index < this.subjectVisibleSetting.subjectIndex + }) + } + }, + methods: { + onOpen(id) { + this.visible = true + this.loading = true + this.id = id + this.activity = {} + this.subjects = [] + this.activeSubjectId = null + $.post("/platform/qsv/activity/findOne", {id}).then((res) => { + if (res.code === 0) { + this.activity = res.data || {} + } + }) + $.post("/platform/qsv/activity/listSubjects", {activityId: id}).then((res) => { + if (res.code === 0) { + this.subjects = (res.data || []).map((subject) => { + return this.normalizeSubject(subject) + }) + this.initPreviewAnswers() + if (this.subjects.length > 0) { + this.activeSubjectId = this.subjects[0].id + } + } + }).always(() => { + this.loading = false + }) + }, + normalizeSubject(subject) { + return { + ...subject, + visibleRuleEnabled: !!subject.visibleRuleEnabled, + visibleRuleAction: subject.visibleRuleEnabled ? (subject.visibleRuleAction || "hide") : "", + visibleRuleLogic: subject.visibleRuleLogic || "AND", + visibleRuleConditions: this.normalizeVisibleRuleConditions(subject.visibleRuleConditions, subject.visibleRuleLogic) + } + }, + normalizeVisibleRuleConditions(conditions, legacyLogic) { + let parsedConditions = conditions || [] + if (typeof parsedConditions === "string") { + try { + parsedConditions = JSON.parse(parsedConditions) + } catch (e) { + parsedConditions = [] + } + } + if (!Array.isArray(parsedConditions) || parsedConditions.length === 0) { + return [] + } + if (parsedConditions.length === 1 && parsedConditions[0].type === "group") { + return [this.normalizeConditionGroup(parsedConditions[0])] + } + return [this.normalizeLegacyConditionGroup(parsedConditions, legacyLogic)] + }, + normalizeConditionGroup(group) { + return { + type: "group", + logic: (group.logic || "and").toLowerCase() === "or" ? "or" : "and", + children: Array.isArray(group.children) ? group.children.map((child) => { + if (child.type === "group") { + return this.normalizeConditionGroup(child) + } + return this.normalizeConditionItem(child) + }) : [] + } + }, + normalizeConditionItem(item) { + return { + type: "item", + field: item.field || item.subjectId || "", + operator: item.operator || (item.optionId ? "contains" : "=="), + value: item.value !== undefined && item.value !== null ? item.value : (item.optionId || "") + } + }, + normalizeLegacyConditionGroup(conditions, legacyLogic) { + return { + type: "group", + logic: legacyLogic === "OR" ? "or" : "and", + children: conditions.map((condition) => { + return { + type: "item", + field: condition.subjectId || condition.field || "", + operator: condition.operator || "contains", + value: condition.optionId || condition.value || "" + } + }) + } + }, + createDefaultConditionGroup() { + return { + type: "group", + logic: "and", + children: [ + { + type: "item", + field: "", + operator: "==", + value: "" + } + ] + } + }, + initPreviewAnswers() { + const previewAnswers = {} + const previewTextAnswers = {} + this.subjects.forEach((subject) => { + if (["radio", "checkbox"].includes(subject.type)) { + previewAnswers[subject.id] = [] + } else { + previewTextAnswers[subject.id] = "" + } + }) + this.previewAnswers = previewAnswers + this.previewTextAnswers = previewTextAnswers + }, + selectSubject(subject) { + this.activeSubjectId = subject.id + this.scrollPreviewToSubject(subject.id) + }, + scrollPreviewToSubject(subjectId) { + this.$nextTick(() => { + const node = this.$el.querySelector('[data-preview-subject="' + subjectId + '"]') + if (node && node.scrollIntoView) { + node.scrollIntoView({behavior: "smooth", block: "center"}) + } + }) + }, + getSubjectTypeText(subject) { + const typeMap = { + radio: "单选题", + checkbox: "多选题", + text: "填空题" + } + return typeMap[subject.type] || "题目" + }, + getSubjectTypeClass(subject) { + if (subject.type === "checkbox") { + return "multiple" + } + if (subject.type === "text") { + return "text" + } + return "single" + }, + getPreviewRadioValue(subject) { + const value = this.previewAnswers[subject.id] + return value && value.length > 0 ? value[0] : "" + }, + setPreviewRadioValue(subject, optionId) { + this.$set(this.previewAnswers, subject.id, optionId ? [optionId] : []) + this.refreshPreview() + }, + refreshPreview() { + this.clearHiddenPreviewAnswers() + }, + refreshConditionDialog() { + this.$forceUpdate() + }, + openSubjectVisibleSetting(subjectIndex, subject) { + const rootGroup = subject.visibleRuleConditions && subject.visibleRuleConditions.length > 0 + ? this.cloneConditionGroup(subject.visibleRuleConditions[0]) + : this.createDefaultConditionGroup() + this.activeSubjectId = subject.id + this.subjectVisibleSetting.subjectIndex = subjectIndex + this.subjectVisibleSetting.subjectTitle = subject.title || "未填写题目" + this.subjectVisibleSetting.formData = { + enabled: !!subject.visibleRuleEnabled, + action: subject.visibleRuleEnabled ? (subject.visibleRuleAction || "hide") : "hide", + rootGroup + } + this.subjectVisibleSetting.visible = true + }, + cloneConditionGroup(group) { + return JSON.parse(JSON.stringify(group)) + }, + clearSubjectVisibleRule(subjectIndex) { + const subject = this.subjects[subjectIndex] + if (!subject || !subject.visibleRuleEnabled) { + return + } + this.$set(subject, "visibleRuleEnabled", false) + this.$set(subject, "visibleRuleAction", "show") + this.$set(subject, "visibleRuleLogic", "AND") + this.$set(subject, "visibleRuleConditions", []) + this.activeSubjectId = subject.id + this.refreshPreview() + this.scrollPreviewToSubject(subject.id) + }, + onSubjectVisibleSettingConfirm() { + const subjectIndex = this.subjectVisibleSetting.subjectIndex + if (subjectIndex === null) { + return + } + const formData = this.subjectVisibleSetting.formData + if (formData.enabled && !this.validateConditionGroup(formData.rootGroup)) { + return + } + const normalizedGroup = this.normalizeConditionGroup(formData.rootGroup) + this.$set(this.subjects[subjectIndex], "visibleRuleEnabled", formData.enabled) + this.$set(this.subjects[subjectIndex], "visibleRuleAction", formData.enabled ? formData.action : "show") + this.$set(this.subjects[subjectIndex], "visibleRuleLogic", normalizedGroup.logic === "or" ? "OR" : "AND") + this.$set(this.subjects[subjectIndex], "visibleRuleConditions", formData.enabled ? [normalizedGroup] : []) + this.subjectVisibleSetting.visible = false + this.refreshPreview() + this.scrollPreviewToSubject(this.subjects[subjectIndex].id) + }, + validateConditionGroup(group) { + if (!group.children || group.children.length === 0) { + this.$message.warning("请至少添加一条条件") + return false + } + for (let i = 0; i < group.children.length; i++) { + const child = group.children[i] + if (child.type === "group") { + if (!this.validateConditionGroup(child)) { + return false + } + } else if (!child.field || !child.operator || child.value === "" || child.value === null || child.value === undefined) { + this.$message.warning("请完整配置条件" + (i + 1)) + return false + } + } + return true + }, + isSubjectVisible(subject) { + if (!subject.visibleRuleEnabled) { + return true + } + const rootGroup = this.getVisibleRuleRootGroup(subject) + if (!rootGroup) { + return true + } + const matched = this.evaluateConditionGroup(rootGroup) + return subject.visibleRuleAction === "hide" ? !matched : matched + }, + getVisibleRuleRootGroup(subject) { + const conditions = this.normalizeVisibleRuleConditions(subject.visibleRuleConditions, subject.visibleRuleLogic) + return conditions.length > 0 ? conditions[0] : null + }, + evaluateConditionGroup(group) { + const children = (group.children || []).filter((child) => { + return child.type === "group" || (child.field && child.operator) + }) + if (children.length === 0) { + return true + } + const results = children.map((child) => { + if (child.type === "group") { + return this.evaluateConditionGroup(child) + } + return this.evaluateConditionItem(child) + }) + return group.logic === "or" + ? results.some((item) => { + return item + }) + : results.every((item) => { + return item + }) + }, + evaluateConditionItem(condition) { + const subject = this.subjects.find((item) => { + return item.id === condition.field + }) + if (!subject) { + return false + } + const answer = ["radio", "checkbox"].includes(subject.type) + ? (this.previewAnswers[subject.id] || []) + : this.previewTextAnswers[subject.id] + return this.compareRuleValue(answer, condition.operator, condition.value) + }, + compareRuleValue(answer, operator, value) { + if (Array.isArray(answer)) { + if (operator === "!=") { + return !answer.includes(value) + } + return answer.includes(value) + } + if ([">", "<", ">=", "<="].includes(operator)) { + const answerNumber = Number(answer) + const valueNumber = Number(value) + if (isNaN(answerNumber) || isNaN(valueNumber)) { + return false + } + if (operator === ">") { + return answerNumber > valueNumber + } + if (operator === "<") { + return answerNumber < valueNumber + } + if (operator === ">=") { + return answerNumber >= valueNumber + } + return answerNumber <= valueNumber + } + const answerText = answer === undefined || answer === null ? "" : String(answer) + const valueText = value === undefined || value === null ? "" : String(value) + if (operator === "!=") { + return answerText !== valueText + } + if (operator === "contains") { + return answerText.indexOf(valueText) !== -1 + } + return answerText === valueText + }, + clearHiddenPreviewAnswers() { + this.subjects.forEach((subject) => { + if (this.isSubjectVisible(subject)) { + return + } + if (["radio", "checkbox"].includes(subject.type)) { + this.$set(this.previewAnswers, subject.id, []) + } else { + this.$set(this.previewTextAnswers, subject.id, "") + } + }) + }, + buildSubmitSubjects() { + return this.subjects.map((subject) => { + const conditions = this.normalizeVisibleRuleConditions(subject.visibleRuleConditions, subject.visibleRuleLogic) + return { + ...subject, + visibleRuleEnabled: !!subject.visibleRuleEnabled, + visibleRuleAction: subject.visibleRuleEnabled ? subject.visibleRuleAction : "show", + visibleRuleLogic: subject.visibleRuleEnabled && conditions[0] && conditions[0].logic === "or" ? "OR" : "AND", + visibleRuleConditions: subject.visibleRuleEnabled ? conditions : [] + } + }) + }, + saveLogic() { + this.saveLoading = true + $.post("/platform/qsv/activity/saveSubjects", { + activityId: this.id, + subjects: JSON.stringify(this.buildSubmitSubjects()) + }).then((res) => { + if (res.code === 0) { + this.$message.success(res.msg || "保存成功") + this.$emit("refresh") + } + }).always(() => { + this.saveLoading = false + }) + } + } +} diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/new.html b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/new.html new file mode 100644 index 00000000..8889c22c --- /dev/null +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/activity/new.html @@ -0,0 +1,496 @@ + + + + +
+
+
+

{{pageTitle}}

+ 返回 +
+ + +

基础信息

+ + + + + {{item.label}} + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + 设置 + +
+
+ +

题目信息

+ + + + + +
+ 只能上传1个文件;只能上传.jpg,.jpeg,.png文件;单个文件大小不能超过1.00M; +
+
+
+
+ + + + +
+
+ + + + diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/online/index.html b/src/main/resources/views/platform/zhgh/dayofficework/qsv/online/index.html new file mode 100644 index 00000000..e89ef94d --- /dev/null +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/online/index.html @@ -0,0 +1,1165 @@ + + + + +
+ + + + + + + + + + + + + + + + 已答题 + 未答题 + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

{{answerDialog.activity.title}}

+
+
+ {{answerDialog.voteStats.optionCount || voteOptionList.length}} + 参与选项 +
+
+ {{answerDialog.voteStats.totalParticipants || 0}} + 投票总人数 +
+
+ + 排行榜 +
+
+
+
+ 距离投票结束还有: + + {{answerDialog.voteCountdown.days}}天 + {{answerDialog.voteCountdown.hours}}时 + {{answerDialog.voteCountdown.minutes}}分 + {{answerDialog.voteCountdown.seconds}}秒 + +
+
投票已结束
+ +
+
+ * + {{currentVoteSubject.title}} + + 【请选择1-{{currentVoteSubject.maxMulti}}项,已选择{{currentVoteSubject.userSelectOptionIds.length}}项】 + + 【请选择{{currentVoteSubject.type === 'radio' ? '1' : '至少1'}}项】 +
+
+
+
+ +
+
+ {{index + 1}} +
+
+ + +
+ {{getVoteOptionVotes(option.id)}}票({{getVoteOptionPercent(option.id)}}%) + {{option.text}} +
+
+ 详情 +
+
+
+
+
+ +
+ +
+
+ {{index + 1}}. + {{subject.title}} + * +
+ +
+ + + +
+ +
+ + + +
+ + +
+
+ +
+ + + + + + + + + + + +
+
+ +
+

{{answerDialog.voteDetailOption.text}}

+
+ {{getVoteOptionVotes(answerDialog.voteDetailOption.id)}}票 + {{getVoteOptionPercent(answerDialog.voteDetailOption.id)}}% + 第{{getVoteOptionRank(answerDialog.voteDetailOption.id)}}名 +
+
+

开始时间:{{answerDialog.activity.startTime || '-'}}

+

结束时间:{{answerDialog.activity.endTime || '-'}}

+ 查看规则详情 +
+
+ +
+ +
+
+ + + + diff --git a/src/main/resources/views/platform/zhgh/dayofficework/qsv/survey/reportPage.html b/src/main/resources/views/platform/zhgh/dayofficework/qsv/survey/reportPage.html new file mode 100644 index 00000000..20a9415b --- /dev/null +++ b/src/main/resources/views/platform/zhgh/dayofficework/qsv/survey/reportPage.html @@ -0,0 +1,1323 @@ + + + + +
+ + +
+
{{activityTitle || '分析报告'}}
+
+ 返回 + 导出xlsx + 下载报告 +
+
+ + + +
+ +
暂无分析数据
+
+
+ + +
+
选择条件
+
+
{{index + 1}}.
+ + + + +
+ +
选择分类
+
+ + {{item.label}} + +
+
请选择两个选择题条件后生成分类
+
+ +
+ 当前分类:{{categoryActiveLabel}} +
+ +
+ +
暂无分析数据
+
+
+ + +
+ + 交叉分析 + 对比分析 + + + + + +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + diff --git a/src/main/resources/views/platform/zhghh5/dayofficework/qsv/vote/index.html b/src/main/resources/views/platform/zhghh5/dayofficework/qsv/vote/index.html new file mode 100644 index 00000000..a0da21a7 --- /dev/null +++ b/src/main/resources/views/platform/zhghh5/dayofficework/qsv/vote/index.html @@ -0,0 +1,872 @@ + + +
+ + +
+

{{activity.title}}

+
+
+ {{voteStats.optionCount || optionList.length}} + 参与选项 +
+
+ {{voteStats.totalParticipants || 0}} + 投票总人数 +
+ +
+
+ +
+ 距离投票结束还有: + {{countdown.days}} 天 + {{countdown.hours}} 时 + {{countdown.minutes}} 分 + {{countdown.seconds}} 秒 +
+
投票已结束
+ + + +
+
+ * + {{currentSubject.title}} + + 【请选择1-{{currentSubject.maxMulti}}项,已选择{{selectedOptionIds.length}}项】 + + 【请选择{{currentSubject.type === 'radio' ? '1' : '至少1'}}项】 +
+ +
+
+
+ +
+
+ {{index + 1}} +
+
+ +
+ {{getOptionVotes(option.id)}}票({{getOptionPercent(option.id)}}%) + {{option.text}} +
+ +
+
+
+
+ + + +
+ + {{answerRecord.isFinish ? '已投票' : '投票'}} + +
+ + +
+
+ {{index + 1}} +
{{item.text}}
+
{{item.votes}}票
+
+
+
+ +
+
+

{{activity.title}}

+

》 {{detailOption.text}} 《

+
+ {{getOptionVotes(detailOption.id)}}票 + 第{{getOptionRank(detailOption.id)}}名 +
+
+
+ 距离投票结束还有: + {{countdown.days}} 天 + {{countdown.hours}} 时 + {{countdown.minutes}} 分 + {{countdown.seconds}} 秒 +
+
投票已结束
+
+

投票规则

+

开始时间:{{activity.startTime || '-'}}

+

结束时间:{{activity.endTime || '-'}}

+ 查看规则详情 +
+
+

详情介绍

+
+
暂无详情
+
+
+ + +
+
+
+ + + + + + From 643c97869283bb5b308dc70f380d0aeb7830cd3e Mon Sep 17 00:00:00 2001 From: zhouhefeng Date: Mon, 15 Jun 2026 14:36:39 +0800 Subject: [PATCH 4/4] Changes --- src/main/resources/db/menu/init_qsv_menu.sql | 160 +++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/main/resources/db/menu/init_qsv_menu.sql 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 00000000..eda22599 --- /dev/null +++ b/src/main/resources/db/menu/init_qsv_menu.sql @@ -0,0 +1,160 @@ +-- 问卷服务菜单初始化 +-- 使用 permission 做幂等判断,避免同一环境重复执行时插入重复菜单。 + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT + 'f6c0c37d2d7a4a22b5c7b1a1qsv0001', + '', + LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'), + '日常办公', + 'Day Office Work', + 'menu', + '', + '', + 'ti-briefcase', + 1, + 0, + 'dayofficework', + NULL, + 600, + 1, + '', + UNIX_TIMESTAMP(NOW()) * 1000, + '', + UNIX_TIMESTAMP(NOW()) * 1000, + 0, + 'PC', + NULL, + NULL, + 'r', + 0, + 0 +FROM sys_menu +WHERE (parentId = '' OR parentId IS NULL) + AND CHAR_LENGTH(path) = 4 +HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'dayofficework') t); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT + 'f6c0c37d2d7a4a22b5c7b1a1qsv0002', + p.id, + CONCAT(p.path, LPAD(IFNULL(MAX(CAST(SUBSTRING(c.path, CHAR_LENGTH(p.path) + 1, 4) AS UNSIGNED)), 0) + 1, 4, '0')), + '问卷服务', + 'Questionnaire Service', + 'menu', + '', + '', + 'fa fa-list-alt', + 1, + 0, + 'qsv', + NULL, + 1, + 1, + '', + UNIX_TIMESTAMP(NOW()) * 1000, + '', + UNIX_TIMESTAMP(NOW()) * 1000, + 0, + 'PC', + p.moduleId, + NULL, + 'w', + 0, + 0 +FROM sys_menu p +LEFT JOIN sys_menu c ON c.parentId = p.id AND CHAR_LENGTH(c.path) = CHAR_LENGTH(p.path) + 4 +WHERE p.permission = 'dayofficework' +GROUP BY p.id, p.path, p.moduleId +HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv') t); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT 'f6c0c37d2d7a4a22b5c7b1a1qsv0003', p.id, CONCAT(p.path, LPAD(IFNULL(MAX(CAST(SUBSTRING(c.path, CHAR_LENGTH(p.path) + 1, 4) AS UNSIGNED)), 0) + 1, 4, '0')), '问卷管理', 'Questionnaire Manage', 'menu', '/platform/qsv/activity', 'data-pjax', '', 1, 0, 'qsv.activity', NULL, 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', p.moduleId, NULL, 'w', 0, 0 +FROM sys_menu p +LEFT JOIN sys_menu c ON c.parentId = p.id AND CHAR_LENGTH(c.path) = CHAR_LENGTH(p.path) + 4 +WHERE p.permission = 'qsv' +GROUP BY p.id, p.path, p.moduleId +HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.activity') t); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT 'f6c0c37d2d7a4a22b5c7b1a1qsv0004', p.id, CONCAT(p.path, LPAD(IFNULL(MAX(CAST(SUBSTRING(c.path, CHAR_LENGTH(p.path) + 1, 4) AS UNSIGNED)), 0) + 1, 4, '0')), '题库管理', 'Question Bank', 'menu', '/platform/qsv/bank', 'data-pjax', '', 1, 0, 'qsv.bank', NULL, 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', p.moduleId, NULL, 't', 0, 0 +FROM sys_menu p +LEFT JOIN sys_menu c ON c.parentId = p.id AND CHAR_LENGTH(c.path) = CHAR_LENGTH(p.path) + 4 +WHERE p.permission = 'qsv' +GROUP BY p.id, p.path, p.moduleId +HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.bank') t); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT 'f6c0c37d2d7a4a22b5c7b1a1qsv0005', p.id, CONCAT(p.path, LPAD(IFNULL(MAX(CAST(SUBSTRING(c.path, CHAR_LENGTH(p.path) + 1, 4) AS UNSIGNED)), 0) + 1, 4, '0')), '调查统计', 'Survey Statistics', 'menu', '/platform/qsv/survey', 'data-pjax', '', 1, 0, 'qsv.survey', NULL, 3, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', p.moduleId, NULL, 'd', 0, 0 +FROM sys_menu p +LEFT JOIN sys_menu c ON c.parentId = p.id AND CHAR_LENGTH(c.path) = CHAR_LENGTH(p.path) + 4 +WHERE p.permission = 'qsv' +GROUP BY p.id, p.path, p.moduleId +HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.survey') t); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT 'f6c0c37d2d7a4a22b5c7b1a1qsv0006', p.id, CONCAT(p.path, LPAD(IFNULL(MAX(CAST(SUBSTRING(c.path, CHAR_LENGTH(p.path) + 1, 4) AS UNSIGNED)), 0) + 1, 4, '0')), '答题排行', 'Quiz Rank', 'menu', '/platform/qsv/quizRank', 'data-pjax', '', 1, 0, 'qsv.quiz.rank', NULL, 4, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', p.moduleId, NULL, 'd', 0, 0 +FROM sys_menu p +LEFT JOIN sys_menu c ON c.parentId = p.id AND CHAR_LENGTH(c.path) = CHAR_LENGTH(p.path) + 4 +WHERE p.permission = 'qsv' +GROUP BY p.id, p.path, p.moduleId +HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.quiz.rank') t); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT 'f6c0c37d2d7a4a22b5c7b1a1qsv0007', p.id, CONCAT(p.path, LPAD(IFNULL(MAX(CAST(SUBSTRING(c.path, CHAR_LENGTH(p.path) + 1, 4) AS UNSIGNED)), 0) + 1, 4, '0')), '在线答题', 'Online Quiz', 'menu', '/platform/qsv/online', 'data-pjax', '', 1, 0, 'qsv.online', NULL, 5, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', p.moduleId, NULL, 'z', 0, 0 +FROM sys_menu p +LEFT JOIN sys_menu c ON c.parentId = p.id AND CHAR_LENGTH(c.path) = CHAR_LENGTH(p.path) + 4 +WHERE p.permission = 'qsv' +GROUP BY p.id, p.path, p.moduleId +HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.online') t); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT + 'f6c0c37d2d7a4a22b5c7b1a1qsv0008', + '', + LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'), + '问卷服务', + 'Questionnaire Service', + 'menu', + '/platform/h5/qsv', + 'data-pjax', + '', + 1, + 0, + 'h5.qsv', + NULL, + 601, + 0, + '', + UNIX_TIMESTAMP(NOW()) * 1000, + '', + UNIX_TIMESTAMP(NOW()) * 1000, + 0, + 'H5', + NULL, + '/assets/mobile/svg/qsv/icon.svg', + 'w', + 1, + 1 +FROM sys_menu +WHERE (parentId = '' OR parentId IS NULL) + AND CHAR_LENGTH(path) = 4 +HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'h5.qsv') t); + +-- 给系统管理员默认授权,其他角色可在角色管理中按需分配。 +INSERT INTO sys_role_menu (roleId, menuId) +SELECT r.id, m.id +FROM sys_role r +JOIN sys_menu m ON m.permission IN ( + 'dayofficework', + 'qsv', + 'qsv.activity', + 'qsv.bank', + 'qsv.survey', + 'qsv.quiz.rank', + 'qsv.online', + 'h5.qsv' +) +LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id +WHERE r.code = 'SYSADMIN' + AND rm.roleId IS NULL;