# Conflicts:
#	src/main/resources/application-dev.yaml
This commit is contained in:
2026-06-16 10:28:39 +08:00
46 changed files with 10089 additions and 772 deletions
@@ -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;
@@ -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 '投票选项横向列数';
+24 -2
View File
@@ -365,6 +365,7 @@ layout("/layouts/v4/baseLayout.html"){
// 地址选中
hrefSelect() {
const targetHref = this.getMenuOwnerPath()
function findMenuPath(menus, targetHref) {
function search(items, path) {
for (const item of items) {
@@ -383,7 +384,7 @@ layout("/layouts/v4/baseLayout.html"){
return search(menus, []);
}
const result = findMenuPath(this.menus, window.location.pathname)
const result = findMenuPath(this.menus, targetHref)
if(result){
this.activeMenuIndex = result[result.length-1]['id']
this.openedMenus = result.map(v=>v.id)
@@ -396,6 +397,27 @@ layout("/layouts/v4/baseLayout.html"){
// 默认选中
getMenuOwnerPath() {
const pathname = window.location.pathname
if (pathname === "/platform/qsv/survey/reportPage") {
return "/platform/qsv/survey"
}
if (pathname === "/platform/qsv/new") {
return "/platform/qsv/activity"
}
return pathname
},
getMenuOwnerFullPath() {
if (window.location.pathname === "/platform/qsv/survey/reportPage") {
return "/platform/qsv/survey"
}
if (window.location.pathname === "/platform/qsv/new") {
return "/platform/qsv/activity"
}
return getFullSubAppPath()
},
defaultSelect() {
// 查找第一个有href的子菜单
function findFirstChildWithHref(menus) {
@@ -445,7 +467,7 @@ layout("/layouts/v4/baseLayout.html"){
// 页面加载时获取到正确的菜单
const pathname = window.location.pathname
if (!pathname.startsWith("/platform/v4/subApp")) {
const path = getFullSubAppPath()
const path = this.getMenuOwnerFullPath()
this.$axios.post("/platform/sys/user/rootMenuByPath", {pathname: path}).then((res) => {
if (res.code === 0) {
this.setAppInfo(res.data)
@@ -58,6 +58,17 @@ const basicForm = {
</div>
</el-form-item>
<el-form-item label="封面" prop="cover">
<file-upload
:upload_number="1"
:value.sync="formData.cover"
accept=".jpg,.jpeg,.png"
upload_mode="image"
upload_result_category="interval"
upload_result_type="url"
></file-upload>
</el-form-item>
<template v-if="formData.category==='QUIZ'">
<el-form-item label="出题模式" prop="mode">
<span slot="label">
@@ -171,17 +182,6 @@ const basicForm = {
</el-radio-group>
</el-form-item>
<!-- <el-form-item label="封面" prop="cover">-->
<!-- <file-upload-->
<!-- :upload_number="1"-->
<!-- :value.sync="formData.cover"-->
<!-- accept=".jpg,.jpeg,.png"-->
<!-- complete_result-->
<!-- upload_mode="image"-->
<!-- upload_result_category="interval"-->
<!-- ></file-upload>-->
<!-- </el-form-item>-->
</template>
</el-form>
</div>
@@ -204,7 +204,8 @@ const basicForm = {
return {
dialogVisible: false,
formData: {
category: ''
category: '',
cover: ''
},
formRules: {
category: [{ required: true, message: "请选择类型", trigger: "change" }],
@@ -229,7 +230,7 @@ const basicForm = {
})
} else {
this.formData = {
category: 'QUIZ'
cover: ''
}
}
},
@@ -2,6 +2,382 @@
layout("/layouts/platform.html"){
#-->
<style>
.qsv-logic-drawer .el-drawer__header {
margin-bottom: 0;
padding: 18px 24px;
border-bottom: 1px solid #ebeef5;
color: #303133;
font-weight: 600;
}
.qsv-logic-drawer {
top: 64px !important;
right: 0 !important;
bottom: auto !important;
width: calc(100% - 250px) !important;
height: calc(100vh - 64px) !important;
}
.qsv-logic-drawer .el-drawer__body {
height: calc(100% - 58px);
background: #f0f2f5;
overflow: hidden;
}
.qsv-logic-workspace {
height: 100%;
display: grid;
grid-template-columns: minmax(520px, 46%) minmax(0, 1fr);
gap: 20px;
padding: 20px;
box-sizing: border-box;
background-color: #f0f2f5;
}
.qsv-logic-section {
min-width: 0;
min-height: 0;
background: #ffffff;
border: 1px solid #ebeef5;
border-radius: 4px;
display: flex;
flex-direction: column;
box-shadow: none;
}
.qsv-logic-section-header {
min-height: 58px;
padding: 0 20px;
border-bottom: 1px solid #ebeef5;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
box-sizing: border-box;
}
.qsv-logic-section-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.qsv-logic-section-header .el-button--success {
min-width: 88px;
height: 32px;
font-weight: 600;
}
.qsv-logic-question-list,
.qsv-logic-preview-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
box-sizing: border-box;
}
.qsv-logic-question-card {
position: relative;
min-height: 66px;
border: 1px solid #e4e7ed;
border-radius: 12px;
margin-bottom: 12px;
padding: 16px 20px 16px 54px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: grab;
transition: all .2s ease-in-out;
box-sizing: border-box;
background-color: #ffffff;
}
.qsv-logic-question-card:hover,
.qsv-logic-question-card.active {
border-color: #409eff;
box-shadow: 0 6px 16px rgba(64, 158, 255, .12);
transform: translateY(-2px);
background: #ffffff;
}
.qsv-logic-question-card:active {
cursor: grabbing;
}
.qsv-question-card-main {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.qsv-question-index {
position: absolute;
left: 16px;
width: 26px;
height: 26px;
border-radius: 50%;
background: #f0f2f5;
border: 1px solid #e4e7ed;
color: #909399;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
}
.qsv-question-type {
height: 24px;
line-height: 24px;
min-width: 70px;
padding: 0 10px;
border-radius: 4px;
color: #ffffff;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
text-align: center;
}
.qsv-question-type.single {
background: #409eff;
}
.qsv-question-type.multiple {
background: #67c23a;
}
.qsv-question-type.text {
background: #e6a23c;
}
.qsv-question-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #303133;
font-weight: 600;
font-size: 15px;
}
.qsv-question-title.has-visible-rule {
color: #f56c6c;
}
.qsv-question-actions {
display: flex;
align-items: center;
gap: 8px;
opacity: 0;
transform: scale(.9);
pointer-events: none;
transition: all .2s cubic-bezier(.175, .885, .32, 1.275);
flex-shrink: 0;
}
.qsv-logic-question-card:hover .qsv-question-actions,
.qsv-logic-question-card.active .qsv-question-actions {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.qsv-question-actions .el-button-group .el-button {
padding: 10px 15px;
}
.qsv-preview-controls {
display: flex;
align-items: center;
gap: 12px;
}
.qsv-preview-question {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px 24px;
margin-bottom: 8px;
box-sizing: border-box;
transition: all .2s ease;
}
.qsv-preview-question.active {
background: #f8fbff;
}
.qsv-preview-index {
min-width: 22px;
line-height: 22px;
color: #409eff;
font-size: 14px;
font-weight: 700;
text-align: right;
flex-shrink: 0;
padding-top: 1px;
}
.qsv-preview-content {
flex: 1;
min-width: 0;
}
.qsv-preview-title {
margin-bottom: 14px;
line-height: 22px;
color: #4a5568;
font-size: 14px;
}
.qsv-preview-options .el-radio,
.qsv-preview-options .el-checkbox {
margin: 0 32px 12px 0;
color: #3f4a5a;
font-weight: 500;
}
.qsv-preview-options .el-radio__label,
.qsv-preview-options .el-checkbox__label {
font-size: 14px;
}
.qsv-condition-dialog .el-dialog__body {
padding: 18px 20px 8px;
}
.qsv-condition-group {
background-color: #f9f9f5;
border: 1px solid #e8e8e0;
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
box-sizing: border-box;
}
.qsv-condition-group.is-root {
box-shadow: none;
}
.qsv-condition-root-title {
margin-bottom: -4px;
font-size: 13px;
color: #606266;
}
.qsv-condition-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px dashed #dcdcdc;
gap: 12px;
}
.qsv-condition-group-actions {
display: flex;
align-items: center;
gap: 10px;
}
.qsv-condition-children {
display: flex;
flex-direction: column;
gap: 12px;
}
.qsv-condition-item-card {
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 10px;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
transition: all .2s ease-in-out;
}
.qsv-condition-item-card:hover {
border-color: #dcdfe6;
box-shadow: 0 2px 10px rgba(0, 0, 0, .03);
}
.qsv-condition-item-index {
width: 24px;
height: 24px;
border-radius: 50%;
background: #f0f2f5;
border: 1px solid #e4e7ed;
color: #909399;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 12px;
}
.qsv-condition-item-content {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.qsv-nested-group-wrapper {
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 10px;
padding: 12px;
}
.qsv-nested-group-label {
margin-bottom: 10px;
padding-left: 4px;
display: flex;
align-items: center;
gap: 4px;
color: #909399;
font-size: 12px;
}
.qsv-condition-empty-tip {
padding: 28px;
text-align: center;
color: #909399;
background: #ffffff;
border: 1px dashed #dcdfe6;
border-radius: 10px;
font-size: 13px;
}
.qsv-condition-group-footer {
padding-top: 12px;
border-top: 1px dashed #dcdcdc;
display: flex;
gap: 12px;
}
.qsv-add-group-text {
color: #67c23a;
}
@media (max-width: 1200px) {
.qsv-logic-workspace {
grid-template-columns: 1fr;
}
}
</style>
<div id="app">
<guava ref="guava">
<el-card shadow="never">
@@ -10,32 +386,54 @@ layout("/layouts/platform.html"){
<el-date-picker placeholder="请选择年度" style="width: 100%" type="year" v-model="pageForm.year"
value-format="yyyy"></el-date-picker>
</search-item>
<search-item label="标题">
<el-input placeholder="请输入标题" v-model="pageForm.title" clearable></el-input>
<search-item label="标题名称">
<el-input placeholder="请输入标题名称" v-model="pageForm.title" clearable></el-input>
</search-item>
</search>
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="管理列表">
<el-button @click="$refs.basicFormRef.onOpen()" size="small" type="primary" class="mr5">新增</el-button>
<table-tool label="标题列表">
</table-tool>
<el-table :data="tableData" ref="tableRef" @sort-change="pageOrder">
<el-table-column label="序号" width="50" type="index" :index="indexMethod"></el-table-column>
<el-table-column label="标题" prop="title" sortable></el-table-column>
<el-table-column label="标题名称" prop="title" sortable></el-table-column>
<el-table-column label="类型" prop="category" sortable width="200">
<template scope="{row}">
<dict-tag :options="dict.type.ACTIVITY_QSV_CATEGORY" :value="row.category"></dict-tag>
<el-tag size="mini" :type="getCategoryTagType(row.category)">
{{getCategoryText(row.category)}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="开始时间" prop="startTime" sortable></el-table-column>
<el-table-column label="结束时间" prop="endTime" sortable></el-table-column>
<el-table-column label="操作" fixed="right" width="450px">
<el-table-column label="状态" width="100">
<template scope="{row}">
<el-tag v-if="row.enabled !== false" type="success">开启</el-tag>
<el-tag v-else type="info">关闭</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="580px">
<template scope="{row}">
<el-button size="mini" type="primary" @click="openQrCode(row)">二维码</el-button>
<el-button size="mini" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="mini" type="primary" @click="openSubject(row.id)">题目设置</el-button>
<el-button
v-if="row.category === 'SURVEY'"
size="mini"
type="primary"
@click="openLogic(row.id)">逻辑</el-button>
<el-button
v-if="row.enabled === false"
size="mini"
type="success"
@click="updateEnabled(row, true)">开启</el-button>
<el-button
v-else
size="mini"
type="warning"
@click="updateEnabled(row, false)">关闭</el-button>
<el-button size="mini" type="danger" @click="onDelete(row.id)">删除</el-button>
</template>
</el-table-column>
@@ -48,22 +446,22 @@ layout("/layouts/platform.html"){
</template>
</guava>
<basic-form ref="basicFormRef" @refresh="pageData"></basic-form>
<logic-form ref="logicFormRef" @refresh="pageData"></logic-form>
<qr-code-plus :url="signatureAddress" ref="qrCodePlusRef"></qr-code-plus>
</div>
<script nonce="${cspNonce!}">
<!--#include('basicForm.js'){}#-->
<!--#include('subjectForm.js'){}#-->
<!--#include('logicForm.js'){}#-->
new Vue({
el: "#app",
dicts: ["ACTIVITY_QSV_CATEGORY"],
mixins: [initTableMixins],
components: {
"basic-form": basicForm,
"subject-form": subjectForm
"subject-form": subjectForm,
"logic-form": logicForm
},
data() {
return {
@@ -71,9 +469,24 @@ layout("/layouts/platform.html"){
}
},
methods: {
getCategoryText(category) {
const options = this.dict && this.dict.type ? (this.dict.type.ACTIVITY_QSV_CATEGORY || []) : []
const item = options.find(item => item.code === category)
return item ? item.name : category
},
getCategoryTagType(category) {
const map = {
SURVEY: "success",
QUIZ: "primary",
VOTE: "danger"
}
return map[category] || "info"
},
// 编辑
openEdit(row) {
this.$refs.basicFormRef.onOpen(row.id)
location.href = "/platform/qsv/new?id=" + row.id
},
// 打开二维码
@@ -94,9 +507,34 @@ layout("/layouts/platform.html"){
})
},
// 调查类型独立维护题目之间的显隐逻辑
openLogic(id) {
this.$refs.logicFormRef.onOpen(id)
},
// 开启或关闭活动,关闭后移动端首页不展示,直达手机端问卷也不能答题
updateEnabled(row, enabled) {
const actionText = enabled ? "开启" : "关闭"
this.$confirm("您确认" + actionText + "该活动吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
$.post("/platform/qsv/activity/updateEnabled", {
id: row.id,
enabled
}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.pageData()
}
})
})
},
// 删除
onDelete(id) {
this.$confirm("您确认删除吗, 是否继续?", "提示", {
this.$confirm("删除后将同步删除该任务的题目、选项、用户答题记录、统计分析和成绩数据,且不可恢复。确定删除吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
@@ -0,0 +1,744 @@
const conditionGroupEditor = {
name: "condition-group-editor",
props: {
group: {
type: Object,
required: true
},
questions: {
type: Array,
default() {
return []
}
},
isRoot: {
type: Boolean,
default: false
}
},
template: /*language=HTML*/ `
<div :class="['qsv-condition-group', {'is-root': isRoot}]">
<div v-if="isRoot" class="qsv-condition-root-title">条件规则</div>
<div class="qsv-condition-group-header">
<el-select v-model="group.logic" size="small" style="width: 120px;" @change="emitChange">
<el-option label="并且 (AND)" value="and"></el-option>
<el-option label="或者 (OR)" value="or"></el-option>
</el-select>
<div class="qsv-condition-group-actions">
<el-button type="primary" plain size="small" icon="el-icon-plus" @click="addItem">添加条件</el-button>
<el-button type="success" plain size="small" icon="el-icon-folder-add" @click="addGroup">添加分组</el-button>
<el-button
v-if="!isRoot"
type="danger"
size="small"
icon="el-icon-delete"
circle
@click="$emit('remove')">
</el-button>
</div>
</div>
<div class="qsv-condition-children">
<div v-for="(child, index) in group.children" :key="index" class="qsv-condition-row">
<template v-if="child.type === 'item'">
<div class="qsv-condition-item-card">
<span class="qsv-condition-item-index">{{index + 1}}</span>
<div class="qsv-condition-item-content">
<el-select
v-model="child.field"
placeholder="选择题目"
size="small"
filterable
style="flex: 1; min-width: 180px;"
@change="onFieldChange(child)">
<el-option
v-for="question in questions"
:key="question.id"
:label="question.title || '未填写题目'"
:value="question.id">
</el-option>
</el-select>
<el-select
v-model="child.operator"
size="small"
style="width: 110px;"
@change="emitChange">
<el-option label="等于" :value="'=='"></el-option>
<el-option label="不等于" :value="'!='"></el-option>
<el-option label="包含" :value="'contains'"></el-option>
<el-option label="大于" :value="'>'"></el-option>
<el-option label="小于" :value="'<'"></el-option>
<el-option label="大于等于" :value="'>='"></el-option>
<el-option label="小于等于" :value="'<='"></el-option>
</el-select>
<el-select
v-if="isChoiceQuestion(child.field)"
v-model="child.value"
placeholder="选择值"
size="small"
filterable
allow-create
style="width: 180px;"
@change="emitChange">
<el-option
v-for="option in getQuestionOptions(child.field)"
:key="option.id"
:label="option.text || '未填写选项'"
:value="option.id">
</el-option>
</el-select>
<el-input-number
v-else-if="isNumericOperator(child.operator)"
v-model="child.value"
size="small"
:controls="false"
style="width: 180px;"
@change="emitChange">
</el-input-number>
<el-input
v-else
v-model="child.value"
placeholder="输入值"
size="small"
style="width: 180px;"
@change="emitChange">
</el-input>
</div>
<el-button
type="danger"
size="small"
icon="el-icon-delete"
circle
@click="removeChild(index)">
</el-button>
</div>
</template>
<template v-else-if="child.type === 'group'">
<div class="qsv-nested-group-wrapper">
<div class="qsv-nested-group-label">
<i class="el-icon-folder"></i>
<span>嵌套分组</span>
</div>
<condition-group-editor
:group="child"
:questions="questions"
:is-root="false"
@change="emitChange"
@remove="removeChild(index)">
</condition-group-editor>
</div>
</template>
</div>
<div v-if="!group.children.length" class="qsv-condition-empty-tip">
暂无条件,请点击上方按钮添加
</div>
</div>
<div class="qsv-condition-group-footer">
<el-button type="text" icon="el-icon-plus" @click="addItem">添加条件</el-button>
<el-button type="text" icon="el-icon-folder-add" class="qsv-add-group-text" @click="addGroup">添加分组</el-button>
</div>
</div>
`,
methods: {
addItem() {
this.group.children.push({
type: "item",
field: "",
operator: "==",
value: ""
})
this.emitChange()
},
addGroup() {
this.group.children.push({
type: "group",
logic: "and",
children: []
})
this.emitChange()
},
removeChild(index) {
this.group.children.splice(index, 1)
this.emitChange()
},
onFieldChange(condition) {
condition.value = ""
const question = this.getQuestion(condition.field)
if (question && question.type === "checkbox" && condition.operator === "==") {
condition.operator = "contains"
}
this.emitChange()
},
emitChange() {
this.$emit("change")
},
getQuestion(questionId) {
return this.questions.find((question) => {
return question.id === questionId
})
},
isChoiceQuestion(questionId) {
const question = this.getQuestion(questionId)
return question && ["radio", "checkbox"].includes(question.type)
},
getQuestionOptions(questionId) {
const question = this.getQuestion(questionId)
return question && question.options ? question.options : []
},
isNumericOperator(operator) {
return [">", "<", ">=", "<="].includes(operator)
}
}
}
const logicForm = {
components: {
"condition-group-editor": conditionGroupEditor
},
template: /*language=HTML*/ `
<el-drawer
:visible.sync="visible"
:title="activity.title ? '题目逻辑 - ' + activity.title : '题目逻辑'"
direction="rtl"
size="calc(100% - 250px)"
:modal="false"
append-to-body
custom-class="qsv-logic-drawer">
<div class="qsv-logic-workspace" v-loading="loading">
<section class="qsv-logic-section qsv-logic-question-section">
<div class="qsv-logic-section-header">
<span class="qsv-logic-section-title">题目列表</span>
<el-button type="success" size="small" :loading="saveLoading" @click="saveLogic">
保存问卷
</el-button>
</div>
<div class="qsv-logic-question-list">
<div
v-for="(subject, index) in subjects"
:key="subject.id"
:class="['qsv-logic-question-card', {active: activeSubjectId === subject.id}]"
@click="selectSubject(subject)">
<div class="qsv-question-card-main">
<span class="qsv-question-index">{{index + 1}}</span>
<span :class="['qsv-question-type', getSubjectTypeClass(subject)]">
{{getSubjectTypeText(subject)}}
</span>
<span :class="['qsv-question-title', {'has-visible-rule': subject.visibleRuleEnabled}]">
{{subject.title || '未填写题目'}}
</span>
</div>
<div class="qsv-question-actions" v-if="index > 0">
<el-button-group>
<el-tooltip content="设置显隐逻辑" placement="top">
<el-button
type="warning"
size="small"
icon="el-icon-share"
@click.stop="openSubjectVisibleSetting(index, subject)">
</el-button>
</el-tooltip>
<el-tooltip content="删除逻辑" placement="top">
<el-button
type="danger"
size="small"
icon="el-icon-delete"
:disabled="!subject.visibleRuleEnabled"
@click.stop="clearSubjectVisibleRule(index)">
</el-button>
</el-tooltip>
</el-button-group>
</div>
</div>
<el-empty v-if="subjects.length === 0" description="暂无题目"></el-empty>
</div>
</section>
<section class="qsv-logic-section qsv-logic-preview-section">
<div class="qsv-logic-section-header">
<span class="qsv-logic-section-title">实时预览</span>
<div class="qsv-preview-controls">
<el-checkbox v-model="showPreviewIndex" size="small">显示序号</el-checkbox>
<el-tag type="info" size="small">配置即所得</el-tag>
</div>
</div>
<div class="qsv-logic-preview-list">
<div
v-for="(subject, index) in visiblePreviewSubjects"
:key="subject.id"
:data-preview-subject="subject.id"
:class="['qsv-preview-question', {active: activeSubjectId === subject.id}]">
<span v-if="showPreviewIndex" class="qsv-preview-index">{{index + 1}}.</span>
<div class="qsv-preview-content">
<div class="qsv-preview-title">{{subject.title || '未填写题目'}}</div>
<template v-if="subject.type === 'radio'">
<el-radio-group
class="qsv-preview-options"
:value="getPreviewRadioValue(subject)"
@input="setPreviewRadioValue(subject, $event)">
<el-radio
v-for="option in subject.options"
:key="option.id"
:label="option.id">
{{option.text || '未填写选项'}}
</el-radio>
</el-radio-group>
</template>
<template v-else-if="subject.type === 'checkbox'">
<el-checkbox-group
class="qsv-preview-options"
v-model="previewAnswers[subject.id]"
@change="refreshPreview">
<el-checkbox
v-for="option in subject.options"
:key="option.id"
:label="option.id">
{{option.text || '未填写选项'}}
</el-checkbox>
</el-checkbox-group>
</template>
<el-input
v-else
v-model="previewTextAnswers[subject.id]"
placeholder="请输入"
size="small"
@input="refreshPreview">
</el-input>
</div>
</div>
<el-empty v-if="visiblePreviewSubjects.length === 0" description="暂无可见题目"></el-empty>
</div>
</section>
</div>
<el-dialog
:title="subjectVisibleSetting.subjectTitle ? '配置题目逻辑 - ' + subjectVisibleSetting.subjectTitle : '配置题目逻辑'"
:visible.sync="subjectVisibleSetting.visible"
append-to-body
custom-class="qsv-condition-dialog"
width="800px">
<el-form :model="subjectVisibleSetting.formData" label-width="88px">
<el-form-item label="启用逻辑">
<el-switch
v-model="subjectVisibleSetting.formData.enabled"
active-text="启用"
inactive-text="关闭">
</el-switch>
</el-form-item>
<template v-if="subjectVisibleSetting.formData.enabled">
<el-form-item label="处理方式">
<el-radio-group v-model="subjectVisibleSetting.formData.action">
<el-radio label="show">条件满足后显示本题</el-radio>
<el-radio label="hide">条件满足后隐藏本题</el-radio>
</el-radio-group>
</el-form-item>
<condition-group-editor
:group="subjectVisibleSetting.formData.rootGroup"
:questions="subjectVisibleRuleSubjects"
:is-root="true"
@change="refreshConditionDialog">
</condition-group-editor>
</template>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="subjectVisibleSetting.visible = false">取 消</el-button>
<el-button type="primary" @click="onSubjectVisibleSettingConfirm">保存逻辑</el-button>
</div>
</el-dialog>
</el-drawer>
`,
data() {
return {
visible: false,
loading: false,
saveLoading: false,
id: null,
activity: {},
subjects: [],
activeSubjectId: null,
previewAnswers: {},
previewTextAnswers: {},
showPreviewIndex: true,
subjectVisibleSetting: {
visible: false,
subjectIndex: null,
subjectTitle: "",
formData: {
enabled: false,
action: "hide",
rootGroup: {
type: "group",
logic: "and",
children: []
}
}
}
}
},
computed: {
visiblePreviewSubjects() {
return this.subjects.filter((subject) => {
return this.isSubjectVisible(subject)
})
},
subjectVisibleRuleSubjects() {
if (this.subjectVisibleSetting.subjectIndex === null) {
return []
}
return this.subjects.filter((item, index) => {
return index < this.subjectVisibleSetting.subjectIndex
})
}
},
methods: {
onOpen(id) {
this.visible = true
this.loading = true
this.id = id
this.activity = {}
this.subjects = []
this.activeSubjectId = null
$.post("/platform/qsv/activity/findOne", {id}).then((res) => {
if (res.code === 0) {
this.activity = res.data || {}
}
})
$.post("/platform/qsv/activity/listSubjects", {activityId: id}).then((res) => {
if (res.code === 0) {
this.subjects = (res.data || []).map((subject) => {
return this.normalizeSubject(subject)
})
this.initPreviewAnswers()
if (this.subjects.length > 0) {
this.activeSubjectId = this.subjects[0].id
}
}
}).always(() => {
this.loading = false
})
},
normalizeSubject(subject) {
return {
...subject,
visibleRuleEnabled: !!subject.visibleRuleEnabled,
visibleRuleAction: subject.visibleRuleEnabled ? (subject.visibleRuleAction || "hide") : "",
visibleRuleLogic: subject.visibleRuleLogic || "AND",
visibleRuleConditions: this.normalizeVisibleRuleConditions(subject.visibleRuleConditions, subject.visibleRuleLogic)
}
},
normalizeVisibleRuleConditions(conditions, legacyLogic) {
let parsedConditions = conditions || []
if (typeof parsedConditions === "string") {
try {
parsedConditions = JSON.parse(parsedConditions)
} catch (e) {
parsedConditions = []
}
}
if (!Array.isArray(parsedConditions) || parsedConditions.length === 0) {
return []
}
if (parsedConditions.length === 1 && parsedConditions[0].type === "group") {
return [this.normalizeConditionGroup(parsedConditions[0])]
}
return [this.normalizeLegacyConditionGroup(parsedConditions, legacyLogic)]
},
normalizeConditionGroup(group) {
return {
type: "group",
logic: (group.logic || "and").toLowerCase() === "or" ? "or" : "and",
children: Array.isArray(group.children) ? group.children.map((child) => {
if (child.type === "group") {
return this.normalizeConditionGroup(child)
}
return this.normalizeConditionItem(child)
}) : []
}
},
normalizeConditionItem(item) {
return {
type: "item",
field: item.field || item.subjectId || "",
operator: item.operator || (item.optionId ? "contains" : "=="),
value: item.value !== undefined && item.value !== null ? item.value : (item.optionId || "")
}
},
normalizeLegacyConditionGroup(conditions, legacyLogic) {
return {
type: "group",
logic: legacyLogic === "OR" ? "or" : "and",
children: conditions.map((condition) => {
return {
type: "item",
field: condition.subjectId || condition.field || "",
operator: condition.operator || "contains",
value: condition.optionId || condition.value || ""
}
})
}
},
createDefaultConditionGroup() {
return {
type: "group",
logic: "and",
children: [
{
type: "item",
field: "",
operator: "==",
value: ""
}
]
}
},
initPreviewAnswers() {
const previewAnswers = {}
const previewTextAnswers = {}
this.subjects.forEach((subject) => {
if (["radio", "checkbox"].includes(subject.type)) {
previewAnswers[subject.id] = []
} else {
previewTextAnswers[subject.id] = ""
}
})
this.previewAnswers = previewAnswers
this.previewTextAnswers = previewTextAnswers
},
selectSubject(subject) {
this.activeSubjectId = subject.id
this.scrollPreviewToSubject(subject.id)
},
scrollPreviewToSubject(subjectId) {
this.$nextTick(() => {
const node = this.$el.querySelector('[data-preview-subject="' + subjectId + '"]')
if (node && node.scrollIntoView) {
node.scrollIntoView({behavior: "smooth", block: "center"})
}
})
},
getSubjectTypeText(subject) {
const typeMap = {
radio: "单选题",
checkbox: "多选题",
text: "填空题"
}
return typeMap[subject.type] || "题目"
},
getSubjectTypeClass(subject) {
if (subject.type === "checkbox") {
return "multiple"
}
if (subject.type === "text") {
return "text"
}
return "single"
},
getPreviewRadioValue(subject) {
const value = this.previewAnswers[subject.id]
return value && value.length > 0 ? value[0] : ""
},
setPreviewRadioValue(subject, optionId) {
this.$set(this.previewAnswers, subject.id, optionId ? [optionId] : [])
this.refreshPreview()
},
refreshPreview() {
this.clearHiddenPreviewAnswers()
},
refreshConditionDialog() {
this.$forceUpdate()
},
openSubjectVisibleSetting(subjectIndex, subject) {
const rootGroup = subject.visibleRuleConditions && subject.visibleRuleConditions.length > 0
? this.cloneConditionGroup(subject.visibleRuleConditions[0])
: this.createDefaultConditionGroup()
this.activeSubjectId = subject.id
this.subjectVisibleSetting.subjectIndex = subjectIndex
this.subjectVisibleSetting.subjectTitle = subject.title || "未填写题目"
this.subjectVisibleSetting.formData = {
enabled: !!subject.visibleRuleEnabled,
action: subject.visibleRuleEnabled ? (subject.visibleRuleAction || "hide") : "hide",
rootGroup
}
this.subjectVisibleSetting.visible = true
},
cloneConditionGroup(group) {
return JSON.parse(JSON.stringify(group))
},
clearSubjectVisibleRule(subjectIndex) {
const subject = this.subjects[subjectIndex]
if (!subject || !subject.visibleRuleEnabled) {
return
}
this.$set(subject, "visibleRuleEnabled", false)
this.$set(subject, "visibleRuleAction", "show")
this.$set(subject, "visibleRuleLogic", "AND")
this.$set(subject, "visibleRuleConditions", [])
this.activeSubjectId = subject.id
this.refreshPreview()
this.scrollPreviewToSubject(subject.id)
},
onSubjectVisibleSettingConfirm() {
const subjectIndex = this.subjectVisibleSetting.subjectIndex
if (subjectIndex === null) {
return
}
const formData = this.subjectVisibleSetting.formData
if (formData.enabled && !this.validateConditionGroup(formData.rootGroup)) {
return
}
const normalizedGroup = this.normalizeConditionGroup(formData.rootGroup)
this.$set(this.subjects[subjectIndex], "visibleRuleEnabled", formData.enabled)
this.$set(this.subjects[subjectIndex], "visibleRuleAction", formData.enabled ? formData.action : "show")
this.$set(this.subjects[subjectIndex], "visibleRuleLogic", normalizedGroup.logic === "or" ? "OR" : "AND")
this.$set(this.subjects[subjectIndex], "visibleRuleConditions", formData.enabled ? [normalizedGroup] : [])
this.subjectVisibleSetting.visible = false
this.refreshPreview()
this.scrollPreviewToSubject(this.subjects[subjectIndex].id)
},
validateConditionGroup(group) {
if (!group.children || group.children.length === 0) {
this.$message.warning("请至少添加一条条件")
return false
}
for (let i = 0; i < group.children.length; i++) {
const child = group.children[i]
if (child.type === "group") {
if (!this.validateConditionGroup(child)) {
return false
}
} else if (!child.field || !child.operator || child.value === "" || child.value === null || child.value === undefined) {
this.$message.warning("请完整配置条件" + (i + 1))
return false
}
}
return true
},
isSubjectVisible(subject) {
if (!subject.visibleRuleEnabled) {
return true
}
const rootGroup = this.getVisibleRuleRootGroup(subject)
if (!rootGroup) {
return true
}
const matched = this.evaluateConditionGroup(rootGroup)
return subject.visibleRuleAction === "hide" ? !matched : matched
},
getVisibleRuleRootGroup(subject) {
const conditions = this.normalizeVisibleRuleConditions(subject.visibleRuleConditions, subject.visibleRuleLogic)
return conditions.length > 0 ? conditions[0] : null
},
evaluateConditionGroup(group) {
const children = (group.children || []).filter((child) => {
return child.type === "group" || (child.field && child.operator)
})
if (children.length === 0) {
return true
}
const results = children.map((child) => {
if (child.type === "group") {
return this.evaluateConditionGroup(child)
}
return this.evaluateConditionItem(child)
})
return group.logic === "or"
? results.some((item) => {
return item
})
: results.every((item) => {
return item
})
},
evaluateConditionItem(condition) {
const subject = this.subjects.find((item) => {
return item.id === condition.field
})
if (!subject) {
return false
}
const answer = ["radio", "checkbox"].includes(subject.type)
? (this.previewAnswers[subject.id] || [])
: this.previewTextAnswers[subject.id]
return this.compareRuleValue(answer, condition.operator, condition.value)
},
compareRuleValue(answer, operator, value) {
if (Array.isArray(answer)) {
if (operator === "!=") {
return !answer.includes(value)
}
return answer.includes(value)
}
if ([">", "<", ">=", "<="].includes(operator)) {
const answerNumber = Number(answer)
const valueNumber = Number(value)
if (isNaN(answerNumber) || isNaN(valueNumber)) {
return false
}
if (operator === ">") {
return answerNumber > valueNumber
}
if (operator === "<") {
return answerNumber < valueNumber
}
if (operator === ">=") {
return answerNumber >= valueNumber
}
return answerNumber <= valueNumber
}
const answerText = answer === undefined || answer === null ? "" : String(answer)
const valueText = value === undefined || value === null ? "" : String(value)
if (operator === "!=") {
return answerText !== valueText
}
if (operator === "contains") {
return answerText.indexOf(valueText) !== -1
}
return answerText === valueText
},
clearHiddenPreviewAnswers() {
this.subjects.forEach((subject) => {
if (this.isSubjectVisible(subject)) {
return
}
if (["radio", "checkbox"].includes(subject.type)) {
this.$set(this.previewAnswers, subject.id, [])
} else {
this.$set(this.previewTextAnswers, subject.id, "")
}
})
},
buildSubmitSubjects() {
return this.subjects.map((subject) => {
const conditions = this.normalizeVisibleRuleConditions(subject.visibleRuleConditions, subject.visibleRuleLogic)
return {
...subject,
visibleRuleEnabled: !!subject.visibleRuleEnabled,
visibleRuleAction: subject.visibleRuleEnabled ? subject.visibleRuleAction : "show",
visibleRuleLogic: subject.visibleRuleEnabled && conditions[0] && conditions[0].logic === "or" ? "OR" : "AND",
visibleRuleConditions: subject.visibleRuleEnabled ? conditions : []
}
})
},
saveLogic() {
this.saveLoading = true
$.post("/platform/qsv/activity/saveSubjects", {
activityId: this.id,
subjects: JSON.stringify(this.buildSubmitSubjects())
}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg || "保存成功")
this.$emit("refresh")
}
}).always(() => {
this.saveLoading = false
})
}
}
}
@@ -0,0 +1,496 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
.qsv-new-page {
padding-bottom: 72px;
}
.qsv-new-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.qsv-new-title {
margin: 0;
font-size: 20px;
line-height: 32px;
font-weight: 600;
color: #303133;
}
.qsv-new-card {
margin-bottom: 12px;
}
.qsv-new-sub-title {
margin: 0 0 18px;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.qsv-new-footer {
position: fixed;
left: 250px;
right: 0;
bottom: 0;
z-index: 900;
padding: 12px 24px;
border-top: 1px solid #ebeef5;
background: #ffffff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);
text-align: right;
box-sizing: border-box;
}
.qsv-cover-tip {
margin-top: 8px;
line-height: 20px;
font-size: 12px;
color: #909399;
}
.qsv-cover-tip span {
color: #f56c6c;
}
@media (max-width: 768px) {
.qsv-new-footer {
left: 0;
}
.qsv-scope-select {
width: 100% !important;
min-width: 0 !important;
}
}
</style>
<div id="app" v-cloak>
<div class="qsv-new-page">
<div class="qsv-new-header">
<h2 class="qsv-new-title">{{pageTitle}}</h2>
<el-button icon="el-icon-back" @click="goBack">返回</el-button>
</div>
<el-card shadow="never" class="qsv-new-card">
<h3 class="qsv-new-sub-title">基础信息</h3>
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="150px">
<el-form-item label="类型" prop="category">
<el-radio-group v-model="formData.category" size="small" @change="onCategoryChange">
<el-radio v-for="item in categoryOptions" :label="item.code"
:key="item.code"
border>
{{item.label}}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" maxlength="50"
show-word-limit></el-input>
</el-form-item>
<el-form-item label="说明" prop="description">
<text-editor v-model="formData.description" :height="150"></text-editor>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="开始时间" prop="startTime">
<el-date-picker v-model="formData.startTime" type="datetime" placeholder="选择日期时间"
style="width: 100%"
value-format="yyyy-MM-dd HH:mm:ss"></el-date-picker>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束时间" prop="endTime">
<el-date-picker v-model="formData.endTime" type="datetime" placeholder="选择日期时间"
style="width: 100%"
value-format="yyyy-MM-dd HH:mm:ss"></el-date-picker>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="参加人员范围" prop="groupId">
<div style="display: flex;column-gap: 10px">
<el-select class="qsv-scope-select"
clearable filterable
placeholder="参加人员范围"
style="width: 640px; max-width: calc(100% - 82px);"
v-model="formData.groupId">
<el-option :key="item.groupId"
:label="item.groupName"
:value="item.groupId"
v-for="item in activityGroupOptions"></el-option>
</el-select>
<el-button
@click="$refs.drawerUserScope.userScopeDialog = true"
type="primary">设置
</el-button>
</div>
</el-form-item>
<h3 class="qsv-new-sub-title" v-if="formData.category==='QUIZ'">题目信息</h3>
<template v-if="formData.category==='QUIZ'">
<el-form-item label="答题模式" prop="mode">
<el-radio-group v-model="formData.mode" size="small" @change="onModeChange">
<el-radio v-for="item in modeOptions" :label="item.code"
:key="item.code"
border>
{{item.label}}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="常规题目范围" prop="displayMode"
v-if="isRegularMode()">
<el-radio-group v-model="formData.displayMode" size="small" @change="normalizeQuizConfig">
<el-radio label="ALL" border>显示全部题目</el-radio>
<el-radio label="RANDOM" border>随机抽取题目</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="题目展示顺序打乱" prop="shuffleSubject">
<el-switch v-model="formData.shuffleSubject" inactive-text="否" active-text="是"></el-switch>
</el-form-item>
<el-form-item label="每次随机题数" prop="randomCount"
v-if="isRegularMode() && isRandomDisplayMode()">
<el-input-number v-model="formData.randomCount" :min="1" :max="100"></el-input-number>
</el-form-item>
<el-form-item label="最多随机答题次数" prop="totalRandom"
v-if="isRegularMode() && isRandomDisplayMode()">
<el-input-number v-model="formData.totalRandom" :min="1" :max="100"></el-input-number>
</el-form-item>
<el-form-item label="允许重复答题" prop="repeatable"
v-if="canRepeatAnswer()">
<el-switch v-model="formData.repeatable" inactive-text="否" active-text="是"
@change="normalizeQuizConfig"></el-switch>
</el-form-item>
<el-form-item label="最多答题次数" prop="maxAttempts"
v-if="canRepeatAnswer() && formData.repeatable">
<el-input-number v-model="formData.maxAttempts" :min="1" :max="100"></el-input-number>
</el-form-item>
<el-form-item label="时间限制(min)" prop="timeLimit">
<el-input-number v-model="formData.timeLimit" :min="0" :max="100"
:precision="0"
placeholder="0表示不限时"></el-input-number>
</el-form-item>
<el-form-item label="重复答题计分方式" prop="scoreMode"
v-if="canRepeatAnswer() && formData.repeatable">
<el-radio-group v-model="formData.scoreMode" size="small">
<el-radio v-for="item in dict.type.ACTIVITY_QSV_SCORE_MODE" :label="item.code"
:key="item.code" border>
{{item.label}}
</el-radio>
</el-radio-group>
</el-form-item>
</template>
<el-form-item label="封面" prop="cover">
<file-upload
:upload_number="1"
:upload_size="1024 * 1024"
:value.sync="formData.cover"
accept=".jpg,.jpeg,.png"
upload_mode="image"
upload_result_category="interval"
upload_result_type="url"
></file-upload>
<div class="qsv-cover-tip">
只能上传<span>1</span>个文件;只能上传<span>.jpg,.jpeg,.png</span>文件;单个文件大小不能超过<span>1.00</span>M
</div>
</el-form-item>
</el-form>
</el-card>
<div class="qsv-new-footer">
<el-button @click="goBack">取消</el-button>
<el-button :loading="saveLoading" @click="saveDraft">保存</el-button>
<el-button type="primary" :loading="saveLoading" @click="submitActivity">提交</el-button>
</div>
<drawer-user-scope
@group_change="getActivityGroup"
ref="drawerUserScope"
:group_id.sync="formData.groupId"
></drawer-user-scope>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
dicts: ["ACTIVITY_QSV_CATEGORY", "ACTIVITY_QSV_MODE", "ACTIVITY_QSV_REPEAT_MODE", "ACTIVITY_QSV_SCORE_MODE"],
components: {
"drawer-user-scope": httpVueLoader("/components/module/activity/DrawerUserScope.vue")
},
data() {
return {
saveLoading: false,
activityId: "",
formData: {
category: "QUIZ",
cover: "",
enabled: false,
mode: "REGULAR",
shuffleSubject: false,
repeatable: false,
displayMode: "ALL",
timeLimit: 0
},
formRules: {
category: [{ required: true, message: "请选择类型", trigger: "change" }],
title: [{ required: true, message: "请输入标题", trigger: "change" }],
startTime: [{ required: true, message: "请选择开始时间", trigger: "change" }],
endTime: [{ required: true, message: "请选择结束时间", trigger: "change" }],
mode: [{ required: true, message: "请选择答题模式", trigger: "change" }],
scoreMode: [{ required: true, message: "请选择重复答题计分方式", trigger: "change" }]
},
activityGroupOptions: []
}
},
methods: {
getQueryString(name) {
const reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)")
const result = window.location.search.substr(1).match(reg)
return result ? decodeURIComponent(result[2]) : ""
},
onCategoryChange(category) {
this.formData.category = this.normalizeCategoryValue(category)
if (this.formData.category === "QUIZ") {
this.normalizeQuizConfig()
}
},
onModeChange(mode) {
this.$set(this.formData, "mode", this.normalizeModeValue(mode))
this.normalizeQuizConfig()
},
getDefaultScoreMode() {
const scoreModes = this.dict && this.dict.type ? (this.dict.type.ACTIVITY_QSV_SCORE_MODE || []) : []
const highMode = scoreModes.find(item => item.code === "HIGH")
const lastMode = scoreModes.find(item => item.code === "LAST")
return (lastMode || highMode || scoreModes[0] || { code: "LAST" }).code
},
normalizeCategoryValue(category) {
const value = (category || "").toString().trim().toUpperCase()
if (value === "QUIZ" || value === "VOTE") {
return value
}
return "SURVEY"
},
normalizeModeValue(mode) {
const value = (mode || "").toString().trim().toUpperCase()
if (value.indexOf("SCHEDULE") > -1 || value.indexOf("定时") > -1) {
return "SCHEDULED"
}
return "REGULAR"
},
normalizeModeOptionValue(item) {
const code = item && item.code ? item.code.toString().trim() : ""
if (code.toUpperCase().indexOf("SCHEDULE") > -1 || code.indexOf("定时") > -1) {
return "SCHEDULED"
}
if (code.toUpperCase().indexOf("REGULAR") > -1 || code.indexOf("常规") > -1) {
return "REGULAR"
}
return this.normalizeModeValue([item && item.name, item && item.label, item && item.text].join(" "))
},
getDefaultModeValue() {
const regularOption = this.modeOptions.find(item => this.normalizeModeOptionValue(item) === "REGULAR")
return regularOption ? regularOption.code : "REGULAR"
},
normalizeDisplayModeValue(displayMode) {
const value = (displayMode || "").toString().trim().toUpperCase()
if (value === "RANDOM") {
return "RANDOM"
}
return "ALL"
},
isRegularMode(data) {
const formData = data || this.formData
return this.normalizeModeValue(formData.mode) === "REGULAR"
},
isRandomDisplayMode(data) {
const formData = data || this.formData
return this.normalizeDisplayModeValue(formData.displayMode) === "RANDOM"
},
canRepeatAnswer(data) {
const formData = data || this.formData
const category = this.normalizeCategoryValue(formData.category)
const mode = this.normalizeModeValue(formData.mode)
const displayMode = this.normalizeDisplayModeValue(formData.displayMode)
return category === "QUIZ"
&& (mode === "SCHEDULED" || (mode === "REGULAR" && displayMode === "ALL"))
},
normalizeQuizConfig() {
this.formData.category = this.normalizeCategoryValue(this.formData.category)
if (this.formData.category !== "QUIZ") {
return
}
this.$set(this.formData, "mode", this.formData.mode ? this.normalizeModeValue(this.formData.mode) : this.getDefaultModeValue())
this.$set(this.formData, "displayMode", this.normalizeDisplayModeValue(this.formData.displayMode))
this.$set(this.formData, "timeLimit", this.formData.timeLimit || 0)
if (!this.isRegularMode()) {
this.$set(this.formData, "displayMode", "ALL")
this.$set(this.formData, "randomCount", null)
this.$set(this.formData, "totalRandom", null)
} else if (!this.isRandomDisplayMode()) {
this.$set(this.formData, "randomCount", null)
this.$set(this.formData, "totalRandom", null)
} else {
this.$set(this.formData, "repeatable", false)
this.$set(this.formData, "randomCount", this.formData.randomCount || 1)
this.$set(this.formData, "totalRandom", this.formData.totalRandom || 1)
}
if (!this.canRepeatAnswer()) {
this.$set(this.formData, "repeatable", false)
this.$set(this.formData, "maxAttempts", 1)
this.$set(this.formData, "scoreMode", this.getDefaultScoreMode())
return
}
if (this.formData.repeatable) {
this.$set(this.formData, "maxAttempts", this.formData.maxAttempts || 1)
this.$set(this.formData, "scoreMode", this.formData.scoreMode || this.getDefaultScoreMode())
return
}
this.$set(this.formData, "maxAttempts", 1)
this.$set(this.formData, "scoreMode", this.getDefaultScoreMode())
},
getActivityGroup() {
$.post("/platform/activity/basic/scope/getActivityUserScopeGroup").then((res) => {
if (res.code === 0) {
this.activityGroupOptions = res.data || []
}
})
},
loadActivity() {
if (!this.activityId) {
return
}
$.post("/platform/qsv/activity/findOne", { id: this.activityId }).then((res) => {
if (res.code === 0 && res.data) {
this.formData = Object.assign({}, this.formData, res.data)
this.normalizeQuizConfig()
}
})
},
saveDraft() {
const enabled = this.activityId ? this.formData.enabled !== false : false
this.saveActivity(enabled, "保存")
},
submitActivity() {
this.saveActivity(true, "提交")
},
buildSubmitData(enabled) {
const formData = Object.assign({}, this.formData, { enabled })
formData.category = this.normalizeCategoryValue(formData.category)
if (formData.category !== "QUIZ") {
delete formData.mode
delete formData.repeatable
delete formData.repeatMode
delete formData.maxAttempts
delete formData.displayMode
delete formData.randomCount
delete formData.totalRandom
delete formData.shuffleSubject
delete formData.timeLimit
delete formData.scoreMode
} else {
formData.mode = this.normalizeModeValue(formData.mode)
formData.displayMode = this.normalizeDisplayModeValue(formData.displayMode)
formData.timeLimit = formData.timeLimit || 0
if (formData.mode !== "REGULAR") {
formData.displayMode = "ALL"
delete formData.randomCount
delete formData.totalRandom
} else if (formData.displayMode !== "RANDOM") {
delete formData.randomCount
delete formData.totalRandom
} else {
formData.repeatable = false
formData.maxAttempts = 1
formData.scoreMode = this.getDefaultScoreMode()
}
if (!this.canRepeatAnswer(formData) || !formData.repeatable) {
formData.repeatable = false
formData.maxAttempts = 1
formData.scoreMode = this.getDefaultScoreMode()
} else {
formData.maxAttempts = formData.maxAttempts || 1
formData.scoreMode = formData.scoreMode || this.getDefaultScoreMode()
}
}
return formData
},
saveActivity(enabled, actionText) {
this.$refs.formRef.validate((valid) => {
if (!valid) {
return
}
this.$confirm("确定" + actionText + "吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
this.saveLoading = true
const formData = this.buildSubmitData(enabled)
$.post("/platform/qsv/activity/save", formData).then((res) => {
if (res.code !== 0) {
this.saveLoading = false
return
}
this.$message.success(actionText + "成功")
location.href = "/platform/qsv/activity"
}).fail(() => {
this.saveLoading = false
})
})
})
},
goBack() {
location.href = "/platform/qsv/activity"
}
},
mounted() {
this.activityId = this.getQueryString("id")
this.getActivityGroup()
this.loadActivity()
if (!this.activityId) {
this.normalizeQuizConfig()
}
},
computed: {
categoryOptions() {
const categoryList = this.dict && this.dict.type ? (this.dict.type.ACTIVITY_QSV_CATEGORY || []) : []
return categoryList.map(item => Object.assign({}, item, {
code: this.normalizeCategoryValue(item.code)
}))
},
modeOptions() {
return [
{ code: "REGULAR", label: "常规模式" },
{ code: "SCHEDULED", label: "定时定题" }
]
},
pageTitle() {
return this.activityId ? "编辑任务" : "新建任务"
}
}
})
</script>
<!--#
}
#-->
@@ -8,6 +8,20 @@ const setting = {
<el-form-item label="详情" prop="description">
<text-editor v-model="formData.description"></text-editor>
</el-form-item>
<el-form-item label="补充填写" prop="fillRequired">
<el-switch
v-model="formData.fillRequired"
active-text="需要填写"
inactive-text="无需填写">
</el-switch>
</el-form-item>
<el-form-item label="填写提示" prop="fillPlaceholder" v-if="formData.fillRequired">
<el-input
maxlength="255"
placeholder="例如:请填写原因"
v-model="formData.fillPlaceholder">
</el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="visible = false">取 消</el-button>
@@ -26,7 +40,11 @@ const setting = {
methods:{
onOpen(option,ext){
console.log(option)
this.formData = { ...option }
this.formData = {
...option,
fillRequired: !!option.fillRequired,
fillPlaceholder: option.fillPlaceholder || ""
}
this.ext = ext
this.visible = true
},
@@ -5,20 +5,20 @@
const subjectForm = {
template: /*language=HTML*/ `
<div class="subject-form-dialog">
<div class="subject-form-dialog qsv-subject-editor">
<el-form ref="form" :model="formData" :rules="formRules" label-width="120px">
<div style="min-height:50vh;overflow-y: auto">
<draggable v-model="subjects" handle=".drag-handler">
<transition-group>
<div v-for="(subject, subjectIndex) in subjects" :key="subject.id" class="subject-item">
<div class="subject-header">
<div style="display: flex; align-items: center;">
<div class="subject-title-row">
<i class="el-icon-rank drag-handler"></i>
<span style="font-weight: bold; margin-right: 10px;">第 {{subjectIndex + 1}} 题</span>
<el-input
class="subject-title-input"
v-model="subject.title"
placeholder="请输入题目"
style="flex: 1">
placeholder="请输入题目">
</el-input>
</div>
@@ -49,6 +49,27 @@ const subjectForm = {
最大选择数:
<el-input-number v-model="subject.maxMulti" placeholder="最多可选数量"></el-input-number>
</div>
<div v-if="activity.category==='VOTE'">
排列方式:
<el-select
v-model="subject.optionLayout"
@change="onVoteLayoutChange(subject)"
placeholder="请选择排列方式"
style="width: 120px;">
<el-option label="竖向排列" value="VERTICAL"></el-option>
<el-option label="横向排列" value="HORIZONTAL"></el-option>
</el-select>
</div>
<div v-if="activity.category==='VOTE' && subject.optionLayout === 'HORIZONTAL'">
横向列数:
<el-select
v-model="subject.optionColumns"
placeholder="请选择列数"
style="width: 100px;">
<el-option label="横向2列" :value="2"></el-option>
<el-option label="横向3列" :value="3"></el-option>
</el-select>
</div>
<!-- <el-input-->
<!-- v-model="subject.hint"-->
<!-- placeholder="题目提示信息"-->
@@ -91,7 +112,7 @@ const subjectForm = {
<div class="option-content">
<el-input
v-model="option.text"
:placeholder="'选项' + (optionIndex + 1)">
:placeholder="'选项'+optionIndex + 1">
<template slot="prepend">选项{{optionIndex + 1}}</template>
</el-input>
<!-- <el-input-->
@@ -119,8 +140,7 @@ const subjectForm = {
<el-switch
v-if="activity.category==='QUIZ'"
v-model="option.isCorrect"
active-text="正确答案"
@change="handleCorrectAnswerChange(subject, optionIndex)">
active-text="正确答案">
</el-switch>
<el-button
@@ -193,7 +213,7 @@ const subjectForm = {
</div>
</el-form>
<div style="border-top: 1px solid var(--border-color-lighter);padding: 12px;text-align: right;">
<div class="qsv-subject-footer">
<!-- <el-button @click="visible = false">取消</el-button>-->
<!--<el-button type="primary" icon="el-icon-plus" @click="openBank">
@@ -201,12 +221,15 @@ const subjectForm = {
</el-button>-->
<el-button type="primary" icon="el-icon-upload" @click="openTxtImport">
从文本导入
导入题目
</el-button>
<el-button type="primary" icon="el-icon-plus" @click="addSubject">
添加题目
</el-button>
<el-button type="primary" icon="el-icon-check" @click="saveQuestionnaire">
<el-button type="primary" icon="el-icon-collection" @click="openSaveBankDialog">
存到题库
</el-button>
<el-button type="primary" icon="el-icon-check" @click="saveQuestionnaire" v-if="!hideSaveButton">
保存题目
</el-button>
</div>
@@ -215,6 +238,22 @@ const subjectForm = {
<setting ref="settingRef" @confirm="onSettingConfirm"></setting>
<txt-import ref="txtImportRef" @confirm="onTxtImportConfirm"></txt-import>
<bank ref="bankRef" @confirm="onBankConfirm"></bank>
<el-dialog
:visible.sync="saveBankDialog.visible"
title="存到题库"
width="420px"
append-to-body
custom-class="save-bank-dialog">
<el-form :model="saveBankDialog.formData" :rules="saveBankDialog.rules" ref="saveBankFormRef" label-width="90px">
<el-form-item label="标题名称" prop="title">
<el-input v-model="saveBankDialog.formData.title" maxlength="50" show-word-limit placeholder="请输入题库标题"></el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="saveBankDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="saveBankDialog.loading" @click="saveToBank">确定</el-button>
</div>
</el-dialog>
</div>
`,
components: {
@@ -223,6 +262,12 @@ const subjectForm = {
'txt-import': txtImport,
'bank': subjectBank
},
props: {
hideSaveButton: {
type: Boolean,
default: false
}
},
data() {
return {
id: null,
@@ -231,7 +276,20 @@ const subjectForm = {
subjects: [],
previewDialogVisible: false,
activity: {},
formRules: {}
formRules: {},
saveBankDialog: {
visible: false,
loading: false,
formData: {
title: ""
},
rules: {
title: [
{ required: true, message: "请输入题库标题", trigger: "blur" },
{ max: 50, message: "标题名称不能超过50个字", trigger: "blur" }
]
}
}
}
},
methods: {
@@ -239,14 +297,19 @@ const subjectForm = {
this.visible = true
if (id) {
this.id = id
$.post("/platform/qsv/activity/findOne", {id}).then((res) => {
if (res.code === 0) {
this.activity = res.data
Promise.all([
$.post("/platform/qsv/activity/findOne", {id}),
$.post("/platform/qsv/activity/listSubjects", {activityId: id})
]).then(([activityRes, subjectsRes]) => {
if (activityRes.code === 0) {
this.activity = activityRes.data
}
})
$.post("/platform/qsv/activity/listSubjects", {activityId: id}).then((res) => {
if (res.code === 0) {
this.subjects = res.data
if (subjectsRes.code === 0) {
this.subjects = subjectsRes.data || []
this.normalizeVoteSubjectLayouts()
}
if (this.activity.category === "VOTE" && this.subjects.length === 0) {
this.addSubject()
}
})
}
@@ -266,6 +329,8 @@ const subjectForm = {
type: "radio",
score: 0,
displayDate: "",
optionLayout: "VERTICAL",
optionColumns: 1,
options: [
{
id: this.generateId(),
@@ -285,14 +350,41 @@ const subjectForm = {
})
},
//题目类型切换
subjectTypeChange(subject, subjectIndex) {
// 切换题型时先清空当前题目的正确答案状态,避免沿用旧题型下的答案配置。
;(subject.options || []).forEach((option) => {
if (option) {
this.$set(option, 'isCorrect', false)
}
// 投票选项排列默认值
normalizeVoteSubjectLayout(subject) {
if (!subject) {
return
}
if (!subject.optionLayout) {
this.$set(subject, "optionLayout", "VERTICAL")
}
if (subject.optionLayout === "HORIZONTAL") {
const columns = Number(subject.optionColumns)
this.$set(subject, "optionColumns", columns === 3 ? 3 : 2)
} else {
this.$set(subject, "optionLayout", "VERTICAL")
this.$set(subject, "optionColumns", 1)
}
},
normalizeVoteSubjectLayouts() {
if (this.activity.category !== "VOTE") {
return
}
this.subjects.forEach((subject) => {
this.normalizeVoteSubjectLayout(subject)
})
},
onVoteLayoutChange(subject) {
if (subject.optionLayout === "HORIZONTAL") {
this.$set(subject, "optionColumns", Number(subject.optionColumns) === 3 ? 3 : 2)
} else {
this.$set(subject, "optionColumns", 1)
}
},
subjectTypeChange(subject, subjectIndex) {
if (subject.type === 'text') {
subject.options = []
}
@@ -317,7 +409,7 @@ const subjectForm = {
addOption(subject) {
subject.options.push({
id: this.generateId(),
text: "选项" + (subject.options.length + 1),
text: "选项" + subject.options.length + 1,
hint: "",
imgUrl: null,
isCorrect: false
@@ -358,7 +450,7 @@ const subjectForm = {
//选项设置确认
onSettingConfirm({option, ext}) {
this.subjects[ext.subjectIndex].options[ext.optionIndex] = option
this.$set(this.subjects[ext.subjectIndex].options, ext.optionIndex, option)
console.log(this.subjects[ext.subjectIndex].options[ext.optionIndex])
// this.$set(this.subjects[ext.subjectIndex].options[ext.optionIndex], "isCorrect", option.isCorrect)
@@ -367,7 +459,7 @@ const subjectForm = {
//从文本导入
openTxtImport() {
this.$refs.txtImportRef.visible = true;
this.$refs.txtImportRef.onOpen(this.activity.category);
},
//处理导入的题目
@@ -382,6 +474,9 @@ const subjectForm = {
// 更新导入题目的序号
importedQuestions.forEach((question, index) => {
question.sortNum = startSortNum + index;
if (this.activity.category === "VOTE") {
this.normalizeVoteSubjectLayout(question)
}
});
// 将导入的题目添加到现有题目列表
@@ -400,67 +495,129 @@ const subjectForm = {
},
// 单选题切换正确答案时保持只有一个正确选项,避免界面状态与题型规则不一致。
handleCorrectAnswerChange(subject, optionIndex) {
if (!subject || subject.type !== 'radio') {
// 打开存到题库对话框
openSaveBankDialog() {
if (!this.subjects || this.subjects.length === 0) {
this.$message.warning("请先添加题目")
return
}
const options = subject.options || []
const currentOption = options[optionIndex]
if (!currentOption || !currentOption.isCorrect) {
return
}
for (let index = 0; index < options.length; index++) {
if (index !== optionIndex) {
this.$set(options[index], 'isCorrect', false)
this.saveBankDialog.formData.title = this.activity && this.activity.title ? this.activity.title : ""
this.saveBankDialog.visible = true
this.$nextTick(() => {
if (this.$refs.saveBankFormRef) {
this.$refs.saveBankFormRef.clearValidate()
}
}
})
},
// 保存前校验选项内容,避免题目存在空选项或缺少正确答案时仍然提交到后端。
validateSubjectOptions() {
for (let subjectIndex = 0; subjectIndex < this.subjects.length; subjectIndex++) {
const subject = this.subjects[subjectIndex]
if (!subject || subject.type === 'text') {
continue
}
const options = subject.options || []
if (options.length === 0) {
this.$message.warning('请设置第' + (subjectIndex + 1) + '题的选项')
return false
}
let correctAnswerCount = 0
for (let optionIndex = 0; optionIndex < options.length; optionIndex++) {
const option = options[optionIndex]
const optionText = option && option.text ? option.text.trim() : ''
if (!optionText) {
this.$message.warning('第' + (subjectIndex + 1) + '题的第' + (optionIndex + 1) + '个选项不能为空')
return false
}
if (option && option.isCorrect) {
correctAnswerCount++
}
}
if (this.activity.category === 'QUIZ') {
if (subject.type === 'radio' && correctAnswerCount > 1) {
this.$message.warning('第' + (subjectIndex + 1) + '题为单选题,只能设置一个正确答案')
return false
}
if (correctAnswerCount === 0) {
this.$message.warning('第' + (subjectIndex + 1) + '题请至少设置一个正确答案')
return false
}
// 复制题目到题库时重新生成ID,避免与活动题目关联
buildBankSubjects() {
const subjects = JSON.parse(JSON.stringify(this.subjects || []))
const subjectIdMap = {}
const optionIdMap = {}
const remapValue = (value, map) => {
if (Array.isArray(value)) {
return value.map((item) => map[item] || item)
}
return map[value] || value
}
return true
const remapVisibleConditions = (conditions) => {
if (!Array.isArray(conditions)) {
return conditions
}
return conditions.map((condition) => {
if (condition.subjectId) {
condition.subjectId = subjectIdMap[condition.subjectId] || condition.subjectId
}
if (condition.field) {
condition.field = subjectIdMap[condition.field] || condition.field
}
if (condition.optionId) {
condition.optionId = optionIdMap[condition.optionId] || condition.optionId
}
if (Object.prototype.hasOwnProperty.call(condition, "value")) {
condition.value = remapValue(condition.value, optionIdMap)
}
if (Array.isArray(condition.children)) {
condition.children = remapVisibleConditions(condition.children)
}
return condition
})
}
subjects.forEach((subject) => {
const oldSubjectId = subject.id
const newSubjectId = this.generateId()
subject._bankNewSubjectId = newSubjectId
if (oldSubjectId) {
subjectIdMap[oldSubjectId] = newSubjectId
}
if (Array.isArray(subject.options)) {
subject.options.forEach((option) => {
const oldOptionId = option.id
const newOptionId = this.generateId()
option._bankNewOptionId = newOptionId
if (oldOptionId) {
optionIdMap[oldOptionId] = newOptionId
}
})
}
})
return subjects.map((subject, index) => {
const newSubjectId = this.generateId()
subject.id = subject._bankNewSubjectId || newSubjectId
delete subject._bankNewSubjectId
subject.activityId = null
subject.sortNum = index + 1
if (Array.isArray(subject.options)) {
subject.options = subject.options.map((option, optionIndex) => {
option.id = option._bankNewOptionId || this.generateId()
delete option._bankNewOptionId
option.subjectId = subject.id
option.sortNum = optionIndex + 1
return option
})
}
if (Array.isArray(subject.correctAnswer)) {
subject.correctAnswer = remapValue(subject.correctAnswer, optionIdMap)
}
if (Array.isArray(subject.visibleRuleConditions)) {
subject.visibleRuleConditions = remapVisibleConditions(subject.visibleRuleConditions)
}
return subject
})
},
// 保存当前全部题目到题库
saveToBank() {
this.$refs.saveBankFormRef.validate((valid) => {
if (!valid) {
return
}
const qsvBank = {
title: this.saveBankDialog.formData.title,
category: this.activity.category,
subjects: this.buildBankSubjects()
}
this.saveBankDialog.loading = true
$.post("/platform/qsv/bank/save", { qsvBank: JSON.stringify(qsvBank) }).then((res) => {
this.saveBankDialog.loading = false
if (res.code === 0) {
this.$message.success("已存到题库")
this.saveBankDialog.visible = false
}
}).fail(() => {
this.saveBankDialog.loading = false
})
})
},
//保存
saveQuestionnaire() {
if (!this.validateSubjectOptions()) {
return
}
$.post("/platform/qsv/activity/saveSubjects", {
this.normalizeVoteSubjectLayouts()
$
.post("/platform/qsv/activity/saveSubjects", {
activityId: this.id,
subjects: JSON.stringify(this.subjects)
})
@@ -474,6 +631,38 @@ const subjectForm = {
}
},
style: /*language=CSS*/ `
/deep/ .qsv-subject-editor {
padding-bottom: 72px;
}
/deep/ .qsv-subject-footer {
position: fixed;
left: 0;
width: 100%;
right: 0;
bottom: 0;
z-index: 900;
padding: 12px 24px;
border-top: 1px solid #ebeef5;
background: #ffffff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);
text-align: right;
box-sizing: border-box;
}
@media (max-width: 768px) {
/deep/ .qsv-subject-footer {
left: 0;
width: 100%;
}
}
/deep/ .save-bank-dialog {
top: 50%;
margin-top: 0 !important;
transform: translateY(calc(-50% + 250px));
}
/*.subject-form-dialog,.subject-form-dialog .el-dialog__body {*/
/* background: rgb(248, 249, 250);*/
/*}*/
@@ -513,6 +702,15 @@ const subjectForm = {
margin-bottom: 15px;
}
/deep/ .subject-title-row {
display: flex;
align-items: center;
}
/deep/ .subject-title-input {
flex: 1;
}
/deep/ .subject-toolbar {
display: flex;
justify-content: space-between;
File diff suppressed because it is too large Load Diff
@@ -1,40 +1,52 @@
const basicForm = {
template: /*language=HTML*/ `
<el-dialog :visible.sync="dialogVisible" title="基础设置" width="70%">
<div style="overflow-y: auto">
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="150px">
<el-form-item label="类型" prop="category">
<el-radio-group v-model="formData.category" size="small">
<el-radio v-for="item in dict.type.ACTIVITY_QSV_CATEGORY" :label="item.code"
:key="item.code"
border>
{{item.label}}
</el-radio>
</el-radio-group>
</el-form-item>
<el-drawer
:title="formData.id ? '编辑题库' : '新增题库'"
:visible.sync="dialogVisible"
direction="rtl"
size="1024px"
custom-class="qsv-bank-basic-drawer"
append-to-body
:wrapper-closable="false"
:close-on-press-escape="false">
<div class="qsv-bank-basic-drawer-page" v-loading="saveLoading">
<el-card shadow="never" class="qsv-bank-basic-drawer-card">
<h3 class="qsv-bank-sub-title">基础信息</h3>
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="96px">
<el-form-item label="类型" prop="category">
<el-radio-group v-model="formData.category" size="small">
<el-radio v-for="item in dict.type.ACTIVITY_QSV_CATEGORY" :label="item.code"
:key="item.code"
border>
{{item.label || item.name}}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" maxlength="50"
show-word-limit></el-input>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" maxlength="50"
show-word-limit></el-input>
</el-form-item>
<el-form-item label="说明" prop="description">
<text-editor v-model="formData.description" :height="150"></text-editor>
</el-form-item>
</el-form>
<el-form-item label="说明" prop="description">
<text-editor v-model="formData.description" :height="180"></text-editor>
</el-form-item>
</el-form>
</el-card>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" @click="onSubmit">确 定</el-button>
<div class="qsv-bank-basic-drawer-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saveLoading" @click="onSubmit">保存</el-button>
</div>
</el-dialog>
</el-drawer>
`,
dicts: ["ACTIVITY_QSV_CATEGORY", "ACTIVITY_QSV_MODE", "ACTIVITY_QSV_REPEAT_MODE", "ACTIVITY_QSV_SCORE_MODE"],
data() {
return {
dialogVisible: false,
saveLoading: false,
formData: {
category: ''
category: 'QUIZ'
},
formRules: {
category: [{required: true, message: "请选择类型", trigger: "change"}],
@@ -45,27 +57,42 @@ const basicForm = {
methods: {
onOpen(id) {
this.dialogVisible = true
this.saveLoading = false
if (id) {
$.post("/platform/qsv/bank/findOne", {id}).then((res) => {
if (res.code === 0) {
this.formData = res.data
this.formData = res.data || {category: 'QUIZ'}
}
})
} else {
this.formData = {}
this.formData = {
category: 'QUIZ'
}
this.$nextTick(() => {
if (this.$refs.formRef) {
this.$refs.formRef.clearValidate()
}
})
}
},
onSubmit() {
this.$refs.formRef.validate((valid) => {
if (valid) {
$.post("/platform/qsv/bank/" + (this.formData.id ? "update" : "save"), {qsvBank: JSON.stringify(this.formData)}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.dialogVisible = false
this.$emit("refresh", null)
}
})
if (!valid) {
return
}
this.saveLoading = true
$.post("/platform/qsv/bank/" + (this.formData.id ? "update" : "save"), {
qsvBank: JSON.stringify(this.formData)
}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg || "保存成功")
this.dialogVisible = false
this.$emit("refresh", null)
}
this.saveLoading = false
}).fail(() => {
this.saveLoading = false
})
})
}
}
@@ -2,30 +2,152 @@
layout("/layouts/platform.html"){
#-->
<style>
.el-drawer.qsv-bank-drawer {
top: 64px !important;
right: 0 !important;
bottom: auto !important;
width: calc(100% - 250px) !important;
height: calc(100vh - 64px) !important;
display: flex;
flex-direction: column;
}
.qsv-bank-drawer .el-drawer__header {
margin-bottom: 0;
padding: 18px 24px;
border-bottom: 1px solid #ebeef5;
color: #303133;
font-weight: 600;
}
.qsv-bank-drawer .el-drawer__body {
flex: 1;
min-height: 0;
padding: 0;
background: #f0f2f5;
overflow: hidden;
}
.qsv-bank-drawer-page {
height: 100%;
padding: 16px 24px 72px;
box-sizing: border-box;
overflow-y: auto;
}
.qsv-bank-drawer-card {
min-height: 100%;
}
.qsv-bank-sub-title {
margin: 0 0 18px;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.qsv-bank-drawer-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
padding: 12px 24px;
border-top: 1px solid #ebeef5;
background: #ffffff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);
text-align: right;
box-sizing: border-box;
}
.el-drawer.qsv-bank-basic-drawer {
top: 64px !important;
right: 0 !important;
bottom: auto !important;
width: 1024px !important;
height: calc(100vh - 64px) !important;
display: flex;
flex-direction: column;
}
.qsv-bank-basic-drawer .el-drawer__header {
margin-bottom: 0;
padding: 18px 24px;
border-bottom: 1px solid #ebeef5;
color: #303133;
font-weight: 600;
}
.qsv-bank-basic-drawer .el-drawer__body {
flex: 1;
min-height: 0;
padding: 0;
background: #f0f2f5;
overflow: hidden;
}
.qsv-bank-basic-drawer-page {
height: 100%;
padding: 16px 16px 72px;
box-sizing: border-box;
overflow-y: auto;
}
.qsv-bank-basic-drawer-card {
min-height: 100%;
}
.qsv-bank-basic-drawer-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
padding: 12px 24px;
border-top: 1px solid #ebeef5;
background: #ffffff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);
text-align: right;
box-sizing: border-box;
}
@media (max-width: 768px) {
.el-drawer.qsv-bank-drawer,
.el-drawer.qsv-bank-basic-drawer {
top: 0 !important;
width: 100% !important;
height: 100vh !important;
}
}
</style>
<div id="app">
<guava ref="guava">
<el-card shadow="never">
<search @search="doSearch">
<search-item label="标题">
<el-input placeholder="请输入标题" v-model="pageForm.title" clearable></el-input>
<search-item label="标题名称">
<el-input placeholder="请输入标题名称" v-model="pageForm.title" clearable></el-input>
</search-item>
</search>
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="管理列表">
<table-tool label="题库列表">
<el-button @click="$refs.basicFormRef.onOpen()" size="small" type="primary" class="mr5">新增</el-button>
</table-tool>
<el-table :data="tableData" ref="tableRef" @sort-change="pageOrder">
<el-table-column label="序号" width="50" type="index" :index="indexMethod"></el-table-column>
<el-table-column label="标题" prop="title" sortable></el-table-column>
<el-table-column label="标题名称" prop="title" sortable></el-table-column>
<el-table-column label="类型" prop="category" sortable width="200">
<template scope="{row}">
<dict-tag :options="dict.type.ACTIVITY_QSV_CATEGORY" :value="row.category"></dict-tag>
<el-tag size="mini" :type="getCategoryTagType(row.category)">
{{getCategoryText(row.category)}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="450px">
<el-table-column label="操作" width="450px">
<template scope="{row}">
<el-button size="mini" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="mini" type="primary" @click="openSubject(row.id)">题目设置</el-button>
@@ -61,6 +183,21 @@ layout("/layouts/platform.html"){
}
},
methods: {
getCategoryText(category) {
const options = this.dict && this.dict.type ? (this.dict.type.ACTIVITY_QSV_CATEGORY || []) : []
const item = options.find(item => item.code === category)
return item ? item.name : category
},
getCategoryTagType(category) {
const map = {
SURVEY: "success",
QUIZ: "primary",
VOTE: "danger"
}
return map[category] || "info"
},
// 编辑
openEdit(row) {
this.$refs.basicFormRef.onOpen(row.id)
@@ -80,7 +217,7 @@ layout("/layouts/platform.html"){
cancelButtonText: "取消",
type: "warning"
}).then(() => {
$.post("/platform/qsv/activity/delete", {id}).then((res) => {
$.post("/platform/qsv/bank/delete", {id}).then((res) => {
if (res.code === 0) {
this.$message.success("删除成功")
this.pageData()
@@ -4,7 +4,7 @@
const subjectForm = {
template: /*language=HTML*/ `
<div class="subject-form-dialog">
<div class="subject-form-dialog qsv-subject-editor">
<el-form ref="form" :model="formData" :rules="formRules" label-width="120px">
<div style="min-height:50vh;overflow-y: auto">
<draggable v-model="subjects" handle=".drag-handler">
@@ -192,10 +192,10 @@ const subjectForm = {
</div>
</el-form>
<div style="border-top: 1px solid var(--border-color-lighter);padding: 12px;text-align: right;">
<div class="qsv-subject-footer">
<!-- <el-button @click="visible = false">取消</el-button>-->
<el-button type="primary" icon="el-icon-upload" @click="openTxtImport">
从文本导入
导入题目
</el-button>
<el-button type="primary" icon="el-icon-plus" @click="addSubject">
添加题目
@@ -349,7 +349,9 @@ const subjectForm = {
//从文本导入
openTxtImport() {
this.$refs.txtImportRef.visible = true;
this.$refs.txtImportRef.onOpen(this.activity.category, {
excludeBankId: this.id
});
},
//处理导入的题目
@@ -387,6 +389,32 @@ const subjectForm = {
}
},
style: /*language=CSS*/ `
/deep/ .qsv-subject-editor {
padding-bottom: 72px;
}
/deep/ .qsv-subject-footer {
position: fixed;
left: 0;
width: 100%;
right: 0;
bottom: 0;
z-index: 900;
padding: 12px 24px;
border-top: 1px solid #ebeef5;
background: #ffffff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, .04);
text-align: right;
box-sizing: border-box;
}
@media (max-width: 768px) {
/deep/ .qsv-subject-footer {
left: 0;
width: 100%;
}
}
/*.subject-form-dialog,.subject-form-dialog .el-dialog__body {*/
/* background: rgb(248, 249, 250);*/
/*}*/
File diff suppressed because it is too large Load Diff
@@ -54,7 +54,7 @@ layout("/layouts/platform.html"){
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="管理列表">
<table-tool label="统计列表">
<el-button @click="exportXlsx" icon="el-icon-download" size="small" type="primary" class="mr5">
导出xlsx
</el-button>
@@ -5,7 +5,7 @@ const answer = {
<el-row type="flex" justify="end" class="mb10">
<el-button type="primary" size="small" icon="el-icon-download" @click="exportUserAnswerXlsx">导出xlsx</el-button>
</el-row>
<el-table :data="tableData">
<el-table :data="tableData" v-loading="tableLoading">
<el-table-column label="序号" type="index" width="70"></el-table-column>
<el-table-column v-for="item in tableColumns" :key="item.prop" :label="item.label" :prop="item.prop"></el-table-column>
<el-table-column label="操作" fixed="right" width="100px">
@@ -30,17 +30,24 @@ const answer = {
onOpen(activityId) {
this.activityId = activityId
this.dialogVisible = true
this.tableColumns = []
this.tableData = []
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.pageData()
},
pageData() {
this.pageForm.activityId = this.activityId
this.tableLoading = true
$.post("/platform/qsv/survey/userAnswer", this.pageForm).then((res) => {
if (res.code === 0) {
this.tableColumns = res.data.tableColumns
this.tableData = res.data.tableData.list
this.pageForm.totalCount = res.data.tableData.totalCount
}
}).always(() => {
this.tableLoading = false
})
},
@@ -53,7 +60,7 @@ const answer = {
$.post("/platform/qsv/survey/deleteUserAnswer", { id }).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.pageData()()
this.pageData()
}
})
})
@@ -2,6 +2,42 @@
layout("/layouts/platform.html"){
#-->
<style>
.survey-report-dialog {
margin-top: 40px !important;
height: calc(100vh - 80px);
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.survey-report-dialog .el-dialog__header {
flex: 0 0 auto;
}
.survey-report-dialog .el-dialog__body {
flex: 1;
min-height: 0;
overflow: hidden;
padding-top: 10px;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.survey-report-toolbar {
flex: 0 0 auto;
}
.survey-report-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 8px;
box-sizing: border-box;
}
</style>
<div id="app" v-cloak>
<guava>
<el-card shadow="never">
@@ -24,17 +60,19 @@ layout("/layouts/platform.html"){
</table-tool>
<el-table :data="tableData" ref="tableRef" @sort-change="pageOrder">
<el-table-column label="序号" width="50" type="index" :index="indexMethod"></el-table-column>
<el-table-column label="标题" prop="title" width="320" sortable></el-table-column>
<el-table-column label="标题名称" prop="title" width="320" sortable></el-table-column>
<el-table-column label="类型" prop="category" sortable>
<template scope="{row}">
<dict-tag :options="dict.type.ACTIVITY_QSV_CATEGORY" :value="row.category"></dict-tag>
<el-tag size="mini" :type="getCategoryTagType(row.category)">
{{getCategoryText(row.category)}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="开始时间" prop="startTime" sortable></el-table-column>
<el-table-column label="结束时间" prop="endTime" sortable></el-table-column>
<el-table-column label="操作" fixed="right" width="200px">
<template scope="{row}">
<el-button size="mini" type="primary" @click="$refs.reportRef.onOpen(row.id)">查看分析
<el-button size="mini" type="primary" @click="openReportPage(row.id)">查看分析
</el-button>
<el-button size="mini" type="primary" @click="$refs.answerRef.onOpen(row.id)">查看答卷
</el-button>
@@ -44,22 +82,38 @@ layout("/layouts/platform.html"){
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
<report ref="reportRef"></report>
<answer ref="answerRef"></answer>
</div>
<script nonce="${cspNonce!}">
<!--#include('report.js'){}#-->
<!--#include('answer.js'){}#-->
new Vue({
el: "#app",
dicts: ["ACTIVITY_QSV_CATEGORY"],
components: {report, answer},
components: {answer},
mixins: [initTableMixins],
data() {
return {}
},
methods: {
getCategoryText(category) {
const options = this.dict && this.dict.type ? (this.dict.type.ACTIVITY_QSV_CATEGORY || []) : []
const item = options.find(item => item.code === category)
return item ? item.name : category
},
getCategoryTagType(category) {
const map = {
SURVEY: "success",
QUIZ: "primary",
VOTE: "danger"
}
return map[category] || "info"
},
openReportPage(activityId) {
window.location.href = "/platform/qsv/survey/reportPage?activityId=" + encodeURIComponent(activityId)
},
exportXlsx() {
this.$downLoad("/platform/qsv/survey/exportXlsx", this.pageForm)
}
@@ -1,41 +1,44 @@
const report = {
/*language=HTML*/
template: `
<el-dialog title="分析报告" :visible.sync="dialogVisible" width="50%">
<el-row type="flex" justify="end" class="mb10">
<el-button type="primary" size="small" icon="el-icon-download" @click="exportReportXlsx">导出xlsx</el-button>
</el-row>
<div v-for="(subject,subjectIndex) in subjects" class="subject">
<div>
<div class="title">
第{{subjectIndex+1}}题: {{subject.title}}
<span class="type" v-if="subject.type==='text'">[填空题]</span>
<span class="type" v-else-if="subject.type==='radio'">[单选题]</span>
<span class="type" v-else-if="subject.type==='text'">[多选题]</span>
</div>
<el-dialog title="分析报告" :visible.sync="dialogVisible" width="50%" custom-class="survey-report-dialog">
<div class="survey-report-toolbar">
<el-row type="flex" justify="end" class="mb10">
<el-button type="primary" size="small" icon="el-icon-download" @click="exportReportXlsx">导出xlsx</el-button>
</el-row>
</div>
<div class="survey-report-body">
<div v-for="(subject,subjectIndex) in subjects" class="subject">
<div>
<div class="title">
第{{subjectIndex+1}}题: {{subject.title}}
<span class="type" v-if="subject.type==='text'">[填空题]</span>
<span class="type" v-else-if="subject.type==='radio'">[单选题]</span>
<span class="type" v-else-if="subject.type==='checkbox'">[多选题]</span>
</div>
<div v-if="subject.type==='text'">
{{subject.texts.join('、')}}
</div>
<div v-if="subject.type==='text'">
<el-button type="text" @click="openTextDetail(subject)">查看填写内容</el-button>
</div>
<div v-else-if="subject.type==='radio' || subject.type==='checkbox'">
<el-table :data="subject.options" size="small">
<el-table-column label="选项" prop="text"></el-table-column>
<el-table-column label="小计" prop="selectCount" width="100"></el-table-column>
<el-table-column label="比例" width="500">
<template slot-scope="scope">
<el-progress
:percentage="Math.round(scope.row.selectCount / subject.selectTotal * 100)"></el-progress>
</template>
</el-table-column>
<el-table-column label="详情" width="100px">
<template slot-scope="{row}">
<el-button type="text" @click="openDetail(subject.id,row.id)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div v-else-if="subject.type==='radio' || subject.type==='checkbox'">
<el-table :data="subject.options" size="small">
<el-table-column label="选项" prop="text"></el-table-column>
<el-table-column label="小计" prop="selectCount" width="100"></el-table-column>
<el-table-column label="比例" width="500">
<template slot-scope="scope">
<el-progress
:percentage="Math.round(scope.row.selectCount / subject.selectTotal * 100)"></el-progress>
</template>
</el-table-column>
<el-table-column label="详情" width="100px">
<template slot-scope="{row}">
<el-button type="text" @click="openDetail(subject.id,row.id)">详情</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</div>
@@ -45,9 +48,22 @@ const report = {
<el-table-column label="工号" prop="loginName"></el-table-column>
<el-table-column label="所属工会" prop="unionName"></el-table-column>
<el-table-column label="所属单位" prop="unitName"></el-table-column>
<el-table-column label="补充理由" prop="fillContent" show-overflow-tooltip></el-table-column>
<el-table-column label="选择时间" prop="attemptDate"></el-table-column>
</el-table>
</el-dialog>
<el-dialog :title="textSubject.title || '填写内容'" :visible.sync="textVisible" width="50%" append-to-body>
<el-table :data="textAnswers" size="small">
<el-table-column label="序号" type="index" width="70"></el-table-column>
<el-table-column label="姓名" prop="userName" width="100"></el-table-column>
<el-table-column label="工号" prop="loginName" width="120"></el-table-column>
<el-table-column label="所属工会" prop="unionName" width="140"></el-table-column>
<el-table-column label="所属单位" prop="unitName" width="180" show-overflow-tooltip></el-table-column>
<el-table-column label="填写内容" prop="text" show-overflow-tooltip></el-table-column>
<el-table-column label="提交时间" prop="submitTime" width="160"></el-table-column>
</el-table>
</el-dialog>
</el-dialog>
`,
@@ -59,7 +75,10 @@ const report = {
report: null,
subjects: [],
optionVisible: false,
optionUsers: []
optionUsers: [],
textVisible: false,
textSubject: {},
textAnswers: []
}
},
methods: {
@@ -73,8 +92,6 @@ const report = {
})
},
openDetail(subjectId, optionId) {
console.log(subjectId)
console.log(optionId)
$.post("/platform/qsv/survey/selectOptionUsers", {
activityId: this.activityId,
subjectId,
@@ -87,6 +104,13 @@ const report = {
}
})
},
openTextDetail(subject) {
this.textSubject = subject
this.textAnswers = subject.textAnswers || (subject.texts || []).map((text) => {
return { text }
})
this.textVisible = true
},
exportReportXlsx(){
this.$downLoad("/platform/qsv/survey/exportReportXlsx", { activityId: this.activityId })
}
File diff suppressed because it is too large Load Diff
@@ -20,7 +20,7 @@
<!-- <el-input placeholder="请输入内容" clearable v-model="pageForm.searchKeyword"></el-input>-->
</search-item>
<search-item label="所属工会">
<el-select @change="flushUnits" @clear="flushUnits" clearable filterable
<el-select @change="handleUnionChange" @clear="handleUnionClear" clearable filterable
placeholder="请选择所属工会" style="width: 100%;" v-model="pageForm.unionId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in unions"></el-option>
@@ -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) => {
@@ -4,88 +4,187 @@ layout("/layouts/platform.html"){
<div id="app" v-cloak>
<guava ref="guava">
<template>
<el-card shadow="never">
<common-query ref="commonQueryRef" @search="search"></common-query>
</el-card>
<el-row type="flex" :gutter="20" style="height: calc(100vh - 126px)">
<el-col :span="5">
<el-card shadow="never" style="height: 100%" :body-style="{ height: '100%' }">
<el-input placeholder="请输入关键字进行查找" v-model="filterText" clearable></el-input>
<div style="max-height: calc(100vh - 205px); overflow-y: auto; margin-top: 10px;">
<el-tree
:data="treeData"
ref="treeRef"
node-key="id"
:expand-on-click-node="false"
:props="{
children: 'children',
label: 'name'
}"
default-expand-all
:filter-node-method="filterNode"
highlight-current
@node-click="treeNodeClick"
>
<template slot-scope="{ node, data }">
<span class="el-tree-node__label">
<i class="el-icon-folder-opened" v-if="node.level===1"></i>
<i class="el-icon-folder" v-else></i>
{{node.label}}
</span>
</template>
</el-tree>
</div>
</el-card>
</el-col>
<el-col :span="19">
<el-card shadow="never" v-show="tabActive === 'memberList'">
<common-query ref="commonQueryRef" @search="search" @union-change="queryUnionChange"></common-query>
</el-card>
<el-card shadow="never" v-show="tabActive !== 'memberList' && currentUnionId">
<div style="display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 600; color: #303133;">
<i class="el-icon-office-building"></i>
<span>{{ unionOverview.unionName || '-' }}</span>
<el-tag size="mini" type="primary">基层工会</el-tag>
</div>
<div style="margin-top: 10px; color: #606266; font-size: 13px;">
<span>负责人:{{ unionOverview.chairman || '-' }}</span>
<span style="margin: 0 12px; color: #dcdfe6;">|</span>
<span>联系电话:{{ unionOverview.telephone || unionOverview.chairmanMobile || '-' }}</span>
<span style="margin: 0 12px; color: #dcdfe6;">|</span>
<span>当前会员总数:{{ unionOverview.memberTotal || 0 }} 人</span>
</div>
<el-row :gutter="12" style="margin-top: 18px;">
<el-col :span="6" v-for="card in unionOverviewCards" :key="card.label">
<div style="background: #f5f9ff; border: 1px solid #e6f0ff; border-radius: 6px; padding: 18px 20px;">
<div style="font-size: 28px; line-height: 1; font-weight: 700; color: #0b72c9;">{{ card.value }}</div>
<div style="margin-top: 10px; color: #606266; font-size: 13px;">{{ card.label }}</div>
</div>
</el-col>
</el-row>
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="会员列表">
<el-button type="primary" size="small" icon="el-icon-upload2" @click="openImport">导入会员</el-button>
<el-button
type="primary"
v-if="$auth.hasRoleOr(['SYSADMIN','SCHOOL_UNION_ADMIN'])"
size="small"
icon="el-icon-time"
@click="openArchive"
>
备份历史会员
</el-button>
<el-button type="primary" size="small" icon="el-icon-download" @click="exportMember">会员档案导出</el-button>
<el-popover
placement="bottom"
trigger="click"
width="200">
<el-card shadow="never" class="mt10">
<el-tabs v-model="tabActive" type="card" @tab-click="tabChange">
<el-tab-pane name="memberList">
<span slot="label">
<i class="el-icon-user"></i>
会员列表
</span>
<table-tool label="会员列表">
<el-button type="primary" size="small" icon="el-icon-upload2" @click="openImport">导入会员</el-button>
<el-button
type="primary"
v-if="$auth.hasRoleOr(['SYSADMIN','SCHOOL_UNION_ADMIN'])"
size="small"
icon="el-icon-time"
@click="openArchive"
>
备份历史会员
</el-button>
<el-button type="primary" size="small" icon="el-icon-download" @click="exportMember">会员档案导出</el-button>
<el-popover
placement="bottom"
trigger="click"
width="200">
<div style="padding:4px 0;border-bottom:1px solid #eee;">
<el-button size="mini" @click="checkAll">全选</el-button>
<!-- <el-button size="mini" @click="invertCheck">反选</el-button>-->
</div>
<div style="padding:4px 0;border-bottom:1px solid #eee;">
<el-button size="mini" @click="checkAll">全选</el-button>
<!-- <el-button size="mini" @click="invertCheck">反选</el-button>-->
</div>
<div style="max-height:40vh;overflow-y:auto;padding:6px 0;">
<el-checkbox-group v-model="checkedFields">
<el-checkbox
v-for="f in tableColumns"
:key="f.prop"
:label="f.prop"
style="display:block;margin:6px 0;">
{{ f.label }}
</el-checkbox>
</el-checkbox-group>
</div>
<div style="max-height:40vh;overflow-y:auto;padding:6px 0;">
<el-checkbox-group v-model="checkedFields">
<el-checkbox
v-for="f in tableColumns"
:key="f.prop"
:label="f.prop"
style="display:block;margin:6px 0;">
{{ f.label }}
</el-checkbox>
</el-checkbox-group>
</div>
<el-button
slot="reference"
type="text"
icon="el-icon-s-operation"
size="small"
style="margin-left:12px;">
列设置
</el-button>
</el-popover>
</table-tool>
<el-button
slot="reference"
type="text"
icon="el-icon-s-operation"
size="small"
style="margin-left:12px;">
列设置
</el-button>
</el-popover>
</table-tool>
<el-table :data="tableData" style="width: 100%" row-key="id" @sort-change="pageOrder" :size="tableSize" class="vi-table">
<el-table-column
align="center"
header-align="center"
type="index"
fixed="left"
:index="indexMethod"
label="序号"
width="80px"
></el-table-column>
<el-table-column
show-overflow-tooltip
align="center"
header-align="center"
v-for="column in tableColumns"
v-if="checkedFields.includes(column.prop)"
:label="column.label"
:fixed="column.fixed"
:prop="column.prop"
:sortable="column.sortable"
:width="column.width"
min-width="50"
></el-table-column>
<el-table :data="tableData" style="width: 100%" row-key="id" @sort-change="pageOrder" :size="tableSize" class="vi-table">
<el-table-column
align="center"
header-align="center"
type="index"
fixed="left"
:index="indexMethod"
label="序号"
width="80px"
></el-table-column>
<el-table-column
show-overflow-tooltip
align="center"
header-align="center"
v-for="column in tableColumns"
v-if="checkedFields.includes(column.prop)"
:label="column.label"
:fixed="column.fixed"
:prop="column.prop"
:sortable="column.sortable"
:width="column.width"
min-width="50"
></el-table-column>
<el-table-column prop="userOnline" align="center" header-align="center" label="操作" fixed="right" width="100px">
<template scope="scope">
<el-button size="mini" type="primary" @click="openView(scope.row.id)">查看</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
<el-table-column prop="userOnline" align="center" header-align="center" label="操作" fixed="right" width="100px">
<template scope="scope">
<el-button size="mini" type="primary" @click="openView(scope.row.id)">查看</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-tab-pane>
<el-tab-pane name="branchUnionUserManage">
<span slot="label">
<i class="el-icon-school"></i>
分工会干部
</span>
<sys-union-branch-union-user-manage
v-if="currentUnionId"
ref="branchUnionUserManageRef"
:union_id="currentUnionId"
></sys-union-branch-union-user-manage>
<el-empty v-else description="请选择一个分工会"></el-empty>
</el-tab-pane>
<el-tab-pane name="branchUnionPartUnitManage">
<span slot="label">
<i class="el-icon-school"></i>
组成单位
</span>
<sys-union-branch-union-part-unit-manage
v-if="currentUnionId"
ref="branchUnionPartUnitManageRef"
:union_id="currentUnionId"
></sys-union-branch-union-part-unit-manage>
<el-empty v-else description="请选择一个分工会"></el-empty>
</el-tab-pane>
<el-tab-pane name="branchUnionGroup">
<span slot="label">
<i class="el-icon-school"></i>
工会小组
</span>
<sys-union-branch-union-group-manage
v-if="currentUnionId"
ref="branchUnionGroupRef"
:union_id="currentUnionId"
></sys-union-branch-union-group-manage>
<el-empty v-else description="请选择一个分工会"></el-empty>
</el-tab-pane>
</el-tabs>
</el-card>
</el-col>
</el-row>
</template>
<template #view>
@@ -109,12 +208,21 @@ layout("/layouts/platform.html"){
<!--#include("archiveMember.js"){}#-->
<!--#include("../common/commonQuery.js"){}#-->
<!--#include("../../common/info/memberInfo.js"){}#-->
<!--#include("/platform/sys/union/branchUnionUserManage.js"){}#-->
<!--#include("/platform/sys/union/branchUnionPartUnitManage.js"){}#-->
<!--#include("/platform/sys/union/branchUnionGroupManage.js"){}#-->
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
filterText: null,
unionRootId: "member-union-root",
treeData: [],
currentTreeData: null,
tabActive: "memberList",
unionOverview: {},
pageForm: {},
tableColumns: [
{ prop: "loginname", label: "工号", width: 120, fixed: "left"},
@@ -122,17 +230,17 @@ layout("/layouts/platform.html"){
{ prop: "sex", label: "性别", width: 120},
{ prop: "birthday", label: "出生年月", width: 120, sortable: true },
{ prop: "nationality", label: "国籍", width: 120, sortable: true, checked: 0 },
{ prop: "nation", label: "民族", width: 120, sortable: true},
// { prop: "nation", label: "民族", width: 120, sortable: true},
{ prop: "mobile", label: "联系电话", width: 120 },
{ prop: "political", label: "政治面貌", width: 120, sortable: true},
// { prop: "political", label: "政治面貌", width: 120, sortable: true},
{ prop: "userState", label: "在职状态", width: 120, sortable: true },
{ prop: "personType", label: "人员类型", width: 120, sortable: true, checked: 0 },
// { prop: "preparedBy", label: "编制类别", width: 120, sortable: true },
{ prop: "postDoctoralJoinDate", label: "进站时间", sortable: true, checked: 0 },
{ prop: "unionName", label: "所属工会", width: 120, sortable: true },
{ prop: "unitName", label: "所属单位", width: 120, sortable: true },
{ prop: "aidFundMemberUserType", label: "人员分类", width: 120, sortable: true },
{ prop: "userAttribute", label: "人员属性", width: 120, sortable: true },
// { prop: "aidFundMemberUserType", label: "人员分类", width: 120, sortable: true },
// { prop: "userAttribute", label: "人员属性", width: 120, sortable: true },
{ prop: "idCardType", label: "证件类型", width: 120, sortable: true, checked: 0 },
{ prop: "idCard", label: "证件号码", width: 120, sortable: true, checked: 0 },
{ prop: "marriage", label: "婚姻状况", width: 120, sortable: true, checked: 0 },
@@ -154,11 +262,19 @@ layout("/layouts/platform.html"){
importDialog: false
}
},
watch: {
filterText(val) {
this.$refs.treeRef.filter(val)
}
},
components: {
"file-import": httpVueLoader("/components/plugins/sysImport/index.vue?v=" + new Date().getTime()),
"archive-member": ARCHIVE_MEMBER,
"member-info": MEMBER_INFO,
"common-query": COMMON_QUERY,
"sys-union-branch-union-user-manage": branchUnionUserManage,
"sys-union-branch-union-part-unit-manage": branchUnionPartUnitManage,
"sys-union-branch-union-group-manage": branchUnionGroupManage,
},
methods: {
openImport() {
@@ -188,6 +304,137 @@ layout("/layouts/platform.html"){
this.$refs.guava.view(() => {
this.$refs.infoRef.onOpen(userId)
})
},
initDefaultView() {
this.tabActive = "memberList"
this.currentTreeData = null
this.unionOverview = {}
this.pageForm = {
...this.pageForm,
unionId: "",
unitId: null
}
},
treeNodeClick(data) {
const unionId = data && data.type === "union" ? data.id : ""
this.currentTreeData = data
if (this.$refs.commonQueryRef) {
this.$refs.commonQueryRef.setUnionFromTree(unionId)
this.$nextTick(() => {
if (this.tabActive === "memberList") {
this.$refs.commonQueryRef.doSearch()
} else {
this.loadUnionOverview()
this.refreshCurrentTab()
}
})
} else {
this.pageForm = {
...this.pageForm,
unionId,
unitId: null
}
if (this.tabActive === "memberList") {
this.pageData()
} else {
this.loadUnionOverview()
this.refreshCurrentTab()
}
}
},
queryUnionChange(unionId) {
const nodeKey = unionId || this.unionRootId
this.currentTreeData = this.findTreeNode(this.treeData, nodeKey)
this.setCurrentTreeNode(nodeKey)
this.$nextTick(() => {
if (this.tabActive !== "memberList") {
this.loadUnionOverview()
this.refreshCurrentTab()
}
})
},
filterNode(value, data) {
if (!value) return true
return data.name.indexOf(value) !== -1
},
findTreeNode(list, id) {
for (let i = 0; i < (list || []).length; i++) {
const item = list[i]
if (item.id === id) {
return item
}
const child = this.findTreeNode(item.children, id)
if (child) {
return child
}
}
return null
},
getTreeData() {
const hasAdmin = this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN", "SCHOOL_UNION_MEMBER_ADMIN"])
const unionId = hasAdmin ? null : this.$store.state.user.union.id
this.$businessTool.listUnion(unionId).then((data) => {
this.treeData = [{
id: this.unionRootId,
name: "工会委员会",
type: "root",
children: (data || []).map((item) => ({
id: item.id,
name: item.name,
type: "union"
}))
}]
this.$nextTick(() => {
this.currentTreeData = this.treeData[0]
this.setCurrentTreeNode(this.unionRootId)
if (this.tabActive !== "memberList") {
this.tabActive = "memberList"
}
})
})
},
setCurrentTreeNode(nodeKey) {
this.$nextTick(() => {
if (this.$refs.treeRef) {
this.$refs.treeRef.setCurrentKey(nodeKey)
}
})
},
tabChange(tab) {
this.$nextTick(() => {
if (tab.name === "memberList") {
if (this.$refs.commonQueryRef) {
this.$refs.commonQueryRef.doSearch()
} else {
this.pageData()
}
} else {
this.loadUnionOverview()
this.refreshCurrentTab(tab.name)
}
})
},
loadUnionOverview() {
if (!this.currentUnionId) {
this.unionOverview = {}
return
}
this.$axios.post(loc() + "/unionOverview", { unionId: this.currentUnionId }).then((data) => {
if (data.code === 0) {
this.unionOverview = data.data || {}
} else {
this.$message.error(data.msg)
}
})
},
refreshCurrentTab(tabName = this.tabActive) {
if (tabName === "memberList" || !this.currentUnionId) {
return
}
const ref = this.$refs[tabName + "Ref"]
if (ref && ref.doSearch) {
ref.doSearch()
}
},
search(pageForm) {
this.pageForm = {
@@ -227,10 +474,23 @@ layout("/layouts/platform.html"){
computed: {
showColumns() {
return this.tableColumns.filter(c => this.checkedFields.includes(c.prop));
}
},
currentUnionId() {
return this.currentTreeData && this.currentTreeData.type === "union" ? this.currentTreeData.id : ""
},
unionOverviewCards() {
return [
{ label: "会员总数", value: this.unionOverview.memberTotal || 0 },
{ label: "教职工数", value: this.unionOverview.staffTotal || 0 },
{ label: "男性", value: this.unionOverview.maleTotal || 0 },
{ label: "女性", value: this.unionOverview.femaleTotal || 0 },
]
}
},
created() {
this.checkedFields = this.tableColumns.filter(c => c.checked !== 0).map(c => c.prop);
this.initDefaultView()
this.getTreeData()
this.pageData()
}
})
@@ -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 : {}
}
})
})
@@ -0,0 +1,872 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<div id="app" v-cloak class="vote-page">
<van-nav-bar :title="pageTitle" left-arrow left-text="返回" @click-left="historyBack" placeholder fixed></van-nav-bar>
<div class="vote-header">
<h1>{{activity.title}}</h1>
<div class="vote-stat-box">
<div class="vote-stat-item">
<strong>{{voteStats.optionCount || optionList.length}}</strong>
<span>参与选项</span>
</div>
<div class="vote-stat-item">
<strong>{{voteStats.totalParticipants || 0}}</strong>
<span>投票总人数</span>
</div>
<button class="vote-rank-btn" @click="rankShow = true">
<i class="fa fa-bar-chart"></i>
<span>排行榜</span>
<van-icon name="arrow"></van-icon>
</button>
</div>
</div>
<div class="vote-countdown" v-if="!isEnd">
距离投票结束还有:
<b>{{countdown.days}}</b>
<b>{{countdown.hours}}</b>
<b>{{countdown.minutes}}</b>
<b>{{countdown.seconds}}</b>
</div>
<div class="vote-countdown" v-else>投票已结束</div>
<div class="vote-search">
<van-search
v-model="keyword"
placeholder="请输入选项编号/名称"
clearable
shape="square"
background="transparent">
</van-search>
<button @click="noop">搜索</button>
</div>
<div class="vote-subject" v-if="currentSubject">
<div class="vote-subject-title">
<span>*</span>
{{currentSubject.title}}
<em v-if="currentSubject.type === 'checkbox' && currentSubject.maxMulti">
【请选择1-{{currentSubject.maxMulti}}项,已选择{{selectedOptionIds.length}}项】
</em>
<em v-else>【请选择{{currentSubject.type === 'radio' ? '1' : '至少1'}}项】</em>
</div>
<div :class="getVoteOptionListClass(currentSubject)">
<div
v-for="(option, index) in filteredOptions"
:key="option.id"
:class="['vote-option-card', isOptionSelected(option.id) ? 'active' : '']"
@click="toggleOption(option)">
<div class="vote-option-img" v-if="option.imgUrl">
<img :src="option.imgUrl" alt="">
</div>
<div class="vote-option-img vote-option-placeholder" v-else>
<span>{{index + 1}}</span>
</div>
<div class="vote-option-info">
<input
class="vote-option-control"
:type="currentSubject.type === 'radio' ? 'radio' : 'checkbox'"
:checked="isOptionSelected(option.id)"
:disabled="answerRecord.isFinish || isEnd"
@click.stop.prevent="toggleOption(option)">
<div class="vote-option-text">
<strong>{{getOptionVotes(option.id)}}票({{getOptionPercent(option.id)}}%)</strong>
<span>{{option.text}}</span>
</div>
<button
class="vote-option-detail-btn"
v-if="hasOptionDetail(option)"
@click.stop="openOptionDetail(option)">
详情
</button>
</div>
</div>
</div>
</div>
<van-empty v-if="!currentSubject" description="暂无投票题目"></van-empty>
<div class="vote-bottom">
<van-button
type="primary"
block
color="#ff4d00"
:disabled="answerRecord.isFinish || isEnd"
@click="onSubmit">
{{answerRecord.isFinish ? '已投票' : '投票'}}
</van-button>
</div>
<van-action-sheet v-model="rankShow" title="排行榜">
<div class="vote-rank-list">
<div v-for="(item, index) in voteStats.rankList || []" :key="item.optionId" class="vote-rank-item">
<span class="vote-rank-index">{{index + 1}}</span>
<div class="vote-rank-name">{{item.text}}</div>
<div class="vote-rank-votes">{{item.votes}}票</div>
</div>
</div>
</van-action-sheet>
<div class="vote-detail-page" v-if="detailVisible && detailOption">
<div class="vote-detail-hero">
<h2>{{activity.title}}</h2>
<h3>》 {{detailOption.text}} 《</h3>
<div class="vote-detail-stats">
<span><i class="fa fa-th-large"></i> {{getOptionVotes(detailOption.id)}}票</span>
<span><i class="fa fa-institution"></i> 第{{getOptionRank(detailOption.id)}}名</span>
</div>
</div>
<div class="vote-countdown vote-detail-countdown" v-if="!isEnd">
距离投票结束还有:
<b>{{countdown.days}}</b>
<b>{{countdown.hours}}</b>
<b>{{countdown.minutes}}</b>
<b>{{countdown.seconds}}</b>
</div>
<div class="vote-countdown vote-detail-countdown" v-else>投票已结束</div>
<div class="vote-detail-section">
<h4>投票规则</h4>
<p><i class="fa fa-play-circle"></i> 开始时间:{{activity.startTime || '-'}}</p>
<p><i class="fa fa-stop-circle"></i> 结束时间:{{activity.endTime || '-'}}</p>
<a v-if="detailOption.link" :href="detailOption.link" target="_blank">查看规则详情</a>
</div>
<div class="vote-detail-section">
<h4>详情介绍</h4>
<div class="vote-detail-content" v-if="detailOption.description" v-html="detailOption.description"></div>
<div class="vote-detail-empty" v-else>暂无详情</div>
</div>
<div class="vote-detail-bottom">
<button @click="closeOptionDetail">返回</button>
<button
class="primary"
:disabled="answerRecord.isFinish || isEnd"
@click="voteFromDetail">
{{answerRecord.isFinish ? '已投票' : '投票'}}
</button>
</div>
</div>
</div>
<script nonce="${cspNonce!}">
const vue = new Vue({
el: "#app",
data() {
return {
id: GetQueryString("id"),
activity: {},
subjects: [],
answerRecordId: null,
answerRecord: {},
voteStats: {},
keyword: "",
selectedOptionIds: [],
countdown: {
days: 0,
hours: 0,
minutes: 0,
seconds: 0
},
countdownTimer: null,
rankShow: false,
detailVisible: false,
detailOption: null
}
},
computed: {
currentSubject() {
return this.subjects.length > 0 ? this.subjects[0] : null
},
optionList() {
return this.currentSubject && this.currentSubject.options ? this.currentSubject.options : []
},
filteredOptions() {
const keyword = (this.keyword || "").trim()
if (!keyword) {
return this.optionList
}
return this.optionList.filter((option, index) => {
return String(index + 1).indexOf(keyword) > -1 || (option.text || "").indexOf(keyword) > -1
})
},
pageTitle() {
return this.getCategoryPageTitle(this.activity && this.activity.category, "投票")
},
isEnd() {
if (!this.activity || !this.activity.endTime) {
return false
}
return this.$moment().unix() > this.$moment(this.activity.endTime).unix()
}
},
methods: {
getCategoryPageTitle(category, defaultTitle) {
const titleMap = {
QUIZ: "在线答题",
SURVEY: "问卷调查",
VOTE: "投票"
}
return titleMap[category] || defaultTitle
},
noop() {
},
getVoteOptionColumns(subject) {
if (!subject || subject.optionLayout !== "HORIZONTAL") {
return 1
}
return Number(subject.optionColumns) === 3 ? 3 : 2
},
getVoteOptionListClass(subject) {
return "vote-option-list vote-option-list-" + this.getVoteOptionColumns(subject)
},
hasOptionDetail(option) {
return !!(option && ((option.description && option.description.trim()) || option.link))
},
openOptionDetail(option) {
this.detailOption = option
this.detailVisible = true
},
closeOptionDetail() {
this.detailVisible = false
this.detailOption = null
},
getOptionRank(optionId) {
const list = this.voteStats.rankList || []
const index = list.findIndex((item) => String(item.optionId) === String(optionId))
return index > -1 ? index + 1 : "-"
},
voteFromDetail() {
if (!this.detailOption || this.answerRecord.isFinish || this.isEnd) {
return
}
if (!this.isOptionSelected(this.detailOption.id)) {
this.toggleOption(this.detailOption)
}
if (this.isOptionSelected(this.detailOption.id)) {
this.onSubmit()
}
},
listSubjects() {
this.$axios.post("/platform/h5/qsv/vote/subjects", {activityId: this.id}).then((res) => {
if (res.code === 0) {
this.activity = res.data.activity || {}
this.subjects = res.data.subjects || []
this.answerRecordId = res.data.answerRecordId
this.answerRecord = res.data.answerRecord || {}
this.voteStats = res.data.voteStats || {}
this.initAnswer()
this.startCountdown()
} else {
this.$dialog.alert({
title: "提示",
message: res.msg
}).then(() => {
pjaxReplace("/platform/h5/qsv")
})
}
})
},
initAnswer() {
if (!this.currentSubject || !this.answerRecord || !this.answerRecord.extJson) {
this.selectedOptionIds = []
return
}
const answer = this.answerRecord.extJson[this.currentSubject.id]
this.selectedOptionIds = answer && answer.optionIds ? answer.optionIds.map(String) : []
},
startCountdown() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
}
this.updateCountdown()
this.countdownTimer = setInterval(() => {
this.updateCountdown()
}, 1000)
},
updateCountdown() {
if (!this.activity || !this.activity.endTime) {
return
}
const diff = Math.max(0, this.$moment(this.activity.endTime).unix() - this.$moment().unix())
this.countdown.days = Math.floor(diff / 86400)
this.countdown.hours = Math.floor((diff % 86400) / 3600)
this.countdown.minutes = Math.floor((diff % 3600) / 60)
this.countdown.seconds = diff % 60
},
toggleOption(option) {
if (this.answerRecord.isFinish || this.isEnd) {
return
}
const optionId = String(option.id)
const index = this.selectedOptionIds.indexOf(optionId)
if (this.currentSubject.type === "radio") {
this.selectedOptionIds = index > -1 ? [] : [optionId]
return
}
if (index > -1) {
this.selectedOptionIds.splice(index, 1)
return
}
if (this.currentSubject.maxMulti && this.selectedOptionIds.length >= this.currentSubject.maxMulti) {
this.$toast("最多选择" + this.currentSubject.maxMulti + "项")
return
}
this.selectedOptionIds.push(optionId)
},
isOptionSelected(optionId) {
return this.selectedOptionIds.indexOf(String(optionId)) > -1
},
getOptionStats(optionId) {
return this.voteStats.optionStats && this.voteStats.optionStats[optionId]
? this.voteStats.optionStats[optionId]
: {votes: 0, percent: 0}
},
getOptionVotes(optionId) {
return this.getOptionStats(optionId).votes || 0
},
getOptionPercent(optionId) {
const percent = this.getOptionStats(optionId).percent || 0
return Number(percent).toFixed(2)
},
previewOptionImg(img) {
vant.ImagePreview([img])
},
onSubmit() {
if (!this.currentSubject) {
return
}
if (this.isEnd) {
this.$toast("投票已结束")
return
}
if (this.selectedOptionIds.length === 0) {
this.$toast.fail("请选择投票选项")
return
}
this.$axios.post("/platform/h5/qsv/vote/submitAnswer", {
answer: JSON.stringify({
activityId: this.id,
answerRecordId: this.answerRecordId,
subjects: [
{
id: this.currentSubject.id,
userSelectOptionIds: this.selectedOptionIds
}
]
})
}).then((res) => {
if (res.code === 0) {
this.$toast.success(res.msg)
this.listSubjects()
}
})
},
historyBack() {
pjaxReplace("/platform/h5/qsv")
}
},
created() {
if (this.id) {
this.listSubjects()
}
},
beforeDestroy() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
}
}
})
</script>
<style>
.vote-page {
min-height: 100vh;
background: #fff1e9;
padding-bottom: calc(20px + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.vote-header {
padding: 36px 20px 28px;
background: radial-gradient(circle at right top, #fff 0, #fff7f2 38%, #fffdfb 100%);
}
.vote-header h1 {
margin: 0 0 34px;
color: #ff4d00;
font-size: 22px;
text-align: center;
font-weight: 700;
}
.vote-stat-box {
height: 106px;
border: 1px solid #ff6a2b;
border-radius: 8px;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
position: relative;
background: rgba(255, 255, 255, .62);
}
.vote-stat-box:before,
.vote-stat-box:after {
content: "";
position: absolute;
top: -1px;
width: 14px;
height: 14px;
background: #fff7f2;
}
.vote-stat-box:before {
left: -1px;
border-right: 1px solid #ff6a2b;
border-bottom: 1px solid #ff6a2b;
border-radius: 0 0 8px 0;
}
.vote-stat-box:after {
right: -1px;
border-left: 1px solid #ff6a2b;
border-bottom: 1px solid #ff6a2b;
border-radius: 0 0 0 8px;
}
.vote-stat-item {
text-align: center;
color: #999;
}
.vote-stat-item strong {
display: block;
margin-bottom: 8px;
color: #ff4d00;
font-size: 24px;
font-weight: 500;
}
.vote-stat-item span {
font-size: 13px;
}
.vote-rank-btn {
justify-self: center;
min-width: 82px;
height: 30px;
border: 1px solid #ff6a2b;
border-radius: 4px;
background: #fff;
color: #ff4d00;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 13px;
}
.vote-rank-btn .fa {
margin-right: 8px;
font-size: 16px;
}
.vote-countdown {
padding: 12px 16px;
color: #ff4d00;
font-size: 13px;
background: #fff7f2;
}
.vote-countdown b {
display: inline-block;
min-width: 22px;
height: 20px;
margin: 0 2px;
border-radius: 2px;
background: #ff5a1f;
color: #fff;
text-align: center;
line-height: 20px;
font-size: 14px;
}
.vote-search {
display: flex;
align-items: center;
margin: 8px 4px 0;
background: #fff;
border: 1px solid #f0f0f0;
}
.vote-search .van-search {
flex: 1;
min-width: 0;
padding: 0;
}
.vote-search button {
width: 58px;
height: 44px;
border: 0;
border-left: 1px solid #f0f0f0;
background: #fff;
color: #ff4d00;
}
.vote-subject {
padding: 14px 15px 18px;
}
.vote-subject-title {
margin-bottom: 12px;
color: #333;
font-size: 16px;
line-height: 1.55;
}
.vote-subject-title span {
color: #e61f1f;
}
.vote-subject-title em {
color: #666;
font-style: normal;
}
.vote-option-list {
display: block;
}
.vote-option-list-2,
.vote-option-list-3 {
display: grid;
gap: 12px;
}
.vote-option-list-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.vote-option-list-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.vote-option-card {
margin-bottom: 14px;
background: #fff;
border: 1px solid transparent;
box-sizing: border-box;
transition: border-color .2s, background-color .2s;
}
.vote-option-list-2 .vote-option-card,
.vote-option-list-3 .vote-option-card {
min-width: 0;
margin-bottom: 0;
}
.vote-option-card.active {
border-color: #ff4d00;
background: #fff8f4;
}
.vote-option-img {
position: relative;
width: 100%;
height: 172px;
border-radius: 6px;
overflow: hidden;
background: #f4f4f4;
}
.vote-option-list-2 .vote-option-img {
height: 126px;
}
.vote-option-list-3 .vote-option-img {
height: 92px;
}
.vote-option-img img {
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
display: block;
}
.vote-option-img .van-icon {
position: absolute;
right: 8px;
top: 8px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(255, 255, 255, .78);
color: #5a8ee8;
text-align: center;
line-height: 18px;
}
.vote-option-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: #ff7b3c;
font-size: 42px;
font-weight: 700;
background: linear-gradient(135deg, #fff7f2, #ffe4d7);
}
.vote-option-info {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
min-height: 50px;
box-sizing: border-box;
}
.vote-option-control {
flex-shrink: 0;
width: 20px;
height: 20px;
margin: 0;
accent-color: #ff4d00;
cursor: pointer;
}
.vote-option-control:disabled {
cursor: not-allowed;
}
.vote-option-text {
flex: 1;
min-width: 0;
}
.vote-option-text strong {
display: block;
margin-bottom: 5px;
color: #ff4d00;
font-size: 14px;
font-weight: 600;
}
.vote-option-text span {
display: block;
color: #333;
font-size: 15px;
line-height: 20px;
overflow-wrap: anywhere;
}
.vote-option-detail-btn {
flex-shrink: 0;
align-self: center;
margin: 0;
padding: 4px 0 4px 8px;
border: 0;
background: transparent;
color: #ff4d00;
font-size: 14px;
line-height: 22px;
}
.vote-bottom {
position: relative;
width: auto;
margin: 0 15px;
padding: 14px 19px calc(14px + env(safe-area-inset-bottom));
background: #fff1e9;
z-index: 1;
box-sizing: border-box;
}
.vote-rank-list {
padding: 12px;
background: #f8f8f8;
}
.vote-rank-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
margin-bottom: 8px;
border-radius: 8px;
background: #fff;
}
.vote-rank-index {
width: 24px;
height: 24px;
border-radius: 50%;
background: #ff5a1f;
color: #fff;
text-align: center;
line-height: 24px;
}
.vote-rank-name {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.vote-rank-votes {
color: #ff4d00;
font-weight: 600;
}
.vote-detail-page {
position: fixed;
inset: 0;
z-index: 1000;
overflow-y: auto;
padding-bottom: calc(88px + env(safe-area-inset-bottom));
background: #f5f8fb;
box-sizing: border-box;
-webkit-overflow-scrolling: touch;
}
.vote-detail-hero {
padding: 30px 18px 28px;
min-height: 150px;
color: #fff;
text-align: center;
background: linear-gradient(135deg, #ff4d00 0%, #ff6a1f 48%, #f24a00 100%);
box-sizing: border-box;
}
.vote-detail-hero h2 {
margin: 0 0 24px;
font-size: 21px;
font-weight: 700;
}
.vote-detail-hero h3 {
margin: 0 0 28px;
font-size: 20px;
font-weight: 700;
}
.vote-detail-stats {
display: flex;
justify-content: space-around;
font-size: 15px;
font-weight: 700;
}
.vote-detail-countdown {
margin-bottom: 10px;
}
.vote-detail-section {
margin: 10px 0;
padding: 16px;
background: #fff;
}
.vote-detail-section h4 {
margin: 0 0 14px;
color: #222;
font-size: 16px;
}
.vote-detail-section p {
margin: 10px 0;
color: #333;
font-size: 14px;
}
.vote-detail-section i {
margin-right: 8px;
color: #ff4d00;
}
.vote-detail-section a {
color: #ff4d00;
font-size: 14px;
}
.vote-detail-content {
color: #333;
font-size: 14px;
line-height: 1.7;
overflow-wrap: anywhere;
}
.vote-detail-content img {
max-width: 100%;
border-radius: 6px;
}
.vote-detail-empty {
color: #999;
font-size: 14px;
}
.vote-detail-bottom {
position: fixed;
left: 50%;
bottom: 0;
width: 100%;
max-width: 750px;
transform: translateX(-50%);
display: grid;
grid-template-columns: 1fr 1fr;
min-height: calc(54px + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
border-top: 1px solid #eee;
background: #fff;
z-index: 1001;
box-sizing: border-box;
}
.vote-detail-bottom button {
border: 0;
background: #fff;
color: #ff4d00;
font-size: 16px;
}
.vote-detail-bottom button.primary {
background: #ff4d00;
color: #fff;
}
.vote-detail-bottom button:disabled {
background: #f3f3f3;
color: #bbb;
}
</style>
<!--#
}
#-->