This commit is contained in:
zhouhefeng
2026-06-15 14:18:01 +08:00
parent 449428b762
commit 873213de53
35 changed files with 4044 additions and 701 deletions
@@ -2,30 +2,71 @@
layout("/layouts/platform_h5.html"){
#-->
<div id="app" v-cloak>
<van-nav-bar @click-left="historyBack" left-arrow left-text="返回" title="问卷列表" placeholder fixed></van-nav-bar>
<div id="app" v-cloak class="qsv-h5-page">
<div class="qsv-h5-header">
<div class="qsv-h5-nav">
<button class="qsv-h5-back-btn" @click="historyBack">
<i class="fa fa-angle-left"></i>
</button>
<div class="qsv-h5-nav-title">问卷服务</div>
<div class="qsv-h5-nav-placeholder"></div>
</div>
<div class="qsv-h5-search-row">
<van-search
v-model="pageForm.title"
shape="round"
background="transparent"
placeholder="搜索标题名称"
clearable
@search="onRefresh"
@clear="onRefresh">
</van-search>
<button class="qsv-h5-search-btn" @click="onRefresh">
<i class="fa fa-search"></i>
</button>
</div>
<div class="qsv-h5-filter-row">
<button
v-for="item in categoryOptions"
:key="item.value"
:class="['qsv-h5-filter-btn', pageForm.category === item.value ? 'active' : '']"
@click="changeCategory(item.value)">
<i :class="pageForm.category === item.value ? 'fa fa-check-circle' : 'fa fa-circle-o'"></i>
<span>{{item.label}}</span>
</button>
</div>
</div>
<van-sticky offset-top="46px">
<van-tabs v-model="pageForm.category" @change="doSearch">
<van-tab v-for="item in dict.type.ACTIVITY_QSV_CATEGORY" :name="item.code" :title="item.label"
:key="item.code"></van-tab>
</van-tabs>
</van-sticky>
<table-list api="/platform/h5/qsv/pageData" :page_form.sync="pageForm" ref="tableListRef" title="title"
@ready="doSearch">
<!-- <template #header="{index,row}"></template>-->
<template v-slot="{index,row}">
<table-column label="开始时间">{{row.startTime}}</table-column>
<table-column label="结束时间">{{row.endTime}}</table-column>
</template>
<template #actions="{index,row}">
<div class="action-btn" @click="onEnter(row)">
<i class="fa fa-eye"></i>
<span>查看</span>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh" class="qsv-h5-list-wrap">
<van-list v-model="loading" :finished="finished" finished-text="暂无更多内容" @load="onLoad">
<div
v-for="row in list"
:key="row.id"
class="qsv-h5-card"
@click="onEnter(row)">
<div class="qsv-h5-cover">
<img :src="row.cover || defaultCover" alt="">
</div>
<div class="qsv-h5-card-body">
<div class="qsv-h5-title-row">
<div class="qsv-h5-title">{{row.title}}</div>
<div class="qsv-h5-answered" v-if="row.answeredText || row.isAnswered">{{row.answeredText || getAnsweredText(row.category)}}</div>
</div>
<div class="qsv-h5-meta-row">
<div class="qsv-h5-type">
<i :class="getCategoryIcon(row.category)"></i>
<span>{{getCategoryText(row.category)}}</span>
</div>
</div>
<div class="qsv-h5-time">
<div>开始时间:{{formatDateTime(row.startTime)}}</div>
<div>结束时间:{{formatDateTime(row.endTime)}}</div>
</div>
</div>
</div>
</template>
</table-list>
<van-empty v-if="!loading && list.length === 0" description="暂无内容"></van-empty>
</van-list>
</van-pull-refresh>
</div>
<script nonce="${cspNonce!}">
@@ -35,12 +76,26 @@ layout("/layouts/platform_h5.html"){
store,
data() {
return {
loading: false,
requesting: false,
finished: false,
refreshing: false,
cacheRestored: false,
list: [],
defaultCover: "/assets/mobile/svg/qsv/icon.svg",
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
category: "QUIZ"
title: "",
category: ""
},
categoryOptions: [
{label: "全部", value: ""},
{label: "答题", value: "QUIZ"},
{label: "问卷", value: "SURVEY"},
{label: "投票", value: "VOTE"}
],
categoryPaths: {
QUIZ: "/platform/h5/qsv/quiz",
SURVEY: "/platform/h5/qsv/survey",
@@ -50,30 +105,114 @@ layout("/layouts/platform_h5.html"){
},
methods: {
historyBack() {
this.$pjaxReplace('/platform/h5/home')
this.$pjaxReplace('/platform/h5/home')
},
doSearch() {
this.$nextTick(() => {
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.$refs.tableListRef.doSearch()
changeCategory(category) {
if (this.pageForm.category === category) {
return
}
this.pageForm.category = category
this.onRefresh()
},
onRefresh() {
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.list = []
this.finished = false
this.clearListCache()
this.onLoad()
},
onLoad() {
if (this.requesting || this.finished) {
return
}
this.requesting = true
this.loading = true
this.$axios.post("/platform/h5/qsv/pageData", this.pageForm).then((res) => {
if (res.code === 0) {
const pageData = res.data || {}
const rows = pageData.list || []
this.list = this.list.concat(rows)
this.pageForm.totalCount = pageData.totalCount || 0
if (this.list.length >= this.pageForm.totalCount || rows.length === 0) {
this.finished = true
}
this.pageForm.pageNumber++
this.saveListCache()
}
}).finally(() => {
this.requesting = false
this.loading = false
this.refreshing = false
})
},
async onEnter(item) {
if (item.groupId) {
const {
code,
data
} = await $.get('/platform/activity/basic/scope/getScopeUser', {activityGroupId: item.groupId})
if (code === 0 && data === 0) {
this.$toast.fail("您没有权限参与")
return
}
refreshListSilently() {
if (this.requesting) {
return
}
const requestForm = Object.assign({}, this.pageForm, {
pageNumber: 1
})
this.requesting = true
this.$axios.post("/platform/h5/qsv/pageData", requestForm).then((res) => {
if (res.code === 0) {
const pageData = res.data || {}
const rows = pageData.list || []
this.list = rows
this.pageForm = Object.assign({}, this.pageForm, {
pageNumber: 2,
totalCount: pageData.totalCount || 0
})
this.finished = rows.length >= this.pageForm.totalCount || rows.length === 0
this.saveListCache()
}
}).finally(() => {
this.requesting = false
this.loading = false
this.refreshing = false
})
},
const {startTime, endTime} = item
getCategoryText(category) {
const map = {
QUIZ: "答题",
SURVEY: "问卷",
VOTE: "投票"
}
return map[category] || "活动"
},
getCategoryIcon(category) {
const map = {
QUIZ: "fa fa-check-square-o",
SURVEY: "fa fa-list-alt",
VOTE: "fa fa-bar-chart"
}
return map[category] || "fa fa-tags"
},
getAnsweredText(category) {
const map = {
QUIZ: "已答题",
SURVEY: "已填写",
VOTE: "已投票"
}
return map[category] || "已完成"
},
formatDateTime(value) {
if (!value) {
return "-"
}
return this.$moment(value).format("YYYY-MM-DD HH:mm")
},
onEnter(item) {
const {startTime} = item
if (this.$moment(startTime).unix() > this.$moment().unix()) {
this.$toast("未开始")
return
@@ -81,13 +220,315 @@ layout("/layouts/platform_h5.html"){
const path = this.categoryPaths[item.category]
if (path) {
this.saveListCache()
pjaxReplace(path + "?id=" + item.id)
}
},
getCacheKey() {
return "qsv:h5:list"
},
saveListCache() {
try {
sessionStorage.setItem(this.getCacheKey(), JSON.stringify({
time: Date.now(),
list: this.list,
pageForm: this.pageForm,
finished: this.finished
}))
} catch (e) {
}
},
restoreListCache() {
try {
const cacheText = sessionStorage.getItem(this.getCacheKey())
if (!cacheText) {
return
}
const cache = JSON.parse(cacheText)
if (!cache || Date.now() - cache.time > 5 * 60 * 1000) {
this.clearListCache()
return
}
this.list = cache.list || []
this.pageForm = Object.assign({}, this.pageForm, cache.pageForm || {})
this.finished = !!cache.finished
this.cacheRestored = this.list.length > 0
} catch (e) {
this.clearListCache()
}
},
clearListCache() {
try {
sessionStorage.removeItem(this.getCacheKey())
} catch (e) {
}
}
},
created() {
this.restoreListCache()
},
mounted() {
if (this.cacheRestored) {
setTimeout(() => {
this.refreshListSilently()
}, 200)
}
}
})
</script>
<style>
.qsv-h5-page {
min-height: 100vh;
background: #f4f8ff;
padding-bottom: 18px;
box-sizing: border-box;
}
.qsv-h5-header {
padding: 10px 14px 16px;
background: linear-gradient(180deg, #1688f8 0%, #0f83ee 100%);
box-sizing: border-box;
}
.qsv-h5-nav {
height: 38px;
display: flex;
align-items: center;
margin-bottom: 8px;
color: #ffffff;
}
.qsv-h5-back-btn,
.qsv-h5-nav-placeholder {
width: 36px;
height: 36px;
flex-shrink: 0;
}
.qsv-h5-back-btn {
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, .16);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
padding: 0;
}
.qsv-h5-nav-title {
flex: 1;
min-width: 0;
text-align: center;
font-size: 18px;
font-weight: 700;
line-height: 36px;
}
.qsv-h5-search-row {
display: flex;
align-items: center;
gap: 10px;
}
.qsv-h5-header .van-search {
flex: 1;
min-width: 0;
padding: 0;
}
.qsv-h5-header .van-search__content {
background: rgba(255, 255, 255, .92);
}
.qsv-h5-search-btn {
width: 42px;
height: 42px;
border: 1px solid rgba(255, 255, 255, .45);
border-radius: 50%;
background: rgba(255, 255, 255, .18);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 18px;
box-sizing: border-box;
}
.qsv-h5-filter-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
padding-bottom: 1px;
}
.qsv-h5-filter-row::-webkit-scrollbar {
display: none;
}
.qsv-h5-filter-btn {
height: 32px;
width: 100%;
padding: 0 6px;
border: 0;
border-radius: 17px;
background: rgba(255, 255, 255, .92);
color: #1688f8;
font-size: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
}
.qsv-h5-filter-btn.active {
background: #ffffff;
font-weight: 600;
}
.qsv-h5-filter-btn i {
font-size: 15px;
}
.qsv-h5-list-wrap {
min-height: calc(100vh - 170px);
padding: 14px;
box-sizing: border-box;
}
.qsv-h5-card {
display: flex;
gap: 14px;
padding: 14px;
margin-bottom: 14px;
background: #ffffff;
border-radius: 13px;
box-shadow: 0 8px 24px rgba(35, 102, 168, .1);
box-sizing: border-box;
}
.qsv-h5-cover {
width: 116px;
height: 87px;
aspect-ratio: 4 / 3;
border-radius: 8px;
overflow: hidden;
background: #eaf2ff;
flex-shrink: 0;
}
.qsv-h5-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.qsv-h5-card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
padding: 2px 0;
}
.qsv-h5-title-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.qsv-h5-title {
flex: 1;
min-width: 0;
color: #1f2f46;
font-size: 16px;
font-weight: 700;
line-height: 1.35;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.qsv-h5-answered {
height: 22px;
min-width: 50px;
padding: 0 8px;
border: 1px solid #19a05f;
border-radius: 11px;
background: #f0fff8;
color: #149356;
font-size: 12px;
font-weight: 700;
line-height: 20px;
text-align: center;
white-space: nowrap;
flex-shrink: 0;
box-sizing: border-box;
}
.qsv-h5-meta-row {
display: flex;
align-items: center;
min-height: 20px;
}
.qsv-h5-type {
display: inline-flex;
align-items: center;
gap: 4px;
height: 20px;
padding: 0 6px;
border: 1px solid #ff9f43;
border-radius: 4px;
background: #fff7ec;
color: #f28b18;
font-size: 12px;
font-weight: 600;
line-height: 18px;
flex-shrink: 0;
box-sizing: border-box;
}
.qsv-h5-type i {
color: #f28b18;
font-size: 12px;
}
.qsv-h5-time {
color: #71829b;
font-size: 12px;
line-height: 1.75;
}
@media (max-width: 360px) {
.qsv-h5-card {
gap: 10px;
padding: 12px;
}
.qsv-h5-cover {
width: 96px;
height: 72px;
}
.qsv-h5-title {
font-size: 16px;
}
}
</style>
<!--#
}
@@ -3,17 +3,19 @@ layout("/layouts/platform_h5.html"){
#-->
<div id="app" v-cloak>
<van-nav-bar @click-left="historyBack" left-arrow left-text="返回" title="问卷列表" placeholder fixed></van-nav-bar>
<van-nav-bar @click-left="historyBack" left-arrow left-text="返回" :title="pageTitle" placeholder fixed></van-nav-bar>
<div class="container" :style="{'padding-bottom': answerRecord.isFinish ? 0 : '84px'}">
<van-sticky offset-top="46px">
<!--开启计时 未完成 活动未结束-->
<div class="timer" v-if="activity.timeLimit > 0 && !answerRecord.isFinish && !isEnd">⏰{{ remainingTime }}s
<div class="quiz-hero" :style="heroStyle">
<div class="quiz-hero-mask">
<div class="timer" v-if="activity.timeLimit > 0 && !answerRecord.isFinish && !isEnd">
⏰ {{ remainingTime }}s
</div>
<h2 class="quiz-title">{{ activity.title }}</h2>
</div>
<h2 class="title">{{ activity.title }}</h2>
</van-sticky>
</div>
<div v-if="!answerRecord.isFinish">
<div class="quiz-card-scroll" v-if="!answerRecord.isFinish">
<div v-for="(subject, index) in subjects" :key="index" class="subject">
<p>{{index+1}}、{{ subject.title }}</p>
<div class="tag">
@@ -84,9 +86,28 @@ layout("/layouts/platform_h5.html"){
return this.activity.timeLimit * 60 - this.answerTime
}
return 0
},
pageTitle() {
return this.getCategoryPageTitle(this.activity && this.activity.category, "在线答题")
},
heroStyle() {
if (this.activity && this.activity.cover) {
return {
backgroundImage: "url(" + this.activity.cover + ")"
}
}
return {}
}
},
methods: {
getCategoryPageTitle(category, defaultTitle) {
const titleMap = {
QUIZ: "在线答题",
SURVEY: "问卷调查",
VOTE: "投票"
}
return titleMap[category] || defaultTitle
},
//获取活动、题目
listSubjects() {
this.$axios.post("/platform/h5/qsv/quiz/subjects", {activityId: this.id}).then((res) => {
@@ -245,14 +266,6 @@ layout("/layouts/platform_h5.html"){
return
}
const selectedOptionIds = subject.userSelectOptionIds || []
const hasSelected = selectedOptionIds.includes(optionId)
// 多选题达到最大可选数量后禁止继续勾选,但允许取消已选项。
if (subject.type === "checkbox" && !hasSelected && subject.maxMulti && subject.maxMulti > 0 && selectedOptionIds.length >= subject.maxMulti) {
this.$toast("本题最多选择" + subject.maxMulti + "项")
return
}
if (subject.type === "radio") {
this.$refs["subject" + subject.id][0].toggleAll(false)
}
@@ -341,20 +354,53 @@ layout("/layouts/platform_h5.html"){
style: /*language=CSS*/ `
.container {
padding: 0;
background: #f4f8ff;
height: calc(100vh - 46px);
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
/deep/ .container .title {
text-align: center;
padding: 20px;
background: #ffffff;
margin: 0 0 10px 0 !important;
color: var(--color-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
/deep/ .quiz-hero {
flex: 0 0 auto;
min-height: 170px;
background: linear-gradient(135deg, #1688f8, #0a55c5);
background-size: cover;
background-position: center;
position: relative;
overflow: hidden;
margin: 12px 12px 14px;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(35, 102, 168, .14);
}
.container .card {
padding: 10px;
background: #ffffff;
/deep/ .quiz-hero-mask {
min-height: 170px;
padding: 24px 20px 22px;
display: flex;
flex-direction: column;
justify-content: flex-end;
box-sizing: border-box;
background: linear-gradient(180deg, rgba(0, 0, 0, .16), rgba(0, 0, 0, .56));
}
/deep/ .quiz-title {
margin: 0;
color: #fff;
font-size: 22px;
font-weight: 700;
line-height: 1.35;
text-shadow: 0 2px 8px rgba(0, 0, 0, .28);
}
.quiz-card-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0 12px;
box-sizing: border-box;
}
.subject {
@@ -377,10 +423,15 @@ layout("/layouts/platform_h5.html"){
}
/deep/ .timer {
text-align: center;
font-size: 18px;
background: #fff;
padding: 10px 0;
align-self: flex-start;
margin-bottom: 12px;
padding: 5px 10px;
border-radius: 14px;
background: rgba(255, 255, 255, .22);
color: #fff;
font-size: 14px;
font-weight: 600;
backdrop-filter: blur(6px);
}
.submit-button {
@@ -6,15 +6,15 @@ layout("/layouts/platform_h5.html"){
<van-nav-bar @click-left="historyBack" left-arrow left-text="返回" title="问卷列表" placeholder fixed></van-nav-bar>
<div class="container" :style="{'padding-bottom': answerRecord.isFinish ? 0 : '84px'}">
<van-sticky offset-top="46px">
<h2 class="title">{{ activity.title }}</h2>
<div class="score-form">
<div class="score-form-total">
<div class="score-font-style">{{totalScore}}</div>
<i class="score-underline"></i>
<div class="quiz-hero" :style="heroStyle">
<div class="quiz-hero-mask">
<h2 class="quiz-title">{{ activity.title }}</h2>
<div class="quiz-score">
<span>{{totalScore}}</span>
<em>分</em>
</div>
</div>
</van-sticky>
</div>
<div>
<div v-for="(subject, index) in subjects" :key="index" class="subject">
@@ -111,6 +111,14 @@ layout("/layouts/platform_h5.html"){
}
}
return totalScore
},
heroStyle() {
if (this.activity && this.activity.cover) {
return {
backgroundImage: "url(" + this.activity.cover + ")"
}
}
return {}
}
},
filters: {
@@ -199,23 +207,60 @@ layout("/layouts/platform_h5.html"){
style: /*language=CSS*/ `
.container {
padding: 0;
background: #f4f8ff;
min-height: 100vh;
}
/deep/ .container .title {
text-align: center;
padding: 20px;
margin: 0 !important;
background: #ffffff;
color: var(--color-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
/deep/ .quiz-hero {
min-height: 190px;
background: linear-gradient(135deg, #1688f8, #0a55c5);
background-size: cover;
background-position: center;
position: relative;
overflow: hidden;
margin: 12px 12px 14px;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(35, 102, 168, .14);
}
.container .score {
/deep/ .quiz-hero-mask {
min-height: 190px;
padding: 28px 20px 24px;
display: flex;
flex-direction: column;
justify-content: flex-end;
box-sizing: border-box;
background: linear-gradient(180deg, rgba(0, 0, 0, .18), rgba(0, 0, 0, .58));
}
.container .card {
padding: 10px;
background: #ffffff;
/deep/ .quiz-title {
margin: 0 0 14px;
color: #fff;
font-size: 22px;
font-weight: 700;
line-height: 1.35;
text-shadow: 0 2px 8px rgba(0, 0, 0, .28);
}
/deep/ .quiz-score {
display: flex;
align-items: flex-end;
color: #fff;
text-shadow: 0 2px 10px rgba(0, 0, 0, .3);
}
/deep/ .quiz-score span {
font-size: 48px;
font-weight: 800;
line-height: 1;
}
/deep/ .quiz-score em {
margin-left: 6px;
margin-bottom: 5px;
font-style: normal;
font-size: 18px;
font-weight: 600;
}
.subject {
@@ -237,46 +282,6 @@ layout("/layouts/platform_h5.html"){
top: 0;
}
/deep/ .score-form {
background: #ffffff;
display: flex;
margin-bottom: 10px;
border-top: 1px solid #f1f1f1;
align-items: center;
}
/deep/ .score-form-total {
flex: 1;
background: #fff;
display: flex;
padding: 10px 20px 10px 0;
flex-direction: column;
align-items: flex-end;
}
/deep/ .score-text-news {
flex: 1;
background: #fff;
}
/deep/ .score-font-style {
font-size: 32px;
color: #ff6a00;
word-break: keep-all;
line-height: 38px;
min-width: 47px;
text-align: center;
}
/deep/ .score-underline {
background: url(//image.wjx.cn/images/newimg/score-form/score-underline@2x.png) no-repeat center;
background-size: 47px 16px;
display: inline-block;
height: 16px;
width: 47px;
margin-top: 7px;
}
.van-checkbox__icon--checked .van-icon {
color: #fff !important;
background-color: var(--color-primary) !important;
@@ -5,18 +5,53 @@ layout("/layouts/platform_h5.html"){
<style>
.container {
padding: 0;
background: #f4f8ff;
height: calc(100vh - 46px);
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.container .title {
text-align: center;
padding: 20px;
background: #ffffff;
margin: 0 0 10px 0;
.quiz-hero {
flex: 0 0 auto;
min-height: 170px;
background: linear-gradient(135deg, #1688f8, #0a55c5);
background-size: cover;
background-position: center;
position: relative;
overflow: hidden;
margin: 12px 12px 14px;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(35, 102, 168, .14);
}
.container .card {
padding: 10px;
background: #ffffff;
.quiz-hero-mask {
min-height: 170px;
padding: 24px 20px 22px;
display: flex;
flex-direction: column;
justify-content: flex-end;
box-sizing: border-box;
background: linear-gradient(180deg, rgba(0, 0, 0, .16), rgba(0, 0, 0, .56));
}
.quiz-title {
margin: 0;
color: #fff;
font-size: 22px;
font-weight: 700;
line-height: 1.35;
text-shadow: 0 2px 8px rgba(0, 0, 0, .28);
}
.quiz-card-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0 12px;
box-sizing: border-box;
}
.subject {
@@ -49,16 +84,30 @@ layout("/layouts/platform_h5.html"){
background: #fff;
}
.van-checkbox__icon--checked .van-icon {
.van-checkbox__icon--checked .van-icon,
.van-radio__icon--checked .van-icon {
color: #fff !important;
background-color: var(--color-primary) !important;
border-color: var(--color-primary) !important;
}
.van-checkbox__icon--disabled .van-icon {
.van-checkbox__icon--disabled .van-icon,
.van-radio__icon--disabled .van-icon {
background-color: #fff;
}
.survey-option-finished-selected .survey-option-text {
color: #00a000;
font-weight: 600;
}
.survey-option-finished-selected .van-radio__icon--disabled .van-icon,
.survey-option-finished-selected .van-checkbox__icon--disabled .van-icon {
color: #fff !important;
background-color: #d8d8d8 !important;
border-color: #d8d8d8 !important;
}
.ui-input-box {
border: 1px solid #e3e3e3;
margin: 5px 0;
@@ -80,57 +129,145 @@ layout("/layouts/platform_h5.html"){
resize: none;
width: 100%;
}
.option-fill-box {
border: 1px solid #e3e3e3;
margin: 0 16px 8px 46px;
background-color: #fff;
padding: 0;
display: flex;
}
.option-fill-box input {
background-color: #fff;
border: none !important;
padding: 10px;
font-size: 14px;
line-height: 18px;
display: inline-block;
margin: 0;
-webkit-appearance: none;
resize: none;
width: 100%;
}
.required-star {
color: #ee0a24;
margin-left: 4px;
font-weight: bold;
}
</style>
<div id="app" v-cloak>
<van-nav-bar title="问卷调查" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
<div class="container" :style="{'padding-bottom': answerRecord.isFinish ? 0 : '84px'}">
<h2 class="title">{{ activity.title }}</h2>
<div v-if="!isFinished">
<div v-for="(subject, index) in subjects" :key="index" class="subject">
<p>{{index+1}}、{{ subject.title }}</p>
<van-nav-bar :title="pageTitle" left-arrow left-text="返回" @click-left="historyBack" placeholder fixed></van-nav-bar>
<div class="container" :style="{'padding-bottom': '84px'}">
<div class="quiz-hero" :style="heroStyle">
<div class="quiz-hero-mask">
<h2 class="quiz-title">{{ activity.title }}</h2>
</div>
</div>
<div class="quiz-card-scroll" v-if="!isFinished">
<div v-for="(subject, index) in visibleSubjects" :key="subject.id" class="subject">
<p>{{index+1}}、{{ subject.title }}<span class="required-star">*</span></p>
<div class="tag">
<van-tag v-if="subject.type === 'checkbox'" type="primary" size="large">多选</van-tag>
<van-tag v-if="subject.type === 'radio'" type="primary" size="large">单选</van-tag>
<van-tag v-if="subject.score" type="primary" size="large">{{subject.score}}分</van-tag>
</div>
<!--单选、多选-->
<van-checkbox-group
v-model="subject.userSelectOptionIds"
:ref="'subject'+subject.id"
v-if="['radio','checkbox'].includes(subject.type)"
<!--单选-->
<van-radio-group
:value="getRadioValue(subject)"
v-if="subject.type === 'radio'"
>
<van-cell-group>
<van-cell
v-for="(option,index) in subject.options"
clickable
:key="option.id"
:title="option.text"
@click="cellToggle(subject,option.id)"
>
<template #icon>
<van-checkbox :name="option.id" :ref="'option'+option.id" style="margin-right: 10px"></van-checkbox>
</template>
<img
slot="right-icon"
v-if="option.imgUrl"
:src="option.imgUrl"
alt=""
style="width: 40px; height: 40px"
@click.stop="previewOptionImg(option.imgUrl)"
/>
</van-cell>
<template v-for="(option,index) in subject.options">
<van-cell
clickable
:key="option.id"
:title="option.text"
:class="getFinishedOptionClass(subject, option)"
@click="cellToggle(subject,option.id)"
>
<template #title>
<span class="survey-option-text">{{option.text}}</span>
</template>
<template #icon>
<van-radio :name="option.id" :disabled="answerRecord.isFinish" style="margin-right: 10px"></van-radio>
</template>
<img
slot="right-icon"
v-if="option.imgUrl"
:src="option.imgUrl"
alt=""
style="width: 40px; height: 40px"
@click.stop="previewOptionImg(option.imgUrl)"
/>
</van-cell>
<div
class="option-fill-box"
v-if="isOptionFillVisible(subject, option)"
:key="option.id + '-fill'">
<input
v-model="subject.optionFillContents[option.id]"
:placeholder="option.fillPlaceholder || '请输入补充内容'">
</div>
</template>
</van-cell-group>
</van-radio-group>
<!--多选-->
<van-checkbox-group
v-model="subject.userSelectOptionIds"
v-if="subject.type === 'checkbox'"
>
<van-cell-group>
<template v-for="(option,index) in subject.options">
<van-cell
clickable
:key="option.id"
:title="option.text"
:class="getFinishedOptionClass(subject, option)"
@click="cellToggle(subject,option.id)"
>
<template #title>
<span class="survey-option-text">{{option.text}}</span>
</template>
<template #icon>
<van-checkbox :name="option.id" :ref="'option'+option.id" :disabled="answerRecord.isFinish" style="margin-right: 10px"></van-checkbox>
</template>
<img
slot="right-icon"
v-if="option.imgUrl"
:src="option.imgUrl"
alt=""
style="width: 40px; height: 40px"
@click.stop="previewOptionImg(option.imgUrl)"
/>
</van-cell>
<div
class="option-fill-box"
:key="option.id + '-fill'"
v-if="isOptionFillVisible(subject, option)">
<input
type="text"
v-model="subject.optionFillContents[option.id]"
:placeholder="option.fillPlaceholder || '请填写补充内容'"
:readonly="answerRecord.isFinish"
@click.stop />
</div>
</template>
</van-cell-group>
</van-checkbox-group>
<!--填空题-->
<div class="ui-input-box" v-if="subject.type==='text'">
<input type="text" v-model="subject.userFillContent" :readonly="answerRecord.isFinish" />
<input type="text" v-model="subject.userFillContent" :placeholder="'请输入' + subject.title" :readonly="answerRecord.isFinish" />
</div>
</div>
<div class="button-control" v-if="!answerRecord.isFinish">
<van-button type="primary" @click="onSubmit" block :disabled="isEnd">{{isEnd ? '已结束' : '提交'}}</van-button>
<div class="button-control">
<van-button v-if="!answerRecord.isFinish" type="primary" @click="onSubmit" block :disabled="isEnd">{{isEnd ? '已结束' : '提交'}}</van-button>
<van-button v-else type="primary" @click="historyBack" block>返回</van-button>
</div>
</div>
</div>
@@ -158,10 +295,34 @@ layout("/layouts/platform_h5.html"){
return this.$moment().unix() > this.$moment(this.activity.endTime).unix()
}
return true
},
visibleSubjects() {
return this.subjects.filter((subject) => {
return this.isSubjectVisible(subject)
})
},
pageTitle() {
return this.getCategoryPageTitle(this.activity && this.activity.category, "问卷调查")
},
heroStyle() {
if (this.activity && this.activity.cover) {
return {
backgroundImage: "url(" + this.activity.cover + ")"
}
}
return {}
}
},
methods: {
getCategoryPageTitle(category, defaultTitle) {
const titleMap = {
QUIZ: "在线答题",
SURVEY: "问卷调查",
VOTE: "投票"
}
return titleMap[category] || defaultTitle
},
//获取活动、题目
listSubjects() {
this.$axios.post("/platform/h5/qsv/survey/subjects", { activityId: this.id }).then((res) => {
@@ -193,14 +354,214 @@ layout("/layouts/platform_h5.html"){
const answer = this.answerRecord.extJson[subject.id]
if (["radio", "checkbox"].includes(subject.type)) {
this.$refs["subject" + subject.id][0].toggleAll(false)
answer?.optionIds.forEach((optionId) => {
this.$nextTick(() => {
this.$refs["option" + optionId][0].toggle()
})
})
const optionIds = answer?.optionIds || []
this.$set(subject, "userSelectOptionIds", subject.type === "radio" ? optionIds.slice(0, 1) : optionIds)
this.$set(subject, "optionFillContents", answer?.optionFillContents || {})
} else if (subject.type === "text") {
subject.userFillContent = answer?.text
this.$set(subject, "userFillContent", answer?.text)
}
})
if (!this.answerRecord.isFinish) {
this.clearHiddenSubjectAnswers()
}
},
//判断题目是否显示,未配置隐显逻辑的题目默认显示;已配置的题目按条件树递归计算后再执行显示或隐藏动作
isSubjectVisible(subject) {
if (!subject.visibleRuleEnabled) {
return true
}
const rootGroup = this.getVisibleRuleRootGroup(subject)
if (!rootGroup) {
return true
}
const matched = this.evaluateVisibleRuleGroup(rootGroup)
if (subject.visibleRuleAction === "hide") {
return !matched
}
return matched
},
//读取隐显逻辑根分组,兼容旧版 subjectId/optionId 平铺条件
getVisibleRuleRootGroup(subject) {
let conditions = subject.visibleRuleConditions || []
if (typeof conditions === "string") {
try {
conditions = JSON.parse(conditions)
} catch (e) {
conditions = []
}
}
if (!Array.isArray(conditions) || conditions.length === 0) {
return null
}
if (conditions.length === 1 && conditions[0].type === "group") {
return this.normalizeVisibleRuleGroup(conditions[0])
}
const children = conditions.filter((condition) => {
return condition.subjectId && condition.optionId
}).map((condition) => {
return {
type: "item",
field: condition.subjectId,
operator: "contains",
value: condition.optionId
}
})
if (children.length === 0) {
return null
}
return {
type: "group",
logic: subject.visibleRuleLogic === "OR" ? "or" : "and",
children
}
},
//规整条件分组,避免历史数据或空字段影响递归判断
normalizeVisibleRuleGroup(group) {
return {
type: "group",
logic: (group.logic || "and").toLowerCase() === "or" ? "or" : "and",
children: Array.isArray(group.children) ? group.children.map((child) => {
if (child.type === "group") {
return this.normalizeVisibleRuleGroup(child)
}
return {
type: "item",
field: child.field || child.subjectId || "",
operator: child.operator || (child.optionId ? "contains" : "=="),
value: child.value !== undefined && child.value !== null ? child.value : (child.optionId || "")
}
}) : []
}
},
//递归计算条件分组,支持并且、或者和嵌套分组
evaluateVisibleRuleGroup(group) {
const children = (group.children || []).filter((child) => {
return child.type === "group" || (child.field && child.operator)
})
if (children.length === 0) {
return true
}
const results = children.map((child) => {
if (child.type === "group") {
return this.evaluateVisibleRuleGroup(child)
}
return this.evaluateVisibleRuleItem(child)
})
return group.logic === "or"
? results.some((item) => {
return item
})
: results.every((item) => {
return item
})
},
//计算单条条件,选择题按选项ID判断,填空题支持文本包含和数字大小比较
evaluateVisibleRuleItem(condition) {
const triggerSubject = this.subjects.find((item) => {
return item.id === condition.field
})
if (!triggerSubject) {
return false
}
const answer = ["radio", "checkbox"].includes(triggerSubject.type)
? (triggerSubject.userSelectOptionIds || [])
: triggerSubject.userFillContent
return this.compareVisibleRuleValue(answer, condition.operator, condition.value)
},
//比较条件值,和后台逻辑抽屉实时预览保持同一套语义
compareVisibleRuleValue(answer, operator, value) {
if (Array.isArray(answer)) {
if (operator === "!=") {
return !answer.includes(value)
}
return answer.includes(value)
}
if ([">", "<", ">=", "<="].includes(operator)) {
const answerNumber = Number(answer)
const valueNumber = Number(value)
if (isNaN(answerNumber) || isNaN(valueNumber)) {
return false
}
if (operator === ">") {
return answerNumber > valueNumber
}
if (operator === "<") {
return answerNumber < valueNumber
}
if (operator === ">=") {
return answerNumber >= valueNumber
}
return answerNumber <= valueNumber
}
const answerText = answer === undefined || answer === null ? "" : String(answer)
const valueText = value === undefined || value === null ? "" : String(value)
if (operator === "!=") {
return answerText !== valueText
}
if (operator === "contains") {
return answerText.indexOf(valueText) !== -1
}
return answerText === valueText
},
//清空当前隐藏题目的答案,避免隐藏题残留旧选项或填空内容参与后续提交
clearHiddenSubjectAnswers() {
this.subjects.forEach((subject) => {
if (this.isSubjectVisible(subject)) {
return
}
if (["radio", "checkbox"].includes(subject.type)) {
this.$set(subject, "userSelectOptionIds", [])
this.$set(subject, "optionFillContents", {})
} else if (subject.type === "text") {
this.$set(subject, "userFillContent", null)
}
})
},
//判断选项是否已被选择,用于控制选项补充填写框显示和提交校验
isOptionSelected(subject, optionId) {
return subject.userSelectOptionIds && subject.userSelectOptionIds.includes(optionId)
},
getFinishedOptionClass(subject, option) {
return this.answerRecord.isFinish && this.isOptionSelected(subject, option.id)
? "survey-option-finished-selected"
: ""
},
//单选题仍按数组保存,这里只取第一个值用于 radio 组件展示
getRadioValue(subject) {
return subject.userSelectOptionIds && subject.userSelectOptionIds.length > 0
? subject.userSelectOptionIds[0]
: ""
},
//选中需要补充填写的选项后显示输入框,例如选择“否”后填写原因
isOptionFillVisible(subject, option) {
return option.fillRequired && this.isOptionSelected(subject, option.id)
},
//清空未选中选项的补充填写内容,避免取消选择后残留旧内容
clearUnselectedOptionFillContents(subject) {
if (!subject.optionFillContents) {
this.$set(subject, "optionFillContents", {})
}
if (!subject.options) {
return
}
subject.options.forEach((option) => {
if (!this.isOptionSelected(subject, option.id)) {
this.$delete(subject.optionFillContents, option.id)
}
})
},
@@ -212,9 +573,14 @@ layout("/layouts/platform_h5.html"){
}
if (subject.type === "radio") {
this.$refs["subject" + subject.id][0].toggleAll(false)
this.$set(subject, "userSelectOptionIds", [optionId])
} else {
this.$refs["option" + optionId][0].toggle()
}
this.$refs["option" + optionId][0].toggle()
this.$nextTick(() => {
this.clearUnselectedOptionFillContents(subject)
this.clearHiddenSubjectAnswers()
})
},
//预览图片
@@ -231,13 +597,20 @@ layout("/layouts/platform_h5.html"){
}
//提示那些题没有作答
for (let i = 0; i < this.subjects.length; i++) {
const subject = this.subjects[i]
for (let i = 0; i < this.visibleSubjects.length; i++) {
const subject = this.visibleSubjects[i]
if (["radio", "checkbox"].includes(subject.type)) {
if (!subject.userSelectOptionIds || subject.userSelectOptionIds.length === 0) {
this.$toast.fail("第" + (i + 1) + "题未做答")
return
}
for (let j = 0; j < subject.options.length; j++) {
const option = subject.options[j]
if (this.isOptionFillVisible(subject, option) && !subject.optionFillContents[option.id]) {
this.$toast.fail("请填写第" + (i + 1) + "题的补充内容")
return
}
}
} else if ("text" === subject.type) {
if (!subject.userFillContent) {
this.$toast.fail("第" + (i + 1) + "题未作答")
@@ -255,11 +628,12 @@ layout("/layouts/platform_h5.html"){
answer: JSON.stringify({
activityId: this.id,
answerRecordId: this.answerRecordId,
subjects: this.subjects.map((subject) => {
subjects: this.visibleSubjects.map((subject) => {
return {
id: subject.id,
userSelectOptionIds: ["checkbox", "radio"].includes(subject.type) ? subject.userSelectOptionIds : [],
userFillContent: subject.type === "text" ? subject.userFillContent : null
userFillContent: subject.type === "text" ? subject.userFillContent : null,
optionFillContents: ["checkbox", "radio"].includes(subject.type) ? subject.optionFillContents : {}
}
})
})