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:
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} > 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)">请假天数 > 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} > 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">预览:请假天数 > 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` 应失败。
|
||||
- 前端手测:勾选锁定必填、取消有引用拦截、下拉只含勾选字段、人话标签、端到端请假式流程。
|
||||
Reference in new issue
Block a user