Skip to content

Commit 4948bbb

Browse files
committed
feat: Visibility rules
1 parent a73b6b3 commit 4948bbb

8 files changed

Lines changed: 272 additions & 59 deletions

File tree

apps/application/flow/step_node/form_node/impl/base_form_node.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
from application.flow.common import Answer
1717
from application.flow.i_step_node import NodeResult
1818
from application.flow.step_node.form_node.i_form_node import IFormNode
19+
import re
1920

21+
_TEMPLATE_RE = re.compile(r'\{\{([^.\s}]+)\.([^.\s}]+)\}\}')
2022
multi_select_list = [
2123
'MultiSelect',
2224
'MultiRow'
@@ -106,8 +108,63 @@ def reset_field(self, field):
106108
field['default_value'] = self.workflow_manage.get_reference_field(field.get('default_value')[0],
107109
field.get('default_value')[1:])
108110

111+
visibility_rules = field.get('visibility_rules')
112+
if visibility_rules and isinstance(visibility_rules.get('conditions'), list):
113+
for cond in visibility_rules['conditions']:
114+
cond_field = cond.get('field')
115+
if not cond_field or len(cond_field) < 2 or not cond_field[0] or not cond_field[1]:
116+
continue
117+
118+
# cross node -------> _left
119+
if cond_field[0] != self.node.id:
120+
cond['_left'] = self.workflow_manage.get_reference_field(cond_field[0], cond_field[1:])
121+
# 右值 {{}}
122+
cond_value = cond.get("value")
123+
if isinstance(cond_value, str) and _TEMPLATE_RE.search(cond_value):
124+
cond['value'] = self._render_cond_value(cond_value)
125+
109126
return field
110127

128+
def _render_cond_value(self, value):
129+
"""
130+
render cross-node/global/chat {{}} to literal, preserve same-form {{}}
131+
match.group(0) → "{{开始.question}}" # 完整匹配
132+
match.group(1) → "开始" # 第一个 () 捕获的
133+
match.group(2) → "question" # 第二个 () 捕获的
134+
match.start() → 3 # 匹配起始位置
135+
match.end() → 16 # 匹配结束位置
136+
"""
137+
def replacer(match):
138+
node_display = match.group(1)
139+
field_name = match.group(2)
140+
141+
# field_list: cross_node
142+
for f in self.workflow_manage.field_list:
143+
if f.get('node_name') == node_display and f.get('value') == field_name:
144+
if f.get('node_id') == self.node.id:
145+
return match.group(0) # same node
146+
ref = self.workflow_manage.get_reference_field(f.get('node_id'),[field_name])
147+
return str(ref) if ref is not None else ''
148+
149+
# global
150+
if node_display in ('全局变量', 'global'):
151+
for f in self.workflow_manage.global_field_list:
152+
if f.get('value') == field_name:
153+
ref = self.workflow_manage.get_reference_field('global', [field_name])
154+
return str(ref) if ref is not None else ''
155+
156+
# chat
157+
if node_display == 'chat':
158+
for f in self.workflow_manage.chat_field_list:
159+
if f.get("value") == field_name:
160+
ref = self.workflow_manage.get_reference_field('chat', [field_name])
161+
return str(ref) if ref is not None else ''
162+
return match.group(0)
163+
try:
164+
return _TEMPLATE_RE.sub(replacer, value)
165+
except Exception:
166+
return value
167+
111168
def execute(self, form_field_list, form_content_format, form_data, **kwargs) -> NodeResult:
112169
if form_data is not None:
113170
self.context['is_submit'] = True

ui/src/components/dynamics-form/index.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ const show = (field: FormField) => {
109109
return evaluateVisibility(field.visibility_rules, {
110110
formValue: formValue.value,
111111
currentNodeId: field.visibility_rules.node_id,
112-
currentNodeName: '',
112+
currentNodeName: field.visibility_rules.node_name || '',
113113
})
114114
}
115115

ui/src/components/dynamics-form/visibility/ConditionRow.vue

Lines changed: 73 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,72 @@
11
<template>
22
<el-row :gutter="8">
33
<el-col :span="8">
4-
<FieldSelector
5-
:nodeModel="nodeModel"
6-
v-model="cond.field"
7-
@change="onFieldChange"
8-
:currentNodeFields="currentNodeFields"
9-
:currentEditingIndex="currentEditingIndex"
10-
class="w-full"
11-
/>
4+
<el-form-item :error="cond._fieldError">
5+
<FieldSelector
6+
:nodeModel="nodeModel"
7+
v-model="cond.field"
8+
@change="onFieldChange"
9+
:currentNodeFields="currentNodeFields"
10+
:currentEditingIndex="currentEditingIndex"
11+
class="w-full"
12+
/>
13+
</el-form-item>
1214
</el-col>
1315
<el-col :span="6">
14-
<el-select v-model="cond.compare" clearable>
15-
<el-option
16-
v-for="op in cond._ops || compareList"
17-
:key="op.value"
18-
:label="op.label"
19-
:value="op.value"
20-
/>
21-
</el-select>
16+
<el-form-item :error="cond._compareError">
17+
<el-select v-model="cond.compare" @change="cond._compareError = ''" clearable>
18+
<el-option
19+
v-for="op in cond._ops || compareList"
20+
:key="op.value"
21+
:label="op.label"
22+
:value="op.value"
23+
/>
24+
</el-select>
25+
</el-form-item>
2226
</el-col>
2327
<el-col :span="8" v-if="!['is_true', 'is_not_true'].includes(cond.compare)">
24-
<el-select
25-
v-if="['SingleSelect', 'RadioCard', 'RadioRow'].includes(cond._fieldType || '')"
26-
v-model="cond.value"
27-
clearable
28-
>
29-
<el-option
30-
v-for="o in cond._options || []"
31-
:key="o.value"
32-
:label="`${o.label} (${o.value})`"
33-
:value="o.value"
28+
<el-form-item :error="cond._valueError">
29+
<el-select
30+
v-if="['SingleSelect', 'RadioCard', 'RadioRow'].includes(cond._fieldType || '')"
31+
v-model="cond.value"
32+
@change="cond._valueError = ''"
33+
clearable
34+
>
35+
<el-option
36+
v-for="o in cond._options || []"
37+
:key="o.value"
38+
:label="`${o.label} (${o.value})`"
39+
:value="o.value"
40+
/>
41+
</el-select>
42+
43+
<el-select
44+
v-else-if="cond._fieldType === 'MultiSelect'"
45+
v-model="cond.value"
46+
@change="cond._valueError = ''"
47+
multiple
48+
clearable
49+
>
50+
<el-option
51+
v-for="o in cond._options || []"
52+
:key="o.value"
53+
:label="`${o.label} (${o.value})`"
54+
:value="o.value"
55+
/>
56+
</el-select>
57+
58+
<el-tree-select
59+
v-else-if="cond._fieldType === 'TreeSelect'"
60+
v-model="cond.value"
61+
@change="cond._valueError = ''"
62+
:data="cond._treeData || []"
63+
:multiple="cond._treeMultiple"
64+
:render-after-expand="false"
65+
clearable
3466
/>
35-
</el-select>
3667

37-
<el-input v-else v-model="cond.value" />
68+
<el-input v-else v-model="cond.value" @input="cond._valueError = ''" />
69+
</el-form-item>
3870
</el-col>
3971
<el-col :span="2">
4072
<el-button link type="info" @click="$emit('delete')">
@@ -62,17 +94,28 @@ defineEmits<{
6294
}>()
6395
6496
function onFieldChange() {
97+
props.cond._fieldError = ''
98+
props.cond._compareError = ''
99+
props.cond._valueError = ''
100+
65101
const fieldType = inferFieldType(props.cond.field, props.nodeModel, props.currentNodeFields)
66-
const allowed = getAllowedOps(fieldType)
67102
const fieldConfig = getFieldConfig(props.cond.field, props.nodeModel, props.currentNodeFields)
68103
104+
const isTreeMultiple = fieldType === 'TreeSelect' && fieldConfig?.attrs?.multiple
105+
const allowed = isTreeMultiple ? ['contain', 'not_contain'] : getAllowedOps(fieldType)
106+
69107
props.cond._ops = compareList.filter((op) => allowed.includes(op.value))
70108
props.cond._fieldType = fieldType
71109
props.cond._options = fieldConfig?.option_list ?? []
110+
props.cond._treeData = fieldConfig?.attrs?.data ?? []
111+
props.cond._treeMultiple = isTreeMultiple
112+
113+
// 类型切换时重置 value
114+
const isMultiple = ['MultiSelect'].includes(fieldType || '') || isTreeMultiple
72115
73116
if (!allowed.includes(props.cond.compare)) {
74117
props.cond.compare = ''
75-
props.cond.value = ''
118+
props.cond.value = isMultiple ? [] : ''
76119
}
77120
}
78121
</script>

ui/src/components/dynamics-form/visibility/Constructor.vue

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -67,20 +67,32 @@ function removeCondition(idx: number) {
6767
}
6868
6969
function validate(): Promise<void> {
70+
let hasError = false
71+
for (const cond of formData.value.conditions) {
72+
cond._fieldError = ''
73+
cond._compareError = ''
74+
cond._valueError = ''
75+
}
7076
for (const cond of formData.value.conditions) {
7177
const hasAny = cond.field[0] || cond.field[1] || cond.compare
7278
if (!hasAny) continue
7379
if (!cond.field[0] || !cond.field[1]) {
74-
return Promise.reject('请选择变量')
80+
cond._fieldError = '请选择变量'
81+
hasError = true
7582
}
7683
if (!cond.compare) {
77-
return Promise.reject('请选择运算符')
84+
cond._compareError = '请选择运算符'
85+
hasError = true
7886
}
79-
if (!['is_true', 'is_not_true'].includes(cond.compare) && !cond.value && cond.value !== 0) {
80-
return Promise.reject('请填写匹配值')
87+
const isEmpty = Array.isArray(cond.value)
88+
? cond.value.length === 0
89+
: !cond.value && cond.value !== 0
90+
if (!['is_true', 'is_not_true'].includes(cond.compare) && isEmpty) {
91+
cond._valueError = '请填写匹配值'
92+
hasError = true
8193
}
8294
}
83-
return Promise.resolve()
95+
return hasError ? Promise.reject() : Promise.resolve()
8496
}
8597
8698
function getData(): VisibilityRules | null {
@@ -91,13 +103,16 @@ function getData(): VisibilityRules | null {
91103
action: formData.value.action,
92104
condition: formData.value.condition,
93105
node_id: props.nodeModel?.id,
94-
conditions: conds.map((c) => ({
95-
id: c.id,
96-
field: c.field,
97-
compare: c.compare,
98-
value: c.value,
99-
// _ops, _fieldType, _options 不持久化
100-
})),
106+
node_name: props.nodeModel?.properties?.stepName,
107+
conditions: conds
108+
.filter((c) => c.field[0] && c.field[1] && c.compare)
109+
.map((c) => ({
110+
id: c.id,
111+
field: c.field,
112+
compare: c.compare,
113+
value: c.value,
114+
// _ops, _fieldType, _options 不持久化
115+
})),
101116
}
102117
}
103118
@@ -114,11 +129,20 @@ function restore(rules: VisibilityRules | null) {
114129
formData.value.conditions.forEach((cond) => {
115130
if (cond.field && cond.field[0] && cond.field[1]) {
116131
const fieldType = inferFieldType(cond.field, props.nodeModel, props.currentNodeFields)
117-
const allowed = getAllowedOps(fieldType)
118132
const fieldConfig = getFieldConfig(cond.field, props.nodeModel, props.currentNodeFields)
133+
const isTreeMultiple = fieldType === 'TreeSelect' && fieldConfig?.attrs?.multiple
134+
const allowed = isTreeMultiple ? ['contain', 'not_contain'] : getAllowedOps(fieldType)
119135
cond._ops = compareList.filter((op) => allowed.includes(op.value))
120136
cond._fieldType = fieldType
121137
cond._options = fieldConfig?.option_list ?? []
138+
cond._treeData = fieldConfig?.attrs?.data ?? []
139+
cond._treeMultiple = isTreeMultiple
140+
const isMultiple = ['MultiSelect'].includes(fieldType || '') || isTreeMultiple
141+
// 清理脏数据
142+
if (cond.compare && !allowed.includes(cond.compare)) {
143+
cond.compare = ''
144+
cond.value = isMultiple ? [] : ''
145+
}
122146
}
123147
})
124148
}

ui/src/components/dynamics-form/visibility/FieldSelector.vue

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,31 @@ const injectDraftSiblings = (rawList: Array<any>) => {
115115
}))
116116
// 将 draft parameter 转换成 cascader 适配的 {label, value} 格式
117117
118+
// base-node
119+
const excludeSet = new Set(
120+
(props.currentNodeFields ?? [])
121+
.filter((f: any, idx: number) => {
122+
if (props.currentEditingIndex != null && idx >= props.currentEditingIndex) return true
123+
if (props.excludeFieldName && f.field === props.excludeFieldName) return true
124+
return false
125+
})
126+
.map((f: any) => f.field),
127+
)
128+
118129
return rawList
119-
.map((entry: any) =>
120-
entry.value === currentNodeId ? { ...entry, children: draftSiblings } : entry,
121-
)
130+
.map((entry: any) => {
131+
const isCurrentNode =
132+
entry.value === currentNodeId || (currentNodeId === 'base-node' && entry.value === 'global')
133+
if (!isCurrentNode) return entry
134+
135+
return {
136+
...entry,
137+
children:
138+
currentNodeId === 'base-node'
139+
? (entry.children || []).filter((c: any) => !excludeSet.has(c.value))
140+
: draftSiblings,
141+
}
142+
})
122143
.filter((entry: any) => entry.children && entry.children.length > 0)
123144
}
124145

ui/src/components/dynamics-form/visibility/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface VisibilityRules {
2222
action: 'show' | 'hide'
2323
condition: 'and' | 'or'
2424
node_id?: string
25+
node_name?: string
2526
conditions: VisibilityCondition[]
2627
}
2728

@@ -42,12 +43,12 @@ export interface VisibilityCtx {
4243
* 预渲染为字面量,前端不会再看到这些形态。
4344
*/
4445
export function resolveValue(raw: string, ctx: VisibilityCtx): string {
45-
return raw.replace(/\{\{\s*([^.\s}]+)\.([^.\s}]+)\s*\}\}/g, (match, nodeName, fieldName) => {
46+
return raw.replace(/\{\{([^.\s}]+)\.([^.\s}]+)\}\}/g, (match, nodeName, fieldName) => {
4647
if (nodeName !== ctx.currentNodeName) {
4748
return match // 非同表单,前置node 引用
4849
}
4950
const v = ctx.formValue?.[fieldName]
50-
return v == null ? '' : String(v)
51+
return v == null ? match : String(v)
5152
})
5253
}
5354

@@ -105,6 +106,9 @@ export function compareByOp(left: any, op: CompareOptions, right: any): boolean
105106
}
106107

107108
function containImpl(source: any, target: any): boolean {
109+
if (Array.isArray(target)) {
110+
return target.every((t) => containImpl(source, t))
111+
}
108112
const t = String(target)
109113
if (typeof source === 'string') return source.includes(t)
110114
if (Array.isArray(source)) return source.some((item) => String(item) === t)
@@ -137,6 +141,11 @@ export function evaluateVisibility(
137141

138142
const results = rules.conditions.map((cond) => {
139143
const left = lookupLeft(cond, ctx)
144+
145+
if (left == null && cond.compare !== 'is_true' && cond.compare !== 'is_not_true') {
146+
return false
147+
}
148+
140149
const right = typeof cond.value === 'string' ? resolveValue(cond.value, ctx) : cond.value
141150
return compareByOp(left, cond.compare as CompareOptions, right)
142151
})

0 commit comments

Comments
 (0)