feat(oa): visual condition rules driven by form field flags

Mark eligible form fields for condition branches with required lock,
and replace formula inputs with a field/op/value editor in the flow designer.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-15 17:09:23 +08:00
1 parent 9ae239347a
commit 638c4184d2
7 files changed
+529 -37

No files matched your search

@@ -22,6 +22,8 @@
const props = defineProps<{
/** { rule, option } */
modelValue?: { rule?: unknown; option?: unknown } | null
/** 已被流程条件引用的 field 列表(用于拦截取消勾选) */
flowConditionFields?: string[]
}>()
const frameRef = ref<HTMLIFrameElement | null>(null)
@@ -36,7 +38,8 @@
READY: 'oa-fc:ready',
SET: 'oa-fc:set-schema',
GET: 'oa-fc:get-schema',
SCHEMA: 'oa-fc:schema'
SCHEMA: 'oa-fc:schema',
FLOW_REFS: 'oa-fc:set-flow-refs'
} as const
const hostSrc = computed(() => {
@@ -75,8 +78,19 @@
})
}
function pushFlowRefs() {
if (!frameReady.value) return
postToFrame({
type: MSG.FLOW_REFS,
fields: Array.isArray(props.flowConditionFields) ? props.flowConditionFields : []
})
}
function onFrameLoad() {
if (frameReady.value) pushSchemaToFrame()
if (frameReady.value) {
pushSchemaToFrame()
pushFlowRefs()
}
}
function onMessage(ev: MessageEvent) {
@@ -85,6 +99,7 @@
if (data.type === MSG.READY) {
frameReady.value = true
pushSchemaToFrame()
pushFlowRefs()
// 宿主刚就绪时再补推一次,避免 setRule 早于内部 dragForm 初始化
window.setTimeout(() => pushSchemaToFrame(), 50)
window.setTimeout(() => pushSchemaToFrame(), 200)
@@ -129,6 +144,14 @@
{ deep: true }
)
watch(
() => props.flowConditionFields,
() => {
pushFlowRefs()
},
{ deep: true }
)
onMounted(() => {
window.addEventListener('message', onMessage)
})
@@ -0,0 +1,244 @@
<!-- 条件分支可视化规则:字段 + 运算符 + 值 -->
<template>
<div class="cond-rule-editor">
<el-alert
v-if="legacyWhen && !(modelValue && modelValue.length)"
type="warning"
:closable="false"
show-icon
class="cond-rule-editor__legacy"
title="检测到旧版条件公式,请用下方可视化重新配置"
/>
<div v-if="!candidates.length" class="cond-rule-editor__empty">
请先到「2 表单设计」勾选参与条件分支的字段
</div>
<template v-else>
<div
v-for="(rule, idx) in localRules"
:key="idx"
class="cond-rule-editor__row-wrap"
>
<div class="cond-rule-editor__row">
<el-select
:model-value="rule.field"
placeholder="字段"
:disabled="disabled"
@update:model-value="(v) => onFieldChange(idx, String(v))"
>
<el-option
v-for="c in candidates"
:key="c.field"
:label="c.title"
:value="c.field"
/>
</el-select>
<el-select
:model-value="rule.op"
placeholder="条件"
:disabled="disabled"
@update:model-value="(v) => onOpChange(idx, v)"
>
<el-option
v-for="o in opsForRow(rule)"
:key="o.value"
:label="o.label"
:value="o.value"
/>
</el-select>
<el-select
v-if="optionsFor(rule.field)?.length"
:model-value="rule.value"
placeholder="值"
:disabled="disabled"
@update:model-value="(v) => onValueChange(idx, v)"
>
<el-option
v-for="opt in optionsFor(rule.field)"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-input
v-else
:model-value="valueText(rule.value)"
placeholder="比较值"
:disabled="disabled"
@update:model-value="(v) => onValueChange(idx, coerceValue(rule.field, v))"
/>
</div>
<div v-if="idx < localRules.length - 1" class="cond-rule-editor__and">且(AND)</div>
</div>
<el-button
class="btn-ghost"
:disabled="disabled || !candidates.length"
@click="addRule"
>
<el-icon><Plus /></el-icon>
且条件
</el-button>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import {
opsForFieldType,
type ConditionCandidate,
type ConditionOp
} from '@/components/oa-approval/oa-condition-fields'
import type { ConditionRule } from './types'
defineOptions({ name: 'OaConditionRuleEditor' })
const props = withDefaults(
defineProps<{
modelValue?: ConditionRule[]
disabled?: boolean
candidates: ConditionCandidate[]
legacyWhen?: string
}>(),
{
modelValue: () => [],
disabled: false,
candidates: () => [],
legacyWhen: ''
}
)
const emit = defineEmits<{
'update:modelValue': [value: ConditionRule[]]
}>()
const localRules = computed(() => props.modelValue || [])
function meta(field: string) {
return props.candidates.find((c) => c.field === field)
}
function opsForRow(rule: ConditionRule) {
const t = meta(rule.field)?.type || 'number'
return opsForFieldType(t)
}
function optionsFor(field: string) {
return meta(field)?.options
}
function valueText(v: unknown) {
return v == null ? '' : String(v)
}
function coerceValue(field: string, raw: string): string | number {
const t = meta(field)?.type || ''
if (t === 'inputNumber' || t === 'number' || t === 'money') {
if (raw === '' || raw == null) return ''
const n = Number(raw)
return Number.isFinite(n) ? n : raw
}
return raw
}
function emitRules(next: ConditionRule[]) {
emit('update:modelValue', next)
}
function ensureSeed() {
if (props.disabled) return
if (!props.candidates.length) return
if ((props.modelValue || []).length) return
const first = props.candidates[0]
emitRules([
{
field: first.field,
op: opsForFieldType(first.type)[0].value,
value: first.options?.[0]?.value ?? ''
}
])
}
watch(
() => [props.disabled, props.candidates.length, (props.modelValue || []).length] as const,
() => {
if (!props.disabled) ensureSeed()
},
{ immediate: true }
)
function onFieldChange(idx: number, field: string) {
const next = [...localRules.value]
const t = meta(field)?.type || 'number'
const ops = opsForFieldType(t)
const opts = optionsFor(field)
next[idx] = {
field,
op: ops[0].value,
value: opts?.[0]?.value ?? ''
}
emitRules(next)
}
function onOpChange(idx: number, op: unknown) {
const next = [...localRules.value]
next[idx] = { ...next[idx], op: op as ConditionOp }
emitRules(next)
}
function onValueChange(idx: number, value: unknown) {
const next = [...localRules.value]
next[idx] = { ...next[idx], value: value as string | number }
emitRules(next)
}
function addRule() {
if (!props.candidates.length) return
const first = props.candidates[0]
emitRules([
...localRules.value,
{
field: first.field,
op: opsForFieldType(first.type)[0].value,
value: first.options?.[0]?.value ?? ''
}
])
}
</script>
<style scoped lang="scss">
.cond-rule-editor {
margin-bottom: 12px;
}
.cond-rule-editor__legacy {
margin-bottom: 10px;
}
.cond-rule-editor__empty {
padding: 10px 12px;
margin-bottom: 8px;
color: #d97706;
font-size: 12px;
background: #fffbeb;
border: 1px dashed #fde68a;
border-radius: 8px;
}
.cond-rule-editor__row {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 6px;
margin-bottom: 6px;
}
.cond-rule-editor__and {
margin: 0 0 6px;
color: var(--mint-text-muted, #94a3b8);
font-size: 11px;
text-align: center;
}
</style>
@@ -34,13 +34,13 @@
class="flow-cond-col"
>
<div class="flow-cond-col__title" @click.stop="emit('edit', node)">
{{ g.name || (g.isDefault ? '默认' : `条件${idx + 1}`) }}
<span v-if="g.when" class="muted">{{ g.when }}</span>
{{ branchTitle(g, idx) }}
</div>
<FlowBranch
v-if="g.child"
:node="g.child"
:depth="depth + 1"
:candidates="candidates"
@edit="(n) => emit('edit', n)"
@remove="(id) => emit('remove', id)"
@add="(payload) => emit('add', payload)"
@@ -66,6 +66,7 @@
v-if="node.child"
:node="node.child"
:depth="depth"
:candidates="candidates"
@edit="(n) => emit('edit', n)"
@remove="(id) => emit('remove', id)"
@add="(payload) => emit('add', payload)"
@@ -78,7 +79,11 @@
<script setup lang="ts">
import { Delete } from '@element-plus/icons-vue'
import type { FlowNode } from './types'
import {
formatConditionPreview,
type ConditionCandidate
} from '@/components/oa-approval/oa-condition-fields'
import type { ConditionGroup, FlowNode } from './types'
import { summarizeApprover } from './types'
// 递归自引用
import FlowBranch from './FlowBranch.vue'
@@ -89,8 +94,9 @@
defineProps<{
node: FlowNode
depth?: number
candidates?: ConditionCandidate[]
}>(),
{ depth: 0 }
{ depth: 0, candidates: () => [] }
)
const emit = defineEmits<{
@@ -107,6 +113,13 @@
return t
}
function branchTitle(g: ConditionGroup, idx: number) {
if (g.isDefault) return g.name || '默认 · 其他情况'
const preview = formatConditionPreview(g.rules || [], props.candidates || [])
if (preview && preview !== '未配置') return preview
return g.name || `条件${idx + 1}`
}
function nodeSummary(n: FlowNode) {
if (n.type === 'start') return '谁可以发起本审批'
if (n.type === 'condition') {
@@ -8,6 +8,7 @@
<div class="oa-flow-designer__canvas-inner">
<FlowBranch
:node="root"
:candidates="candidates"
@edit="openEdit"
@remove="onRemove"
@add="onAdd"
@@ -217,13 +218,12 @@
<el-form-item :label="`分支 ${idx + 1} 名称`">
<el-input v-model="g.name" />
</el-form-item>
<el-form-item label="条件公式(空=默认分支)">
<el-input
v-model="g.when"
placeholder="如 {days} > 3"
:disabled="g.isDefault"
/>
</el-form-item>
<ConditionRuleEditor
v-model="g.rules"
:disabled="!!g.isDefault"
:candidates="candidates"
:legacy-when="g.when"
/>
<el-form-item label="默认分支">
<el-switch
v-model="g.isDefault"
@@ -252,6 +252,8 @@
import { fetchUserList } from '@/api/admin'
import { fetchOaRoleList } from '@/api/oaApproval'
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
import type { ConditionCandidate } from '@/components/oa-approval/oa-condition-fields'
import ConditionRuleEditor from './ConditionRuleEditor.vue'
import FlowBranch from './FlowBranch.vue'
import {
createEndNode,
@@ -260,6 +262,7 @@
findParent,
type ApproverConfig,
type AssigneeType,
type ConditionRule,
type FlowNode
} from './types'
@@ -278,9 +281,15 @@
'#ec4899'
]
const props = defineProps<{
modelValue: FlowNode | Record<string, unknown> | null
}>()
const props = withDefaults(
defineProps<{
modelValue: FlowNode | Record<string, unknown> | null
candidates?: ConditionCandidate[]
}>(),
{ candidates: () => [] }
)
const candidates = computed(() => props.candidates || [])
const emit = defineEmits<{
'update:modelValue': [value: FlowNode]
@@ -329,7 +338,7 @@
const t = editing.value?.type
if (t === 'approval') return '配置本节点审批人与多人会签规则'
if (t === 'cc') return '配置本节点抄送人'
if (t === 'condition') return '配置条件分支名称与公式'
if (t === 'condition') return '配置条件分支名称与可视化规则'
if (t === 'start') return '流程起点节点'
return '配置流程节点'
})
@@ -496,6 +505,12 @@
async function openEdit(node: FlowNode) {
editing.value = JSON.parse(JSON.stringify(node)) as FlowNode
if (editing.value.type === 'condition') {
editing.value.conditionGroups = (editing.value.conditionGroups || []).map((g) => ({
...g,
rules: Array.isArray(g.rules) ? g.rules : []
}))
}
const ac = editing.value.approverConfig || { assigneeType: 'dept_leader' as const }
cfg.assigneeType = ac.assigneeType || 'dept_leader'
cfg.userIds = [...(ac.userIds || [])]
@@ -516,10 +531,24 @@
if (!editing.value?.conditionGroups || !v) return
editing.value.conditionGroups.forEach((g, i) => {
g.isDefault = i === idx
if (g.isDefault) g.when = ''
if (g.isDefault) {
g.when = ''
g.rules = []
}
})
}
function sanitizeConditionRules(rules?: ConditionRule[]): ConditionRule[] {
return (rules || []).filter(
(r) =>
!!r &&
String(r.field || '').trim() &&
String(r.op || '').trim() &&
r.value !== '' &&
r.value != null
)
}
function addConditionGroup() {
if (!editing.value) return
if (!editing.value.conditionGroups) editing.value.conditionGroups = []
@@ -550,10 +579,16 @@
}
if (live.type === 'condition' && editing.value.conditionGroups) {
const oldGroups = live.conditionGroups || []
live.conditionGroups = editing.value.conditionGroups.map((g, i) => ({
...g,
child: oldGroups[i]?.child || g.child || createEndNode()
}))
live.conditionGroups = editing.value.conditionGroups.map((g, i) => {
const isDefault = !!g.isDefault
return {
...g,
isDefault,
when: '',
rules: isDefault ? [] : sanitizeConditionRules(g.rules),
child: oldGroups[i]?.child || g.child || createEndNode()
}
})
}
commit()
drawerVisible.value = false
@@ -15,10 +15,18 @@ export interface ApproverConfig {
signMode?: 'or' | 'and'
}
export type ConditionOp = '>' | '>=' | '<' | '<=' | '==' | '!='
export interface ConditionRule {
field: string
op: ConditionOp
value: string | number
}
export interface ConditionGroup {
name: string
when?: string
rules?: unknown[]
rules?: ConditionRule[]
isDefault?: boolean
child: FlowNode | null
}
+156 -4
View File
@@ -1,6 +1,6 @@
<!-- FormCreate 全屏宿主(无 Tailwind) -->
<template>
<div class="oa-fc-host">
<div class="oa-fc-host" :class="{ 'oa-fc-host--lock-required': lockRequired }">
<fc-designer
ref="designerRef"
class="oa-fc-host__designer"
@@ -8,13 +8,24 @@
:config="designerConfig"
:locale="fcLocale"
/>
<div v-show="showCondFlag" class="oa-cond-flag">
<div class="oa-cond-flag__row">
<el-switch :model-value="flagOn" @change="onFlagChange" />
<div>
<div class="oa-cond-flag__title">参与流程条件分支</div>
<div class="oa-cond-flag__tip">勾选后该字段必填且不可改为选填;可在「3 流程设计」条件中选用</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import formCreateZh from '@form-create/designer/locale/zh-cn'
import { registerOaDesignerAttachment } from '@/components/oa-approval/oa-form-components'
import { CONDITION_ELIGIBLE_TYPES } from '@/components/oa-approval/oa-condition-fields'
defineOptions({ name: 'OaFcHostApp' })
@@ -41,19 +52,102 @@
READY: 'oa-fc:ready',
SET: 'oa-fc:set-schema',
GET: 'oa-fc:get-schema',
SCHEMA: 'oa-fc:schema'
SCHEMA: 'oa-fc:schema',
FLOW_REFS: 'oa-fc:set-flow-refs'
} as const
const flowConditionFields = ref<string[]>([])
const showCondFlag = ref(false)
const flagOn = ref(false)
const lockRequired = ref(false)
let pollTimer: number | null = null
let lastActiveId = ''
function getDesigner(): DesignerApi | null {
const inst = designerRef.value as any
if (!inst) return null
// Vue3 组件实例可能挂在 expose / proxy 上
if (typeof inst.setRule === 'function') return inst
if (inst.$ && typeof inst.$.exposed?.setRule === 'function') return inst.$.exposed
if (typeof inst.setupState?.setRule === 'function') return inst.setupState
return inst
}
function getSetupState(): any {
const inst = designerRef.value as any
if (!inst) return null
return inst.setupState || inst.$?.setupState || inst.$.exposed?.setupState || null
}
function getActiveRule(): any {
const state = getSetupState()
return state?.activeRule || null
}
function readFlag(rule: any): boolean {
return rule?.useInCondition === true || rule?.props?.useInCondition === true
}
function applyFlagToRule(rule: any, on: boolean) {
rule.useInCondition = on
if (!rule.props || typeof rule.props !== 'object') rule.props = {}
rule.props.useInCondition = on
if (on) {
rule.$required = true
rule.required = true
}
}
function syncActivePanel() {
const rule = getActiveRule()
const type = String(rule?.type || '')
const eligible = !!(rule?.field && CONDITION_ELIGIBLE_TYPES.has(type))
showCondFlag.value = eligible
if (!eligible) {
flagOn.value = false
lockRequired.value = false
lastActiveId = ''
return
}
const id = String(rule._fc_id || rule.field || '')
const on = readFlag(rule)
flagOn.value = on
lockRequired.value = on
if (on) applyFlagToRule(rule, true)
lastActiveId = id
}
function onFlagChange(val: string | number | boolean) {
const on = !!val
const rule = getActiveRule()
if (!rule?.field) return
if (!on) {
const field = String(rule.field)
if (flowConditionFields.value.includes(field)) {
ElMessage.warning('请先在流程设计中删除对该字段的条件')
flagOn.value = true
return
}
applyFlagToRule(rule, false)
flagOn.value = false
lockRequired.value = false
return
}
applyFlagToRule(rule, true)
flagOn.value = true
lockRequired.value = true
// 同步校验面板必填开关
try {
const state = getSetupState()
const vapi = state?.validateForm?.api
if (vapi?.setValue) {
const cur = typeof vapi.formData === 'function' ? vapi.formData() : {}
vapi.setValue({ ...cur, $required: true })
}
} catch {
/* ignore */
}
}
function readSchema() {
const d = getDesigner()
return {
@@ -82,6 +176,7 @@
d.setOption(JSON.parse(JSON.stringify(option)))
}
pendingSchema = null
window.setTimeout(() => syncActivePanel(), 80)
} catch (e) {
console.warn('[oa-fc-host] applySchema failed', e)
if (attempt < 5) {
@@ -102,6 +197,12 @@
{ type: MSG.SCHEMA, schema: readSchema(), requestId: data.requestId },
'*'
)
return
}
if (data.type === MSG.FLOW_REFS) {
flowConditionFields.value = Array.isArray(data.fields)
? data.fields.map((x: unknown) => String(x))
: []
}
}
@@ -129,12 +230,23 @@
if (pendingSchema) {
void applySchema(pendingSchema)
}
pollTimer = window.setInterval(() => {
const rule = getActiveRule()
const id = rule ? String(rule._fc_id || rule.field || '') : ''
if (id !== lastActiveId || (rule && readFlag(rule) !== flagOn.value)) {
syncActivePanel()
}
}, 250)
})
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
brandObserver?.disconnect()
brandObserver = null
if (pollTimer != null) {
window.clearInterval(pollTimer)
pollTimer = null
}
})
</script>
@@ -151,6 +263,7 @@
}
.oa-fc-host {
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
@@ -165,6 +278,46 @@
height: 100% !important;
}
.oa-cond-flag {
position: absolute;
top: 56px;
right: 12px;
z-index: 30;
width: 292px;
padding: 12px;
background: #ecfdf5;
border: 1px dashed #a7f3d0;
border-radius: 8px;
box-shadow: 0 2px 8px rgb(15 23 42 / 8%);
}
.oa-cond-flag__row {
display: flex;
gap: 10px;
align-items: flex-start;
}
.oa-cond-flag__title {
font-size: 13px;
font-weight: 600;
color: #059669;
}
.oa-cond-flag__tip {
margin-top: 2px;
font-size: 12px;
line-height: 1.4;
color: #64748b;
}
/* 参与分支时锁定「是否必填」 */
.oa-fc-host--lock-required ._fc-r .el-form-item:has([name='$required']),
.oa-fc-host--lock-required ._fc-r .el-form-item:has(.fc-required),
.oa-fc-host--lock-required ._fc-validate {
pointer-events: none;
opacity: 0.55;
}
/* 防止宿主环境意外丢弃 aside 宽度 */
.oa-fc-host .el-aside._fc-l-menu {
width: 40px !important;
@@ -201,7 +354,6 @@
overflow: auto;
}
/* 兜底:若短暂出现水印节点,先视觉藏住(最终由 JS 移除) */
.oa-fc-host ._fc-m-con > a[href*='form-create.com'] {
clip-path: inset(50%) !important;
transform: scale(0) !important;
@@ -72,11 +72,12 @@
v-if="formDesignerMounted"
ref="formEmbedRef"
v-model="formSchema"
:flow-condition-fields="flowConditionFields"
/>
</div>
<div v-show="activeStep === 'flow'" class="oa-designer__step oa-designer__flow-step">
<FlowDesigner v-model="flowTreeJson" />
<FlowDesigner v-model="flowTreeJson" :candidates="conditionCandidates" />
</div>
<div v-show="activeStep === 'advanced'" class="oa-designer__step">
@@ -107,7 +108,7 @@
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { UploadRequestOptions } from 'element-plus'
@@ -117,6 +118,11 @@
import FlowDesigner from '@/components/oa-approval/flow-tree/FlowDesigner.vue'
import { createDefaultFlow, ensureFlowRoot } from '@/components/oa-approval/flow-tree/types'
import OaFormDesignerEmbed from '@/components/oa-approval/OaFormDesignerEmbed.vue'
import {
collectConditionReferencedFields,
listConditionCandidates,
normalizeSchemaConditionRequired
} from '@/components/oa-approval/oa-condition-fields'
import { pickRandomOaAppIconUrl, resolveOaAppIconUrl } from '@/utils/oa-app-icons'
defineOptions({ name: 'OaApprovalDesigner' })
@@ -157,20 +163,31 @@
const flowTreeJson = ref(createDefaultFlow())
const flowConditionFields = computed(() =>
Array.from(collectConditionReferencedFields(flowTreeJson.value))
)
const conditionCandidates = computed(() => listConditionCandidates(formSchema.value))
async function collectFormSchema() {
let raw: { rule: unknown; option: unknown }
if (formEmbedRef.value?.fetchSchema) {
try {
const schema = await formEmbedRef.value.fetchSchema()
formSchema.value = schema
return schema
raw = await formEmbedRef.value.fetchSchema()
} catch {
/* 超时则回退本地缓存 */
raw = {
rule: formSchema.value?.rule ?? draft.value?.formSchemaJson?.rule ?? [],
option: formSchema.value?.option ?? draft.value?.formSchemaJson?.option ?? {}
}
}
} else {
raw = {
rule: formSchema.value?.rule ?? draft.value?.formSchemaJson?.rule ?? [],
option: formSchema.value?.option ?? draft.value?.formSchemaJson?.option ?? {}
}
}
return {
rule: formSchema.value?.rule ?? draft.value?.formSchemaJson?.rule ?? [],
option: formSchema.value?.option ?? draft.value?.formSchemaJson?.option ?? {}
}
const normalized = normalizeSchemaConditionRequired(raw)
formSchema.value = normalized
return normalized
}
async function uploadBasicIcon(options: UploadRequestOptions) {