Files
zkxw/docs/mockups/oa-approval-condition-from-form-demo.html
T
zlwuandCursor 23907a544b 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>
2026-07-15 16:48:58 +08:00

717 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>