docs(oa): design form-driven visual condition branches

Spec and mockup for marking form fields for conditions and configuring
branches with field/op/value UI instead of raw formulas.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-15 16:48:58 +08:00
1 parent 28e59a2262
commit 23907a544b
2 files changed
+912

No files matched your search

@@ -0,0 +1,716 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OA 条件分支 · 表单字段驱动 Demo</title>
<style>
:root {
--primary: #10b981;
--primary-dark: #059669;
--primary-bg: #ecfdf5;
--text: #0f172a;
--text-2: #334155;
--muted: #64748b;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--bg: #f1f5f9;
--warn: #d97706;
--radius: 8px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.5 Inter, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background: var(--bg);
}
.page { max-width: 1180px; margin: 0 auto; padding: 16px 20px 40px; }
.banner {
display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: flex-start;
justify-content: space-between;
padding: 12px 16px; margin-bottom: 14px;
background: #fff; border: 1px solid var(--border); border-radius: 10px;
}
.banner h1 { margin: 0 0 4px; font-size: 16px; }
.banner p { margin: 0; color: var(--muted); font-size: 13px; max-width: 720px; }
.tag {
display: inline-block; padding: 2px 8px; border-radius: 999px;
background: var(--primary-bg); color: var(--primary-dark);
font-size: 12px; font-weight: 600;
}
.panel {
background: #fff; border: 1px solid var(--border); border-radius: 10px;
overflow: hidden;
}
.toolbar {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-bottom: 1px solid var(--border);
}
.btn {
display: inline-flex; align-items: center; gap: 6px;
height: 34px; padding: 0 12px;
border: 1px solid var(--border); border-radius: 8px;
background: #fff; color: var(--text-2); cursor: pointer; font-size: 13px;
}
.btn:hover { border-color: var(--primary); color: var(--primary-dark); }
.btn.primary {
background: var(--primary); border-color: var(--primary); color: #fff;
}
.btn.primary:hover { background: var(--primary-dark); }
.btn.sm { height: 28px; padding: 0 8px; font-size: 12px; }
.tabs {
display: flex; gap: 0; border-bottom: 1px solid var(--border);
padding: 0 12px; background: #fafafa;
}
.tab {
border: 0; background: transparent; padding: 12px 14px;
color: var(--muted); cursor: pointer; font-size: 13px;
border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.tab.active {
color: var(--primary-dark); font-weight: 600;
border-bottom-color: var(--primary);
}
.step-body { display: none; min-height: 520px; }
.step-body.show { display: block; }
/* ===== 表单设计 ===== */
.form-layout {
display: grid;
grid-template-columns: 200px 1fr 280px;
min-height: 520px;
}
.palette, .props {
background: #fafafa; padding: 12px;
border-right: 1px solid var(--border);
}
.props { border-right: 0; border-left: 1px solid var(--border); }
.palette h3, .props h3 {
margin: 0 0 10px; font-size: 12px; color: var(--muted);
font-weight: 600; letter-spacing: .02em;
}
.palette-item {
padding: 8px 10px; margin-bottom: 6px;
background: #fff; border: 1px solid var(--border); border-radius: 6px;
font-size: 13px; color: var(--text-2);
}
.canvas { padding: 20px 24px; }
.canvas-title { font-size: 15px; font-weight: 600; margin-bottom: 16px; }
.field-card {
position: relative;
padding: 12px 14px; margin-bottom: 10px;
border: 1px solid var(--border); border-radius: 8px;
cursor: pointer; background: #fff;
}
.field-card:hover { border-color: #a7f3d0; }
.field-card.selected {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(16, 185, 129, .15);
}
.field-card .label { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.field-card .fake-input {
height: 36px; border: 1px solid var(--border); border-radius: 8px;
background: #f8fafc; display: flex; align-items: center; padding: 0 10px;
color: var(--muted); font-size: 13px;
}
.field-card .badge {
position: absolute; top: 8px; right: 8px;
font-size: 11px; padding: 1px 6px; border-radius: 4px;
background: var(--primary-bg); color: var(--primary-dark);
display: none;
}
.field-card.in-flow .badge { display: inline-block; }
.prop-row { margin-bottom: 14px; }
.prop-row label {
display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px;
}
.prop-row input, .prop-row select {
width: 100%; height: 34px; padding: 0 10px;
border: 1px solid var(--border); border-radius: 8px; font-size: 13px;
}
.switch-row {
display: flex; align-items: flex-start; gap: 10px;
padding: 12px; border: 1px dashed #a7f3d0; border-radius: 8px;
background: var(--primary-bg);
}
.switch-row input { margin-top: 3px; width: 16px; height: 16px; accent-color: var(--primary); }
.switch-row .t { font-size: 13px; font-weight: 600; color: var(--primary-dark); }
.switch-row .d { font-size: 12px; color: var(--muted); margin-top: 2px; }
.hint {
margin-top: 12px; padding: 10px 12px; font-size: 12px; color: var(--muted);
background: #fff; border: 1px solid var(--border); border-radius: 8px;
}
/* ===== 流程设计 ===== */
.flow-layout {
display: grid; grid-template-columns: 1fr 340px; min-height: 520px;
}
.flow-tree {
padding: 28px 24px; display: flex; flex-direction: column; align-items: center;
background: linear-gradient(180deg, #f8fafc, #fff);
}
.node {
width: 220px; border-radius: 10px; background: #fff;
border: 1px solid var(--border); overflow: hidden; cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.node:hover { border-color: #a7f3d0; }
.node.active { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(16,185,129,.2); }
.node .hd {
padding: 6px 10px; font-size: 12px; font-weight: 600; color: #fff;
}
.node.start .hd { background: #64748b; }
.node.approval .hd { background: var(--primary); }
.node.condition .hd { background: #f59e0b; }
.node.end .hd { background: #94a3b8; }
.node .bd { padding: 10px 12px; font-size: 12px; color: var(--muted); }
.vline {
width: 2px; height: 18px; background: var(--border-strong);
}
.plus {
width: 24px; height: 24px; border-radius: 50%;
border: 1px solid var(--border); background: #fff;
display: flex; align-items: center; justify-content: center;
color: var(--primary-dark); font-weight: 700; font-size: 14px;
}
.branches {
display: flex; gap: 24px; align-items: flex-start; margin-top: 8px;
position: relative;
}
.branches::before {
content: ""; position: absolute; top: 0; left: 25%; right: 25%;
height: 2px; background: var(--border-strong);
}
.branch {
display: flex; flex-direction: column; align-items: center; width: 200px;
}
.branch-tag {
margin: 8px 0; padding: 4px 10px; border-radius: 999px;
background: #fffbeb; border: 1px solid #fde68a;
color: var(--warn); font-size: 12px; font-weight: 600;
max-width: 190px; text-align: center; cursor: pointer;
}
.branch-tag.default { background: #f1f5f9; border-color: var(--border); color: var(--muted); }
.branch-tag.active {
outline: 2px solid var(--primary); outline-offset: 2px;
}
.drawer {
border-left: 1px solid var(--border); background: #fff; padding: 14px 16px;
display: flex; flex-direction: column; gap: 12px;
}
.drawer h3 { margin: 0; font-size: 15px; }
.drawer .sub { margin: 0; font-size: 12px; color: var(--muted); }
.legacy {
padding: 10px; border-radius: 8px; background: #fff7ed; border: 1px solid #fed7aa;
font-size: 12px; color: #9a3412;
}
.legacy code {
display: block; margin-top: 6px; padding: 6px 8px;
background: #fff; border-radius: 6px; font-family: ui-monospace, monospace;
color: var(--text);
}
.cond-box {
border: 1px solid var(--border); border-radius: 8px; padding: 12px;
background: #fafafa;
}
.cond-box .title {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 10px; font-weight: 600; font-size: 13px;
}
.rule-row {
display: grid; grid-template-columns: 1fr auto 1fr;
gap: 6px; margin-bottom: 8px; align-items: center;
}
.rule-row select, .rule-row input {
height: 32px; border: 1px solid var(--border); border-radius: 6px;
padding: 0 8px; font-size: 12px; background: #fff; width: 100%;
}
.rule-row select:disabled, .rule-row input:disabled {
background: #f1f5f9; color: var(--muted);
}
.preview {
margin-top: 8px; padding: 8px 10px; border-radius: 6px;
background: var(--primary-bg); color: var(--primary-dark);
font-size: 12px;
}
.empty-tip {
padding: 10px; font-size: 12px; color: var(--warn);
background: #fffbeb; border-radius: 6px; border: 1px dashed #fde68a;
}
.footer-note {
margin-top: 14px; font-size: 12px; color: var(--muted); text-align: center;
}
</style>
</head>
<body>
<div class="page">
<div class="banner">
<div>
<h1>条件分支:从表单字段点选配置(交互 Demo)</h1>
<p>
对照现状「手写 <code>{days} &gt; 5</code>」。本 Demo 演示:在<strong>表单设计</strong>勾选参与分支的字段 →
在<strong>流程设计</strong>用「字段 + 运算符 + 值」配置条件,无需记变量名。
</p>
</div>
<span class="tag">Fresh Mint · 可点击</span>
</div>
<div class="panel">
<div class="toolbar">
<button class="btn" type="button" disabled>← 返回</button>
<div style="display:flex;gap:8px;">
<button class="btn" type="button">保存草稿</button>
<button class="btn primary" type="button">发布</button>
</div>
</div>
<div class="tabs">
<button class="tab" type="button" data-step="basic">1 基础设置</button>
<button class="tab active" type="button" data-step="form">2 表单设计</button>
<button class="tab" type="button" data-step="flow">3 流程设计</button>
<button class="tab" type="button" data-step="advanced">4 高级设置</button>
</div>
<!-- 表单设计 -->
<div id="step-form" class="step-body show">
<div class="form-layout">
<aside class="palette">
<h3>控件库</h3>
<div class="palette-item">单行输入</div>
<div class="palette-item">数字</div>
<div class="palette-item">金额</div>
<div class="palette-item">单选</div>
<div class="palette-item">日期</div>
<div class="palette-item">附件</div>
</aside>
<main class="canvas">
<div class="canvas-title">请假申请 · 表单画布</div>
<div
class="field-card selected in-flow"
data-field="days"
data-title="请假天数"
data-type="number"
onclick="selectField(this)"
>
<span class="badge">用于分支</span>
<div class="label">请假天数 *</div>
<div class="fake-input">请输入数字</div>
</div>
<div
class="field-card"
data-field="reason"
data-title="请假事由"
data-type="text"
onclick="selectField(this)"
>
<span class="badge">用于分支</span>
<div class="label">请假事由 *</div>
<div class="fake-input">请输入</div>
</div>
<div
class="field-card in-flow"
data-field="leaveType"
data-title="请假类型"
data-type="select"
onclick="selectField(this)"
>
<span class="badge">用于分支</span>
<div class="label">请假类型</div>
<div class="fake-input">事假 / 病假 / 年假</div>
</div>
<div
class="field-card"
data-field="attach"
data-title="附件"
data-type="attachment"
onclick="selectField(this)"
>
<span class="badge">用于分支</span>
<div class="label">附件</div>
<div class="fake-input">上传附件</div>
</div>
</main>
<aside class="props">
<h3>字段属性</h3>
<div class="prop-row">
<label>字段标题</label>
<input id="prop-title" value="请假天数" />
</div>
<div class="prop-row">
<label>字段标识(系统用,用户无需关心)</label>
<input id="prop-field" value="days" readonly style="color:var(--muted)" />
</div>
<div class="switch-row">
<input id="prop-in-flow" type="checkbox" checked onchange="toggleInFlow()" />
<div>
<div class="t">参与流程条件分支</div>
<div class="d">勾选后,可在「3 流程设计」的分支条件里选到此字段,无需手写变量名。</div>
</div>
</div>
<div class="hint" id="prop-hint">
数字 / 金额 / 单选 / 日期等适合做条件;附件类控件一般不可勾选(本 Demo 里附件勾选会提示)。
</div>
</aside>
</div>
</div>
<!-- 流程设计 -->
<div id="step-flow" class="step-body">
<div class="flow-layout">
<div class="flow-tree">
<div class="node start">
<div class="hd">发起人</div>
<div class="bd">所有人可发起</div>
</div>
<div class="vline"></div>
<div class="plus">+</div>
<div class="vline"></div>
<div class="node condition active" onclick="focusCondition()">
<div class="hd">条件分支</div>
<div class="bd" id="cond-summary">2 个分支 · 点击右侧配置</div>
</div>
<div class="branches">
<div class="branch">
<span class="branch-tag active" id="tag-0" onclick="selectBranch(0)">请假天数 &gt; 3</span>
<div class="vline"></div>
<div class="node approval">
<div class="hd">审批人</div>
<div class="bd">总经理</div>
</div>
<div class="vline"></div>
<div class="node end"><div class="hd">结束</div><div class="bd">流程结束</div></div>
</div>
<div class="branch">
<span class="branch-tag default" id="tag-1" onclick="selectBranch(1)">默认 · 其他情况</span>
<div class="vline"></div>
<div class="node approval">
<div class="hd">审批人</div>
<div class="bd">部门负责人</div>
</div>
<div class="vline"></div>
<div class="node end"><div class="hd">结束</div><div class="bd">流程结束</div></div>
</div>
</div>
</div>
<aside class="drawer">
<h3>配置条件分支</h3>
<p class="sub">用表单里勾选了「参与流程条件分支」的字段来搭建规则</p>
<div class="legacy">
<strong>现状(不好用)</strong>
<code>{days} &gt; 3</code>
普通用户不知道 days 从哪来、运算符怎么写。
</div>
<div class="prop-row">
<label>当前编辑的分支</label>
<select id="branch-pick" onchange="selectBranch(+this.value)">
<option value="0">分支 1 · 大于 3 天</option>
<option value="1">分支 2 · 默认</option>
</select>
</div>
<div class="cond-box" id="rule-editor">
<div class="title">
<span>条件规则</span>
<button class="btn sm" type="button" onclick="addRule()" id="btn-add-rule">+ 且条件</button>
</div>
<div id="rules"></div>
<div class="preview" id="rule-preview">预览:请假天数 &gt; 3</div>
</div>
<div class="prop-row">
<label><input type="checkbox" id="is-default" onchange="toggleDefault()" /> 设为默认分支(其他条件都不满足时走这里)</label>
</div>
<div class="empty-tip" id="no-fields-tip" style="display:none">
暂无可用字段。请先到「2 表单设计」勾选至少一个「参与流程条件分支」的字段。
</div>
<button class="btn primary" type="button" style="align-self:flex-start">保存节点配置</button>
</aside>
</div>
</div>
<div id="step-basic" class="step-body" style="padding:48px;text-align:center;color:var(--muted)">
(本 Demo 不展开)基础设置:图标、名称、说明…
</div>
<div id="step-advanced" class="step-body" style="padding:48px;text-align:center;color:var(--muted)">
(本 Demo 不展开)高级设置:…</div>
</div>
<p class="footer-note">
文件位置:<code>docs/mockups/oa-approval-condition-from-form-demo.html</code>
· 试用:切到「2 表单设计」勾选字段 → 再切「3 流程设计」拉下拉里应能看到标题而非 variable 名
</p>
</div>
<script>
/** 画布字段状态 */
const fields = {
days: { title: '请假天数', type: 'number', inFlow: true },
reason: { title: '请假事由', type: 'text', inFlow: false },
leaveType: { title: '请假类型', type: 'select', inFlow: true, options: ['事假', '病假', '年假'] },
attach: { title: '附件', type: 'attachment', inFlow: false }
}
let selectedField = 'days'
let branchIdx = 0
const branches = [
{
name: '大于 3 天',
isDefault: false,
rules: [{ field: 'days', op: '>', value: '3' }]
},
{
name: '默认',
isDefault: true,
rules: []
}
]
const OPS = {
number: [
{ v: '>', t: '大于' },
{ v: '>=', t: '大于等于' },
{ v: '<', t: '小于' },
{ v: '<=', t: '小于等于' },
{ v: '==', t: '等于' },
{ v: '!=', t: '不等于' }
],
text: [
{ v: '==', t: '等于' },
{ v: '!=', t: '不等于' },
{ v: 'contains', t: '包含' }
],
select: [
{ v: '==', t: '等于' },
{ v: '!=', t: '不等于' }
]
}
document.querySelectorAll('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'))
tab.classList.add('active')
const step = tab.dataset.step
document.querySelectorAll('.step-body').forEach((el) => el.classList.remove('show'))
const body = document.getElementById('step-' + step)
if (body) body.classList.add('show')
if (step === 'flow') renderRules()
})
})
function selectField(el) {
document.querySelectorAll('.field-card').forEach((c) => c.classList.remove('selected'))
el.classList.add('selected')
selectedField = el.dataset.field
const f = fields[selectedField]
document.getElementById('prop-title').value = f.title
document.getElementById('prop-field').value = selectedField
const cb = document.getElementById('prop-in-flow')
cb.checked = !!f.inFlow
cb.disabled = f.type === 'attachment'
const hint = document.getElementById('prop-hint')
if (f.type === 'attachment') {
hint.textContent = '附件不适合做条件判断,已禁用「参与流程条件分支」。'
} else {
hint.textContent = '勾选后出现在「3 流程设计」的字段下拉里,展示标题「' + f.title + '」,内部仍用 field=' + selectedField + '。'
}
}
function toggleInFlow() {
const f = fields[selectedField]
if (f.type === 'attachment') {
document.getElementById('prop-in-flow').checked = false
alert('附件类型不能参与条件分支')
return
}
f.inFlow = document.getElementById('prop-in-flow').checked
const card = document.querySelector('.field-card[data-field="' + selectedField + '"]')
card.classList.toggle('in-flow', f.inFlow)
}
document.getElementById('prop-title').addEventListener('input', (e) => {
fields[selectedField].title = e.target.value
const card = document.querySelector('.field-card[data-field="' + selectedField + '"]')
card.querySelector('.label').textContent = e.target.value + (selectedField === 'days' || selectedField === 'reason' ? ' *' : '')
renderRules()
})
function flowFields() {
return Object.keys(fields)
.filter((k) => fields[k].inFlow && fields[k].type !== 'attachment')
.map((k) => ({ key: k, ...fields[k] }))
}
function selectBranch(idx) {
branchIdx = idx
document.getElementById('branch-pick').value = String(idx)
document.getElementById('is-default').checked = !!branches[idx].isDefault
document.querySelectorAll('.branch-tag').forEach((t, i) => {
t.classList.toggle('active', i === idx)
})
renderRules()
}
function focusCondition() {
selectBranch(branchIdx)
}
function toggleDefault() {
const on = document.getElementById('is-default').checked
branches.forEach((b, i) => { b.isDefault = on && i === branchIdx })
if (on) branches[branchIdx].rules = []
updateTags()
renderRules()
}
function opLabel(op) {
const map = { '>': '大于', '>=': '大于等于', '<': '小于', '<=': '小于等于', '==': '等于', '!=': '不等于', contains: '包含' }
return map[op] || op
}
function renderRules() {
const box = document.getElementById('rules')
const tip = document.getElementById('no-fields-tip')
const editor = document.getElementById('rule-editor')
const b = branches[branchIdx]
const available = flowFields()
if (!available.length) {
tip.style.display = 'block'
} else {
tip.style.display = 'none'
}
if (b.isDefault) {
editor.style.opacity = '0.45'
document.getElementById('btn-add-rule').disabled = true
box.innerHTML = '<div style="font-size:12px;color:var(--muted)">默认分支无需配置条件</div>'
document.getElementById('rule-preview').textContent = '预览:其他条件均不满足时走此分支'
updateTags()
return
}
editor.style.opacity = '1'
document.getElementById('btn-add-rule').disabled = false
if (!b.rules.length) {
b.rules.push({ field: available[0] ? available[0].key : '', op: '>', value: '' })
}
box.innerHTML = ''
b.rules.forEach((rule, i) => {
const fMeta = fields[rule.field] || available[0]
const type = fMeta ? fMeta.type : 'number'
const ops = OPS[type] || OPS.number
const row = document.createElement('div')
row.className = 'rule-row'
const selF = document.createElement('select')
if (!available.length) {
selF.innerHTML = '<option value="">无可用字段</option>'
selF.disabled = true
} else {
available.forEach((f) => {
const opt = document.createElement('option')
opt.value = f.key
opt.textContent = f.title
if (f.key === rule.field) opt.selected = true
selF.appendChild(opt)
})
}
selF.onchange = () => {
rule.field = selF.value
const t = fields[rule.field].type
rule.op = (OPS[t] || OPS.number)[0].v
rule.value = ''
renderRules()
}
const selOp = document.createElement('select')
ops.forEach((o) => {
const opt = document.createElement('option')
opt.value = o.v
opt.textContent = o.t
if (o.v === rule.op) opt.selected = true
selOp.appendChild(opt)
})
selOp.onchange = () => { rule.op = selOp.value; updateTags() }
let valCtrl
if (type === 'select' && fields[rule.field].options) {
valCtrl = document.createElement('select')
fields[rule.field].options.forEach((o) => {
const opt = document.createElement('option')
opt.value = o
opt.textContent = o
if (o === rule.value) opt.selected = true
valCtrl.appendChild(opt)
})
if (!rule.value) rule.value = fields[rule.field].options[0]
valCtrl.onchange = () => { rule.value = valCtrl.value; updateTags() }
} else {
valCtrl = document.createElement('input')
valCtrl.placeholder = type === 'number' ? '数值' : '比较值'
valCtrl.value = rule.value
valCtrl.oninput = () => { rule.value = valCtrl.value; updateTags() }
}
row.appendChild(selF)
row.appendChild(selOp)
row.appendChild(valCtrl)
box.appendChild(row)
if (i < b.rules.length - 1) {
const and = document.createElement('div')
and.style.cssText = 'font-size:11px;color:var(--muted);margin:-2px 0 6px;text-align:center'
and.textContent = '且(AND)'
box.appendChild(and)
}
})
updateTags()
}
function addRule() {
const available = flowFields()
if (!available.length || branches[branchIdx].isDefault) return
branches[branchIdx].rules.push({
field: available[0].key,
op: (OPS[available[0].type] || OPS.number)[0].v,
value: ''
})
renderRules()
}
function updateTags() {
const b0 = branches[0]
const parts = (b0.rules || []).map((r) => {
const title = (fields[r.field] && fields[r.field].title) || r.field
return title + ' ' + opLabel(r.op) + ' ' + (r.value === '' ? '?' : r.value)
})
const text0 = parts.length ? parts.join(' 且 ') : '未配置'
document.getElementById('tag-0').textContent = text0
document.getElementById('tag-1').textContent = '默认 · 其他情况'
document.getElementById('cond-summary').textContent = '2 个分支 · ' + text0
if (!branches[branchIdx].isDefault) {
const cur = branches[branchIdx]
const p = (cur.rules || []).map((r) => {
const title = (fields[r.field] && fields[r.field].title) || r.field
return title + ' ' + opLabel(r.op) + ' ' + (r.value === '' ? '?' : r.value)
})
document.getElementById('rule-preview').textContent =
'预览:' + (p.join(' 且 ') || '未配置') +
' | 引擎侧仍存为 {field, op, value},等价公式 ' +
(cur.rules[0] ? '{' + cur.rules[0].field + '} ' + cur.rules[0].op + ' ' + cur.rules[0].value : '')
}
}
// init
renderRules()
</script>
</body>
</html>
@@ -0,0 +1,196 @@
# OA 条件分支:表单字段驱动可视化配置
**日期:** 2026-07-15
**范围:** `web-admin` 审批设计器步骤 2(表单设计)+ 步骤 3(流程设计);`backend` 发布校验(引擎匹配逻辑已具备)
**Demo:** `docs/mockups/oa-approval-condition-from-form-demo.html`
**关联:** `docs/superpowers/specs/2026-07-14-oa-approval-dingtalk-design.md` § 条件分支;`backend/app/services/oa_engine.py` `_match_condition_group`
## 1. 背景与目标
### 现状问题
流程「条件分支」配置要求用户手写公式,例如 `{days} > 5`。普通配置者不了解表单字段 `field` 标识,学习成本高、易配错。
### 目标
1. 在**表单设计**中显式勾选哪些控件「参与流程条件分支」。
2. 勾选后该字段在发起时**必填**,且必填开关在勾选期间**锁定不可关**。
3. 在**流程设计**中用可视化规则配置条件:选择字段标题 + 运算符 + 比较值;**不提供**自定义公式入口。
4. 运行时仍用表单填报值做分支匹配,不引入独立的「流程实例变量」配置概念。
### 非目标
- 系统变量(发起人部门等)进入条件(可二期)。
- 条件组内 OR、跨组复杂布尔、嵌套括号。
- 保留或新增「高级:自定义公式」折叠区(已否定,降低双模式复杂度)。
- 改写公式求值引擎核心算法(已有 `rules` → 表达式路径够用)。
## 2. 产品约定(已确认)
| 项 | 结论 |
|----|------|
| 字段入池方式 | 表单控件属性勾选「参与流程条件分支」(方案 A) |
| 必填联动 | 勾选 ⇒ 强制 `required=true`,必填开关禁用;取消勾选后才能改回选填 |
| 可勾选控件 | 仅:数字、金额、单选、下拉、日期 |
| 条件 UI | 纯可视化;无高级公式 |
| 多条件 | 同一分支内多条规则 **AND** |
| 默认分支 | `isDefault=true`,不配 `rules` |
| 取消勾选有引用 | **拦截**,提示先到流程设计删除对该字段的条件引用 |
| 架构方案 | 方案 1:Schema 打标 + `conditionGroups[].rules` |
## 3. 架构
```
[2 表单设计] [3 流程设计]
FormCreate 字段属性 FlowDesigner 条件抽屉
· props.useInCondition · 下拉 = 已勾选字段(展示 title)
· 勾选 ⇒ required 锁定 · 编辑 rules[{field,op,value}]
↓ ↓
form_schema_json flow_tree_json
↘ ↙
发布校验 / 运行引擎
· 参与字段必须 required
· rules 只引用合法参与字段
· _match_condition_group 优先 rules
```
**职责边界**
- **表单:** 谁可以进入条件池,并保证运行时有值(必填)。
- **流程:** 如何用这些字段做比较。
- **引擎:** 基本沿用现有 `rules` 匹配;新配置不再依赖用户手写 `when`。
## 4. 数据契约
### 4.1 表单字段(`form_schema_json`)
在合格控件的 `props`(或项目统一挂载的扩展字段,与 FormCreate 属性面板约定一致)增加:
| 属性 | 类型 | 说明 |
|------|------|------|
| `useInCondition` | `boolean` | 是否参与流程条件分支 |
| `required` | `boolean` | `useInCondition === true` 时必须为 `true` |
**可勾选 type(实现时映射现有 FormCreate type 名):**
- 数字:如 `inputNumber`
- 金额:如项目内金额控件 type(若与数字共用则同一逻辑)
- 单选:如 `radio`
- 下拉:如 `select`
- 日期:如 `datePicker` / `date`
附件、说明、布局、公式展示等控件**不展示**该开关。
### 4.2 条件分支组(`flow_tree_json`)
```ts
interface ConditionGroup {
name: string
isDefault?: boolean
/** 新配置主存;默认分支为空数组 */
rules: Array<{
field: string
op: '>' | '>=' | '<' | '<=' | '==' | '!='
value: string | number
}>
/** 存量兼容:仅运行时读取;新保存应清空 */
when?: string
child: FlowNode | null
}
```
**运算符策略**
| 字段类型 | 可用 op |
|----------|---------|
| 数字 / 金额 / 日期 | `>` `>=` `<` `<=` `==` `!=` |
| 单选 / 下拉 | `==` `!=` |
同组多条 `rules`:引擎现有逻辑拼为 **AND**(与 `oa_engine._match_condition_group` 一致)。
### 4.3 引擎行为(保持兼容)
`_match_condition_group` 已支持:
1. `isDefault` 或空规则 → 记为默认分支候选
2. `when` 字符串公式
3. `rules` 列表 → 生成临时公式再 `eval_formula`
**本需求约定:** 设计器新写入以 `rules` 为准;保存可视化配置后清空同组 `when`,避免双源不一致。存量仅有 `when` 的实例版本仍可运行。
## 5. 交互
### 5.1 表单设计
1. 选中合格控件:属性面板显示「参与流程条件分支」。
2. 打开开关:设置 `useInCondition=true`、`required=true`,禁用必填控件。
3. 关闭开关:若任意条件节点 `rules[].field` 仍引用当前 `field` → 拦截并提示去流程设计处理;无引用则允许关闭并恢复必填开关可编辑(关闭后不强制把 `required` 改回 `false`,仅解锁)。
4. 可选:画布角标「用于分支」(Demo 已示意,实现按需)。
### 5.2 流程设计
1. 条件节点抽屉:去掉「条件公式」输入框。
2. 规则行:字段下拉(label=标题,value=`field`)+ 运算符 + 值;单选/下拉值用选项列表。
3. 空池:提示先到步骤 2 勾选;禁用添加规则。
4. 默认分支:禁用规则编辑,`rules=[]`。
5. 非默认:至少一行完整规则;支持「+ 且条件」。
6. 树上分支标签:人话预览(如「请假天数 大于 3」),不展示 `{field}`。
### 5.3 设计器数据流
- `designer/index.vue` 将当前 `formSchema`(或从中解析的条件候选字段列表)传入 `FlowDesigner`。
- 候选列表过滤:`useInCondition === true` 且 type 在白名单。
## 6. 校验
### 6.1 前端(保存草稿 / 切步)
- 建议:切换到流程步或保存草稿时做软提示;**发布**做硬拦截。
### 6.2 发布(`oa_publish` + 前端发布前)
| 检查 | 处理 |
|------|------|
| `useInCondition` 但 `required` 不为真 | 发布前自动纠正为 required,或拦截(推荐自动纠正并写入 schema) |
| 非默认分支 `rules` 为空或 `field`/`op`/`value` 缺失 | 拦截:「请完善条件分支」 |
| `rules.field` 在 schema 中不存在或未 `useInCondition` | 拦截:「条件引用了无效字段」 |
| 分支仅有存量 `when`、无有效 `rules` | **不允许发布**;提示用可视化重新配置 |
| 默认分支缺失或不唯一 | 沿用现有发布规则 |
### 6.3 运行时
- 发起校验:必填字段由动态表单 required 保障。
- 分支匹配:`form_data[field]` + `rules`;不另建变量表。
## 7. 存量与迁移
| 场景 | 行为 |
|------|------|
| 已发布版本仅含 `when` | 运行时继续按公式走;不影响在途实例 |
| 打开草稿/新版本编辑 | 条件抽屉:若无 `rules` 仅有 `when`,展示提示「请用可视化重新配置」;保存后写入 `rules` 并清空 `when` |
| V1 不做自动公式→rules 解析 | 避免半吊子解析;配置者重配一次即可 |
## 8. 改动面(实现指引)
| 层 | 内容 |
|----|------|
| 前端表单 | FormCreate 属性面板扩展 `useInCondition` + 必填锁定 |
| 前端流程 | `FlowDesigner.vue` 条件 UI;`types.ts` 明确 `rules` 形状 |
| 前端设计器壳 | `designer/index.vue` 传递 form 候选字段给流程步 |
| 后端 | `oa_publish` 校验补强;引擎优先不改或仅单测覆盖 rules 路径 |
| 文档/Demo | 本 spec + 已有 mockup |
## 9. 验收标准
1. 配置者无需知道 `field` 变量名即可配置「天数大于 3」类分支。
2. 未勾选参与的字段不会出现在条件字段下拉中。
3. 勾选参与后发起页该字段必填且属性上无法关掉必填。
4. 发布应用后:满足条件走对应分支,否则走默认分支。
5. UI 上不再出现条件公式输入框。
6. 试图取消仍被引用的「参与分支」勾选时被拦截并有中文提示。
## 10. 测试要点
- 单元:`rules` 匹配(数字比较、字符串等于、默认分支回落)— 扩展现有 `test_oa_engine` / `test_oa_publish`。
- 发布:引用未勾选字段应失败;仅有 `when` 无 `rules` 应失败。
- 前端手测:勾选锁定必填、取消有引用拦截、下拉只含勾选字段、人话标签、端到端请假式流程。