Compare commits

...

4 Commits

Author SHA1 Message Date
dayou
8200763748
fix: 修复文件缺少后缀 (#144) 2024-05-20 21:44:00 +08:00
dayou
34f242a892
fix: 手动解决lint校验 (#142) 2024-05-20 18:16:29 +08:00
dayou
c714f13905
perl:代码优化 (#139) 2024-05-20 17:40:30 +08:00
dayou
990126f976
feat: 显示逻辑 (#107) 2024-05-10 22:57:21 +08:00
55 changed files with 5460 additions and 394 deletions

View File

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

20
web/components.d.ts vendored
View File

@ -12,39 +12,38 @@ declare module 'vue' {
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
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']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSlider: typeof import('element-plus/es')['ElSlider']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
'IEp-[]': typeof import('~icons/ep/[]')['default']
'IEp-[test]': typeof import('~icons/ep/[test]')['default']
'IEp-]': typeof import('~icons/ep/]')['default']
IEpBottom: typeof import('~icons/ep/bottom')['default']
IEpCheck: typeof import('~icons/ep/check')['default']
IEpCirclePlus: typeof import('~icons/ep/circle-plus')['default']
IEpClose: typeof import('~icons/ep/close')['default']
IEpCopyDocument: typeof import('~icons/ep/copy-document')['default']
IEpDelete: typeof import('~icons/ep/delete')['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']
IEpRank: typeof import('~icons/ep/rank')['default']
IEpRemove: typeof import('~icons/ep/remove')['default']
@ -58,6 +57,5 @@ declare module 'vue' {
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
vPopover: typeof import('element-plus/es')['ElPopoverDirective']
}
}

View File

@ -23,13 +23,15 @@
"element-plus": "^2.7.0",
"lodash-es": "^4.17.21",
"moment": "^2.29.4",
"nanoid": "^5.0.7",
"node-forge": "^1.3.1",
"qrcode": "^1.5.3",
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"vuedraggable": "^4.1.0",
"vuex": "^4.0.2",
"xss": "^1.0.14"
"xss": "^1.0.14",
"yup": "^1.4.0"
},
"devDependencies": {
"@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,32 @@
/**
* in,
* eq: 等于
* nin: 不包含
* neq
*/
export enum Operator {
Include = 'in',
Equal = 'eq',
NotEqual = 'neq',
NotInclude = 'nin',
}
export enum PrefixID {
Rule = 'r',
Condition = 'c'
}
export enum Scope {
Question = 'question',
Option = 'option'
}
export type FieldTypes = string | string[];
// 定义事实对象类型
export type Fact = {
[key: string]: any;
};

View File

@ -0,0 +1,145 @@
import { nanoid } from 'nanoid';
import * as yup from 'yup'
import { type FieldTypes, PrefixID, Operator, Scope } from './BasicType'
export function generateID(prefix = PrefixID.Rule) {
return `${prefix}-${nanoid(5)}`
}
// 定义条件规则类
export class ConditionNode {
id: string = '';
public field: string = '';
public operator: Operator = Operator.Include;
public value: FieldTypes = []
constructor(field: string = '', operator: Operator = Operator.Include, value: FieldTypes = []) {
this.field = field;
this.operator = operator;
this.value = value;
this.id = generateID(PrefixID.Condition)
}
setField(field: string) {
this.field = field;
}
setOperator(operator: Operator) {
this.operator = operator;
}
setValue(value: FieldTypes) {
this.value = value;
}
}
export class RuleNode {
id: string = '';
conditions: ConditionNode[] = []
scope: string = Scope.Question
target: string = ''
constructor(scope:string = Scope.Question, target: string = '') {
this.id = generateID(PrefixID.Rule)
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[] = [];
static instance: RuleBuild;
constructor() {
this.rules = [];
if (!RuleBuild.instance) {
RuleBuild.instance = this;
}
return RuleBuild.instance;
}
// 添加条件规则到规则引擎中
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,201 @@
import { Operator, type FieldTypes, type Fact } from "./BasicType";
// 定义条件规则类
export class ConditionNode<F extends string, O extends Operator> {
// 默认显示
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 Operator.Equal:
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 Operator.Include:
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 Operator.NotInclude:
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 Operator.NotEqual:
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, Operator>>; // 使用哈希表存储条件规则对象
public result: boolean = false;
constructor(public target: string, public scope: string) {
this.conditions = new Map();
}
// 添加条件规则到规则引擎中
addCondition(condition: ConditionNode<string, Operator>) {
const hash = condition.calculateHash();
this.conditions.set(hash, condition);
}
// 匹配条件规则
match(fact: Fact) {
const res = Array.from(this.conditions.entries()).every(([, 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(([, 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>;
static instance: any;
constructor() {
this.rules = new Map();
if (!RuleMatch.instance) {
RuleMatch.instance = this;
}
return RuleMatch.instance;
}
fromJson(ruleConf:any) {
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, Operator>) => {
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(([, value]) => {
return [...value.conditions.entries()].filter(([, value]) => {
return value.field === field
})
}))
return [...rules.values()].map(obj => obj.target);
}
toJson() {
return Array.from(this.rules.entries()).map(([, 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) => {
const getQuestionTitle = computed(() => {
const questionDataList = store.state.edit.schema.questionDataList
return () => {
return questionDataList.find((item) => item.field === field)?.title
}
})
const getOptionTitle = computed(() => {
const questionDataList = store.state.edit.schema.questionDataList
return (value) => {
const options = questionDataList.find((item) => item.field === field)?.options || []
if(value instanceof Array) {
return options.filter((item) => value.includes(item.hash)).map((item) => cleanRichText(item.text))
} else {
return options.filter((item) => item.hash === value).map((item) => cleanRichText(item.text))
}
}
})
return { getQuestionTitle, getOptionTitle }
}

View File

@ -0,0 +1,7 @@
import { ref } from 'vue'
import { RuleBuild } from '@/common/logicEngine/RuleBuild'
export const showLogicEngine = ref()
export const initShowLogicEngine = (ruleConf) => {
showLogicEngine.value = new RuleBuild().fromJson(ruleConf)
}

View File

@ -0,0 +1,29 @@
import { computed, unref } from 'vue';
import { useQuestionInfo } from './useQuestionInfo'
import { flatten } from 'lodash-es'
import { cleanRichText } from '@/common/xss'
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine'
// 目标题的显示逻辑提示文案
export const useShowLogicInfo = (field) => {
const hasShowLogic = computed(() => {
const logicEngine = showLogicEngine.value
// 判断该题是否作为了显示逻辑前置题
const isField = logicEngine?.findTargetsByFields(field)?.length > 0
// 判断该题是否作为了显示逻辑目标题
const isTarget = logicEngine?.findTargetsByScope(field)?.length > 0
return isField || isTarget
})
const getShowLogicText = computed(() => {
const logicEngine = showLogicEngine.value
// 获取目标题的规则
const rules = logicEngine?.findConditionByTarget(field) || []
const conditions = flatten(rules).map((item) => {
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 />
</div>
</div>
<div class="logic-text" v-html="getShowLogicText"></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, unref } from 'vue'
import { ElMessageBox } from 'element-plus'
import 'element-plus/theme-chalk/src/message-box.scss'
import { useShowLogicInfo } from '@/management/hooks/useShowLogicInfo'
const props = defineProps({
qIndex: {
@ -57,6 +59,8 @@ const props = defineProps({
})
const emit = defineEmits(['changeSeq', 'select'])
const { getShowLogicText, hasShowLogic } = useShowLogicInfo(props.moduleConfig.field)
const isHover = ref(false)
const itemClass = computed(() => {
@ -111,6 +115,13 @@ const onMoveDown = () => {
isHover.value = false
}
const onDelete = async () => {
if(unref(hasShowLogic)) {
ElMessageBox.alert('该问题被逻辑依赖,请先删除逻辑依赖', '提示', {
confirmButtonText: '确定',
type: 'warning'
})
return
}
try {
await ElMessageBox.confirm('本次操作会影响数据统计查看,是否确认删除?', '提示', {
confirmButtonText: '确定',
@ -178,5 +189,11 @@ const onMove = () => {}
}
}
}
.logic-text{
font-size: 12px;
color: #c8c9cd;
padding: 0 .4rem;
line-height: 26px;
}
}
</style>

View File

@ -22,7 +22,7 @@ import LeftMenu from '@/management/components/LeftMenu.vue'
import CommonTemplate from './components/CommonTemplate.vue'
import Navbar from './components/ModuleNavbar.vue'
import { initShowLogicEngine } from '@/management/hooks/useShowLogicEngine'
export default {
name: 'questionEditPage',
components: {
@ -34,6 +34,8 @@ export default {
this.$store.commit('edit/setSurveyId', this.$route.params.id)
try {
await this.$store.dispatch('edit/init')
// await this.$store.dispatch('logic/initShowLogic', this.$store.state.edit.schema.logicConf.showLogicConf || {})
await initShowLogicEngine(this.$store.state.edit.schema.logicConf.showLogicConf || {})
} catch (error) {
ElMessage.error(error.message)
//

View File

@ -5,14 +5,12 @@
</template>
<script>
import { get as _get } from 'lodash-es'
import { mapState } from 'vuex'
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
import { get as _get } from 'lodash-es'
import { publishSurvey, saveSurvey } from '@/management/api/survey'
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine'
import buildData from './buildData'
export default {
@ -29,6 +27,12 @@ export default {
},
methods: {
async onPublish() {
try {
this.updateLogicConf()
} catch (error) {
ElMessage.error('请检查逻辑配置是否有误')
return
}
const saveData = buildData(this.$store.state.edit.schema)
if (!saveData.surveyId) {
ElMessage.error('未获取到问卷id')
@ -37,6 +41,7 @@ export default {
if (this.isPublishing) {
return
}
try {
this.isPublishing = true
const saveRes = await saveSurvey(saveData)
@ -59,6 +64,14 @@ export default {
} finally {
this.isPublishing = false
}
},
updateLogicConf() {
if(showLogicEngine.value) {
showLogicEngine.value.validateSchema()
const showLogicConf = showLogicEngine.value.toJson()
//
this.$store.dispatch('edit/changeSchema', { key: 'logicConf', value: { showLogicConf } })
}
}
}
}

View File

@ -23,6 +23,7 @@ import 'element-plus/theme-chalk/src/message.scss'
import { saveSurvey } from '@/management/api/survey'
import buildData from './buildData'
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine'
export default {
components: {},
@ -88,6 +89,14 @@ export default {
}, 2000)
}
},
updateLogicConf() {
if(showLogicEngine.value) {
showLogicEngine.value.validateSchema()
const showLogicConf = showLogicEngine.value.toJson()
//
this.$store.dispatch('edit/changeSchema', { key: 'logicConf', value: { showLogicConf } })
}
},
async saveData() {
const saveData = buildData(this.$store.state.edit.schema)
if (!saveData.surveyId) {
@ -102,6 +111,14 @@ export default {
return
}
this.isShowAutoSave = false
try {
this.updateLogicConf()
} catch (error) {
// console.error(error)
ElMessage.error('请检查逻辑配置是否有误')
return
}
try {
this.isSaving = true
const res = await this.saveData()

View File

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

View File

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

View File

@ -0,0 +1,84 @@
<template>
<div class="rule-list">
<RuleNodeView
v-for="(item) 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 { RuleNode, ConditionNode } from '@/common/logicEngine/RuleBuild'
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine'
import RuleNodeView from './components/RuleNodeView.vue'
const list = computed(() => {
return showLogicEngine.value?.rules || []
})
const handleAdd = () => {
const condition = new ConditionNode()
const ruleNode = new RuleNode()
ruleNode.addCondition(condition)
showLogicEngine.value.addRule(ruleNode)
}
const handleDetele = (id: string) => {
showLogicEngine.value.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,195 @@
<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="conditionField"
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="conditionValue"
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 conditionField = computed(() => {
return props.conditionNode.field
})
const conditionValue = computed(() => {
return props.conditionNode.value
})
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,147 @@
<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="ruleTarget"
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 { 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 { showLogicEngine } from '@/management/hooks/useShowLogicEngine'
import ConditionView from './ConditionView.vue'
const renderData = inject<ComputedRef<Array<any>>>('renderData') || ref([])
const props = defineProps({
ruleNode: {
type: RuleNode,
default: () => {}
}
})
const emit = defineEmits(['delete'])
const ruleTarget = computed(() => {
return props.ruleNode.target
})
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: showLogicEngine.value
.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 {
margin-top: 50px;
margin-bottom: 45px;
// min-height: 812px;
overflow-x: hidden;
overflow-y: auto;
padding-right: 30px;

View File

@ -95,25 +95,4 @@ export default {
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>

View File

@ -1,36 +0,0 @@
<template>
<commonTemplate>
<template #left>
<CatalogPanel></CatalogPanel>
</template>
<template #center>
<PreviewPanel></PreviewPanel>
</template>
<template #right>
<SetterPanel></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: 'EditPage',
components: {
commonTemplate,
CatalogPanel,
PreviewPanel,
SetterPanel
}
}
</script>
<style lang="scss" scoped>
.navbar {
border-bottom: 1px solid #e7e9eb;
}
</style>

View File

@ -0,0 +1,32 @@
<template>
<div class="logic-wrapper">
<RulePanel></RulePanel>
</div>
</template>
<script setup lang="ts">
import { computed, provide } from 'vue'
import RulePanel 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: 'editIndex',
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

@ -5,7 +5,7 @@
</template>
<script>
import SettingPanel from '../modules/settingModule/SettingPanel.vue'
import SettingPanel from '../../modules/settingModule/SettingPanel.vue'
export default {
name: 'SettingPage',
components: {

View File

@ -1,5 +1,5 @@
<template>
<commonTemplate>
<CommonTemplate>
<template #left>
<CatalogPanel />
</template>
@ -9,10 +9,10 @@
<template #right>
<SetterPanel />
</template>
</commonTemplate>
</CommonTemplate>
</template>
<script>
import commonTemplate from '../../components/CommonTemplate.vue'
import CommonTemplate from '../../components/CommonTemplate.vue'
import CatalogPanel from '../../modules/settingModule/skin/CatalogPanel.vue'
import PreviewPanel from '../../modules/settingModule/skin/PreviewPanel.vue'
import SetterPanel from '../../modules/settingModule/skin/SetterPanel.vue'
@ -20,7 +20,7 @@ import SetterPanel from '../../modules/settingModule/skin/SetterPanel.vue'
export default {
name: 'ContentPage',
components: {
commonTemplate,
CommonTemplate,
CatalogPanel,
PreviewPanel,
SetterPanel

View File

@ -1,5 +1,5 @@
<template>
<commonTemplate>
<CommonTemplate>
<template #left>
<ResultCatalog />
</template>
@ -9,11 +9,11 @@
<template #right>
<ResultSetter />
</template>
</commonTemplate>
</CommonTemplate>
</template>
<script>
import commonTemplate from '../../components/CommonTemplate.vue'
import CommonTemplate from '../../components/CommonTemplate.vue'
import ResultCatalog from '../../modules/settingModule/result/CatalogPanel.vue'
import ResultPreview from '../../modules/settingModule/result/PreviewPanel.vue'
import ResultSetter from '../../modules/settingModule/result/SetterPanel.vue'
@ -21,7 +21,7 @@ import ResultSetter from '../../modules/settingModule/result/SetterPanel.vue'
export default {
name: 'ResultPage',
components: {
commonTemplate,
CommonTemplate,
ResultCatalog,
ResultPreview,
ResultSetter

View File

@ -1,5 +1,5 @@
export const type = {
normal: '调查问卷',
normal: '基础调查',
vote: '投票评选',
nps: 'NPS评分',
register: '在线报名'

View File

@ -21,7 +21,17 @@ const routes: RouteRecordRaw[] = [
meta: {
needLogin: true
},
name: 'QuestionEdit',
component: () => import('../pages/edit/index.vue'),
children: [
{
path: '',
meta: {
needLogin: true
},
name: 'QuestionEditPage',
component: () =>
import('../pages/edit/pages/edit/index.vue'),
children: [
{
path: '',
@ -29,7 +39,19 @@ const routes: RouteRecordRaw[] = [
meta: {
needLogin: true
},
component: () => import('../pages/edit/pages/EditPage.vue')
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',
@ -37,11 +59,10 @@ const routes: RouteRecordRaw[] = [
meta: {
needLogin: true
},
component: () => import('../pages/edit/pages/SettingPage.vue')
component: () => import('../pages/edit/pages/setting/index.vue')
},
{
path: 'skin',
// name: 'SkinSetting',
meta: {
needLogin: true
},
@ -97,7 +118,7 @@ const routes: RouteRecordRaw[] = [
name: 'login',
component: () => import('../pages/login/LoginPage.vue'),
meta: {
title: '登'
title: '登'
}
}
]

View File

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

View File

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

View File

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

View File

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

View File

@ -3,3 +3,19 @@ export const QOP_MAP = {
COPY: 'copy',
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 { cloneDeep as _cloneDeep, map as _map } from 'lodash-es'
const generateQuestionField = () => {
const num = Math.floor(Math.random() * 1000)
return `data${num}`
}
function getRandom(len) {
export function getRandom(len) {
return Math.random()
.toString()
.slice(len && typeof len === 'number' ? 0 - len : -6)

View File

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

View File

@ -49,7 +49,7 @@
</div>
</template>
<script setup lang="ts">
<script setup>
import { ref, computed, inject } from 'vue'
import OptionConfig from '../AdvancedConfig/OptionConfig.vue'
import RateConfig from '../AdvancedConfig/RateConfig.vue'
@ -75,7 +75,7 @@ defineProps({
})
const emit = defineEmits(['addOther', 'optionChange', 'change'])
const moduleConfig = inject('moduleConfig') as any
const moduleConfig = inject('moduleConfig')
const optionConfigVisible = ref(false)
const openOptionConfig = () => {
console.log('open')
@ -85,10 +85,10 @@ const openOptionConfig = () => {
const addOther = () => {
emit('addOther')
}
const handleOptionChange = (value: any[]) => {
const handleOptionChange = (value) => {
emit('optionChange', value)
}
const handleChange = (data: any) => {
const handleChange = (data) => {
emit('change', data)
}
@ -96,7 +96,7 @@ const rateConfigVisible = ref(false)
const openRateConfig = () => {
rateConfigVisible.value = true
}
const onVisibleChange = (val: boolean) => {
const onVisibleChange = (val) => {
rateConfigVisible.value = val
}

View File

@ -17,6 +17,7 @@ import AlertDialog from './components/AlertDialog.vue'
import LogoIcon from './components/LogoIcon.vue'
import { get as _get } from 'lodash-es'
import { initRuleEngine } from '@/render/hooks/useRuleEngine.js'
export default {
name: 'App',
@ -55,7 +56,7 @@ export default {
const res = await getPublishedSurveyInfo({ surveyPath })
if (res.code === 200) {
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
const questionData = {
bannerConf,
@ -68,6 +69,7 @@ export default {
this.setSkin(skinConf)
this.$store.commit('setSurveyPath', surveyPath)
this.$store.dispatch('init', questionData)
initRuleEngine(logicConf?.showLogicConf)
this.$store.dispatch('getEncryptInfo')
} else {
throw new Error(res.errmsg)
@ -90,7 +92,6 @@ export default {
root.style.setProperty('--primary-background-color', backgroundConf?.color) //
}
if (contentConf?.opacity.toString()) {
console.log({ opacity: contentConf?.opacity / 100 })
root.style.setProperty('--opacity', contentConf?.opacity / 100) //
}
}

View File

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

View File

@ -1,8 +1,7 @@
<template>
<form ref="ruleForm" :model="formModel" :rules="rules">
<questionWrapper
v-for="item in renderData"
:key="item.field"
<form ref="ruleForm" :model="formValues" :rules="rules">
<div v-for="(item) in renderData" :key="item.field">
<QuestionWrapper
class="gap"
v-bind="$attrs"
:moduleConfig="item"
@ -10,15 +9,15 @@
:indexNumber="item.indexNumber"
:showTitle="true"
@change="handleChange"
></questionWrapper>
></QuestionWrapper>
</div>
</form>
</template>
<script setup>
import { inject, provide, computed, onBeforeMount } from 'vue'
import questionWrapper from '../../materials/questions/QuestionRuleContainer'
import QuestionWrapper from './QuestionWrapper.vue'
const $bus = inject('$bus')
const props = defineProps({
rules: {
type: Object,
@ -26,7 +25,7 @@ const props = defineProps({
return {}
}
},
formModel: {
formValues: {
type: Object,
default: () => {
return {}
@ -39,6 +38,7 @@ const props = defineProps({
}
}
})
const emit = defineEmits(['formChange', 'blur'])
// 使changechangeinput
@ -50,7 +50,7 @@ const handleChange = (data) => {
const fields = []
provide('Form', {
model: computed(() => {
return props.formModel
return props.formValues
}),
rules: computed(() => {
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 length = fields.length

View File

@ -0,0 +1,100 @@
<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'
import { ruleEngine } from '@/render/hooks/useRuleEngine.js'
import { QUESTION_TYPE } from '@/render/constant/index'
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 === QUESTION_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(QUESTION_TYPE.CHOICES.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(QUESTION_TYPE.RATES.includes(type) && Object.keys(rest.rangeConfig).filter(index => rest.rangeConfig[index].isShowInput).length > 0) {
let { rangeConfig, othersValue } = useShowInput(field)
moduleConfig.rangeConfig = unref(rangeConfig)
moduleConfig.othersValue = unref(othersValue)
}
return {
...moduleConfig,
options: alloptions,
value: formValues.value[props.moduleConfig.field]
}
})
const { field } = props.moduleConfig
const visible = computed(() => {
// computedmatch
return ruleEngine.match(field, 'question', formValues.value)
})
watch(() => visible.value, (newVal, oldVal) => {
//
const { field, type, innerType } = props.moduleConfig
if(!newVal && oldVal) {
let value = ''
// innerType
if (type === QUESTION_TYPE.CHECKBOX || innerType === QUESTION_TYPE.CHECKBOX) {
value = value ? [value] : []
}
const data = {
key: field,
value: value
}
store.commit('changeFormData', data)
}
})
const handleChange = (data) => {
emit('change', data)
//
if(props.moduleConfig.type === QUESTION_TYPE.VOTE) {
store.dispatch('updateVoteData', data)
}
}
</script>

View File

@ -0,0 +1,12 @@
export const QUESTION_TYPE = {
VOTE: 'vote',
CHECKBOX: 'checkbox',
CHOICES: [ // 选择类题型分类
'radio',
'checkbox',
],
RATES: [ // 评分题题型分类
'radio-star',
'radio-nps'
]
}

View File

@ -0,0 +1,6 @@
import { RuleMatch } from '@/common/logicEngine/RulesMatch'
export const ruleEngine = new RuleMatch()
export const initRuleEngine = (ruleConf) => {
ruleEngine.fromJson(ruleConf)
}

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,17 @@
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

@ -1,12 +1,12 @@
<template>
<div class="index">
<progressBar />
<ProgressBar />
<div class="wrapper" ref="box">
<HeaderSetter></HeaderSetter>
<div class="content">
<MainTitle></MainTitle>
<MainRenderer ref="main"></MainRenderer>
<submit :validate="validate" :renderData="renderData" @submit="onSubmit"></submit>
<Submit :validate="validate" :renderData="renderData" @submit="onSubmit"></Submit>
<LogoIcon />
</div>
</div>
@ -16,17 +16,18 @@
<script>
import HeaderSetter from '../components/HeaderSetter.vue'
import MainTitle from '../components/MainTitle.vue'
import submit from '../components/SubmitSetter.vue'
import Submit from '../components/SubmitSetter.vue'
import MainRenderer from '../components/MainRenderer.vue'
import AlertDialog from '../components/AlertDialog.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import progressBar from '../components/ProgressBar.vue'
import ProgressBar from '../components/ProgressBar.vue'
import LogoIcon from '../components/LogoIcon.vue'
import { submitForm } from '../api/survey'
import encrypt from '../utils/encrypt'
import useCommandComponent from '../hooks/useCommandComponent'
import { cloneDeep } from 'lodash-es'
export default {
name: 'indexPage',
@ -43,15 +44,12 @@ export default {
components: {
HeaderSetter,
MainTitle,
submit,
Submit,
MainRenderer,
progressBar,
ProgressBar,
LogoIcon
},
computed: {
formModel() {
return this.$store.getters.formModel
},
confirmAgain() {
return this.$store.state.submitConf.confirmAgain
},
@ -94,9 +92,11 @@ export default {
}
},
getSubmitData() {
const formValues = cloneDeep(this.$store.state.formValues)
const result = {
surveyPath: this.surveyPath,
data: JSON.stringify(this.formModel),
data: JSON.stringify(formValues),
difTime: Date.now() - this.$store.state.enterTime,
clientTime: Date.now()
}
@ -118,6 +118,7 @@ export default {
async submitForm() {
try {
const submitData = this.getSubmitData()
const res = await submitForm(submitData)
if (res.code === 200) {
this.$store.commit('setRouter', 'successPage')

View File

@ -5,6 +5,7 @@ import 'moment/locale/zh-cn'
moment.locale('zh-cn')
import adapter from '../adapter'
import { queryVote, getEncryptInfo } from '@/render/api/survey'
import { RuleMatch } from '@/common/logicEngine/RulesMatch'
/**
* CODE_MAP不从management引入在dev阶段会导致B端 router被加载进而导致C端路由被添加 baseUrl: /management
*/
@ -13,6 +14,7 @@ const CODE_MAP = {
ERROR: 500,
NO_AUTH: 403
}
const VOTE_INFO_KEY = 'voteinfo'
export default {
// 初始化
@ -101,18 +103,63 @@ export default {
return
}
try {
localStorage.removeItem(VOTE_INFO_KEY)
const voteRes = await queryVote({
surveyPath,
fieldList: fieldList.join(',')
})
if (voteRes.code === 200) {
localStorage.setItem(
VOTE_INFO_KEY,
JSON.stringify({
...voteRes.data
})
)
commit('setVoteMap', voteRes.data)
}
} catch (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 }) {
try {
const res = await getEncryptInfo()
@ -122,5 +169,9 @@ export default {
} catch (error) {
console.log(error)
}
},
async initRuleEngine({ commit }, ruleConf) {
const ruleEngine = new RuleMatch(ruleConf)
commit('setRuleEgine', ruleEngine)
}
}

View File

@ -1,117 +1,31 @@
import { flatten } from 'lodash-es'
export default {
// 题目列表
renderData: (state) => {
const { questionSeq, questionData, formValues } = state
const { questionSeq, questionData } = state
let index = 1
return (
questionSeq &&
questionSeq.reduce((pre, item) => {
const questionArr = []
for (const questionKey of item) {
item.forEach(questionKey => {
console.log('题目重新计算')
const question = { ...questionData[questionKey] }
const { type, extraOptions, options, rangeConfig } = question
const questionVal = formValues[questionKey]
question.value = questionVal
// 本题开启了
// 开启显示序号
if (question.showIndex) {
question.indexNumber = index++
}
const allOptions = []
if (Array.isArray(extraOptions)) {
allOptions.push(...extraOptions)
}
if (Array.isArray(options)) {
allOptions.push(...options)
}
let othersValue = {}
let voteTotal = 0
const voteMap = state.voteMap
if (/vote/.test(type)) {
voteTotal = voteMap?.[questionKey]?.total || 0
}
// 遍历所有的选项
for (const optionItem of allOptions) {
// 开启了更多输入框生成othersValue的值
if (optionItem.others) {
const opKey = `${questionKey}_${optionItem.hash}`
optionItem.othersKey = opKey
optionItem.othersValue = formValues[opKey]
othersValue[opKey] = formValues[opKey]
}
// 投票题,用户手动选择选项后,要实时更新展示数据和进度
if (/vote/.test(type)) {
const voteCount = voteMap?.[questionKey]?.[optionItem.hash] || 0
if (
Array.isArray(questionVal)
? questionVal.includes(optionItem.hash)
: questionVal === optionItem.hash
) {
optionItem.voteCount = voteCount + 1
voteTotal = voteTotal + 1
} else {
optionItem.voteCount = voteCount
}
question.voteTotal = voteTotal
}
}
// 开启了更多输入框要将当前的value赋值给question
if (rangeConfig && Object.keys(rangeConfig).length > 0 && rangeConfig[questionVal]) {
const curRange = rangeConfig[questionVal]
if (curRange?.isShowInput) {
const rangeKey = `${questionKey}_${questionVal}`
curRange.othersKey = rangeKey
curRange.othersValue = formValues[rangeKey]
othersValue[rangeKey] = formValues[rangeKey]
}
}
// 将othersValue赋值给
question.othersValue = othersValue
questionArr.push(question)
}
})
if (questionArr && questionArr.length) {
pre.push(questionArr)
}
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) {
let { key, value } = data
// console.log('formValues', key, value)
set(state, `formValues.${key}`, value)
// set(state, `questionData.${key}.value`, value)
},
changeSelectMoreData(state, data) {
const { key, value, field } = data
@ -36,10 +36,21 @@ export default {
setVoteMap(state, 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) {
state.questionSeq = data
},
setEncryptInfo(state, data) {
state.encryptInfo = data
}
},
setRuleEgine(state, ruleEngine) {
state.ruleEngine = ruleEngine
},
}

View File

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

3977
web/yarn.lock Normal file

File diff suppressed because it is too large Load Diff