feat: 跳转逻辑稳定版 (#399)

* feat: 跳转逻辑 (#388)

* fix: 跳转逻辑优化 (#397)

* fix: 跳转逻辑优化

* fix: processJumpSkip逻辑放在题目组件中
This commit is contained in:
dayou 2024-08-14 17:59:51 +08:00 committed by sudoooooo
parent e7adb05c3d
commit c3f8b2a938
41 changed files with 1425 additions and 204 deletions

1
.gitignore vendored
View File

@ -25,6 +25,7 @@ pnpm-debug.log*
*.sw? *.sw?
.history .history
components.d.ts
# 默认的上传文件夹 # 默认的上传文件夹
userUpload userUpload

78
web/components.d.ts vendored
View File

@ -1,78 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
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']
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']
ElMenuItemGroup: typeof import('element-plus/es')['ElMenuItemGroup']
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']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSelectV2: typeof import('element-plus/es')['ElSelectV2']
ElSlider: typeof import('element-plus/es')['ElSlider']
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']
ElTree: typeof import('element-plus/es')['ElTree']
IEpArrowLeft: typeof import('~icons/ep/arrow-left')['default']
IEpArrowRight: typeof import('~icons/ep/arrow-right')['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']
IEpConnection: typeof import('~icons/ep/connection')['default']
IEpCopyDocument: typeof import('~icons/ep/copy-document')['default']
IEpDelete: typeof import('~icons/ep/delete')['default']
IEpIphone: typeof import('~icons/ep/iphone')['default']
IEpLoading: typeof import('~icons/ep/loading')['default']
IEpMinus: typeof import('~icons/ep/minus')['default']
IEpMonitor: typeof import('~icons/ep/monitor')['default']
IEpMore: typeof import('~icons/ep/more')['default']
IEpMoreFilled: typeof import('~icons/ep/more-filled')['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']
IEpSearch: typeof import('~icons/ep/search')['default']
IEpSort: typeof import('~icons/ep/sort')['default']
IEpSortDown: typeof import('~icons/ep/sort-down')['default']
IEpSortUp: typeof import('~icons/ep/sort-up')['default']
IEpTop: typeof import('~icons/ep/top')['default']
IEpView: typeof import('~icons/ep/view')['default']
IEpWarningFilled: typeof import('~icons/ep/warning-filled')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@ -14,6 +14,8 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"@logicflow/core": "2.0.0",
"@logicflow/extension": "2.0.0",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12", "@wangeditor/editor-for-vue": "^5.1.12",
"async-validator": "^4.2.5", "async-validator": "^4.2.5",

View File

@ -33,8 +33,8 @@ export class RuleNode {
conditions: ConditionNode[] = [] conditions: ConditionNode[] = []
scope: string = Scope.Question scope: string = Scope.Question
target: string = '' target: string = ''
constructor(scope: string = Scope.Question, target: string = '') { constructor(target: string = '', scope: string = Scope.Question, id?: string) {
this.id = generateID(PrefixID.Rule) this.id = id || generateID(PrefixID.Rule)
this.scope = scope this.scope = scope
this.target = target this.target = target
} }
@ -54,14 +54,8 @@ export class RuleNode {
export class RuleBuild { export class RuleBuild {
rules: RuleNode[] = [] rules: RuleNode[] = []
static instance: RuleBuild
constructor() { constructor() {
this.rules = [] this.rules = []
if (!RuleBuild.instance) {
RuleBuild.instance = this
}
return RuleBuild.instance
} }
// 添加条件规则到规则引擎中 // 添加条件规则到规则引擎中
@ -71,6 +65,9 @@ export class RuleBuild {
removeRule(ruleId: string) { removeRule(ruleId: string) {
this.rules = this.rules.filter((rule) => rule.id !== ruleId) this.rules = this.rules.filter((rule) => rule.id !== ruleId)
} }
clear() {
this.rules = []
}
findRule(ruleId: string) { findRule(ruleId: string) {
return this.rules.find((rule) => rule.id === ruleId) return this.rules.find((rule) => rule.id === ruleId)
} }
@ -94,7 +91,7 @@ export class RuleBuild {
if (ruleConf instanceof Array) { if (ruleConf instanceof Array) {
ruleConf.forEach((rule: any) => { ruleConf.forEach((rule: any) => {
const { scope, target } = rule const { scope, target } = rule
const ruleNode = new RuleNode(scope, target) const ruleNode = new RuleNode(target, scope)
rule.conditions.forEach((condition: any) => { rule.conditions.forEach((condition: any) => {
const { field, operator, value } = condition const { field, operator, value } = condition
const conditionNode = new ConditionNode(field, operator, value) const conditionNode = new ConditionNode(field, operator, value)
@ -112,19 +109,19 @@ export class RuleBuild {
findTargetsByScope(scope: string) { findTargetsByScope(scope: string) {
return this.rules.filter((rule) => rule.scope === scope).map((rule) => rule.target) return this.rules.filter((rule) => rule.scope === scope).map((rule) => rule.target)
} }
findRulesByField(field: string) {
return this.rules.filter((rule) => {
return rule.conditions.filter((condition) => condition.field === field).length
})
}
// 实现前置题删除校验 // 实现前置题删除校验
findTargetsByFields(field: string) { findTargetsByField(field: string) {
const nodes = this.rules.filter((rule: RuleNode) => { const nodes = this.findRulesByField(field)
const conditions = rule.conditions.filter((item: any) => {
return item.field === field
})
return conditions.length > 0
})
return nodes.map((item: any) => { return nodes.map((item: any) => {
return item.target return item.target
}) })
} }
// 根据目标题获取显示逻辑 // 根据目标题获取关联的逻辑条件
findConditionByTarget(target: string) { findConditionByTarget(target: string) {
return this.rules.filter((rule) => rule.target === target).map((item) => item.conditions) return this.rules.filter((rule) => rule.target === target).map((item) => item.conditions)
} }

View File

@ -3,7 +3,7 @@ import { Operator, type FieldTypes, type Fact } from './BasicType'
// 定义条件规则类 // 定义条件规则类
export class ConditionNode<F extends string, O extends Operator> { export class ConditionNode<F extends string, O extends Operator> {
// 默认显示 // 默认显示
public result: boolean = false public result: boolean | undefined = undefined
constructor( constructor(
public field: F, public field: F,
public operator: O, public operator: O,
@ -16,7 +16,7 @@ export class ConditionNode<F extends string, O extends Operator> {
return this.field + this.operator + this.value return this.field + this.operator + this.value
} }
match(facts: Fact): boolean { match(facts: Fact): boolean | undefined {
// console.log(this.calculateHash()) // console.log(this.calculateHash())
// 如果该特征在事实对象中不存在则直接返回false // 如果该特征在事实对象中不存在则直接返回false
if (!facts[this.field]) { if (!facts[this.field]) {
@ -45,7 +45,7 @@ export class ConditionNode<F extends string, O extends Operator> {
this.result = this.value.some((v) => !facts[this.field].includes(v)) this.result = this.value.some((v) => !facts[this.field].includes(v))
return this.result return this.result
} else { } else {
this.result = facts[this.field].includes(this.value) this.result = !facts[this.field].includes(this.value)
return this.result return this.result
} }
case Operator.NotEqual: case Operator.NotEqual:
@ -53,7 +53,7 @@ export class ConditionNode<F extends string, O extends Operator> {
this.result = this.value.every((v) => !facts[this.field].includes(v)) this.result = this.value.every((v) => !facts[this.field].includes(v))
return this.result return this.result
} else { } else {
this.result = facts[this.field].includes(this.value) this.result = facts[this.field].toString() !== this.value
return this.result return this.result
} }
// 其他比较操作符的判断逻辑 // 其他比较操作符的判断逻辑
@ -69,7 +69,7 @@ export class ConditionNode<F extends string, O extends Operator> {
export class RuleNode { export class RuleNode {
conditions: Map<string, ConditionNode<string, Operator>> // 使用哈希表存储条件规则对象 conditions: Map<string, ConditionNode<string, Operator>> // 使用哈希表存储条件规则对象
public result: boolean = false public result: boolean | undefined
constructor( constructor(
public target: string, public target: string,
public scope: string public scope: string
@ -83,8 +83,10 @@ export class RuleNode {
} }
// 匹配条件规则 // 匹配条件规则
match(fact: Fact) { match(fact: Fact, comparor?: any) {
const res = Array.from(this.conditions.entries()).every(([, value]) => { let res: boolean | undefined = undefined
if (comparor === 'or') {
res = Array.from(this.conditions.entries()).some(([, value]) => {
const res = value.match(fact) const res = value.match(fact)
if (res) { if (res) {
return true return true
@ -92,6 +94,17 @@ export class RuleNode {
return false return false
} }
}) })
} else {
res = Array.from(this.conditions.entries()).every(([, value]) => {
const res = value.match(fact)
if (res) {
return true
} else {
return false
}
})
}
this.result = res this.result = res
return res return res
} }
@ -121,14 +134,14 @@ export class RuleNode {
export class RuleMatch { export class RuleMatch {
rules: Map<string, RuleNode> rules: Map<string, RuleNode>
static instance: any // static instance: any
constructor() { constructor() {
this.rules = new Map() this.rules = new Map()
if (!RuleMatch.instance) { // if (!RuleMatch.instance) {
RuleMatch.instance = this // RuleMatch.instance = this
} // }
return RuleMatch.instance // return RuleMatch.instance
} }
fromJson(ruleConf: any) { fromJson(ruleConf: any) {
if (ruleConf instanceof Array) { if (ruleConf instanceof Array) {
@ -145,6 +158,7 @@ export class RuleMatch {
this.addRule(ruleNode) this.addRule(ruleNode)
}) })
} }
return this
} }
// 添加条件规则到规则引擎中 // 添加条件规则到规则引擎中
@ -160,22 +174,31 @@ export class RuleMatch {
this.rules.set(hash, rule) this.rules.set(hash, rule)
} }
// 匹配条件规则 // 特定目标题规则匹配
match(target: string, scope: string, fact: Fact) { match(target: string, scope: string, fact: Fact, comparor?: any) {
const hash = this.calculateHash(target, scope) const hash = this.calculateHash(target, scope)
const rule = this.rules.get(hash) const rule = this.rules.get(hash)
if (rule) { if (rule) {
const result = rule.match(fact) const result = rule.match(fact, comparor)
// this.matchCache.set(hash, result);
return result return result
} else { } else {
// 默认显示 // 默认显示
return true return true
} }
} }
/* 获取条件题关联的多个目标题匹配情况 */
getResult(target: string, scope: string) { getResultsByField(field: string, fact: Fact) {
const rules = this.findRulesByField(field)
return rules.map(([, rule]) => {
return {
target: rule.target,
result: this.match(rule.target, 'question', fact, 'or')
}
})
}
/* 获取目标题的规则是否匹配 */
getResultByTarget(target: string, scope: string) {
const hash = this.calculateHash(target, scope) const hash = this.calculateHash(target, scope)
const rule = this.rules.get(hash) const rule = this.rules.get(hash)
if (rule) { if (rule) {
@ -191,15 +214,18 @@ export class RuleMatch {
// 假设哈希值计算方法为简单的字符串拼接或其他哈希算法 // 假设哈希值计算方法为简单的字符串拼接或其他哈希算法
return target + scope return target + scope
} }
findTargetsByField(field: string) { // 查找条件题的规则
const rules = new Map( findRulesByField(field: string) {
[...this.rules.entries()].filter(([, value]) => { const list = [...this.rules.entries()]
return [...value.conditions.entries()].filter(([, value]) => { const match = list.filter(([, ruleValue]) => {
return value.field === field const list = [...ruleValue.conditions.entries()]
const res = list.filter(([, conditionValue]) => {
const hit = conditionValue.field === field
return hit
}) })
return res.length
}) })
) return match
return [...rules.values()].map((obj) => obj.target)
} }
toJson() { toJson() {
return Array.from(this.rules.entries()).map(([, value]) => { return Array.from(this.rules.entries()).map(([, value]) => {

View File

@ -53,7 +53,7 @@ export const defaultQuestionConfig = {
star: 5, star: 5,
optionOrigin: '', optionOrigin: '',
originType: 'selected', originType: 'selected',
innerType:'', innerType: '',
matrixOptionsRely: '', matrixOptionsRely: '',
numberRange: { numberRange: {
min: { min: {

View File

@ -0,0 +1,171 @@
import { useEditStore } from '../stores/edit'
import { Operator } from '@/common/logicEngine/BasicType'
import { cleanRichText } from '@/common/xss'
import { CHOICES } from '@/common/typeEnum'
export const generateNodes = (questionDataList: [any]) => {
let x = 50
const y = 300
const startNode = [
{
id: 'start',
type: 'start-node',
x: 50,
y,
text: '开始'
}
]
const nodes: any[] = questionDataList.map((item) => {
x = x + 300
let options = []
if (CHOICES.includes(item.type)) {
options = item?.options.map((option: any) => {
return {
key: option?.hash,
type: cleanRichText(option?.text)
}
})
}
return {
id: item?.field,
type: 'q-node',
x,
y,
properties: {
questionType: item?.type,
field: item.field,
title: cleanRichText(item?.title),
options
}
}
})
const endNode = [
{
id: 'end',
type: 'end-node',
x: x + 200,
y,
text: '结束'
}
]
return startNode.concat(nodes).concat(endNode)
}
/* 跳转逻辑的初始化 */
export const generateLine = (models: Array<any>) => {
const acc: Array<any> = []
const editStore = useEditStore()
const jumpLogicRule = editStore.jumpLogicEngine?.toJson()
const edges = models.reduce((prev: any, point: any, index: number, array: any[]) => {
if (index === 0) {
return acc
}
const previousPoint: any = array[index - 1]
if (!previousPoint) {
return acc
}
let edge
if (previousPoint?.type === 'start-node') {
// 开始节点连接线
edge = {
type: 'q-edge',
sourceNodeId: previousPoint?.id,
targetNodeId: point?.id,
sourceAnchorId: `${previousPoint.anchors[0].id}`,
targetAnchorId: `${point?.anchors[0].id}`,
// properties: {
draggable: false
// }
}
acc.push(edge)
} else if (previousPoint?.type === 'q-node') {
// 生成题目节点连接线
// 方案1以条件节点为主体
const editStore = useEditStore()
const rules = editStore.jumpLogicEngine.findRulesByField(previousPoint.id)
if (!jumpLogicRule.length || !rules.length) {
edge = {
type: 'q-edge',
sourceNodeId: previousPoint?.id,
targetNodeId: point?.id,
sourceAnchorId: `${previousPoint.anchors[1].id}`,
targetAnchorId: `${point?.anchors[0].id}`
}
acc.push(edge)
} else {
const hasDefault = rules.filter((i: any) => {
return i.conditions.filter((item: any) => item.operator === Operator.NotEqual).length
})
if (!hasDefault.length) {
// 如果规则中没有默认答题跳转则生成一条默认的题目答完链接线
edge = {
type: 'q-edge',
sourceNodeId: previousPoint?.id,
targetNodeId: point?.id,
sourceAnchorId: `${previousPoint.anchors[1].id}`,
targetAnchorId: `${point?.anchors[0].id}`
}
acc.push(edge)
}
rules.forEach((rule: any) => {
const condition = rule.conditions[0]
let sourceAnchorId = `${condition.field}_right`
if (condition.operator === 'in') {
sourceAnchorId = `${condition.value}_right`
}
const targetAnchorId = `${rule.target}_left`
edge = {
type: 'q-edge',
sourceNodeId: previousPoint?.id,
targetNodeId: rule.target,
sourceAnchorId: `${sourceAnchorId}`,
targetAnchorId: `${targetAnchorId}`,
properties: {
ruleId: rule.id
}
}
acc.push(edge)
})
}
} else {
edge = {
type: 'q-edge',
sourceNodeId: previousPoint?.id,
targetNodeId: point?.id,
sourceAnchorId: `${previousPoint.anchors[1].id}`,
targetAnchorId: `${point?.anchors[0].id}`,
draggable: false
}
acc.push(edge)
}
return acc
})
return edges
}
export const getNodesStep = (source: string, target: string, questionDataList: any[]) => {
const sourceIndex = questionDataList.findIndex((item: any) => item.field === source)
const targetIndex = questionDataList.findIndex((item: any) => item.field === target)
return targetIndex - sourceIndex
}
export const getCondition = (sourceInfo: any): any => {
const { nodeId, anchorId } = sourceInfo
const anchorKey = anchorId.split('_right')[0]
if (nodeId === anchorKey) {
// 答完跳转
return {
field: nodeId,
operator: Operator.NotEqual,
value: ''
}
} else {
// 选中optionhash跳转
return {
field: nodeId,
operator: Operator.Include,
value: anchorKey
}
}
}

View File

@ -0,0 +1,42 @@
import { computed, unref } from 'vue'
import { useQuestionInfo } from './useQuestionInfo'
import { useEditStore } from '../stores/edit'
import { storeToRefs } from 'pinia'
const editStore = useEditStore()
const { jumpLogicEngine } = storeToRefs(editStore)
// 目标题的显示逻辑提示文案
export const useJumpLogicInfo = (field) => {
const hasJumpLogic = computed(() => {
const logicEngine = jumpLogicEngine.value
// 判断该题是否作为了跳转逻辑条件
const isField = logicEngine?.findTargetsByField(field)?.length > 0
// 判断该题是否作为了跳转目标
const isTarget = logicEngine?.findConditionByTarget(field)?.length > 0
return isField || isTarget
})
const getJumpLogicText = computed(() => {
const logicEngine = jumpLogicEngine.value
if (!logicEngine) return
// 获取跳转
const rules = logicEngine?.findRulesByField(field) || []
if (!rules) return
const ruleText = rules.map((rule) => {
const conditions = rule.conditions.map((condition) => {
const { getOptionTitle } = useQuestionInfo(condition.field)
if (condition.operator === 'in') {
return `<span> 选择了 【${getOptionTitle.value(unref(condition.value)).join('')}】</span>`
} else if (condition.operator === 'neq') {
return `<span> 答完题目 </span>`
}
return ''
})
const { getQuestionTitle } = useQuestionInfo(rule.target)
return (
conditions.join('') + `<span> &nbsp;则跳转到 【${getQuestionTitle.value()}】</span> </br>`
)
})
return ruleText.join('')
})
return { hasJumpLogic, getJumpLogicText }
}

View File

@ -8,7 +8,8 @@ export const useQuestionInfo = (field) => {
const getQuestionTitle = computed(() => { const getQuestionTitle = computed(() => {
return () => { return () => {
return questionDataList.value.find((item) => item.field === field)?.title if (field === 'end') return '问卷末尾'
return cleanRichText(questionDataList.value.find((item) => item.field === field)?.title)
} }
}) })
const getOptionTitle = computed(() => { const getOptionTitle = computed(() => {

View File

@ -1,7 +0,0 @@
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

@ -2,16 +2,19 @@ import { computed, unref } from 'vue'
import { useQuestionInfo } from './useQuestionInfo' import { useQuestionInfo } from './useQuestionInfo'
import { flatten } from 'lodash-es' import { flatten } from 'lodash-es'
import { cleanRichText } from '@/common/xss' import { cleanRichText } from '@/common/xss'
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine' import { useEditStore } from '../stores/edit'
import { storeToRefs } from 'pinia'
const editStore = useEditStore()
const { showLogicEngine } = storeToRefs(editStore)
// 目标题的显示逻辑提示文案 // 目标题的显示逻辑提示文案
export const useShowLogicInfo = (field) => { export const useShowLogicInfo = (field) => {
const hasShowLogic = computed(() => { const hasShowLogic = computed(() => {
const logicEngine = showLogicEngine.value const logicEngine = showLogicEngine.value
// 判断该题是否作为了显示逻辑前置题 // 判断该题是否作为了显示逻辑前置题
const isField = logicEngine?.findTargetsByFields(field)?.length > 0 const isField = logicEngine?.findTargetsByField(field)?.length > 0
// 判断该题是否作为了显示逻辑目标题 // 判断该题是否作为了显示逻辑目标题
const isTarget = logicEngine?.findTargetsByScope(field)?.length > 0 const isTarget = logicEngine?.findConditionByTarget(field)?.length > 0
return isField || isTarget return isField || isTarget
}) })
const getShowLogicText = computed(() => { const getShowLogicText = computed(() => {

View File

@ -29,8 +29,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useEditStore } from '@/management/stores/edit' import { useEditStore } from '@/management/stores/edit'
import { storeToRefs } from 'pinia'
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine'
import BackPanel from '../modules/generalModule/BackPanel.vue' import BackPanel from '../modules/generalModule/BackPanel.vue'
import TitlePanel from '../modules/generalModule/TitlePanel.vue' import TitlePanel from '../modules/generalModule/TitlePanel.vue'
@ -44,6 +43,8 @@ import CooperationPanel from '../modules/contentModule/CooperationPanel.vue'
const editStore = useEditStore() const editStore = useEditStore()
const { schema, changeSchema } = editStore const { schema, changeSchema } = editStore
const title = computed(() => (editStore.schema?.metaData as any)?.title || '') const title = computed(() => (editStore.schema?.metaData as any)?.title || '')
const { showLogicEngine, jumpLogicEngine } = storeToRefs(editStore)
// - // -
const updateLogicConf = () => { const updateLogicConf = () => {
let res = { let res = {
@ -67,12 +68,16 @@ const updateLogicConf = () => {
} }
const showLogicConf = showLogicEngine.value.toJson() const showLogicConf = showLogicEngine.value.toJson()
// //
changeSchema({ key: 'logicConf', value: { showLogicConf } }) changeSchema({ key: 'logicConf', value: { showLogicConf } })
return res return res
} }
const jumpLogicConf = jumpLogicEngine.value.toJson()
changeSchema({ key: 'logicConf', value: { jumpLogicConf } })
return res return res
} }

View File

@ -29,7 +29,8 @@
<i-ep-close /> <i-ep-close />
</div> </div>
</div> </div>
<div class="logic-text" v-html="getShowLogicText"></div> <div class="logic-text showText" v-html="getShowLogicText"></div>
<div class="logic-text jumpText" v-html="getJumpLogicText"></div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -37,6 +38,7 @@ 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' import { useShowLogicInfo } from '@/management/hooks/useShowLogicInfo'
import { useJumpLogicInfo } from '@/management/hooks/useJumpLogicInfo'
const props = defineProps({ const props = defineProps({
qIndex: { qIndex: {
@ -69,6 +71,7 @@ const props = defineProps({
const emit = defineEmits(['changeSeq', 'select']) const emit = defineEmits(['changeSeq', 'select'])
const { getShowLogicText, hasShowLogic } = useShowLogicInfo(props.moduleConfig.field) const { getShowLogicText, hasShowLogic } = useShowLogicInfo(props.moduleConfig.field)
const { getJumpLogicText, hasJumpLogic } = useJumpLogicInfo(props.moduleConfig.field)
const isHover = ref(false) const isHover = ref(false)
const isMove = ref(false) const isMove = ref(false)
@ -139,7 +142,14 @@ const onMoveDown = () => {
} }
const onDelete = async () => { const onDelete = async () => {
if (unref(hasShowLogic) || getShowLogicText.value) { if (unref(hasShowLogic) || getShowLogicText.value) {
ElMessageBox.alert('该问题被逻辑依赖,请先删除逻辑依赖', '提示', { ElMessageBox.alert('该题目被显示逻辑关联,请先清除逻辑依赖', '提示', {
confirmButtonText: '确定',
type: 'warning'
})
return
}
if (unref(hasJumpLogic)) {
ElMessageBox.alert('该题目被跳转逻辑关联,请先清除逻辑依赖', '提示', {
confirmButtonText: '确定', confirmButtonText: '确定',
type: 'warning' type: 'warning'
}) })

View File

@ -24,10 +24,8 @@ import LeftMenu from '@/management/components/LeftMenu.vue'
import CommonTemplate from './components/CommonTemplate.vue' import CommonTemplate from './components/CommonTemplate.vue'
import Navbar from './components/ModuleNavbar.vue' import Navbar from './components/ModuleNavbar.vue'
import { initShowLogicEngine } from '@/management/hooks/useShowLogicEngine'
const editStore = useEditStore() const editStore = useEditStore()
const { schema, init, setSurveyId } = editStore const { init, setSurveyId } = editStore
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -36,7 +34,6 @@ onMounted(async () => {
try { try {
await init() await init()
await initShowLogicEngine(schema.logicConf.showLogicConf || {})
} catch (err: any) { } catch (err: any) {
ElMessage.error(err.message) ElMessage.error(err.message)

View File

@ -0,0 +1,449 @@
<script setup lang="ts">
import { onMounted, ref, watch, toRaw, computed } from 'vue'
import { ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
import LogicFlow from '@logicflow/core'
import { MiniMap, Control } from '@logicflow/extension'
import '@logicflow/extension/lib/style/index.css'
import '@logicflow/core/es/index.css'
import { useEditStore } from '@/management/stores/edit'
import { RuleNode, ConditionNode } from '@/common/logicEngine/RuleBuild'
import {
generateNodes,
generateLine,
getNodesStep,
getCondition
} from '@/management/hooks/useJumpLogicFlow'
import NodeExtension from './components/nodeExtension/index'
const editStore = useEditStore()
const jumpLogicEngine = computed(() => {
return editStore.jumpLogicEngine
})
const questionDataList = computed(() => {
return editStore.schema.questionDataList
})
const config: Partial<LogicFlow.Options> = {
snapline: false,
isSilentMode: false,
stopScrollGraph: true,
stopZoomGraph: true,
style: {
rect: {
rx: 5,
ry: 5,
strokeWidth: 2
},
circle: {
fill: '#f5f5f5',
stroke: '#666'
},
ellipse: {
fill: '#dae8fc',
stroke: '#6c8ebf'
},
polygon: {
fill: '#d5e8d4',
stroke: '#82b366'
},
diamond: {
fill: '#ffe6cc',
stroke: '#d79b00'
},
text: {
color: '#b85450',
fontSize: 12
}
},
adjustEdgeStartAndEnd: true,
adjustEdgeStart: false,
adjustEdgeEnd: true
// grid: true
}
const customTheme: Partial<LogicFlow.Theme> = {
baseNode: {
stroke: '#FBC559'
},
nodeText: {
overflowMode: 'ellipsis',
lineHeight: 1.5,
fontSize: 13
},
edgeText: {
overflowMode: 'ellipsis',
lineHeight: 1.5,
fontSize: 13,
textWidth: 100
}, // textWidth
polyline: {
stroke: 'red'
},
rect: {
width: 200,
height: 40
},
arrow: {
offset: 4, //
verticalLength: 2 //
}
}
const data = {
nodes: []
}
const lfRef = ref<LogicFlow | null>(null)
const containerRef = ref<HTMLDivElement | null>(null)
const initGraph = (questionDataList: any) => {
const list = toRaw(questionDataList)
if (list.length) {
const nodes = generateNodes(list)
let models: any[] = []
nodes.forEach((item: any) => {
const nodeModel = lfRef.value?.addNode(item)
models.push(nodeModel)
})
const edges = generateLine(models)
edges.forEach((item: any) => {
const edgeModel = lfRef.value?.addEdge(item)
if (edgeModel && ('start_node' === item.sourceNodeId || 'end_node' === item.targetNodeId)) {
edgeModel.draggable = false
edgeModel.isSelected = false
edgeModel.isShowAdjustPoint = false
}
})
}
}
const registerEvents = (lf: LogicFlow) => {
//
lf.on('anchor:drop', ({ edgeModel }) => {
/* 添加规则 **/
const { sourceNodeId, sourceAnchorId, targetNodeId } = edgeModel
const target = targetNodeId
const { field, operator, value } = getCondition({
anchorId: sourceAnchorId,
nodeId: sourceNodeId
})
const conditionNode = new ConditionNode(field, operator, value)
const ruleNode = new RuleNode(target)
ruleNode.addCondition(conditionNode)
edgeModel.setProperties({
ruleId: ruleNode.id,
conditionId: conditionNode.id
})
jumpLogicEngine.value.addRule(ruleNode)
})
// 线
lf.on('edge:exchange-node', ({ data }: any) => {
console.log('edge:exchange-node', { data })
/* 更新规则目标 **/
const { newEdge, oldEdge } = data
const ruleId = oldEdge.properties.ruleId
// 线 == 1
if (
getNodesStep(newEdge.sourceNodeId, newEdge.targetNodeId, questionDataList.value) === 1 &&
ruleId
) {
/** 删除逻辑。step n --> 1 */
console.log('删除逻辑。step n --> 1')
jumpLogicEngine.value.removeRule(ruleId)
} else {
if (ruleId) {
/** 更新逻辑. step n --> m */
console.log('更新逻辑. step n --> m ')
// const ruleId = oldEdge.properties.ruleId
const ruleNode = jumpLogicEngine.value.findRule(ruleId)
ruleNode.setTarget(newEdge.targetNodeId)
} else {
/** 添加逻辑。step 1 --> n */
console.log('添加逻辑。step 1 --> n')
const newEdgeModel = lf.graphModel.getEdgeModelById(newEdge.id)
const { sourceNodeId, sourceAnchorId } = newEdge
const { field, operator, value } = getCondition({
anchorId: sourceAnchorId,
nodeId: sourceNodeId
})
const conditionNode = new ConditionNode(field, operator, value)
const ruleNode = new RuleNode(newEdge.targetNodeId)
ruleNode.addCondition(conditionNode)
newEdgeModel?.setProperties({
ruleId: ruleNode.id,
conditionId: conditionNode.id
})
jumpLogicEngine.value.addRule(ruleNode)
}
}
})
}
onMounted(() => {
if (containerRef.value) {
const lf = new LogicFlow({
...config,
container: containerRef.value,
// height: 700,
translateCenter: true,
multipleSelectKey: 'shift',
disabledTools: ['multipleSelect'],
autoExpand: true,
adjustEdgeStartAndEnd: true,
allowRotate: false,
edgeTextEdit: false,
nodeTextEdit: false,
keyboard: {
enabled: true,
shortcuts: [
{
keys: ['backspace'],
callback: () => {
ElMessageBox.confirm('确定要删除吗?', '删除提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const elements = lf.getSelectElements(true)
lf.clearSelectElements()
elements.edges.forEach((edge) => {
console.log({ edge })
const { sourceNodeId, sourceAnchorId } = edge
if (sourceAnchorId?.split('_right')[0] === sourceNodeId) {
ElMessage({
message: '题目答完跳转的连接线不可以删除',
type: 'warning'
})
} else {
const { properties } = edge
jumpLogicEngine.value.removeRule(properties?.ruleId)
lf.deleteEdge(edge.id)
}
})
console.log(42, elements)
})
.catch(() => {})
}
}
]
},
partial: false,
background: {
color: '#FFFFFF'
},
edgeTextDraggable: false,
edgeType: 'bezier',
style: {
inputText: {
background: 'black',
color: 'white'
}
},
idGenerator(type) {
return type + '_' + Math.random()
},
plugins: [NodeExtension, MiniMap, Control],
pluginsOptions: {
miniMap: {
width: 284,
height: 84,
isShowHeader: false,
isShowCloseIcon: false,
leftPosition: 0,
rightPosition: 0,
bottomPosition: 50,
showEdge: false,
isShow: true
}
}
})
lf.setTheme(customTheme)
registerEvents(lf)
const control = lf.extension.control as Control
control.removeItem('zoom-out')
control.removeItem('zoom-in')
control.removeItem('reset')
control.removeItem('reset')
control.removeItem('undo')
control.removeItem('redo')
control.addItem({
key: 'zoom-out',
iconClass: 'iconfont icon-suoxiao',
title: '缩小流程图',
text: '缩小',
onClick: () => {
lf.zoom(false)
}
})
control.addItem({
key: 'zoom-in',
iconClass: 'iconfont icon-fangda',
title: '放大流程图',
text: '放大',
onClick: () => {
lf.zoom(true)
}
})
control.addItem({
key: 'reset',
iconClass: 'iconfont icon-shiying',
title: '恢复流程原有尺寸',
text: '适应',
onClick: () => {
lf.resetZoom()
}
})
lf.render(data)
const miniMap = lf.extension.miniMap as MiniMap
miniMap?.show()
lfRef.value = lf
if (questionDataList.value.length) {
initGraph(questionDataList.value)
}
}
})
watch(
() => questionDataList.value,
(value) => {
const list = toRaw(value)
if (list.length) {
initGraph(list)
}
}
)
</script>
<template>
<div ref="containerRef" id="graph" class="viewport"></div>
</template>
<style lang="scss">
.viewport {
height: 100%;
/* q-node */
.table-container {
box-sizing: border-box;
padding: 10px;
}
.table-node {
width: 100%;
height: 100%;
overflow: hidden;
background: #fff;
border-radius: 4px;
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
}
.table-node::before {
display: block;
width: 100%;
height: 8px;
background: #fbc559;
content: '';
}
.table-node.table-color-1::before {
background: #9673a6;
}
.table-node.table-color-2::before {
background: #dae8fc;
}
.table-node.table-color-3::before {
background: #82b366;
}
.table-node.table-color-4::before {
background: #f8cecc;
}
.table-name {
padding: 0 10px;
height: 32px;
font-size: 14px;
line-height: 32px;
text-align: left;
color: #4a4c5b;
width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.table-feild {
height: 28x;
padding: 0 10px;
font-size: 14px;
line-height: 28px;
white-space: nowrap;
overflow: hidden;
color: #4a4c5b;
text-overflow: ellipsis;
}
.feild-type {
color: #9f9c9f;
}
/* 自定义锚点样式 */
.custom-anchor {
cursor: crosshair;
fill: #d9d9d9;
stroke: #999;
stroke-width: 1;
rx: 3;
ry: 3;
}
.custom-anchor:hover {
fill: #ff7f0e;
stroke: #ff7f0e;
}
.lf-node-not-allow .custom-anchor:hover {
cursor: not-allowed;
fill: #d9d9d9;
stroke: #999;
}
.incomming-anchor {
stroke: #d79b00;
}
.outgoing-anchor {
stroke: #82b366;
}
.lf-mini-map {
position: absolute;
width: 300px;
height: 100px;
background: #f6f7f9;
border: none;
box-shadow: 0 2px 10px -2px rgba(82, 82, 102, 0.2);
border-radius: 4px;
}
.lf-control {
.iconfont {
color: #6e707c;
}
color: #6e707c;
}
}
</style>

View File

@ -9,7 +9,7 @@ import { storeToRefs } from 'pinia'
import { useEditStore } from '@/management/stores/edit' import { useEditStore } from '@/management/stores/edit'
import { cloneDeep } from 'lodash-es' import { cloneDeep } from 'lodash-es'
import RulePanel from '../../modules/logicModule/RulePanel.vue' import RulePanel from './components/RulePanel.vue'
import { filterQuestionPreviewData } from '@/management/utils/index' import { filterQuestionPreviewData } from '@/management/utils/index'
const editStore = useEditStore() const editStore = useEditStore()
@ -23,11 +23,11 @@ provide('renderData', renderData)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.logic-wrapper { .logic-wrapper {
height: calc(100% - 120px); height: 100%;
width: 100%; width: 100%;
margin: 12px;
background: #fff; background: #fff;
text-align: center; text-align: center;
overflow: auto; overflow: auto;
// position: fixed;
} }
</style> </style>

View File

@ -52,7 +52,10 @@ import { ElMessageBox } from 'element-plus'
import 'element-plus/theme-chalk/src/message-box.scss' import 'element-plus/theme-chalk/src/message-box.scss'
import { RuleNode } from '@/common/logicEngine/RuleBuild' import { RuleNode } from '@/common/logicEngine/RuleBuild'
import { cleanRichText } from '@/common/xss' import { cleanRichText } from '@/common/xss'
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine' import { useEditStore } from '@/management/stores/edit'
import { storeToRefs } from 'pinia'
const editStore = useEditStore()
const { showLogicEngine } = storeToRefs(editStore)
import ConditionView from './ConditionView.vue' import ConditionView from './ConditionView.vue'
const renderData = inject<ComputedRef<Array<any>>>('renderData') || ref([]) const renderData = inject<ComputedRef<Array<any>>>('renderData') || ref([])

View File

@ -21,8 +21,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { shallowRef, computed } from 'vue' import { shallowRef, computed } from 'vue'
import { RuleNode, ConditionNode } from '@/common/logicEngine/RuleBuild' import { RuleNode, ConditionNode } from '@/common/logicEngine/RuleBuild'
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine' import { useEditStore } from '@/management/stores/edit'
import RuleNodeView from './components/RuleNodeView.vue' import { storeToRefs } from 'pinia'
const editStore = useEditStore()
const { showLogicEngine } = storeToRefs(editStore)
import RuleNodeView from './RuleNodeView.vue'
const list = computed(() => { const list = computed(() => {
return showLogicEngine.value?.rules || [] return showLogicEngine.value?.rules || []

View File

@ -0,0 +1,55 @@
import { BezierEdge, BezierEdgeModel } from '@logicflow/core'
class CustomEdge2 extends BezierEdge {}
class CustomEdgeModel2 extends BezierEdgeModel {
getEdgeStyle() {
const style = super.getEdgeStyle()
// svg属性
style.strokeWidth = 1
style.stroke = '#ababac'
return style
}
/**
* 重写此方法使保存数据是能带上锚点数据
*/
getData() {
const data = super.getData()
data.sourceAnchorId = this.sourceAnchorId
data.targetAnchorId = this.targetAnchorId
return data
}
/**
* 给边自定义方案使其支持基于锚点的位置更新边的路径
*/
updatePathByAnchor() {
// TODO
const sourceNodeModel = this.graphModel.getNodeModelById(this.sourceNodeId)
const sourceAnchor = sourceNodeModel
.getDefaultAnchor()
.find((anchor) => anchor.id === this.sourceAnchorId)
const targetNodeModel = this.graphModel.getNodeModelById(this.targetNodeId)
const targetAnchor = targetNodeModel
.getDefaultAnchor()
.find((anchor) => anchor.id === this.targetAnchorId)
const startPoint = {
x: sourceAnchor.x,
y: sourceAnchor.y
}
this.updateStartPoint(startPoint)
const endPoint = {
x: targetAnchor.x,
y: targetAnchor.y
}
this.updateEndPoint(endPoint)
// 这里需要将原有的pointsList设置为空才能触发bezier的自动计算control点。
this.pointsList = []
this.initPoints()
}
}
export default {
type: 'q-edge',
view: CustomEdge2,
model: CustomEdgeModel2
}

View File

@ -0,0 +1,17 @@
import StartNode from './nodes/StartNode'
import EndNode from './nodes/EndNode'
import QNode from './nodes/QNode'
import QEdge from './edges/QEdge'
class NodeExtension {
static pluginName = 'NodeExtension'
constructor({ lf }) {
lf.register(StartNode)
lf.register(EndNode)
lf.register(QNode)
lf.register(QEdge)
lf.setDefaultEdgeType('q-edge')
}
}
export default NodeExtension

View File

@ -0,0 +1,41 @@
import { CircleNode, CircleNodeModel } from '@logicflow/core'
class EndNodeModel extends CircleNodeModel {
constructor(data, graphModel) {
data.text = {
value: data.text,
x: data.x,
y: data.y
}
super(data, graphModel)
this.r = 30
}
/**
* 重写定义锚点
*/
getDefaultAnchor() {
const { x, y, id, width } = this
const anchors = [
{
x: x - width / 2,
y: y,
id: `${id}_left`,
type: 'left'
}
]
return anchors
}
setIsShowAnchor() {
return false
}
isAllowConnectedAsSource() {
return false
}
}
export default {
type: 'end-node',
model: EndNodeModel,
view: CircleNode
}

View File

@ -0,0 +1,177 @@
import { HtmlNode, HtmlNodeModel, h } from '@logicflow/core'
class QNode extends HtmlNode {
/**
* 1.1.7版本后支持在view中重写锚点形状
* 重写锚点新增
*/
getAnchorShape(anchorData) {
const { x, y, type } = anchorData
return h('rect', {
x: x - 5,
y: y - 5,
width: 10,
height: 10,
className: `custom-anchor ${type === 'left' ? 'incomming-anchor' : 'outgoing-anchor'}`
})
}
setHtml(rootEl) {
rootEl.innerHTML = ''
const {
properties: { options = [], title }
} = this.props.model
rootEl.setAttribute('class', 'table-container')
const container = document.createElement('div')
container.className = `table-node`
const tableNameElement = document.createElement('div')
tableNameElement.innerHTML = title
tableNameElement.className = 'table-name'
container.appendChild(tableNameElement)
const fragment = document.createDocumentFragment()
for (let i = 0; i < options.length; i++) {
const item = options[i]
const itemElement = document.createElement('div')
itemElement.className = 'table-feild'
const itemKey = document.createElement('span')
itemKey.innerHTML = item.type
itemElement.appendChild(itemKey)
// itemKey.innerText = item.key;
// const itemType = document.createElement('span');
// itemType.innerHTML = item.type;
// itemType.className = 'feild-type';
// itemElement.appendChild(itemType);
fragment.appendChild(itemElement)
}
container.appendChild(fragment)
rootEl.appendChild(container)
}
}
class QNodeModel extends HtmlNodeModel {
getOutlineStyle() {
const style = super.getOutlineStyle()
style.stroke = 'none'
style.hover.stroke = 'none'
return style
}
// 如果不用修改锚地形状,可以重写颜色相关样式
getAnchorStyle(anchorInfo) {
const style = super.getAnchorStyle()
if (anchorInfo.type === 'left') {
style.fill = 'red'
style.hover.fill = 'transparent'
style.hover.stroke = 'transpanrent'
style.className = 'lf-hide-default'
} else {
style.fill = 'green'
}
return style
}
setAttributes() {
this.width = 200
const {
properties: { options = [] }
} = this
this.height = 60 + options.length * 28
const circleOnlyAsTarget = {
message: '只允许从右边的锚点连出',
validate: (sourceNode, targetNode, sourceAnchor) => {
return sourceAnchor.type === 'right'
}
}
this.sourceRules.push(circleOnlyAsTarget)
this.targetRules.push({
message: '只允许连接左边的锚点',
validate: (sourceNode, targetNode, sourceAnchor, targetAnchor) => {
return targetAnchor.type === 'left'
}
})
}
getDefaultAnchor() {
const {
id,
x,
y,
width,
height,
properties: { options }
} = this
const anchors = [
{
x: x - width / 2 + 10,
y: y - height / 2 + 60 - 28,
id: `${id}_left`,
edgeAddable: false,
type: 'left'
},
{
x: x + width / 2 - 10,
y: y - height / 2 + 60 - 28,
id: `${id}_right`,
edgeAddable: false,
type: 'right'
}
]
options.forEach((feild, index) => {
const anchorId = `${feild.key}_right`
const { edges } = this.outgoing
let edgeAddable = true
if (edges.length) {
const sourceAnchorIds = edges.map((edge) => edge.sourceAnchorId)
edgeAddable = !sourceAnchorIds.includes(anchorId)
}
anchors.push({
x: x + width / 2 - 10,
y: y - height / 2 + 60 - 28 + (index + 1) * 30,
id: anchorId,
type: 'right',
key: feild.key,
edgeAddable
})
})
return anchors
}
setIsShowAnchor() {
return false
}
// 获取当前节点作为边的起始节点规则。
// getConnectedSourceRules() {
// const rules = super.getConnectedSourceRules();
// // 开始节点的锚点只能拉出一条连接线
// const geteWayOnlyAsTarget = {
// message: "开始节点的锚点只能拉出一条连接线",
// validate: (
// source,
// target,
// sourceAnchor,
// targetAnchor
// ) => {
// // 获取该节点下目标连接线
// const edges = this.graphModel.getNodeOutgoingEdge(source.id);
// console.log({edges});
// // 如果连接线的锚点id存在则不允许链接
// const sourceAnchorIds = edges.map(edge => edge.sourceAnchorId);
// // 判断该新拉出的边是否已经有了目标链接有的话disable
// let isValid = true;
// if (sourceAnchorIds.includes(sourceAnchor.id)) {
// isValid = false;
// }
// console.log(edges[0].targetNodeId,target.id, sourceAnchorIds, sourceAnchor.id, isValid)
// return isValid;
// },
// };
// // 如果该题存在无条件跳转则禁用选项跳转
// // 如果该题存在选项跳转则禁用无条件跳转
// rules.push(geteWayOnlyAsTarget);
// return rules;
// }
}
export default {
type: 'q-node',
model: QNodeModel,
view: QNode
}

View File

@ -0,0 +1,42 @@
import { CircleNode, CircleNodeModel } from '@logicflow/core'
class StartNodeModel extends CircleNodeModel {
constructor(data, graphModel) {
data.text = {
value: data.text,
x: data.x,
y: data.y
}
super(data, graphModel)
this.r = 30
}
/**
* 重写定义锚点
*/
getDefaultAnchor() {
const { x, y, id, width } = this
const anchors = [
{
x: x + width / 2,
y: y,
id: `${id}_right`,
type: 'right'
}
]
return anchors
}
setIsShowAnchor() {
return false
}
isAllowConnectedAsTarget() {
return false
}
}
export default {
type: 'start-node',
model: StartNodeModel,
view: CircleNode
}

View File

@ -0,0 +1,3 @@
# 说明
参考node-red样式以logicflow插件的方式实现。

View File

@ -0,0 +1,23 @@
.custom-anchor {
stroke: #999;
stroke-width: 1;
fill: #d9d9d9;
cursor: crosshair;
rx: 3;
ry: 3;
}
.custom-anchor:hover {
fill: #ff7f0e;
stroke: #ff7f0e;
}
.node-red-palette {
width: 150px;
overflow: hidden;
position: absolute;
left: 0px;
top: 0px;
}
.node-red-start {
cursor: pointer;
pointer-events: all;
}

View File

@ -0,0 +1,18 @@
/* 求字符串的字节长度 */
export const getBytesLength = (word) => {
if (!word) {
return 0
}
let totalLength = 0
for (let i = 0; i < word.length; i++) {
const c = word.charCodeAt(i)
if (word.match(/[A-Z]/)) {
totalLength += 1.5
} else if ((c >= 0x0001 && c <= 0x007e) || (c >= 0xff60 && c <= 0xff9f)) {
totalLength += 1
} else {
totalLength += 1.8
}
}
return totalLength
}

View File

@ -0,0 +1,85 @@
<template>
<el-tabs
:modelValue="activeName"
class="loigc-tabs"
:before-leave="beforeTabLeave"
@tab-change="handleChange"
>
<el-tab-pane label="显示逻辑" name="showLogic">
<ShowLogic v-if="activeName == 'showLogic'" />
</el-tab-pane>
<el-tab-pane label="跳转逻辑" name="jumpLogic" class="logic-wrapper">
<JumpLogic v-if="activeName == 'jumpLogic'" />
</el-tab-pane>
</el-tabs>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import ShowLogic from '../../modules/logicModule/ShowLogic.vue'
import JumpLogic from '../../modules/logicModule/JumpLogic.vue'
import { ElMessageBox } from 'element-plus'
import { useEditStore } from '@/management/stores/edit'
const editStore = useEditStore()
const { showLogicEngine, jumpLogicEngine } = storeToRefs(editStore)
const props = defineProps({
active: String
})
const router = useRouter()
const activeName = computed(() => {
return props.active || 'showLogic'
})
const beforeTabLeave = async () => {
if (activeName.value === 'showLogic' && !showLogicEngine.value.rules.length) return true
if (activeName.value === 'jumpLogic' && !jumpLogicEngine.value.rules.length) return true
const title = `提示`
const text = `切换到${activeName.value === 'showLogic' ? '跳转' : '显示'}设置后,将丢失当前${activeName.value === 'showLogic' ? '显示' : '跳转'}逻辑设置`
try {
await ElMessageBox.confirm(text, title, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
if (activeName.value === 'showLogic') {
//
showLogicEngine.value.clear()
} else {
//
jumpLogicEngine.value.clear()
}
return true
} catch (err) {
return false
}
}
const handleChange = (name: any) => {
router.push({
name: 'LogicIndex',
query: { active: name }
})
}
</script>
<style lang="scss" scoped>
.loigc-tabs {
width: 98%;
height: calc(100% - 48px);
padding: 10px;
position: relative;
top: 24px;
background-color: #fff;
:deep(.el-tabs__content) {
height: calc(100% - 10px);
padding-bottom: 10px;
:deep(el-tab-pane) {
height: 100%;
overflow: auto;
}
.logic-wrapper {
height: 100%;
}
}
}
</style>

View File

@ -37,7 +37,9 @@ const activeRouter = ref(route.name)
watch( watch(
activeRouter, activeRouter,
(val: any) => { (val: any) => {
router.push({ name: val }) // query
const query = route.query
router.push({ name: val, query })
}, },
{ {
immediate: true immediate: true

View File

@ -57,7 +57,8 @@ const routes: RouteRecordRaw[] = [
meta: { meta: {
needLogin: true needLogin: true
}, },
component: () => import('../pages/edit/pages/edit/LogicEditPage.vue') component: () => import('../pages/edit/pages/edit/LogicIndex.vue'),
props: (route) => ({ active: route.query.active })
} }
] ]
}, },

View File

@ -21,6 +21,7 @@ import { getBannerData } from '@/management/api/skin.js'
import { getCollaboratorPermissions } from '@/management/api/space' import { getCollaboratorPermissions } from '@/management/api/space'
import useEditGlobalBaseConf, { type TypeMethod } from './composables/useEditGlobalBaseConf' import useEditGlobalBaseConf, { type TypeMethod } from './composables/useEditGlobalBaseConf'
import { CODE_MAP } from '../api/base' import { CODE_MAP } from '../api/base'
import { RuleBuild } from '@/common/logicEngine/RuleBuild'
const innerMetaConfig = { const innerMetaConfig = {
submit: { submit: {
@ -83,10 +84,12 @@ function useInitializeSchema(surveyId: Ref<string>, initializeSchemaCallBack: ()
pageEditOne: 1, pageEditOne: 1,
pageConf: [], // 分页逻辑 pageConf: [], // 分页逻辑
logicConf: { logicConf: {
showLogicConf: [] showLogicConf: [],
jumpLogicConf: []
} }
}) })
const { showLogicEngine, initShowLogicEngine, jumpLogicEngine, initJumpLogicEngine } =
useLogicEngine(schema)
function initSchema({ metaData, codeData }: { metaData: any; codeData: any }) { function initSchema({ metaData, codeData }: { metaData: any; codeData: any }) {
schema.metaData = metaData schema.metaData = metaData
schema.bannerConf = _merge({}, schema.bannerConf, codeData.bannerConf) schema.bannerConf = _merge({}, schema.bannerConf, codeData.bannerConf)
@ -132,6 +135,9 @@ function useInitializeSchema(surveyId: Ref<string>, initializeSchemaCallBack: ()
} }
}) })
initializeSchemaCallBack() initializeSchemaCallBack()
initShowLogicEngine()
initJumpLogicEngine()
} else { } else {
throw new Error(res.errmsg || '问卷不存在') throw new Error(res.errmsg || '问卷不存在')
} }
@ -140,7 +146,9 @@ function useInitializeSchema(surveyId: Ref<string>, initializeSchemaCallBack: ()
return { return {
schema, schema,
initSchema, initSchema,
getSchemaFromRemote getSchemaFromRemote,
showLogicEngine,
jumpLogicEngine
} }
} }
@ -299,6 +307,7 @@ function useCurrentEdit({
changeCurrentEditStatus changeCurrentEditStatus
} }
} }
function usePageEdit( function usePageEdit(
{ {
schema, schema,
@ -431,6 +440,23 @@ function usePageEdit(
} }
} }
function useLogicEngine(schema: any) {
const logicConf = toRef(schema, 'logicConf')
const showLogicEngine = ref()
const jumpLogicEngine = ref()
function initShowLogicEngine() {
showLogicEngine.value = new RuleBuild().fromJson(logicConf.value?.showLogicConf)
}
function initJumpLogicEngine() {
jumpLogicEngine.value = new RuleBuild().fromJson(logicConf.value?.jumpLogicConf)
}
return {
showLogicEngine,
jumpLogicEngine,
initShowLogicEngine,
initJumpLogicEngine
}
}
type IBannerItem = { type IBannerItem = {
name: string name: string
key: string key: string
@ -442,13 +468,13 @@ export const useEditStore = defineStore('edit', () => {
const bannerList: Ref<IBannerList> = ref({}) const bannerList: Ref<IBannerList> = ref({})
const cooperPermissions = ref(Object.values(SurveyPermissions)) const cooperPermissions = ref(Object.values(SurveyPermissions))
const schemaUpdateTime = ref(Date.now()) const schemaUpdateTime = ref(Date.now())
const { schema, initSchema, getSchemaFromRemote } = useInitializeSchema(surveyId, () => { const { schema, initSchema, getSchemaFromRemote, showLogicEngine, jumpLogicEngine } =
useInitializeSchema(surveyId, () => {
editGlobalBaseConf.initCounts() editGlobalBaseConf.initCounts()
}) })
const questionDataList = toRef(schema, 'questionDataList') const questionDataList = toRef(schema, 'questionDataList')
const editGlobalBaseConf = useEditGlobalBaseConf(questionDataList, updateTime) const editGlobalBaseConf = useEditGlobalBaseConf(questionDataList, updateTime)
function setQuestionDataList(data: any) { function setQuestionDataList(data: any) {
schema.questionDataList = data schema.questionDataList = data
} }
@ -469,6 +495,7 @@ export const useEditStore = defineStore('edit', () => {
cooperPermissions.value = res.data.permissions cooperPermissions.value = res.data.permissions
} }
} }
// const { showLogicEngine, initShowLogicEngine, jumpLogicEngine, initJumpLogicEngine } = useLogicEngine(schema)
const { const {
currentEditOne, currentEditOne,
currentEditKey, currentEditKey,
@ -483,7 +510,7 @@ export const useEditStore = defineStore('edit', () => {
async function init() { async function init() {
const { metaData } = schema const { metaData } = schema
if (!metaData || (metaData as any)?._id !== surveyId.value) { if (!metaData || (metaData as any)?._id !== surveyId.value) {
getSchemaFromRemote() await getSchemaFromRemote()
} }
currentEditOne.value = null currentEditOne.value = null
currentEditStatus.value = 'Success' currentEditStatus.value = 'Success'
@ -636,6 +663,8 @@ export const useEditStore = defineStore('edit', () => {
createNewQuestion, createNewQuestion,
changeSchema, changeSchema,
changeThemePreset, changeThemePreset,
compareQuestionSeq compareQuestionSeq,
showLogicEngine,
jumpLogicEngine
} }
}) })

View File

@ -160,3 +160,13 @@
.icon-gauge:before { .icon-gauge:before {
content: '\e6db'; content: '\e6db';
} }
.icon-suoxiao:before {
content: '\e6f4';
}
.icon-fangda:before {
content: '\e6f5';
}
.icon-shiying:before {
content: '\e6f6';
}

View File

@ -42,7 +42,7 @@ export default defineComponent({
} }
}, },
render() { render() {
const { submitConf,isFinallyPage } = this.props const { submitConf, isFinallyPage } = this.props
return ( return (
<div class={['submit-warp', 'preview-submit_wrapper']} onClick={this.handleClick}> <div class={['submit-warp', 'preview-submit_wrapper']} onClick={this.handleClick}>
<button class="submit-btn" type="primary" onClick={this.submit}> <button class="submit-btn" type="primary" onClick={this.submit}>

View File

@ -7,7 +7,7 @@ const adapter = (() => {
const list = [] const list = []
const exec = (questionData) => { const exec = (questionData) => {
return list.reduce((pre, next) => ({ ...pre, ...next(questionData, pre) }), {}) return list.reduce((pre, next, index) => ({ ...pre, index, ...next(questionData, pre) }), {})
} }
return { return {

View File

@ -7,11 +7,12 @@ import { get as _get } from 'lodash-es'
export default function (questionConfig) { export default function (questionConfig) {
let dataList = _get(questionConfig, 'dataConf.dataList') let dataList = _get(questionConfig, 'dataConf.dataList')
// 将题目列表转成对象,并且对题目类型、题目的选项做一些字段的增加和转换 // 将题目列表转成对象,并且对题目类型、题目的选项做一些字段的增加和转换
const questionData = dataList.reduce((pre, item) => { const questionData = dataList.reduce((pre, item, index) => {
Object.assign(pre, { Object.assign(pre, {
[item.field]: { [item.field]: {
indexNumber: '', indexNumber: '',
voteTotal: 0, voteTotal: 0,
index,
...item ...item
} }
}) })

View File

@ -13,6 +13,7 @@
<script setup> <script setup>
import { inject, provide, computed, onBeforeMount } from 'vue' import { inject, provide, computed, onBeforeMount } from 'vue'
import QuestionWrapper from './QuestionWrapper.vue' import QuestionWrapper from './QuestionWrapper.vue'
// import { flatten } from 'lodash-es'
const $bus = inject('$bus') const $bus = inject('$bus')
const props = defineProps({ const props = defineProps({
@ -68,13 +69,6 @@ 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

@ -1,6 +1,6 @@
<template> <template>
<QuestionRuleContainer <QuestionRuleContainer
v-if="visible" v-if="visibily"
:moduleConfig="questionConfig" :moduleConfig="questionConfig"
:indexNumber="indexNumber" :indexNumber="indexNumber"
:showTitle="true" :showTitle="true"
@ -9,12 +9,12 @@
</template> </template>
<script setup> <script setup>
import { unref, computed, watch } from 'vue' import { unref, computed, watch } from 'vue'
import { storeToRefs } from 'pinia'
import QuestionRuleContainer from '../../materials/questions/QuestionRuleContainer' import QuestionRuleContainer from '../../materials/questions/QuestionRuleContainer'
import { useVoteMap } from '@/render/hooks/useVoteMap' import { useVoteMap } from '@/render/hooks/useVoteMap'
import { useShowOthers } from '@/render/hooks/useShowOthers' import { useShowOthers } from '@/render/hooks/useShowOthers'
import { useShowInput } from '@/render/hooks/useShowInput' import { useShowInput } from '@/render/hooks/useShowInput'
import { cloneDeep } from 'lodash-es' import { cloneDeep } from 'lodash-es'
import { ruleEngine } from '@/render/hooks/useRuleEngine.js'
import { useQuestionStore } from '../stores/question' import { useQuestionStore } from '../stores/question'
import { useSurveyStore } from '../stores/survey' import { useSurveyStore } from '../stores/survey'
@ -30,7 +30,7 @@ const props = defineProps({
default: () => { default: () => {
return {} return {}
} }
} },
}) })
const emit = defineEmits(['change']) const emit = defineEmits(['change'])
const questionStore = useQuestionStore() const questionStore = useQuestionStore()
@ -39,17 +39,26 @@ const surveyStore = useSurveyStore()
const formValues = computed(() => { const formValues = computed(() => {
return surveyStore.formValues return surveyStore.formValues
}) })
const { showLogicEngine } = storeToRefs(surveyStore)
const {
changeField,
changeIndex,
needHideFields,
} = storeToRefs(questionStore)
//
const questionConfig = computed(() => { const questionConfig = computed(() => {
let moduleConfig = props.moduleConfig let moduleConfig = props.moduleConfig
const { type, field, options = [], ...rest } = cloneDeep(moduleConfig) const { type, field, options = [], ...rest } = cloneDeep(moduleConfig)
// console.log(field,'formValuechange') // console.log(field,'formValuechange')
let alloptions = options let alloptions = options
if (type === QUESTION_TYPE.VOTE) { if (type === QUESTION_TYPE.VOTE) {
const { options, voteTotal } = useVoteMap(field) const { options, voteTotal } = useVoteMap(field)
const voteOptions = unref(options) const voteOptions = unref(options)
alloptions = alloptions.map((obj, index) => Object.assign(obj, voteOptions[index])) alloptions = alloptions.map((obj, index) => Object.assign(obj, voteOptions[index]))
moduleConfig.voteTotal = unref(voteTotal) moduleConfig.voteTotal = unref(voteTotal)
} }
if ( if (
NORMAL_CHOICES.includes(type) && NORMAL_CHOICES.includes(type) &&
options.filter((optionItem) => optionItem.others).length > 0 options.filter((optionItem) => optionItem.others).length > 0
@ -59,6 +68,7 @@ const questionConfig = computed(() => {
alloptions = alloptions.map((obj, index) => Object.assign(obj, othersOptions[index])) alloptions = alloptions.map((obj, index) => Object.assign(obj, othersOptions[index]))
moduleConfig.othersValue = unref(othersValue) moduleConfig.othersValue = unref(othersValue)
} }
if ( if (
RATES.includes(type) && RATES.includes(type) &&
rest?.rangeConfig && rest?.rangeConfig &&
@ -77,19 +87,29 @@ const questionConfig = computed(() => {
} }
}) })
const { field } = props.moduleConfig const logicshow = computed(() => {
const visible = computed(() => {
// computedmatch // computedmatch
return ruleEngine.match(field, 'question', formValues.value) const result = showLogicEngine.value.match(props.moduleConfig.field, 'question', formValues.value)
return result === undefined ? true : result
}) })
const logicskip = computed(() => {
return needHideFields.value.includes(props.moduleConfig.field)
})
const visibily = computed(() => {
return logicshow.value && !logicskip.value
})
// abbcbc
watch( watch(
() => visible.value, () => visibily.value,
(newVal, oldVal) => { (newVal, oldVal) => {
//
const { field, type, innerType } = props.moduleConfig const { field, type, innerType } = props.moduleConfig
if (!newVal && oldVal) { if (!newVal && oldVal) {
//
if(formValues.value[field].toString()) {
let value = '' let value = ''
// innerType // innerType
if (type === QUESTION_TYPE.CHECKBOX || innerType === QUESTION_TYPE.CHECKBOX) { if (type === QUESTION_TYPE.CHECKBOX || innerType === QUESTION_TYPE.CHECKBOX) {
@ -100,6 +120,8 @@ watch(
value: value value: value
} }
surveyStore.changeData(data) surveyStore.changeData(data)
processJumpSkip()
}
} }
} }
) )
@ -110,5 +132,41 @@ const handleChange = (data) => {
if (props.moduleConfig.type === QUESTION_TYPE.VOTE) { if (props.moduleConfig.type === QUESTION_TYPE.VOTE) {
questionStore.updateVoteData(data) questionStore.updateVoteData(data)
} }
processJumpSkip()
} }
const processJumpSkip = () => {
const targetResult = surveyStore.jumpLogicEngine
.getResultsByField(changeField.value, surveyStore.formValues)
.map(item => {
//
const index = item.target === 'end' ? surveyStore.dataConf.dataList.length : questionStore.getQuestionIndexByField(item.target)
return {
index,
...item
}
})
const notMatchedFields = targetResult.filter(item => !item.result)
const matchedFields = targetResult.filter(item => item.result)
//
if (notMatchedFields.length) {
notMatchedFields.forEach(element => {
const endIndex = element.index
const fields = surveyStore.dataConf.dataList.slice(changeIndex.value + 1, endIndex).map(item => item.field)
// hideMapremove
questionStore.removeNeedHideFields(fields)
});
}
if (!matchedFields.length) return
//
const maxIndexQuestion =
matchedFields.filter(item => item.result).sort((a, b) => b.index - a.index)[0].index
//
const skipKey = surveyStore.dataConf.dataList
.slice(changeIndex.value + 1, maxIndexQuestion).map(item => item.field)
questionStore.addNeedHideFields(skipKey)
}
</script> </script>

View File

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

View File

@ -10,7 +10,7 @@ import useCommandComponent from '../hooks/useCommandComponent'
import { useSurveyStore } from '../stores/survey' import { useSurveyStore } from '../stores/survey'
import AlertDialog from '../components/AlertDialog.vue' import AlertDialog from '../components/AlertDialog.vue'
import { initRuleEngine } from '@/render/hooks/useRuleEngine.js'
const route = useRoute() const route = useRoute()
const surveyStore = useSurveyStore() const surveyStore = useSurveyStore()
const loadData = (res: any, surveyPath: string) => { const loadData = (res: any, surveyPath: string) => {
@ -44,7 +44,8 @@ const loadData = (res: any, surveyPath: string) => {
surveyStore.setSurveyPath(surveyPath) surveyStore.setSurveyPath(surveyPath)
surveyStore.initSurvey(questionData) surveyStore.initSurvey(questionData)
initRuleEngine(logicConf?.showLogicConf) surveyStore.initShowLogicEngine(logicConf?.showLogicConf)
surveyStore.initJumpLogicEngine(logicConf.jumpLogicConf)
} else { } else {
throw new Error(res.errmsg) throw new Error(res.errmsg)
} }

View File

@ -11,6 +11,11 @@ export const useQuestionStore = defineStore('question', () => {
const questionData = ref(null) const questionData = ref(null)
const questionSeq = ref([]) // 题目的顺序,因为可能会有分页的情况,所以是一个二维数组[[qid1, qid2], [qid3,qid4]] const questionSeq = ref([]) // 题目的顺序,因为可能会有分页的情况,所以是一个二维数组[[qid1, qid2], [qid3,qid4]]
const pageIndex = ref(1) // 当前分页的索引 const pageIndex = ref(1) // 当前分页的索引
const changeField = ref(null)
const changeIndex = computed(() => {
return questionData.value[changeField.value].index
})
const needHideFields = ref([])
// 题目列表 // 题目列表
const questionList = computed(() => { const questionList = computed(() => {
@ -177,6 +182,22 @@ export const useQuestionStore = defineStore('question', () => {
}) })
} }
const setChangeField = (field) => {
changeField.value = field
}
const getQuestionIndexByField = (field) => {
return questionData.value[field].index
}
const addNeedHideFields = (fields) => {
fields.forEach(field => {
if(!needHideFields.value.includes(field)) {
needHideFields.value.push(field)
}
})
}
const removeNeedHideFields = (fields) => {
needHideFields.value = needHideFields.value.filter(field => !fields.includes(field))
}
return { return {
voteMap, voteMap,
questionData, questionData,
@ -191,6 +212,13 @@ export const useQuestionStore = defineStore('question', () => {
setVoteMap, setVoteMap,
updateVoteMapByKey, updateVoteMapByKey,
initVoteData, initVoteData,
updateVoteData updateVoteData,
changeField,
changeIndex,
setChangeField,
needHideFields,
addNeedHideFields,
removeNeedHideFields,
getQuestionIndexByField
} }
}) })

View File

@ -15,6 +15,8 @@ import 'moment/locale/zh-cn'
moment.locale('zh-cn') moment.locale('zh-cn')
import adapter from '../adapter' import adapter from '../adapter'
import { RuleMatch } from '@/common/logicEngine/RulesMatch'
// import { jumpLogicRule } from '@/common/logicEngine/jumpLogicRule'
/** /**
* CODE_MAP不从management引入在dev阶段会导致B端 router被加载进而导致C端路由被添加 baseUrl: /management * CODE_MAP不从management引入在dev阶段会导致B端 router被加载进而导致C端路由被添加 baseUrl: /management
@ -40,6 +42,7 @@ export const useSurveyStore = defineStore('survey', () => {
const whiteData = ref({}) const whiteData = ref({})
const pageConf = ref([]) const pageConf = ref([])
const router = useRouter() const router = useRouter()
const questionStore = useQuestionStore() const questionStore = useQuestionStore()
const { setErrorInfo } = useErrorInfo() const { setErrorInfo } = useErrorInfo()
@ -156,6 +159,16 @@ export const useSurveyStore = defineStore('survey', () => {
if (key in formValues.value) { if (key in formValues.value) {
formValues.value[key] = value formValues.value[key] = value
} }
questionStore.setChangeField(key)
}
const showLogicEngine = ref()
const initShowLogicEngine = (showLogicConf) => {
showLogicEngine.value = new RuleMatch().fromJson(showLogicConf)
}
const jumpLogicEngine = ref()
const initJumpLogicEngine = (jumpLogicConf) => {
jumpLogicEngine.value = new RuleMatch().fromJson(jumpLogicConf)
} }
return { return {
@ -173,12 +186,15 @@ export const useSurveyStore = defineStore('survey', () => {
formValues, formValues,
whiteData, whiteData,
pageConf, pageConf,
initSurvey, initSurvey,
changeData, changeData,
setWhiteData, setWhiteData,
setSurveyPath, setSurveyPath,
setEnterTime, setEnterTime,
getEncryptInfo getEncryptInfo,
showLogicEngine,
initShowLogicEngine,
jumpLogicEngine,
initJumpLogicEngine
} }
}) })