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 @@ + + + + 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" >