diff --git a/apps/application/flow/step_node/form_node/impl/base_form_node.py b/apps/application/flow/step_node/form_node/impl/base_form_node.py
index 7b12f0be5f1..710811f1505 100644
--- a/apps/application/flow/step_node/form_node/impl/base_form_node.py
+++ b/apps/application/flow/step_node/form_node/impl/base_form_node.py
@@ -16,7 +16,9 @@
from application.flow.common import Answer
from application.flow.i_step_node import NodeResult
from application.flow.step_node.form_node.i_form_node import IFormNode
+import re
+_TEMPLATE_RE = re.compile(r'\{\{([^.\s}]+)\.([^.\s}]+)\}\}')
multi_select_list = [
'MultiSelect',
'MultiRow'
@@ -106,8 +108,63 @@ def reset_field(self, field):
field['default_value'] = self.workflow_manage.get_reference_field(field.get('default_value')[0],
field.get('default_value')[1:])
+ visibility_rules = field.get('visibility_rules')
+ if visibility_rules and isinstance(visibility_rules.get('conditions'), list):
+ for cond in visibility_rules['conditions']:
+ cond_field = cond.get('field')
+ if not cond_field or len(cond_field) < 2 or not cond_field[0] or not cond_field[1]:
+ continue
+
+ # cross node -------> _left
+ if cond_field[0] != self.node.id:
+ cond['_left'] = self.workflow_manage.get_reference_field(cond_field[0], cond_field[1:])
+ # 右值 {{}}
+ cond_value = cond.get("value")
+ if isinstance(cond_value, str) and _TEMPLATE_RE.search(cond_value):
+ cond['value'] = self._render_cond_value(cond_value)
+
return field
+ def _render_cond_value(self, value):
+ """
+ render cross-node/global/chat {{}} to literal, preserve same-form {{}}
+ match.group(0) → "{{开始.question}}" # 完整匹配
+ match.group(1) → "开始" # 第一个 () 捕获的
+ match.group(2) → "question" # 第二个 () 捕获的
+ match.start() → 3 # 匹配起始位置
+ match.end() → 16 # 匹配结束位置
+ """
+ def replacer(match):
+ node_display = match.group(1)
+ field_name = match.group(2)
+
+ # field_list: cross_node
+ for f in self.workflow_manage.field_list:
+ if f.get('node_name') == node_display and f.get('value') == field_name:
+ if f.get('node_id') == self.node.id:
+ return match.group(0) # same node
+ ref = self.workflow_manage.get_reference_field(f.get('node_id'),[field_name])
+ return str(ref) if ref is not None else ''
+
+ # global
+ if node_display in ('全局变量', 'global'):
+ for f in self.workflow_manage.global_field_list:
+ if f.get('value') == field_name:
+ ref = self.workflow_manage.get_reference_field('global', [field_name])
+ return str(ref) if ref is not None else ''
+
+ # chat
+ if node_display == 'chat':
+ for f in self.workflow_manage.chat_field_list:
+ if f.get("value") == field_name:
+ ref = self.workflow_manage.get_reference_field('chat', [field_name])
+ return str(ref) if ref is not None else ''
+ return match.group(0)
+ try:
+ return _TEMPLATE_RE.sub(replacer, value)
+ except Exception:
+ return value
+
def execute(self, form_field_list, form_content_format, form_data, **kwargs) -> NodeResult:
if form_data is not None:
self.context['is_submit'] = True
diff --git a/ui/src/components/dynamics-form/constructor/index.vue b/ui/src/components/dynamics-form/constructor/index.vue
index aabcf01093f..9b6b8d15297 100644
--- a/ui/src/components/dynamics-form/constructor/index.vue
+++ b/ui/src/components/dynamics-form/constructor/index.vue
@@ -1,91 +1,111 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/components/dynamics-form/visibility/Constructor.vue b/ui/src/components/dynamics-form/visibility/Constructor.vue
new file mode 100644
index 00000000000..6783c3aefca
--- /dev/null
+++ b/ui/src/components/dynamics-form/visibility/Constructor.vue
@@ -0,0 +1,164 @@
+
+
+
+ 显示条件
+ 隐藏条件
+
+
+
+ 符合以下
+
+
+
+
+ 条件
+
+
+
+
+
+
+ 添加
+
+
+
+
+
diff --git a/ui/src/components/dynamics-form/visibility/FieldSelector.vue b/ui/src/components/dynamics-form/visibility/FieldSelector.vue
new file mode 100644
index 00000000000..a97f429a5ac
--- /dev/null
+++ b/ui/src/components/dynamics-form/visibility/FieldSelector.vue
@@ -0,0 +1,186 @@
+
+
+
+
+ {{ data.label }}
+
+
+
+
+
+
diff --git a/ui/src/components/dynamics-form/visibility/field-type.ts b/ui/src/components/dynamics-form/visibility/field-type.ts
new file mode 100644
index 00000000000..1301d78634c
--- /dev/null
+++ b/ui/src/components/dynamics-form/visibility/field-type.ts
@@ -0,0 +1,79 @@
+export type InferredFieldType = string | undefined
+
+export function inferFieldType(
+ fieldPath: [string, string] | Array,
+ nodeModel: any,
+ currentNodeFields?: Array,
+): InferredFieldType {
+ return getFieldConfig(fieldPath, nodeModel, currentNodeFields)?.input_type
+}
+
+// input_type → 允许的运算符(按设计文档表格)
+const TYPE_OP_MAP: Record> = {
+ SwitchInput: ['is_true', 'is_not_true'],
+
+ SingleSelect: ['eq', 'not_eq'],
+ RadioCard: ['eq', 'not_eq'],
+ RadioRow: ['eq', 'not_eq'],
+ TreeSelect: ['eq', 'not_eq'],
+ Model: ['eq', 'not_eq'],
+ Knowledge: ['eq', 'not_eq'],
+ DatePicker: ['eq', 'not_eq'],
+
+ MultiSelect: ['contain', 'not_contain'],
+ MultiRow: ['contain', 'not_contain'],
+
+ TextInput: ['eq', 'not_eq', 'contain', 'not_contain'],
+ TextareaInput: ['eq', 'not_eq', 'contain', 'not_contain'],
+ PasswordInput: ['eq', 'not_eq', 'contain', 'not_contain'],
+ JsonInput: ['eq', 'not_eq', 'contain', 'not_contain'],
+
+ Slider: ['eq', 'not_eq', 'gt', 'ge', 'lt', 'le'],
+}
+
+const ALL_VISIBILITY_OPS = [
+ 'eq',
+ 'not_eq',
+ 'contain',
+ 'not_contain',
+ 'is_true',
+ 'is_not_true',
+ 'gt',
+ 'ge',
+ 'lt',
+ 'le',
+]
+
+export function getAllowedOps(inputType: string | undefined): Array {
+ if (!inputType) return ALL_VISIBILITY_OPS
+ return TYPE_OP_MAP[inputType] ?? ALL_VISIBILITY_OPS
+}
+
+/**
+ * 根据 [node_id, field_name] 取回完整字段配置对象。
+ * 推不出 → 返回 undefined
+ */
+export function getFieldConfig(
+ fieldPath: [string, string] | Array,
+ nodeModel: any,
+ currentNodeFields?: Array,
+): any | undefined {
+ if (!fieldPath || fieldPath.length < 2) return undefined
+ const [nodeId, fieldName] = fieldPath
+
+ if (nodeId === nodeModel?.id) {
+ return (currentNodeFields ?? []).find((f: any) => f.field === fieldName)
+ }
+ const targetNode = nodeModel?.graphModel?.getNodeModelById?.(
+ nodeId === 'global' ? 'base-node' : nodeId,
+ )
+ if (!targetNode) return undefined
+
+ let fieldList: Array = []
+ if (targetNode.type === 'form-node') {
+ fieldList = targetNode.properties?.node_data?.form_field_list ?? []
+ } else if (targetNode.type === 'base-node') {
+ fieldList = targetNode.properties?.user_input_field_list ?? []
+ }
+ return fieldList.find((item: any) => item.field === fieldName)
+}
diff --git a/ui/src/components/dynamics-form/visibility/index.ts b/ui/src/components/dynamics-form/visibility/index.ts
new file mode 100644
index 00000000000..069fe344bb5
--- /dev/null
+++ b/ui/src/components/dynamics-form/visibility/index.ts
@@ -0,0 +1,156 @@
+export type CompareOptions =
+ | 'eq'
+ | 'not_eq'
+ | 'contain'
+ | 'not_contain'
+ | 'is_true'
+ | 'is_not_true'
+ | 'gt'
+ | 'ge'
+ | 'lt'
+ | 'le'
+
+export interface VisibilityCondition {
+ id: string
+ field: [string, string] // [scope_or_node_id, field_name]
+ compare: CompareOptions | ''
+ value: any
+ _left?: any // cross node exist
+}
+
+export interface VisibilityRules {
+ action: 'show' | 'hide'
+ condition: 'and' | 'or'
+ node_id?: string
+ node_name?: string
+ conditions: VisibilityCondition[]
+}
+
+export interface VisibilityCtx {
+ formValue: Record
+ currentNodeId: string // field 同节点判读 node_id
+ currentNodeName: string // current node display name, {{currentNodeName.result}}, same form value. reference
+}
+
+/**
+ * 解析 匹配值 残留的 {{}}
+ *
+ * 前端只处理 同 node 表单 引用
+ * ex: 当前节点叫「表单收集」,{{表单收集.region}} → formValue.region
+ *
+ * 跨节点 {{开始.question}} / {{全局变量.x}} / {{chat.x}} 已由后端 form-node
+ * reset_field 阶段(过滤掉本节点的 field_list 后)通过 generate_prompt
+ * 预渲染为字面量,前端不会再看到这些形态。
+ */
+export function resolveValue(raw: string, ctx: VisibilityCtx): string {
+ return raw.replace(/\{\{([^.\s}]+)\.([^.\s}]+)\}\}/g, (match, nodeName, fieldName) => {
+ if (nodeName !== ctx.currentNodeName) {
+ return match // 非同表单,前置node 引用
+ }
+ const v = ctx.formValue?.[fieldName]
+ return v == null ? match : String(v)
+ })
+}
+
+export function lookupLeft(cond: VisibilityCondition, ctx: VisibilityCtx): any {
+ const scope = cond.field[0] === 'global' ? 'base-node' : cond.field[0]
+ if (scope === ctx.currentNodeId) {
+ return ctx.formValue?.[cond.field[1]] // 同节点:实时从 formValue 取
+ }
+ return (cond as any)._left // 跨节点:后端 返回
+}
+
+type CmpFn = (left: any, right: any) => boolean
+
+const compareHandlers: Record = {
+ eq: (l, r) => String(l) === String(r),
+ not_eq: (l, r) => String(l) !== String(r),
+ contain: (l, r) => containImpl(l, r),
+ not_contain: (l, r) => !containImpl(l, r),
+ is_true: (l) => l === true,
+ is_not_true: (l) => l !== true,
+ gt: (l, r) =>
+ numOrStrCmp(
+ l,
+ r,
+ (a, b) => a > b,
+ (a, b) => a > b,
+ ),
+ ge: (l, r) =>
+ numOrStrCmp(
+ l,
+ r,
+ (a, b) => a >= b,
+ (a, b) => a >= b,
+ ),
+ lt: (l, r) =>
+ numOrStrCmp(
+ l,
+ r,
+ (a, b) => a < b,
+ (a, b) => a < b,
+ ),
+ le: (l, r) =>
+ numOrStrCmp(
+ l,
+ r,
+ (a, b) => a <= b,
+ (a, b) => a <= b,
+ ),
+}
+
+export function compareByOp(left: any, op: CompareOptions, right: any): boolean {
+ const fn = compareHandlers[op]
+ if (!fn) throw new Error(`Unknown compare op: ${op}`)
+ return fn(left, right)
+}
+
+function containImpl(source: any, target: any): boolean {
+ if (Array.isArray(target)) {
+ return target.every((t) => containImpl(source, t))
+ }
+ const t = String(target)
+ if (typeof source === 'string') return source.includes(t)
+ if (Array.isArray(source)) return source.some((item) => String(item) === t)
+ return String(source).includes(t)
+}
+
+function numOrStrCmp(
+ left: any,
+ right: any,
+ numFn: (a: number, b: number) => boolean,
+ strFn: (a: string, b: string) => boolean,
+): boolean {
+ const a = Number(left)
+ const b = Number(right)
+ if (!Number.isNaN(a) && !Number.isNaN(b)) return numFn(a, b)
+ try {
+ return strFn(String(left), String(right))
+ } catch {
+ return false
+ }
+}
+
+export function evaluateVisibility(
+ rules: VisibilityRules | null | undefined,
+ ctx: VisibilityCtx,
+): boolean {
+ if (!rules || !rules.conditions || rules.conditions.length === 0) {
+ return true
+ }
+
+ const results = rules.conditions.map((cond) => {
+ const left = lookupLeft(cond, ctx)
+
+ if (left == null && cond.compare !== 'is_true' && cond.compare !== 'is_not_true') {
+ return false
+ }
+
+ const right = typeof cond.value === 'string' ? resolveValue(cond.value, ctx) : cond.value
+ return compareByOp(left, cond.compare as CompareOptions, right)
+ })
+
+ const matched = rules.condition === 'or' ? results.some(Boolean) : results.every(Boolean)
+
+ return rules.action === 'show' ? matched : !matched
+}
diff --git a/ui/src/workflow/common/AddFormCollect.vue b/ui/src/workflow/common/AddFormCollect.vue
index 3c5c861c905..5423a16d6b2 100644
--- a/ui/src/workflow/common/AddFormCollect.vue
+++ b/ui/src/workflow/common/AddFormCollect.vue
@@ -13,11 +13,16 @@
label-position="top"
require-asterisk-position="right"
ref="dynamicsFormConstructorRef"
+ :nodeModel="nodeModel"
+ :currentNodeFields="currentNodeFields"
+ :currentEditingIndex="currentNodeFields?.length ?? 0"
>
@@ -27,8 +32,13 @@ import { ref } from 'vue'
import DynamicsFormConstructor from '@/components/dynamics-form/constructor/index.vue'
import { t } from '@/locales'
const props = withDefaults(
- defineProps<{ title?: string; addFormField: (form_data: any) => void }>(),
- { title: t('common.param.addParam') }
+ defineProps<{
+ title?: string
+ addFormField: (form_data: any) => void
+ nodeModel?: any
+ currentNodeFields?: Array
+ }>(),
+ { title: t('common.param.addParam') },
)
const dialogVisible = ref(false)
const dynamicsFormConstructorRef = ref>()
diff --git a/ui/src/workflow/common/EditFormCollect.vue b/ui/src/workflow/common/EditFormCollect.vue
index 4369782acdf..50f05cc5e3f 100644
--- a/ui/src/workflow/common/EditFormCollect.vue
+++ b/ui/src/workflow/common/EditFormCollect.vue
@@ -13,6 +13,9 @@
label-position="top"
require-asterisk-position="right"
ref="dynamicsFormConstructorRef"
+ :nodeModel="nodeModel"
+ :currentNodeFields="currentNodeFields"
+ :currentEditingIndex="currentIndex"
>