feat: 显示逻辑 (#107)

This commit is contained in:
dayou 2024-05-10 22:57:21 +08:00 committed by GitHub
parent 412fc75cfe
commit 990126f976
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 1511 additions and 307 deletions

View File

@ -48,5 +48,8 @@
"contentConf": { "contentConf": {
"opacity": 100 "opacity": 100
} }
},
"logicConf": {
"showLogicConf": []
} }
} }

6
web/components.d.ts vendored
View File

@ -18,6 +18,8 @@ declare module 'vue' {
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElIconCheck: typeof import('@element-plus/icons-vue')['Check']
ElIconLoading: typeof import('@element-plus/icons-vue')['Loading']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
@ -44,9 +46,13 @@ declare module 'vue' {
IEpCirclePlus: typeof import('~icons/ep/circle-plus')['default'] IEpCirclePlus: typeof import('~icons/ep/circle-plus')['default']
IEpClose: typeof import('~icons/ep/close')['default'] IEpClose: typeof import('~icons/ep/close')['default']
IEpCopyDocument: typeof import('~icons/ep/copy-document')['default'] IEpCopyDocument: typeof import('~icons/ep/copy-document')['default']
IEpDelete: typeof import('~icons/ep/delete')['default']
IEpLoading: typeof import('~icons/ep/loading')['default'] IEpLoading: typeof import('~icons/ep/loading')['default']
IEpMinus: typeof import('~icons/ep/minus')['default']
IEpPlus: typeof import('~icons/ep/plus')['default']
IEpQuestionFilled: typeof import('~icons/ep/question-filled')['default'] IEpQuestionFilled: typeof import('~icons/ep/question-filled')['default']
IEpRank: typeof import('~icons/ep/rank')['default'] IEpRank: typeof import('~icons/ep/rank')['default']
IEpRankMinus: typeof import('~icons/ep/rank-minus')['default']
IEpRemove: typeof import('~icons/ep/remove')['default'] IEpRemove: typeof import('~icons/ep/remove')['default']
IEpSearch: typeof import('~icons/ep/search')['default'] IEpSearch: typeof import('~icons/ep/search')['default']
IEpSort: typeof import('~icons/ep/sort')['default'] IEpSort: typeof import('~icons/ep/sort')['default']

View File

@ -23,13 +23,15 @@
"element-plus": "^2.7.0", "element-plus": "^2.7.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"moment": "^2.29.4", "moment": "^2.29.4",
"nanoid": "^5.0.7",
"node-forge": "^1.3.1", "node-forge": "^1.3.1",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"vue": "^3.4.15", "vue": "^3.4.15",
"vue-router": "^4.2.5", "vue-router": "^4.2.5",
"vuedraggable": "^4.1.0", "vuedraggable": "^4.1.0",
"vuex": "^4.0.2", "vuex": "^4.0.2",
"xss": "^1.0.14" "xss": "^1.0.14",
"yup": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/ep": "^1.1.15", "@iconify-json/ep": "^1.1.15",

View File

@ -1,68 +0,0 @@
<template>
<div class="editor-v2">
<RichEditor :modelValue="realData" @input="handleChange" @blur="handleBlur" />
</div>
</template>
<script>
import RichEditor from './RichEditor.vue'
export default {
components: {
RichEditor
// ReadOnly,
},
props: {
realData: {
type: String,
default: () => ''
},
questionDataList: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {},
watch: {},
unmounted() {
this.$emit('onDestroy')
},
methods: {
handleChange(v) {
this.$emit('change', v)
},
handleBlur(v) {
this.$emit('blur', v)
}
}
}
</script>
<style lang="scss" scoped>
.editor-v2 {
width: 100%;
display: flex;
.operation {
display: flex;
position: relative;
left: 8px;
top: 2px;
}
}
.RichEditor {
width: 95.5%;
display: inline-block;
margin-right: 3px;
margin-left: -7px;
}
.icon {
margin-left: 4px;
font-size: 17px;
color: #888;
cursor: pointer;
}
.icon.delete:hover {
color: red;
}
</style>

View File

@ -1,90 +0,0 @@
<template>
<div class="read-only" :style="getStyle">
<div v-html="getHtml"></div>
</div>
</template>
<script>
import { filterXSS } from '@/common/xss'
export default {
name: 'ReadOnly',
props: {
realData: {
type: String,
default: () => ''
},
viewData: {
type: String,
default: () => ''
},
tag: {
tyle: String,
default: () => ''
},
border: {
tyle: Boolean,
default: () => false
},
defaultStyle: {
tyle: Boolean,
default: () => false
}
},
data() {
return {}
},
computed: {
tagHtml() {
return this.tag
? `<span contenteditable="false" style="
border: 1px solid #fa881a;
font-size: 0.18rem;
height: 0.4rem;
line-height: 0.4rem;
display: inline-block;
color: #fa881a;
padding: 0 0.16rem;
margin-left: 0.16rem;
border-radius: 0 0.06rem;
background: rgba(250,136,26,0.1);
">${this.tag}</span>`
: ''
},
getHtml() {
const title = filterXSS(this.viewData)
if (!this.tag) return title
let html = this.isRichText(title) ? title : `<p>${title}</p>`
const index = html.lastIndexOf('</p>')
if (this.viewData.indexOf(this.tagHtml) < 0) {
html = html.slice(0, index) + this.tagHtml + html.slice(index)
}
return html
},
getStyle() {
let style = ''
if (this.border) {
style += 'border:1px solid #c8c9cd;padding:10px;'
}
if (this.defaultStyle) {
style += 'color: #6e707c;font-size: 12px;'
}
return style
}
},
unmounted() {
this.$emit('onDestroy')
},
methods: {
isRichText(str) {
return /^<p[\s\S]*>[\s\S]*<\/p>$/.test(str)
}
}
}
</script>
<style lang="scss" scoped>
.read-only {
width: 100%;
min-height: 20px;
padding: 0 10px;
word-break: break-all;
}
</style>

View File

@ -0,0 +1,12 @@
export type BasicOperator = 'in' | 'eq' | 'neq' | 'nin' | 'gt';
// in包含, 选择了,任一
// eq: 等于,选择了,全部
// nin: 不包含,不选择,任一
// neq不等于不选择全部可以实现“填写了”
export type FieldTypes = string | string[];
// 定义事实对象类型
export type Fact = {
[key: string]: any;
};

View File

@ -0,0 +1,139 @@
import { nanoid } from 'nanoid';
import * as yup from 'yup'
import { type BasicOperator, type FieldTypes } from './BasicType'
export function generateID(prefix = 'r') {
return `${prefix}-${nanoid(5)}`
}
// 定义条件规则类
export class ConditionNode {
id: string = '';
public field: string = '';
public operator: BasicOperator = 'in';
public value: FieldTypes = []
constructor(field: string = '', operator: BasicOperator = 'in', value: FieldTypes = []) {
this.field = field;
this.operator = operator;
this.value = value;
this.id = generateID('c')
}
setField(field: string) {
this.field = field;
}
setOperator(operator: BasicOperator) {
this.operator = operator;
}
setValue(value: FieldTypes) {
this.value = value;
}
}
export class RuleNode {
id: string = '';
conditions: ConditionNode[] = []
scope: string = 'question'
target: string = ''
constructor(scope:string = 'question', target: string = '') {
this.id = generateID('r')
this.scope = scope
this.target = target
}
setTarget(value: string) {
this.target = value
}
addCondition(condition: ConditionNode) {
this.conditions.push(condition);
}
removeCondition(id: string) {
this.conditions = this.conditions.filter(v => v.id !== id);
}
findCondition(conditionId: string) {
return this.conditions.find(condition => condition.id === conditionId);
}
}
export class RuleBuild {
rules: RuleNode[] = [];
constructor() {
this.rules = [];
}
// 添加条件规则到规则引擎中
addRule(rule: RuleNode) {
this.rules.push(rule);
}
removeRule(ruleId: string) {
this.rules = this.rules.filter(rule => rule.id !== ruleId);
}
findRule(ruleId: string) {
return this.rules.find(rule => rule.id === ruleId);
}
toJson() {
return this.rules.map(rule => {
return {
target: rule.target,
scope: rule.scope,
conditions: rule.conditions.map(condition => {
return {
field: condition.field,
operator: condition.operator,
value: condition.value
}
})
}
})
}
fromJson(ruleConf: any) {
if(ruleConf instanceof Array) {
ruleConf.forEach((rule: any) => {
const { scope, target } = rule
const ruleNode = new RuleNode(scope, target);
rule.conditions.forEach((condition: any) => {
const { field, operator, value } = condition
const conditionNode = new ConditionNode(field, operator, value);
ruleNode.addCondition(conditionNode)
})
this.addRule(ruleNode)
})
}
return this
}
validateSchema() {
return ruleSchema.validateSync(this.toJson())
}
// 实现目标选择了下拉框置灰效果
findTargetsByScope(scope: string){
return this.rules.filter(rule => rule.scope === scope).map(rule => rule.target)
}
// 实现前置题删除校验
findTargetsByFields(field: string) {
const nodes = this.rules.filter((rule: RuleNode) => {
const conditions = rule.conditions.filter((item: any) => {
return item.field === field
})
return conditions.length > 0
})
return nodes.map((item: any) => {
return item.target
})
}
// 根据目标题获取显示逻辑
findConditionByTarget(target: string) {
return this.rules.filter(rule=> rule.target === target).map(item => item.conditions)
}
}
export const ruleSchema = yup.array().of(
yup.object({
target: yup.string().required(),
scope: yup.string().required(),
conditions: yup.array().of(
yup.object({
field: yup.string().required(),
operator: yup.string().required(),
value: yup.array().of(yup.string().required())
})
)
})
)

View File

@ -0,0 +1,194 @@
import { type BasicOperator, type FieldTypes, type Fact } from "./BasicType";
// 定义条件规则类
export class ConditionNode<F extends string, O extends BasicOperator> {
// 默认显示
public result: boolean = false;
constructor(public field: F, public operator: O, public value: FieldTypes) {
}
// 计算条件规则的哈希值
calculateHash(): string {
// 假设哈希值计算方法为简单的字符串拼接或其他哈希算法
return this.field + this.operator + this.value;
}
match(facts: Fact): boolean {
// console.log(this.calculateHash())
// 如果该特征在事实对象中不存在则直接返回false
if(!facts[this.field]) {
this.result = false
return this.result
}
switch (this.operator) {
case 'eq':
if(this.value instanceof Array) {
this.result = this.value.every(v => facts[this.field].includes(v))
return this.result
} else {
this.result = facts[this.field].includes(this.value);
return this.result
}
case 'in':
if(this.value instanceof Array) {
this.result = this.value.some(v => facts[this.field].includes(v))
return this.result
} else {
this.result = facts[this.field].includes(this.value);
return this.result
}
case 'nin':
if(this.value instanceof Array) {
this.result = this.value.some(v => !facts[this.field].includes(v))
return this.result
} else {
this.result = facts[this.field].includes(this.value);
return this.result
}
case 'neq':
if(this.value instanceof Array) {
this.result = this.value.every(v => !facts[this.field].includes(v))
return this.result
} else {
this.result = facts[this.field].includes(this.value);
return this.result
}
// 其他比较操作符的判断逻辑
default:
return this.result
}
}
getResult() {
return this.result
}
}
export class RuleNode {
conditions: Map<string, ConditionNode<string, BasicOperator>>; // 使用哈希表存储条件规则对象
public result: boolean = false;
constructor(public target: string, public scope: string) {
this.conditions = new Map();
}
// 添加条件规则到规则引擎中
addCondition(condition: ConditionNode<string, BasicOperator>) {
const hash = condition.calculateHash();
this.conditions.set(hash, condition);
}
// 匹配条件规则
match(fact: Fact) {
const res = Array.from(this.conditions.entries()).every(([key, value]) => {
const res = value.match(fact)
if (res) {
return true;
} else {
return false
}
});
this.result = res
return res
}
getResult() {
const res = Array.from(this.conditions.entries()).every(([key, value]) => {
const res = value.getResult()
return res
})
return res
}
// 计算条件规则的哈希值
calculateHash(): string {
// 假设哈希值计算方法为简单的字符串拼接或其他哈希算法
return this.target + this.scope;
}
toJson() {
return {
target: this.target,
scope: this.scope,
conditions: Object.fromEntries(
Array.from(this.conditions, ([key, value]) => [key, value.getResult()])
)
};
}
}
export class RuleMatch {
rules: Map<string, RuleNode>;
constructor(ruleConf: any) {
this.rules = new Map();
if(ruleConf instanceof Array) {
ruleConf.forEach((rule: any) => {
const ruleNode = new RuleNode(rule.target, rule.scope);
rule.conditions.forEach((condition: any) => {
const conditionNode = new ConditionNode(condition.field, condition.operator, condition.value);
ruleNode.addCondition(conditionNode)
});
this.addRule(ruleNode)
})
}
}
// 添加条件规则到规则引擎中
addRule(rule: RuleNode) {
const hash = rule.calculateHash();
if (this.rules.has(hash)) {
const existRule: any = this.rules.get(hash);
existRule.conditions.forEach((item: ConditionNode<string, BasicOperator>) => {
rule.addCondition(item)
})
}
this.rules.set(hash, rule);
}
// 匹配条件规则
match(target: string, scope: string, fact: Fact) {
const hash = this.calculateHash(target, scope);
const rule = this.rules.get(hash);
if (rule) {
const result = rule.match(fact)
// this.matchCache.set(hash, result);
return result
} else {
// 默认显示
return true
}
}
getResult(target: string, scope: string) {
const hash = this.calculateHash(target, scope);
const rule = this.rules.get(hash);
if (rule) {
const result = rule.getResult()
return result
} else {
// 默认显示
return true
}
}
// 计算哈希值的方法
calculateHash(target: string, scope: string): string {
// 假设哈希值计算方法为简单的字符串拼接或其他哈希算法
return target + scope;
}
findTargetsByField(field: string) {
const rules = new Map([...this.rules.entries()].filter(([key, value]) => {
return [...value.conditions.entries()].filter(([key, value]) => {
return value.field === field
})
}))
return [...rules.values()].map(obj => obj.target);
}
toJson() {
return Array.from(this.rules.entries()).map(([key, value]) => {
return value.toJson()
})
}
}

View File

@ -0,0 +1,36 @@
// 静态数据
export const ruleConf = [
{
conditions: [
{
field: 'data515', // 题目2
operator: 'in',
value: ['115019']
}
],
scope: 'question',
target: 'data648' // 题目3
},
{
conditions: [
{
field: 'data648', // 题目3
operator: 'in',
value: ['106374']
}
],
scope: 'question',
target: 'data517' // 题目4
},
{
conditions: [
{
field: 'data648', // 题目3
operator: 'in',
value: ['106374']
}
],
scope: 'option',
target: 'data517-106374' // 题目4
}
]

View File

@ -0,0 +1,23 @@
import { computed } from 'vue';
import store from '@/management/store'
import { cleanRichText } from '@/common/xss'
export const useQuestionInfo = (field: string) => {
const getQuestionTitle = computed(() => {
const questionDataList = store.state.edit.schema.questionDataList
return () => {
return questionDataList.find((item: any) => item.field === field)?.title
}
})
const getOptionTitle = computed(() => {
const questionDataList = store.state.edit.schema.questionDataList
return (value: string | Array<string>) => {
const options = questionDataList.find((item: any) => item.field === field)?.options || []
if(value instanceof Array) {
return options.filter((item: any) => value.includes(item.hash)).map((item: any) => cleanRichText(item.text))
} else {
return options.filter((item: any) => item.hash === value).map((item: any) => cleanRichText(item.text))
}
}
})
return { getQuestionTitle, getOptionTitle }
}

View File

@ -0,0 +1,29 @@
import { computed, unref } from 'vue';
import { useQuestionInfo } from './useQuestionInfo'
import { flatten } from 'lodash-es'
import store from '@/management/store'
import { cleanRichText } from '@/common/xss'
// 目标题的显示逻辑提示文案
export const useShowLogicInfo = (field: string) => {
const hasShowLogic = computed(() => {
const logicEngine = store.state.logic.showLogicEngine
// 判断该题是否作为了显示逻辑前置题
const isField = logicEngine?.findTargetsByFields(field)?.length > 0
// 判断该题是否作为了显示逻辑目标题
const isTarget = logicEngine?.findTargetsByScope(field)?.length > 0
return isField || isTarget
})
const getShowLogicText = computed(() => {
const logicEngine = store.state.logic.showLogicEngine
// 获取目标题的规则
const rules = logicEngine?.findConditionByTarget(field) || []
const conditions = flatten(rules).map((item:any) => {
const { getQuestionTitle, getOptionTitle } = useQuestionInfo(item.field)
return `<span>【 ${cleanRichText(getQuestionTitle.value())}】 选择了 【${getOptionTitle.value(unref(item.value)).join('、')}】</span> <br/>`
})
return conditions.length ? conditions.join('') + '<span> &nbsp;满足以上全部,则显示本题</span>' :''
})
return { hasShowLogic, getShowLogicText }
}

View File

@ -24,12 +24,14 @@
<i-ep-close /> <i-ep-close />
</div> </div>
</div> </div>
<div class="logic-text" v-html="getShowLogicText"></div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, unref } from 'vue'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
import 'element-plus/theme-chalk/src/message-box.scss' import 'element-plus/theme-chalk/src/message-box.scss'
import { useShowLogicInfo } from '@/management/hooks/useShowLogicInfo'
const props = defineProps({ const props = defineProps({
qIndex: { qIndex: {
@ -57,6 +59,8 @@ const props = defineProps({
}) })
const emit = defineEmits(['changeSeq', 'select']) const emit = defineEmits(['changeSeq', 'select'])
const { getShowLogicText, hasShowLogic } = useShowLogicInfo(props.moduleConfig.field)
const isHover = ref(false) const isHover = ref(false)
const itemClass = computed(() => { const itemClass = computed(() => {
@ -111,6 +115,14 @@ const onMoveDown = () => {
isHover.value = false isHover.value = false
} }
const onDelete = async () => { const onDelete = async () => {
// const target = store.state.logic.showLogicEngine.findTargetsByFields(props.moduleConfig.field)
if(unref(hasShowLogic)) {
ElMessageBox.alert('该问题被逻辑依赖,请先删除逻辑依赖', '提示', {
confirmButtonText: '确定',
type: 'warning'
})
return
}
try { try {
await ElMessageBox.confirm('本次操作会影响数据统计查看,是否确认删除?', '提示', { await ElMessageBox.confirm('本次操作会影响数据统计查看,是否确认删除?', '提示', {
confirmButtonText: '确定', confirmButtonText: '确定',
@ -178,5 +190,11 @@ const onMove = () => {}
} }
} }
} }
.logic-text{
font-size: 12px;
color: #c8c9cd;
padding: 0 .4rem;
line-height: 26px;
}
} }
</style> </style>

View File

@ -34,6 +34,7 @@ export default {
this.$store.commit('edit/setSurveyId', this.$route.params.id) this.$store.commit('edit/setSurveyId', this.$route.params.id)
try { try {
await this.$store.dispatch('edit/init') await this.$store.dispatch('edit/init')
await this.$store.dispatch('logic/initShowLogic', this.$store.state.edit.schema.logicConf.showLogicConf || {})
} catch (error) { } catch (error) {
ElMessage.error(error.message) ElMessage.error(error.message)
// //

View File

@ -29,6 +29,12 @@ export default {
}, },
methods: { methods: {
async onPublish() { async onPublish() {
try {
this.updateLogicConf()
} catch (error) {
ElMessage.error('请检查逻辑配置是否有误')
return
}
const saveData = buildData(this.$store.state.edit.schema) const saveData = buildData(this.$store.state.edit.schema)
if (!saveData.surveyId) { if (!saveData.surveyId) {
ElMessage.error('未获取到问卷id') ElMessage.error('未获取到问卷id')
@ -37,6 +43,7 @@ export default {
if (this.isPublishing) { if (this.isPublishing) {
return return
} }
try { try {
this.isPublishing = true this.isPublishing = true
const saveRes = await saveSurvey(saveData) const saveRes = await saveSurvey(saveData)
@ -59,6 +66,20 @@ export default {
} finally { } finally {
this.isPublishing = false this.isPublishing = false
} }
},
updateLogicConf() {
if(this.$store.state.logic.showLogicEngine) {
try {
this.$store.state.logic.showLogicEngine.validateSchema()
} catch (error) {
throw error
return
}
const showLogicConf = this.$store.state.logic.showLogicEngine.toJson()
//
this.$store.dispatch('edit/changeSchema', { key: 'logicConf', value: { showLogicConf } })
}
} }
} }
} }

View File

@ -88,6 +88,19 @@ export default {
}, 2000) }, 2000)
} }
}, },
updateLogicConf() {
if(this.$store.state.logic.showLogicEngine) {
try {
this.$store.state.logic.showLogicEngine.validateSchema()
} catch (error) {
throw error
return
}
const showLogicConf = this.$store.state.logic.showLogicEngine.toJson()
//
this.$store.dispatch('edit/changeSchema', { key: 'logicConf', value: { showLogicConf } })
}
},
async saveData() { async saveData() {
const saveData = buildData(this.$store.state.edit.schema) const saveData = buildData(this.$store.state.edit.schema)
if (!saveData.surveyId) { if (!saveData.surveyId) {
@ -102,6 +115,14 @@ export default {
return return
} }
this.isShowAutoSave = false this.isShowAutoSave = false
try {
this.updateLogicConf()
} catch (error) {
// console.error(error)
ElMessage.error('请检查逻辑配置是否有误')
return
}
try { try {
this.isSaving = true this.isSaving = true
const res = await this.saveData() const res = await this.saveData()

View File

@ -9,7 +9,8 @@ export default function (schema) {
'bottomConf', 'bottomConf',
'skinConf', 'skinConf',
'submitConf', 'submitConf',
'questionDataList' 'questionDataList',
'logicConf'
]) ])
configData.dataConf = { configData.dataConf = {
dataList: configData.questionDataList dataList: configData.questionDataList

View File

@ -10,7 +10,7 @@
<div <div
:class="[ :class="[
'navbar-btn', 'navbar-btn',
(isActive && btnItem.key === 'skinsettings') || isExactActive (isActive && ['skinsettings', 'edit'].includes(btnItem.key)) || isExactActive
? 'router-link-exact-active' ? 'router-link-exact-active'
: '' : ''
]" ]"
@ -19,7 +19,6 @@
<a :href="href" @click="navigate" <a :href="href" @click="navigate"
><span>{{ btnItem.text }}</span></a ><span>{{ btnItem.text }}</span></a
> >
<!-- <span>{{ btnItem.text }}</span> -->
</div> </div>
</router-link> </router-link>
</template> </template>

View File

@ -0,0 +1,87 @@
<template>
<div class="rule-list">
<RuleNodeView
v-for="(item, index) in list"
ref="ruleWrappers"
:key="item.id"
:ruleNode="item"
@delete="handleDetele"
>
</RuleNodeView>
<div class="no-logic" v-if="list.length === 0">
<img src="/imgs/icons/unselected.webp" />
</div>
<el-button type="primary" plain class="add" @click="handleAdd">
<i-ep-plus class="plus-icon" /> 新增显示逻辑
</el-button>
</div>
</template>
<script setup lang="ts">
import { shallowRef, computed } from 'vue'
import { useStore } from 'vuex'
import RuleNodeView from './components/RuleNodeView.vue'
import { RuleNode, ConditionNode } from '@/common/logicEngine/RuleBuild'
const store = useStore()
const list = computed(() => {
return store.state.logic.showLogicEngine?.rules || []
})
const handleAdd = () => {
const condition = new ConditionNode()
const ruleNode = new RuleNode()
ruleNode.addCondition(condition)
store.state.logic.showLogicEngine.addRule(ruleNode)
}
const handleDetele = (id: string) => {
store.state.logic.showLogicEngine.removeRule(id)
}
const ruleWrappers = shallowRef([])
const formValidate = () => {
return ruleWrappers.value.map((item: any) => {
return item?.submitForm()
})
}
const handleValide = () => {
const validPass = formValidate()
const result = !validPass.includes(false)
// result ture
return !result
}
defineExpose({
handleValide
})
</script>
<style lang="scss">
.rule-list {
width: 824px;
text-align: left;
margin: 0 auto;
padding: 12px;
.add {
margin: 12px 0;
width: 100%;
.plus-icon {
margin-right: 5px;
}
}
}
.no-logic {
padding: 100px 0 50px 0;
display: flex;
flex-direction: column;
align-items: center;
img {
width: 200px;
}
}
</style>

View File

@ -0,0 +1,187 @@
<template>
<div class="condition-wrapper" data-content-before="">
<span class="desc">如果</span>
<el-form-item
:prop="`conditions[${index}].field`"
:rules="[{ required: true, message: '请选择题目', trigger: 'change' }]"
>
<el-select
class="select field-select"
v-model="conditionNode.field"
placeholder="请选择题目"
@change="(val: any) => handleChange(conditionNode, 'field', val)"
>
<el-option v-for="{ label, value } in fieldList" :key="value" :label="label" :value="value">
</el-option>
</el-select>
</el-form-item>
<span class="desc">选择了</span>
<el-form-item
class="select value-select"
:prop="`conditions[${index}].value`"
:rules="[{ required: true, message: '请选择选项', trigger: 'change' }]"
>
<el-select
v-model="conditionNode.value"
placeholder="请选择选项"
multiple
@change="(val: any) => handleChange(conditionNode, 'value', val)"
>
<el-option
v-for="{ label, value } in getRelyOptions"
:key="value"
:label="label"
:value="value"
>
</el-option>
</el-select>
</el-form-item>
<span class="desc">中的任一选项 </span>
<span class="opt">
<i-ep-plus class="opt-icon opt-icon-plus" @click="handleAdd" />
<i-ep-minus
class="opt-icon"
v-if="index > 0"
:size="14"
@click="() => handleDelete(conditionNode.id)"
/>
</span>
</div>
</template>
<script setup lang="ts">
import { defineProps, computed, inject, ref, type ComputedRef } from 'vue'
import { ConditionNode, RuleNode } from '@/common/logicEngine/RuleBuild'
import { qAbleList } from '@/management/utils/constant.js'
import { cleanRichText } from '@/common/xss'
const renderData = inject<ComputedRef<Array<any>>>('renderData') || ref([])
const props = defineProps({
index: {
type: Number,
default: 0
},
ruleNode: {
type: RuleNode,
default: () => {
return {}
}
},
conditionNode: {
type: ConditionNode,
default: () => {
return {
field: '',
operator: '',
value: ''
}
}
}
})
const fieldList = computed(() => {
const currentIndex = renderData.value.findIndex((item) => item.field === props.ruleNode.target)
return renderData.value.slice(0, currentIndex)
.filter((question: any) => qAbleList.includes(question.type))
.map((item: any) => {
return {
label: `${item.showIndex ? item.indexNumber + '.' : ''} ${cleanRichText(item.title)}`,
value: item.field
}
})
})
const getRelyOptions = computed(() => {
const { field } = props.conditionNode
if (!field) {
return []
}
const currentQuestion = renderData.value.find((item) => item.field === field)
return (
currentQuestion?.options.map((item: any) => {
return {
label: cleanRichText(item.text),
value: item.hash
}
}) || []
)
})
const handleChange = (conditionNode: ConditionNode, key: string, value: any) => {
switch (key) {
case 'field':
conditionNode.setField(value)
//
conditionNode.setValue([])
break
case 'operator':
conditionNode.setOperator(value)
break
case 'value':
conditionNode.setValue(value)
break
}
}
const handleAdd = () => {
props.ruleNode.addCondition(new ConditionNode())
}
const emit = defineEmits(['delete'])
const handleDelete = (id: any) => {
emit('delete', id)
}
</script>
<style lang="scss" scoped>
.condition-wrapper {
width: 100%;
position: relative;
display: flex;
padding: 24px 0;
&:not(:last-child)::before{
content: attr(data-content-before);
bottom: 0px;
width: 20px;
height: 20px;
background: #FEF6E6;
border-radius: 2px;
color: #FAA600;
font-size: 12px;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
bottom: -8px;
}
&:not(:last-child)::after{
content: "";
display: block;
width: calc(100% - 32px);
border-top: 1px dashed #e3e4e8;
position: absolute;
left: 32px;
bottom: 0;
}
.desc {
display: inline-block;
margin-right: 12px;
color: #333;
line-height: 32px;
}
.opt {
display: flex;
align-items: center;
.opt-icon {
cursor: pointer;
font-size: 12px;
}
.opt-icon-plus {
margin-right: 10px;
}
}
.el-form-item {
display: inline-block;
vertical-align: top !important;
margin-right: 12px;
margin-bottom: 0px;
}
}
.select {
width: 200px;
}
</style>

View File

@ -0,0 +1,149 @@
<template>
<div class="rule-wrapper">
<el-form
:hide-required-asterisk="true"
class="form"
ref="ruleForm"
:inline="true"
:model="ruleNode"
>
<conditionView
v-for="(conditionNode, index) in ruleNode.conditions"
:key="conditionNode.id"
:index="index"
:ruleNode="ruleNode"
:conditionNode="conditionNode"
@delete="handleDeleteCondition"
></conditionView>
<div class="target-wrapper">
<div class="line">
<span class="desc">则显示</span>
<el-form-item
prop="target"
:rules="[{ required: true, message: '请选择目标', trigger: 'change' }]"
>
<el-select
class="select field-select"
v-model="ruleNode.target"
placeholder="请选择"
@change="(val: any) => handleChange(ruleNode, 'target', val)"
>
<el-option
v-for="{ label, value, disabled } in targetQuestionList"
:key="value"
:label="label"
:disabled="disabled && ruleNode.target !== value "
:value="value"
>
</el-option>
</el-select>
</el-form-item>
</div>
<i-ep-delete style="font-size: 14px" @click="() => handleDelete(ruleNode.id)" />
</div>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, computed, shallowRef, inject, type ComputedRef } from 'vue'
import { useStore } from 'vuex'
import { cloneDeep } from 'lodash-es'
import { ElMessageBox } from 'element-plus'
import 'element-plus/theme-chalk/src/message-box.scss'
import { RuleNode } from '@/common/logicEngine/RuleBuild'
import { cleanRichText } from '@/common/xss'
import conditionView from './ConditionView.vue'
const store = useStore()
const renderData = inject<ComputedRef<Array<any>>>('renderData') || ref([])
const props = defineProps({
ruleNode: {
type: RuleNode,
default: () => {}
}
})
const emit = defineEmits(['delete'])
const handleChange = (ruleNode: any, key: any, value: any) => {
switch (key) {
case 'target':
ruleNode.setTarget(value)
break
}
}
const handleDelete = async (id: any) => {
await ElMessageBox.confirm('是否确认删除规则?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
emit('delete', id)
}
const handleDeleteCondition = (id: any) => {
props.ruleNode.removeCondition(id)
}
const ruleForm = shallowRef<any>(null)
const submitForm = () => {
ruleForm.value?.validate((valid: any) => {
if (valid) {
return true
} else {
return false
}
})
}
const targetQuestionList = computed(() => {
const currntIndexs: number[] = []
props.ruleNode.conditions.forEach((el) => {
currntIndexs.push(renderData.value.findIndex((item: { field: string }) => item.field === el.field))
})
const currntIndex = Math.max(...currntIndexs)
let questionList = cloneDeep(renderData.value.slice(currntIndex + 1))
return questionList.map((item: any) => {
return {
label: `${item.showIndex ? item.indexNumber + '.' : ''} ${cleanRichText(item.title)}`,
value: item.field,
disabled: store.state.logic.showLogicEngine
.findTargetsByScope('question')
.includes(item.field)
}
})
})
defineExpose({
submitForm
})
</script>
<style lang="scss" scoped>
.rule-wrapper {
width: 800px;
padding: 10px 24px;
border: 1px solid #e3e4e8;
border-radius: 2px;
display: flex;
margin: 12px 0;
box-sizing: border-box;
.target-wrapper {
padding: 24px 0;
display: flex;
align-items: center;
}
.desc {
display: inline-block;
margin-right: 12px;
color: #333;
line-height: 32px;
}
.el-form-item {
display: inline-block;
vertical-align: top !important;
margin-bottom: 0px;
}
}
.select {
width: 200px;
}
</style>

View File

@ -184,7 +184,6 @@ export default {
.operation-wrapper { .operation-wrapper {
margin-top: 50px; margin-top: 50px;
margin-bottom: 45px; margin-bottom: 45px;
// min-height: 812px;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
padding-right: 30px; padding-right: 30px;

View File

@ -95,25 +95,4 @@ export default {
padding: 0 !important; padding: 0 !important;
} }
} }
.no-select-question {
padding-top: 125px;
display: flex;
flex-direction: column;
align-items: center;
img {
width: 160px;
padding: 25px;
}
.tip {
font-size: 14px;
color: $normal-color;
letter-spacing: 0;
}
}
.question-config-form {
padding: 30px 20px 50px 20px;
}
</style> </style>

View File

@ -0,0 +1,32 @@
<template>
<div class="logic-wrapper">
<RuleListView></RuleListView>
</div>
</template>
<script setup lang="ts">
import { computed, provide } from 'vue'
import RuleListView from '../../modules/logicModule/RulePanel.vue'
import { filterQuestionPreviewData } from '@/management/utils/index'
import { useStore } from 'vuex'
import { cloneDeep } from 'lodash-es'
const store = useStore()
const questionDataList = computed(() => {
return store.state.edit.schema.questionDataList
})
const renderData = computed(() => {
return filterQuestionPreviewData(cloneDeep(questionDataList.value))
})
provide('renderData', renderData)
</script>
<style lang="scss" scoped>
.logic-wrapper {
height: calc(100% - 120px);
width: 100%;
margin: 12px;
background: #fff;
text-align: center;
overflow: auto;
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<commonTemplate>
<template #left>
<catalogPanel />
</template>
<template #center>
<previewPanel />
</template>
<template #right>
<setterPanel />
</template>
</commonTemplate>
</template>
<script>
import commonTemplate from '../../components/CommonTemplate.vue';
import catalogPanel from '../../modules/questionModule/CatalogPanel.vue';
import previewPanel from '../../modules/questionModule/PreviewPanel.vue';
import setterPanel from '../../modules/questionModule/SetterPanel.vue';
export default {
name: 'editInde1111x',
components: {
commonTemplate,
catalogPanel,
previewPanel,
setterPanel,
},
};
</script>
<style lang="scss" scoped>
.navbar {
border-bottom: 1px solid #e7e9eb;
}
</style>

View File

@ -0,0 +1,68 @@
<template>
<div class="question-content">
<div class="navbar-tab">
<el-radio-group v-model="activeRouter">
<el-radio-button
v-for="btnItem in btnList"
:key="btnItem.router"
:label="btnItem.text"
:value="btnItem.router"
/>
</el-radio-group>
</div>
<router-view></router-view>
</div>
</template>
<script>
export default {
name: 'QuestionPage',
props: {},
data() {
return {
activeRouter: this.$route.name,
btnList: [
{
text: '内容设置',
router: 'QuestionEditIndex',
key: 'questionEdit'
},
{
text: '逻辑设置',
router: 'LogicIndex',
key: 'logicEdit'
}
]
}
},
watch: {
activeRouter: {
handler(val) {
this.$router.push({ name: val })
}
}
}
}
</script>
<style lang="scss" scoped>
.question-content {
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: 100%;
height: 100%;
.navbar-tab {
position: absolute;
top: 10px;
cursor: pointer;
:deep(.el-radio-button__original-radio + .el-radio-button__inner) {
font-size: 12px;
height: 28px;
}
:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
color: $primary-color;
background-color: #fff !important;
}
}
}
</style>

View File

@ -21,15 +21,37 @@ const routes: RouteRecordRaw[] = [
meta: { meta: {
needLogin: true needLogin: true
}, },
name: 'QuestionEdit',
component: () => import('../pages/edit/index.vue'), component: () => import('../pages/edit/index.vue'),
children: [ children: [
{ {
path: '', path: '',
name: 'QuestionEditIndex',
meta: { meta: {
needLogin: true needLogin: true
}, },
component: () => import('../pages/edit/pages/EditPage.vue') name: 'QuestionEditPage',
component: () =>
import('../pages/edit/pages/edit/index.vue'),
children: [
{
path: '',
name: 'QuestionEditIndex',
meta: {
needLogin: true
},
component: () =>
import('../pages/edit/pages/edit/QuestionEditPage.vue')
},
{
path: 'logic',
name: 'LogicIndex',
meta: {
needLogin: true
},
component: () =>
import('../pages/edit/pages/edit/LogicEditPage.vue')
}
]
}, },
{ {
path: 'setting', path: 'setting',
@ -41,7 +63,6 @@ const routes: RouteRecordRaw[] = [
}, },
{ {
path: 'skin', path: 'skin',
// name: 'SkinSetting',
meta: { meta: {
needLogin: true needLogin: true
}, },

View File

@ -1,4 +1,5 @@
import { getBannerData } from '@/management/api/skin.js' import { getBannerData } from '@/management/api/skin.js'
import { RuleBuild } from '@/common/logicEngine/RuleBuild'
export default { export default {
async getBannerData({ state, commit }) { async getBannerData({ state, commit }) {
@ -9,5 +10,9 @@ export default {
if (res.code === 200) { if (res.code === 200) {
commit('setBannerList', res.data) commit('setBannerList', res.data)
} }
},
initShowLogic({ commit }, ruleConf) {
const showLogicEngine = new RuleBuild(ruleConf)
commit('setShowLogicEngine', showLogicEngine)
} }
} }

View File

@ -10,12 +10,12 @@ export default {
} }
dispatch('resetState') dispatch('resetState')
}, },
async getSchemaFromRemote({ commit, state }) { async getSchemaFromRemote({ commit, state, dispatch }) {
const res = await getSurveyById(state.surveyId) const res = await getSurveyById(state.surveyId)
if (res.code === 200) { if (res.code === 200) {
const metaData = res.data.surveyMetaRes const metaData = res.data.surveyMetaRes
document.title = metaData.title document.title = metaData.title
const { bannerConf, bottomConf, skinConf, baseConf, submitConf, dataConf } = const { bannerConf, bottomConf, skinConf, baseConf, submitConf, dataConf, logicConf = {} } =
res.data.surveyConfRes.code res.data.surveyConfRes.code
commit('initSchema', { commit('initSchema', {
metaData, metaData,
@ -25,9 +25,11 @@ export default {
skinConf, skinConf,
baseConf, baseConf,
submitConf, submitConf,
questionDataList: dataConf.dataList questionDataList: dataConf.dataList,
logicConf
} }
}) })
} else { } else {
throw new Error(res.errmsg || '问卷不存在') throw new Error(res.errmsg || '问卷不存在')
} }

View File

@ -18,6 +18,7 @@ export default {
state.schema.baseConf = _merge({}, state.schema.baseConf, codeData.baseConf) state.schema.baseConf = _merge({}, state.schema.baseConf, codeData.baseConf)
state.schema.submitConf = _merge({}, state.schema.submitConf, codeData.submitConf) state.schema.submitConf = _merge({}, state.schema.submitConf, codeData.submitConf)
state.schema.questionDataList = codeData.questionDataList || [] state.schema.questionDataList = codeData.questionDataList || []
state.schema.logicConf = codeData.logicConf
}, },
setSurveyId(state, data) { setSurveyId(state, data) {
state.surveyId = data state.surveyId = data

View File

@ -52,6 +52,9 @@ export default {
}, },
link: '' link: ''
}, },
questionDataList: [] questionDataList: [],
logicConf: {
showLogicConf: []
}
} }
} }

View File

@ -1,6 +1,7 @@
import { createStore } from 'vuex' import { createStore } from 'vuex'
import edit from './edit' import edit from './edit'
import user from './user' import user from './user'
import logic from './logic'
import actions from './actions' import actions from './actions'
import mutations from './mutations' import mutations from './mutations'
@ -13,6 +14,7 @@ export default createStore({
actions, actions,
modules: { modules: {
edit, edit,
user user,
logic
} }
}) })

View File

@ -0,0 +1,19 @@
import { RuleBuild } from '@/common/logicEngine/RuleBuild'
export default {
namespaced: true,
state: {
showLogicEngine: null
},
mutations: {
setShowLogicEngine(state, logicEngine) {
state.showLogicEngine = logicEngine
}
},
actions: {
initShowLogic({ commit }, ruleConf) {
const showLogicEngine = new RuleBuild().fromJson(ruleConf)
commit('setShowLogicEngine', showLogicEngine)
}
}
}

View File

@ -1,5 +1,8 @@
export default { export default {
setBannerList(state, data) { setBannerList(state, data) {
state.bannerList = data state.bannerList = data
} },
setShowLogicEngine(state, logicEngine) {
state.logicEngine = logicEngine
},
} }

View File

@ -1,3 +1,4 @@
export default { export default {
bannerList: [] bannerList: [],
logicEngine: null
} }

View File

@ -3,3 +3,19 @@ export const QOP_MAP = {
COPY: 'copy', COPY: 'copy',
EDIT: 'edit' EDIT: 'edit'
} }
export const qAbleList = [,
'radio',
'checkbox',
'binary-choice',
'vote',
]
export const operatorOptions = [
{
label: '选择了',
value: 'in',
},
{
label: '不选择',
value: 'nin',
},
]

View File

@ -1,12 +1,11 @@
import { defaultQuestionConfig } from '../config/questionConfig' import { defaultQuestionConfig } from '../config/questionConfig'
import { cloneDeep as _cloneDeep, map as _map } from 'lodash-es' import { cloneDeep as _cloneDeep, map as _map } from 'lodash-es'
const generateQuestionField = () => { const generateQuestionField = () => {
const num = Math.floor(Math.random() * 1000) const num = Math.floor(Math.random() * 1000)
return `data${num}` return `data${num}`
} }
function getRandom(len) { export function getRandom(len) {
return Math.random() return Math.random()
.toString() .toString()
.slice(len && typeof len === 'number' ? 0 - len : -6) .slice(len && typeof len === 'number' ? 0 - len : -6)

View File

@ -80,7 +80,6 @@ export default defineComponent({
} }
} }
emit('change', values) emit('change', values)
// return values
} }
return { return {
slots, slots,

View File

@ -17,6 +17,7 @@ import AlertDialog from './components/AlertDialog.vue'
import LogoIcon from './components/LogoIcon.vue' import LogoIcon from './components/LogoIcon.vue'
import { get as _get } from 'lodash-es' import { get as _get } from 'lodash-es'
import { ruleConf } from '@/common/logicEngine/ruleConf'
export default { export default {
name: 'App', name: 'App',
@ -55,7 +56,7 @@ export default {
const res = await getPublishedSurveyInfo({ surveyPath }) const res = await getPublishedSurveyInfo({ surveyPath })
if (res.code === 200) { if (res.code === 200) {
const data = res.data const data = res.data
const { bannerConf, baseConf, bottomConf, dataConf, skinConf, submitConf } = data.code const { bannerConf, baseConf, bottomConf, dataConf, skinConf, submitConf, logicConf } = data.code
document.title = data.title document.title = data.title
const questionData = { const questionData = {
bannerConf, bannerConf,
@ -68,6 +69,7 @@ export default {
this.setSkin(skinConf) this.setSkin(skinConf)
this.$store.commit('setSurveyPath', surveyPath) this.$store.commit('setSurveyPath', surveyPath)
this.$store.dispatch('init', questionData) this.$store.dispatch('init', questionData)
this.$store.dispatch('initRuleEngine', logicConf?.showLogicConf);
this.$store.dispatch('getEncryptInfo') this.$store.dispatch('getEncryptInfo')
} else { } else {
throw new Error(res.errmsg) throw new Error(res.errmsg)
@ -90,7 +92,6 @@ export default {
root.style.setProperty('--primary-background-color', backgroundConf?.color) // root.style.setProperty('--primary-background-color', backgroundConf?.color) //
} }
if (contentConf?.opacity.toString()) { if (contentConf?.opacity.toString()) {
console.log({ opacity: contentConf?.opacity / 100 })
root.style.setProperty('--opacity', contentConf?.opacity / 100) // root.style.setProperty('--opacity', contentConf?.opacity / 100) //
} }
} }

View File

@ -5,7 +5,7 @@
ref="formGroup" ref="formGroup"
:render-data="item" :render-data="item"
:rules="rules" :rules="rules"
:formModel="formModel" :formValues="formValues"
@formChange="changeData" @formChange="changeData"
/> />
</template> </template>
@ -27,8 +27,8 @@ export default {
rules() { rules() {
return this.$store.state.rules return this.$store.state.rules
}, },
formModel() { formValues() {
return this.$store.getters.formModel return this.$store.state.formValues
} }
}, },
mounted() {}, mounted() {},

View File

@ -1,24 +1,23 @@
<template> <template>
<form ref="ruleForm" :model="formModel" :rules="rules"> <form ref="ruleForm" :model="formValues" :rules="rules">
<questionWrapper <div v-for="(item) in renderData" :key="item.field">
v-for="item in renderData" <QuestionWrapper
:key="item.field" class="gap"
class="gap" v-bind="$attrs"
v-bind="$attrs" :moduleConfig="item"
:moduleConfig="item" :qIndex="item.qIndex"
:qIndex="item.qIndex" :indexNumber="item.indexNumber"
:indexNumber="item.indexNumber" :showTitle="true"
:showTitle="true" @change="handleChange"
@change="handleChange" ></QuestionWrapper>
></questionWrapper> </div>
</form> </form>
</template> </template>
<script setup> <script setup>
import { inject, provide, computed, onBeforeMount } from 'vue' import { inject, provide, computed, onBeforeMount } from 'vue'
import questionWrapper from '../../materials/questions/QuestionRuleContainer' import QuestionWrapper from './QuestionWrapper.vue'
import store from '@/render/store'
const $bus = inject('$bus') const $bus = inject('$bus')
const props = defineProps({ const props = defineProps({
rules: { rules: {
type: Object, type: Object,
@ -26,7 +25,7 @@ const props = defineProps({
return {} return {}
} }
}, },
formModel: { formValues: {
type: Object, type: Object,
default: () => { default: () => {
return {} return {}
@ -39,6 +38,7 @@ const props = defineProps({
} }
} }
}) })
const emit = defineEmits(['formChange', 'blur']) const emit = defineEmits(['formChange', 'blur'])
// 使changechangeinput // 使changechangeinput
@ -50,7 +50,7 @@ const handleChange = (data) => {
const fields = [] const fields = []
provide('Form', { provide('Form', {
model: computed(() => { model: computed(() => {
return props.formModel return props.formValues
}), }),
rules: computed(() => { rules: computed(() => {
return props.rules return props.rules
@ -71,6 +71,13 @@ onBeforeMount(() => {
} }
}) })
}) })
// const visible = computed(() => {
// return (field) => {
// console.log(field + 'visible'+store.state.ruleEngine.getResult(field, 'question'))
// // -
// return store.state.ruleEngine.getResult(field, 'question')
// }
// })
const validate = (callback) => { const validate = (callback) => {
const length = fields.length const length = fields.length

View File

@ -0,0 +1,114 @@
<template>
<QuestionRuleContainer
v-if="visible"
v-bind="$attrs"
:moduleConfig="questionConfig"
:indexNumber="indexNumber"
:showTitle="true"
@change="handleChange"
></QuestionRuleContainer>
</template>
<script setup>
import { unref, computed, watch } from 'vue'
import QuestionRuleContainer from '../../materials/questions/QuestionRuleContainer'
import { useVoteMap } from '@/render/hooks/useVoteMap'
import { useShowOthers } from '@/render/hooks/useShowOthers'
import { useShowInput } from '@/render/hooks/useShowInput'
import store from '@/render/store'
import { cloneDeep } from 'lodash-es'
const props = defineProps({
indexNumber: {
type: [Number, String],
default: 1
},
moduleConfig: {
type: Object,
default: () => {
return {}
}
},
})
const emit = defineEmits(['change'])
const formValues = computed(() => {
return store.state.formValues
})
const questionConfig = computed(() =>{
let moduleConfig = props.moduleConfig
const { type, field, options, ...rest } = cloneDeep(moduleConfig)
// console.log(field,'formValuechange')
let alloptions = options
if(type === 'vote') {
const { options, voteTotal } = useVoteMap(field)
const voteOptions = unref(options)
alloptions = alloptions.map((obj, index) => Object.assign(obj, voteOptions[index]))
moduleConfig.voteTotal = unref(voteTotal)
}
if(['radio','checkbox'].includes(type) && options.filter(optionItem => optionItem.others).length > 0) {
let { options, othersValue } = useShowOthers(field)
const othersOptions = unref(options)
alloptions = alloptions.map((obj, index) => Object.assign(obj, othersOptions[index]))
moduleConfig.othersValue = unref(othersValue)
}
if(['radio-star','radio-nps'].includes(type) && Object.keys(rest.rangeConfig).filter(index => rest.rangeConfig[index].isShowInput).length > 0) {
let { rangeConfig, othersValue } = useShowInput(field)
// console.log({rangeConfig, othersValue})
moduleConfig.rangeConfig = unref(rangeConfig)
moduleConfig.othersValue = unref(othersValue)
}
return {
...moduleConfig,
options: alloptions,
value: formValues.value[props.moduleConfig.field]
}
})
const visible = computed(() => {
const { field } = props.moduleConfig
const matchRule = store.state.ruleEngine.rules.get(field+'question')
if(matchRule) {
return matchRule.result
} else {
return true
}
})
watch(() => visible.value, (newVal, oldVal) => {
//
const { field, type, innerType } = props.moduleConfig
if(!newVal && oldVal) {
let value = ''
// innerType
if (/checkbox/.test(type) || innerType === 'checkbox') {
value = value ? [value] : []
}
const data = {
key: field,
value: value
}
store.commit('changeFormData', data)
notifyMatch(field)
}
})
const handleChange = (data) => {
const { key } = data
// console.log(key, 'change')
emit('change', data)
//
if(props.moduleConfig.type === 'vote') {
store.dispatch('updateVoteData', data)
}
//
notifyMatch(key)
}
const notifyMatch = (key) => {
let fact = unref(formValues)
const targets = store.state.ruleEngine.findTargetsByField(key) || []
//
targets.forEach((target) => {
store.state.ruleEngine.match(target, 'question', fact)
})
}
</script>

View File

@ -0,0 +1,30 @@
import store from '../store/index'
export const useShowInput = (questionKey) => {
const formValues = store.state.formValues
const questionVal = formValues[questionKey]
let rangeConfig = store.state.questionData[questionKey].rangeConfig
let othersValue = {}
if (rangeConfig && Object.keys(rangeConfig).length > 0) {
for(let key in rangeConfig) {
const curRange = rangeConfig[key]
if (curRange.isShowInput) {
const rangeKey = `${questionKey}_${key}`
othersValue[rangeKey] = formValues[rangeKey]
curRange.othersKey = rangeKey,
curRange.othersValue = formValues[rangeKey]
if(!questionVal.toString().includes(key) && formValues[rangeKey]) {
// 如果分值被未被选中且对应的填写更多有值,则清空填写更多
const data = {
key: rangeKey,
value: ''
}
store.commit('changeFormData', data)
}
}
}
}
return { rangeConfig, othersValue }
}

View File

@ -0,0 +1,29 @@
import store from '../store/index'
export const useShowOthers = (questionKey) => {
const formValues = store.state.formValues
const questionVal = formValues[questionKey]
let othersValue = {}
let options = store.state.questionData[questionKey].options.map(optionItem => {
if (optionItem.others) {
const opKey = `${questionKey}_${optionItem.hash}`
othersValue[opKey] = formValues[opKey]
if(!questionVal.includes(optionItem.hash) && formValues[opKey]) {
// 如果选项被未被选中且对应的填写更多有值,则清空填写更多
const data = {
key: opKey,
value: ''
}
store.commit('changeFormData', data)
}
return {
...optionItem,
othersKey: opKey,
othersValue: formValues[opKey]
}
} else {
return optionItem
}
})
return { options, othersValue }
}

View File

@ -0,0 +1,18 @@
import { computed } from 'vue'
import store from '../store/index'
export const useVoteMap = (questionKey) => {
let voteTotal = store.state.voteMap?.[questionKey]?.total || 0
const options = store.state.questionData[questionKey].options.map(option => {
const optionHash = option.hash
const voteCount = store.state.voteMap?.[questionKey]?.[optionHash] || 0
return {
...option,
voteCount
}
})
return { options, voteTotal }
}

View File

@ -27,6 +27,7 @@ import { submitForm } from '../api/survey'
import encrypt from '../utils/encrypt' import encrypt from '../utils/encrypt'
import useCommandComponent from '../hooks/useCommandComponent' import useCommandComponent from '../hooks/useCommandComponent'
import { cloneDeep } from 'lodash-es'
export default { export default {
name: 'indexPage', name: 'indexPage',
@ -49,9 +50,6 @@ export default {
LogoIcon LogoIcon
}, },
computed: { computed: {
formModel() {
return this.$store.getters.formModel
},
confirmAgain() { confirmAgain() {
return this.$store.state.submitConf.confirmAgain return this.$store.state.submitConf.confirmAgain
}, },
@ -94,9 +92,23 @@ export default {
} }
}, },
getSubmitData() { getSubmitData() {
const formValues = cloneDeep(this.$store.state.formValues)
// -
// -
const formModel = Object.keys(formValues)
.filter(key => this.$store.state.ruleEngine.getResult(key, 'question'))
.reduce((obj, key) => {
obj[key] = formValues[key];
return obj;
}, {});
const result = { const result = {
surveyPath: this.surveyPath, surveyPath: this.surveyPath,
data: JSON.stringify(this.formModel), data: JSON.stringify(formModel),
difTime: Date.now() - this.$store.state.enterTime, difTime: Date.now() - this.$store.state.enterTime,
clientTime: Date.now() clientTime: Date.now()
} }
@ -118,6 +130,7 @@ export default {
async submitForm() { async submitForm() {
try { try {
const submitData = this.getSubmitData() const submitData = this.getSubmitData()
const res = await submitForm(submitData) const res = await submitForm(submitData)
if (res.code === 200) { if (res.code === 200) {
this.$store.commit('setRouter', 'successPage') this.$store.commit('setRouter', 'successPage')

View File

@ -5,6 +5,7 @@ import 'moment/locale/zh-cn'
moment.locale('zh-cn') moment.locale('zh-cn')
import adapter from '../adapter' import adapter from '../adapter'
import { queryVote, getEncryptInfo } from '@/render/api/survey' import { queryVote, getEncryptInfo } from '@/render/api/survey'
import { RuleMatch } from '@/common/logicEngine/RulesMatch'
/** /**
* CODE_MAP不从management引入在dev阶段会导致B端 router被加载进而导致C端路由被添加 baseUrl: /management * CODE_MAP不从management引入在dev阶段会导致B端 router被加载进而导致C端路由被添加 baseUrl: /management
*/ */
@ -13,6 +14,7 @@ const CODE_MAP = {
ERROR: 500, ERROR: 500,
NO_AUTH: 403 NO_AUTH: 403
} }
const VOTE_INFO_KEY = 'voteinfo'
export default { export default {
// 初始化 // 初始化
@ -101,18 +103,63 @@ export default {
return return
} }
try { try {
localStorage.removeItem(VOTE_INFO_KEY)
const voteRes = await queryVote({ const voteRes = await queryVote({
surveyPath, surveyPath,
fieldList: fieldList.join(',') fieldList: fieldList.join(',')
}) })
if (voteRes.code === 200) { if (voteRes.code === 200) {
localStorage.setItem(
VOTE_INFO_KEY,
JSON.stringify({
...voteRes.data
})
)
commit('setVoteMap', voteRes.data) commit('setVoteMap', voteRes.data)
} }
} catch (error) { } catch (error) {
console.log(error) console.log(error)
} }
}, },
updateVoteData({ state, commit }, data) {
const { key: questionKey, value: questionVal } = data
// 更新前获取接口缓存在localStorage中的数据
const localData = localStorage.getItem(VOTE_INFO_KEY)
const voteinfo = JSON.parse(localData)
const currentQuestion = state.questionData[questionKey]
const options = currentQuestion.options
const voteTotal = voteinfo?.[questionKey]?.total || 0
let totalPayload = {
questionKey,
voteKey: 'total',
voteValue: voteTotal
}
options.forEach((option) => {
const optionhash = option.hash
const voteCount = voteinfo?.[questionKey]?.[optionhash] || 0
// 如果选中值包含该选项对应voteCount 和 voteTotal + 1
if (
Array.isArray(questionVal) ? questionVal.includes(optionhash) : questionVal === optionhash
) {
const countPayload = {
questionKey,
voteKey: optionhash,
voteValue: voteCount + 1
}
totalPayload.voteValue += 1
commit('updateVoteMapByKey', countPayload)
} else {
const countPayload = {
questionKey,
voteKey: optionhash,
voteValue: voteCount
}
commit('updateVoteMapByKey', countPayload)
}
commit('updateVoteMapByKey', totalPayload)
})
},
async getEncryptInfo({ commit }) { async getEncryptInfo({ commit }) {
try { try {
const res = await getEncryptInfo() const res = await getEncryptInfo()
@ -122,5 +169,9 @@ export default {
} catch (error) { } catch (error) {
console.log(error) console.log(error)
} }
},
async initRuleEngine({ commit }, ruleConf) {
const ruleEngine = new RuleMatch(ruleConf)
commit('setRuleEgine', ruleEngine)
} }
} }

View File

@ -3,115 +3,92 @@ import { flatten } from 'lodash-es'
export default { export default {
// 题目列表 // 题目列表
renderData: (state) => { renderData: (state) => {
const { questionSeq, questionData, formValues } = state const { questionSeq, questionData } = state
let index = 1 let index = 1
return ( return (
questionSeq && questionSeq &&
questionSeq.reduce((pre, item) => { questionSeq.reduce((pre, item) => {
const questionArr = [] const questionArr = []
for (const questionKey of item) {
item.forEach(questionKey => {
console.log('题目重新计算')
const question = { ...questionData[questionKey] } const question = { ...questionData[questionKey] }
const { type, extraOptions, options, rangeConfig } = question const { type, extraOptions, options, rangeConfig } = question
const questionVal = formValues[questionKey] // const questionVal = formValues[questionKey]
question.value = questionVal // question.value = questionVal
// 本题开启了 // 本题开启了
if (question.showIndex) { if (question.showIndex) {
question.indexNumber = index++ question.indexNumber = index++
} }
const allOptions = [] // const allOptions = []
if (Array.isArray(extraOptions)) { // if (Array.isArray(extraOptions)) {
allOptions.push(...extraOptions) // allOptions.push(...extraOptions)
} // }
if (Array.isArray(options)) { // if (Array.isArray(options)) {
allOptions.push(...options) // allOptions.push(...options)
} // }
let othersValue = {} // let othersValue = {}
let voteTotal = 0 // let voteTotal = 0
const voteMap = state.voteMap // const voteMap = state.voteMap
if (/vote/.test(type)) { // if (/vote/.test(type)) {
voteTotal = voteMap?.[questionKey]?.total || 0 // voteTotal = voteMap?.[questionKey]?.total || 0
} // }
// 遍历所有的选项 // 遍历所有的选项
for (const optionItem of allOptions) { // for (const optionItem of allOptions) {
// 开启了更多输入框生成othersValue的值 // 开启了更多输入框生成othersValue的值
if (optionItem.others) { // if (optionItem.others) {
const opKey = `${questionKey}_${optionItem.hash}` // const opKey = `${questionKey}_${optionItem.hash}`
optionItem.othersKey = opKey // optionItem.othersKey = opKey
optionItem.othersValue = formValues[opKey] // optionItem.othersValue = formValues[opKey]
othersValue[opKey] = formValues[opKey] // othersValue[opKey] = formValues[opKey]
} // }
// 投票题,用户手动选择选项后,要实时更新展示数据和进度 // 投票题,用户手动选择选项后,要实时更新展示数据和进度
if (/vote/.test(type)) { // if (/vote/.test(type)) {
const voteCount = voteMap?.[questionKey]?.[optionItem.hash] || 0 // const voteCount = voteMap?.[questionKey]?.[optionItem.hash] || 0
if ( // if (
Array.isArray(questionVal) // Array.isArray(questionVal)
? questionVal.includes(optionItem.hash) // ? questionVal.includes(optionItem.hash)
: questionVal === optionItem.hash // : questionVal === optionItem.hash
) { // ) {
optionItem.voteCount = voteCount + 1 // optionItem.voteCount = voteCount + 1
voteTotal = voteTotal + 1 // voteTotal = voteTotal + 1
} else { // } else {
optionItem.voteCount = voteCount // optionItem.voteCount = voteCount
} // }
question.voteTotal = voteTotal // question.voteTotal = voteTotal
} // }
} // }
// 开启了更多输入框要将当前的value赋值给question // 开启了更多输入框要将当前的value赋值给question
if (rangeConfig && Object.keys(rangeConfig).length > 0 && rangeConfig[questionVal]) { // if (rangeConfig && Object.keys(rangeConfig).length > 0 && rangeConfig[questionVal]) {
const curRange = rangeConfig[questionVal] // const curRange = rangeConfig[questionVal]
if (curRange?.isShowInput) { // if (curRange?.isShowInput) {
const rangeKey = `${questionKey}_${questionVal}` // const rangeKey = `${questionKey}_${questionVal}`
curRange.othersKey = rangeKey // curRange.othersKey = rangeKey
curRange.othersValue = formValues[rangeKey] // curRange.othersValue = formValues[rangeKey]
othersValue[rangeKey] = formValues[rangeKey] // othersValue[rangeKey] = formValues[rangeKey]
} // }
} // }
// 将othersValue赋值给 // 将othersValue赋值给
question.othersValue = othersValue // question.othersValue = othersValue
questionArr.push(question) questionArr.push(question)
} })
if (questionArr && questionArr.length) { if (questionArr && questionArr.length) {
pre.push(questionArr) pre.push(questionArr)
} }
return pre return pre
}, []) }, [])
) )
},
// 根据渲染的题目生成的用户输入或者选择的数据
formModel: (state, getters) => {
const { renderData } = getters
const formdata = flatten(renderData).reduce((pre, current) => {
const { othersValue, type, field } = current
if (othersValue && Object.keys(othersValue).length) {
Object.assign(pre, othersValue)
}
switch (type) {
// case 'fillin':
// current.fillinConfig.forEach(item => {
// item.forEach(subItem => {
// if (subItem.blanks > 0) {
// const resultField = `${field}_${subItem.hash}`
// Object.assign(pre, { [resultField]: subItem.value })
// }
// })
// })
// Object.assign(pre, { [field]: formValues[field] })
// break
default:
Object.assign(pre, { [field]: current.value })
break
}
return pre
}, {})
return formdata
} }
} }

View File

@ -20,8 +20,8 @@ export default {
}, },
changeFormData(state, data) { changeFormData(state, data) {
let { key, value } = data let { key, value } = data
// console.log('formValues', key, value)
set(state, `formValues.${key}`, value) set(state, `formValues.${key}`, value)
// set(state, `questionData.${key}.value`, value)
}, },
changeSelectMoreData(state, data) { changeSelectMoreData(state, data) {
const { key, value, field } = data const { key, value, field } = data
@ -36,10 +36,21 @@ export default {
setVoteMap(state, data) { setVoteMap(state, data) {
state.voteMap = data state.voteMap = data
}, },
updateVoteMapByKey(state, data) {
const { questionKey, voteKey, voteValue } = data
// 兼容为空的情况
if(!state.voteMap[questionKey]){
state.voteMap[questionKey] = {}
}
state.voteMap[questionKey][voteKey] = voteValue
},
setQuestionSeq(state, data) { setQuestionSeq(state, data) {
state.questionSeq = data state.questionSeq = data
}, },
setEncryptInfo(state, data) { setEncryptInfo(state, data) {
state.encryptInfo = data state.encryptInfo = data
} },
setRuleEgine(state, ruleEngine) {
state.ruleEngine = ruleEngine
},
} }

View File

@ -12,5 +12,6 @@ export default {
enterTime: null, enterTime: null,
questionSeq: [], // 题目的顺序,因为可能会有分页的情况,所以是一个二维数组[[qid1, qid2], [qid3,qid4]] questionSeq: [], // 题目的顺序,因为可能会有分页的情况,所以是一个二维数组[[qid1, qid2], [qid3,qid4]]
voteMap: {}, voteMap: {},
encryptInfo: null encryptInfo: null,
ruleEngine: null
} }