docs(oa): add department select control design and mockup
Capture the approved OA form designer dept control contract (multi/single,
{id,name} path snapshot, mobile full-screen picker) and a Fresh Mint HTML demo.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
d5b0f2d797
commit
19c97d148f
2 files changed
+1124
No files matched your search
@@ -0,0 +1,922 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OA · 部门选择控件 Demo(Fresh Mint)</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #10b981;
|
||||
--primary-dark: #059669;
|
||||
--primary-bg: #ecfdf5;
|
||||
--text: #0f172a;
|
||||
--text-2: #334155;
|
||||
--muted: #94a3b8;
|
||||
--border: #e2e8f0;
|
||||
--border-strong: #cbd5e1;
|
||||
--bg: #f1f5f9;
|
||||
--danger: #ef4444;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
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: 8px 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.banner b { color: var(--primary-dark); }
|
||||
.banner span { color: var(--muted); font-size: 13px; }
|
||||
.tag {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 22px 0 10px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--primary);
|
||||
color: #1e293b;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.section-desc {
|
||||
margin: -4px 0 12px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 方案切换 */
|
||||
.mode-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.mode-btn {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mode-btn:hover { border-color: #a7f3d0; background: var(--primary-bg); }
|
||||
.mode-btn.active {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ===== 设计器三栏缩略 ===== */
|
||||
.designer {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr 260px;
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgb(15 23 42 / 8%);
|
||||
}
|
||||
.pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.pane + .pane { border-left: 1px solid var(--border); }
|
||||
.pane-head {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 42px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.pane-head span {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pane-head span.active {
|
||||
color: var(--text);
|
||||
box-shadow: inset 0 -2px 0 var(--primary);
|
||||
}
|
||||
.pane-body {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin: 4px 0 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ctrl-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.ctrl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
cursor: grab;
|
||||
}
|
||||
.ctrl.highlight {
|
||||
border-style: solid;
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 0 0 2px rgb(16 185 129 / 18%);
|
||||
}
|
||||
.ctrl-ico {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
padding: 16px 20px 24px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.device-toggle {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.device-toggle button {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.device-toggle button.active {
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-card {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.field.active {
|
||||
border-color: var(--primary);
|
||||
background: rgb(236 253 245 / 45%);
|
||||
}
|
||||
.field label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.field label .req {
|
||||
margin-left: 2px;
|
||||
color: var(--danger);
|
||||
}
|
||||
.fake-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fake-input.has-value { color: var(--text); }
|
||||
.fake-input .chev { color: var(--muted); font-size: 11px; }
|
||||
|
||||
.prop-item { margin-bottom: 14px; }
|
||||
.prop-item > label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prop-item input,
|
||||
.prop-item select {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
}
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.switch.on { background: var(--primary); }
|
||||
.switch::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: left 0.15s;
|
||||
}
|
||||
.switch.on::after { left: 18px; }
|
||||
|
||||
/* ===== 发起页运行时 ===== */
|
||||
.runtime {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.designer { grid-template-columns: 1fr; }
|
||||
.pane + .pane { border-left: 0; border-top: 1px solid var(--border); }
|
||||
.runtime { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.panel h3 {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tree-select {
|
||||
position: relative;
|
||||
}
|
||||
.tree-select-trigger {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tree-select-trigger:hover,
|
||||
.tree-select.open .tree-select-trigger {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.tree-select-trigger .placeholder {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip button {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--primary-dark);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.chip button:hover { opacity: 1; }
|
||||
|
||||
.dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgb(15 23 42 / 12%);
|
||||
}
|
||||
.tree-select.open .dropdown { display: block; }
|
||||
.search {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
}
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.tree-node:hover { background: #f1f5f9; }
|
||||
.tree-node.selected {
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tree-node .twisty {
|
||||
width: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.tree-node .check {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tree-node.selected .check {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
box-shadow: inset 0 0 0 2px #fff;
|
||||
}
|
||||
.tree-children { margin-left: 18px; }
|
||||
.tree-children.collapsed { display: none; }
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.kv dt { color: var(--muted); font-size: 12px; }
|
||||
.kv dd {
|
||||
margin: 2px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
.kv dd code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.primary {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="banner">
|
||||
<div>
|
||||
<b>OA 表单设计 ·「部门选择」控件 Demo</b>
|
||||
<span>数据源:本租户组织树(sys_dept / fetchDeptTree)· 选中展示全路径</span>
|
||||
</div>
|
||||
<span class="tag">Fresh Mint</span>
|
||||
</div>
|
||||
|
||||
<div class="mode-bar" role="tablist" aria-label="选择模式">
|
||||
<button type="button" class="mode-btn active" data-mode="single">方案 A · 单选部门</button>
|
||||
<button type="button" class="mode-btn" data-mode="multi">方案 B · 可多选部门</button>
|
||||
<button type="button" class="mode-btn" data-mode="dialog">方案 C · 弹窗树选择</button>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">① 设计器:「2 表单设计」左侧组件库</h2>
|
||||
<p class="section-desc">「部门」出现在增强分组;拖到画布后,右侧可配标题 / 必填 / 单多选等。</p>
|
||||
|
||||
<div class="designer">
|
||||
<aside class="pane">
|
||||
<div class="pane-head"><span class="active">组件</span><span>大纲</span></div>
|
||||
<div class="pane-body">
|
||||
<div class="group-title">基础</div>
|
||||
<div class="ctrl-grid">
|
||||
<div class="ctrl"><span class="ctrl-ico">Aa</span>单行</div>
|
||||
<div class="ctrl"><span class="ctrl-ico">¶</span>多行</div>
|
||||
<div class="ctrl"><span class="ctrl-ico">123</span>数字</div>
|
||||
<div class="ctrl"><span class="ctrl-ico">📅</span>日期</div>
|
||||
</div>
|
||||
<div class="group-title">增强</div>
|
||||
<div class="ctrl-grid">
|
||||
<div class="ctrl"><span class="ctrl-ico">📎</span>附件</div>
|
||||
<div class="ctrl highlight"><span class="ctrl-ico">🏢</span>部门</div>
|
||||
<div class="ctrl"><span class="ctrl-ico">📍</span>省市区</div>
|
||||
<div class="ctrl"><span class="ctrl-ico">ƒ</span>公式</div>
|
||||
<div class="ctrl"><span class="ctrl-ico">🔗</span>关联审批</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="pane">
|
||||
<div class="pane-head"><span class="active">画布 · PC</span></div>
|
||||
<div class="canvas">
|
||||
<div class="device-toggle">
|
||||
<button type="button" class="active">🖥 PC</button>
|
||||
<button type="button">📱 手机</button>
|
||||
</div>
|
||||
<div class="form-card">
|
||||
<div class="field">
|
||||
<label>申请事由</label>
|
||||
<div class="fake-input">请输入<span class="chev">▾</span></div>
|
||||
</div>
|
||||
<div class="field active">
|
||||
<label>所属部门<span class="req">*</span></label>
|
||||
<div class="fake-input has-value" id="canvasPreview">
|
||||
中科兴皖 / 研发中心 / 前端组
|
||||
<span class="chev">▾</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>说明</label>
|
||||
<div class="fake-input">请输入<span class="chev">▾</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="pane">
|
||||
<div class="pane-head"><span class="active">控件</span><span>表单</span></div>
|
||||
<div class="pane-body">
|
||||
<div class="prop-item">
|
||||
<label>标题</label>
|
||||
<input value="所属部门" />
|
||||
</div>
|
||||
<div class="prop-item">
|
||||
<label>字段名</label>
|
||||
<input value="deptId" />
|
||||
</div>
|
||||
<div class="prop-item">
|
||||
<label>占位提示</label>
|
||||
<input value="请选择部门" />
|
||||
</div>
|
||||
<div class="prop-item">
|
||||
<label>必填</label>
|
||||
<div class="switch-row">
|
||||
<span class="switch on" id="propRequired"></span>
|
||||
<span>是</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-item">
|
||||
<label>选择模式</label>
|
||||
<select id="propMode" disabled>
|
||||
<option value="single">仅单选</option>
|
||||
<option value="multi">允许多选</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="note">
|
||||
下拉节点只显示本级名称;输入框 / 发起页 / 详情 / 打印显示<strong>全路径</strong>(分隔符 <code> / </code>)。
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">② 发起页:运行时选择(可点选试用)</h2>
|
||||
<p class="section-desc">左侧交互;右侧同步展示将写入 <code>form_data</code> 的值形态(建议锁定一种契约)。</p>
|
||||
|
||||
<div class="runtime">
|
||||
<div class="panel">
|
||||
<h3>填报表单</h3>
|
||||
<div class="prop-item">
|
||||
<label style="margin-bottom:8px;color:var(--text-2);font-size:13px;font-weight:600">
|
||||
所属部门<span class="req" style="color:var(--danger)">*</span>
|
||||
</label>
|
||||
<div class="tree-select" id="deptSelect">
|
||||
<div class="tree-select-trigger" id="deptTrigger" tabindex="0">
|
||||
<span class="placeholder">请选择部门</span>
|
||||
</div>
|
||||
<div class="dropdown" id="deptDropdown">
|
||||
<input class="search" id="deptSearch" type="search" placeholder="搜索部门名称" />
|
||||
<div id="deptTree"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<button type="button" class="btn">取消</button>
|
||||
<button type="button" class="btn primary" id="btnSubmit">✓ 提交</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>form_data 预览</h3>
|
||||
<dl class="kv">
|
||||
<div>
|
||||
<dt>展示文案(全路径)</dt>
|
||||
<dd id="outLabel">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>存库值(推荐)</dt>
|
||||
<dd id="outValue"><code>—</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>当前方案说明</dt>
|
||||
<dd id="outNote">单选:值为部门 id 字符串。</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="note">
|
||||
规格草案:部门控件 <code>type = "dept"</code>(后端发布白名单已预留)。
|
||||
勿存组件实例 / DOM;名称可按 id 在详情侧补全路径。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗方案 C -->
|
||||
<dialog id="deptDialog" style="
|
||||
border:0;border-radius:12px;padding:0;width:min(440px,92vw);
|
||||
box-shadow:0 16px 48px rgb(15 23 42 / 20%);
|
||||
">
|
||||
<form method="dialog" style="padding:16px 18px 18px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
||||
<strong style="font-size:15px">选择部门</strong>
|
||||
<button value="cancel" class="btn" style="height:28px;padding:0 8px">✕</button>
|
||||
</div>
|
||||
<input class="search" id="dialogSearch" type="search" placeholder="搜索部门名称" style="margin-bottom:10px" />
|
||||
<div id="dialogTree" style="max-height:320px;overflow:auto;border:1px solid var(--border);border-radius:8px;padding:8px"></div>
|
||||
<div class="footer-actions">
|
||||
<button value="cancel" class="btn">取消</button>
|
||||
<button value="ok" class="btn primary" id="dialogOk">确定</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
/** 模拟本租户组织树 */
|
||||
const DEPT_TREE = [
|
||||
{
|
||||
id: 'd1', name: '中科兴皖',
|
||||
children: [
|
||||
{
|
||||
id: 'd2', name: '研发中心',
|
||||
children: [
|
||||
{ id: 'd3', name: '前端组' },
|
||||
{ id: 'd4', name: '后端组' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'd5', name: '质量中心',
|
||||
children: [
|
||||
{ id: 'd6', name: '实验室' },
|
||||
{ id: 'd7', name: '体系办' }
|
||||
]
|
||||
},
|
||||
{ id: 'd8', name: '行政人事部' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const pathMap = {}
|
||||
function walk(nodes, ancestors) {
|
||||
nodes.forEach((n) => {
|
||||
const path = [...ancestors, n.name]
|
||||
pathMap[n.id] = path.join(' / ')
|
||||
if (n.children) walk(n.children, path)
|
||||
})
|
||||
}
|
||||
walk(DEPT_TREE, [])
|
||||
|
||||
let mode = 'single' // single | multi | dialog
|
||||
let selected = new Set(['d3'])
|
||||
let dialogPick = new Set()
|
||||
|
||||
const modeBtns = document.querySelectorAll('.mode-btn')
|
||||
const propMode = document.getElementById('propMode')
|
||||
const canvasPreview = document.getElementById('canvasPreview')
|
||||
const deptSelect = document.getElementById('deptSelect')
|
||||
const deptTrigger = document.getElementById('deptTrigger')
|
||||
const deptTree = document.getElementById('deptTree')
|
||||
const deptSearch = document.getElementById('deptSearch')
|
||||
const outLabel = document.getElementById('outLabel')
|
||||
const outValue = document.getElementById('outValue')
|
||||
const outNote = document.getElementById('outNote')
|
||||
const deptDialog = document.getElementById('deptDialog')
|
||||
const dialogTree = document.getElementById('dialogTree')
|
||||
const dialogSearch = document.getElementById('dialogSearch')
|
||||
|
||||
modeBtns.forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
modeBtns.forEach((b) => b.classList.remove('active'))
|
||||
btn.classList.add('active')
|
||||
mode = btn.dataset.mode
|
||||
propMode.value = mode === 'multi' ? 'multi' : 'single'
|
||||
selected = mode === 'multi' ? new Set(['d3', 'd6']) : new Set(['d3'])
|
||||
deptSelect.classList.remove('open')
|
||||
renderAll()
|
||||
})
|
||||
})
|
||||
|
||||
function filterTree(nodes, q) {
|
||||
if (!q) return nodes
|
||||
const lower = q.toLowerCase()
|
||||
function filt(list) {
|
||||
const out = []
|
||||
list.forEach((n) => {
|
||||
const kids = n.children ? filt(n.children) : []
|
||||
if (n.name.toLowerCase().includes(lower) || kids.length) {
|
||||
out.push({ ...n, children: kids.length ? kids : n.children })
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
return filt(nodes)
|
||||
}
|
||||
|
||||
function renderTree(container, nodes, opts) {
|
||||
const { multi, query, onPick } = opts
|
||||
container.innerHTML = ''
|
||||
const data = filterTree(nodes, query)
|
||||
|
||||
function renderNodes(list, parentEl) {
|
||||
list.forEach((n) => {
|
||||
const row = document.createElement('div')
|
||||
row.className = 'tree-node' + (selected.has(n.id) || (opts.pickSet && opts.pickSet.has(n.id)) ? ' selected' : '')
|
||||
row.style.paddingLeft = '8px'
|
||||
const hasKids = !!(n.children && n.children.length)
|
||||
row.innerHTML =
|
||||
`<span class="twisty">${hasKids ? '▾' : ''}</span>` +
|
||||
(multi ? '<span class="check"></span>' : '') +
|
||||
`<span>${n.name}</span>`
|
||||
row.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
onPick(n.id)
|
||||
})
|
||||
parentEl.appendChild(row)
|
||||
if (hasKids) {
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'tree-children'
|
||||
parentEl.appendChild(wrap)
|
||||
renderNodes(n.children, wrap)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (!data.length) {
|
||||
container.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:13px">无匹配部门</div>'
|
||||
return
|
||||
}
|
||||
renderNodes(data, container)
|
||||
}
|
||||
|
||||
function syncOutput() {
|
||||
const ids = [...selected]
|
||||
const labels = ids.map((id) => pathMap[id] || id)
|
||||
outLabel.textContent = labels.length ? labels.join(';') : '—'
|
||||
canvasPreview.innerHTML =
|
||||
(labels[0] || '请选择部门') + '<span class="chev">▾</span>'
|
||||
canvasPreview.classList.toggle('has-value', !!ids.length)
|
||||
|
||||
if (mode === 'multi') {
|
||||
outValue.innerHTML = `<code>${JSON.stringify(ids)}</code>`
|
||||
outNote.textContent = '多选:值为 deptId 字符串数组。详情按 id 补全全路径。'
|
||||
} else {
|
||||
outValue.innerHTML = `<code>${ids[0] ? JSON.stringify(ids[0]) : '""'}</code>`
|
||||
outNote.textContent =
|
||||
mode === 'dialog'
|
||||
? '弹窗单选:值为部门 id 字符串;窄屏/手机更友好。'
|
||||
: '单选:值为部门 id 字符串(与用户管理「所属组织」一致)。'
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrigger() {
|
||||
const ids = [...selected]
|
||||
deptTrigger.innerHTML = ''
|
||||
if (!ids.length) {
|
||||
const ph = document.createElement('span')
|
||||
ph.className = 'placeholder'
|
||||
ph.textContent = '请选择部门'
|
||||
deptTrigger.appendChild(ph)
|
||||
return
|
||||
}
|
||||
if (mode === 'multi') {
|
||||
ids.forEach((id) => {
|
||||
const chip = document.createElement('span')
|
||||
chip.className = 'chip'
|
||||
chip.innerHTML = `${pathMap[id]}<button type="button" aria-label="移除">×</button>`
|
||||
chip.querySelector('button').addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
selected.delete(id)
|
||||
renderAll()
|
||||
})
|
||||
deptTrigger.appendChild(chip)
|
||||
})
|
||||
} else {
|
||||
const chip = document.createElement('span')
|
||||
chip.className = 'chip'
|
||||
chip.textContent = pathMap[ids[0]]
|
||||
deptTrigger.appendChild(chip)
|
||||
}
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
const multi = mode === 'multi'
|
||||
renderTree(deptTree, DEPT_TREE, {
|
||||
multi,
|
||||
query: deptSearch.value.trim(),
|
||||
onPick: (id) => {
|
||||
if (mode === 'dialog') return
|
||||
if (multi) {
|
||||
if (selected.has(id)) selected.delete(id)
|
||||
else selected.add(id)
|
||||
} else {
|
||||
selected = new Set([id])
|
||||
deptSelect.classList.remove('open')
|
||||
}
|
||||
renderAll()
|
||||
}
|
||||
})
|
||||
renderTrigger()
|
||||
syncOutput()
|
||||
}
|
||||
|
||||
deptTrigger.addEventListener('click', () => {
|
||||
if (mode === 'dialog') {
|
||||
dialogPick = new Set(selected)
|
||||
dialogSearch.value = ''
|
||||
renderDialogTree()
|
||||
deptDialog.showModal()
|
||||
return
|
||||
}
|
||||
deptSelect.classList.toggle('open')
|
||||
})
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!deptSelect.contains(e.target)) deptSelect.classList.remove('open')
|
||||
})
|
||||
|
||||
deptSearch.addEventListener('input', renderAll)
|
||||
|
||||
function renderDialogTree() {
|
||||
renderTree(dialogTree, DEPT_TREE, {
|
||||
multi: false,
|
||||
query: dialogSearch.value.trim(),
|
||||
pickSet: dialogPick,
|
||||
onPick: (id) => {
|
||||
dialogPick = new Set([id])
|
||||
renderDialogTree()
|
||||
}
|
||||
})
|
||||
// highlight based on dialogPick
|
||||
dialogTree.querySelectorAll('.tree-node').forEach((el) => {
|
||||
/* re-render already sets selected from selected; override */
|
||||
})
|
||||
// re-render with temporary selected swap
|
||||
const bak = selected
|
||||
selected = dialogPick
|
||||
renderTree(dialogTree, DEPT_TREE, {
|
||||
multi: false,
|
||||
query: dialogSearch.value.trim(),
|
||||
onPick: (id) => {
|
||||
dialogPick = new Set([id])
|
||||
selected = dialogPick
|
||||
renderDialogTree()
|
||||
}
|
||||
})
|
||||
selected = bak
|
||||
}
|
||||
|
||||
dialogSearch.addEventListener('input', renderDialogTree)
|
||||
|
||||
document.getElementById('dialogOk').addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
selected = new Set(dialogPick)
|
||||
deptDialog.close()
|
||||
renderAll()
|
||||
})
|
||||
|
||||
document.getElementById('btnSubmit').addEventListener('click', () => {
|
||||
if (!selected.size) {
|
||||
alert('请先选择部门')
|
||||
return
|
||||
}
|
||||
alert('Demo:将提交 form_data →\n' + outValue.textContent)
|
||||
})
|
||||
|
||||
document.getElementById('propRequired').addEventListener('click', function () {
|
||||
this.classList.toggle('on')
|
||||
})
|
||||
|
||||
renderAll()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,202 @@
|
||||
# OA「部门选择」控件设计文档
|
||||
|
||||
**日期:** 2026-07-27
|
||||
**范围:** `web-admin` 审批设计器步骤 2(表单设计)+ 发起/详情运行时;`backend` 发布白名单已有 `dept`,补齐值形态校验与打印
|
||||
**状态:** 已评审待实现
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
OA 审批「2 表单设计」需提供可拖拽的 **部门** 控件,供发起人选择本租户组织树(`sys_dept`),与钉钉式「部门」及既有规格 `docs/superpowers/specs/2026-07-14-oa-approval-dingtalk-design.md` §4.5 / §4.6 对齐。
|
||||
|
||||
当前发布字典已允许 `type: "dept"`,但设计器与运行时尚未落地自定义控件。
|
||||
|
||||
### 1.1 产品结论(已确认)
|
||||
|
||||
| 项 | 结论 |
|
||||
|----|------|
|
||||
| 选择能力 | 默认可多选;设计器属性可改为仅单选 |
|
||||
| 存值 | 单选 `{ id, name }`;多选同结构数组 |
|
||||
| `name` | **全路径快照**(分隔符 ` / `) |
|
||||
| 可选范围 | **任意层级**均可选(非仅末级) |
|
||||
| 实现路径 | 自定义 `type=dept`(对齐关联审批四层扩展);PC 用 `el-tree-select` |
|
||||
| 移动兼容 | 窄屏禁止仅依赖宽屏下拉;须全屏/半屏选择器;Schema/`form_data` 双端同一份 |
|
||||
|
||||
### 1.2 非目标
|
||||
|
||||
- 行业通讯录部门
|
||||
- 用本控件结果改写审批人解析(仍用发起人部门 / `oa_role` 等既有策略)
|
||||
- 本期实现小程序页面(但契约与交互必须可映射)
|
||||
- 本控件「参与流程条件分支」(对象/多选不便进比较器;需要时二期单开)
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构
|
||||
|
||||
对齐 FormCreate / FcDesigner **四层扩展**(与「关联审批单」同路径):
|
||||
|
||||
| 层 | 内容 |
|
||||
|----|------|
|
||||
| Runtime | `OaDeptSelect.vue`:`v-model` 为对象或对象数组 |
|
||||
| FormCreate 组件注册 | `formCreate.component('dept', …)` |
|
||||
| DragRule | `menu: 'main'`;`name/label` 为部门;`props.multiple` 默认 `true` |
|
||||
| 双侧注册 | 主应用(发起/详情)+ `oa-fc-host`(设计器 iframe)均调用 `setupOaDeptSelect` |
|
||||
|
||||
数据源:现有 `fetchDeptTree()`(本租户组织树),不另造 API。
|
||||
|
||||
```
|
||||
[设计器 DragRule] → form_schema_json.type = "dept"
|
||||
↓
|
||||
[OaDeptSelect] ← fetchDeptTree
|
||||
↓
|
||||
form_data[field] = { id, name } | { id, name }[]
|
||||
↓
|
||||
[后端发起校验] → 详情 / 打印优先展示 name 快照
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema 与属性
|
||||
|
||||
### 3.1 规则形态(示例)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "dept",
|
||||
"field": "Fxxxx",
|
||||
"title": "所属部门",
|
||||
"info": "",
|
||||
"$required": false,
|
||||
"props": {
|
||||
"multiple": true,
|
||||
"max": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 设计器可配属性
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| 标题 / 提示信息 / 必填 | 与现有控件一致 |
|
||||
| 允许多选 | Switch,**默认 `true`**;`false` 时仅单选 |
|
||||
| 最大可选数 | 可选;仅多选时生效;不填则不限制 |
|
||||
|
||||
树下拉节点文案:**本级名称**;选中后输入框 / 发起页 / 详情 / 打印:**全路径**(与 `tree-path` 约定一致)。
|
||||
|
||||
---
|
||||
|
||||
## 4. `form_data` 契约(锁定)
|
||||
|
||||
```ts
|
||||
type DeptValueItem = {
|
||||
id: string // sys_dept.id
|
||||
name: string // 提交时全路径快照,如 "中科兴皖 / 研发中心 / 前端组"
|
||||
}
|
||||
|
||||
// 单选(props.multiple === false)
|
||||
type DeptFormValueSingle = DeptValueItem | null
|
||||
|
||||
// 多选(props.multiple === true,默认)
|
||||
type DeptFormValueMulti = DeptValueItem[]
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- `id`、`name` 均为非空字符串。
|
||||
- **禁止**写入 DOM、组件实例、仅本级名却标注为全路径、或与 `multiple` 不一致的标量/对象混用。
|
||||
- 组织改名/调整后,历史实例 **SHALL** 仍展示提交时的 `name` 快照;不强制按 `id` 重算路径。
|
||||
- 后端 **SHALL NOT** 强制校验 `id` 仍存在于当前组织树(允许已删/历史部门快照可读)。
|
||||
|
||||
本决定更新钉钉规格 §4.6 值类型表中「部门」一行的歧义:发布锁定为 **`{ id, name }` / 数组**,不再使用裸 `string` deptId。
|
||||
|
||||
---
|
||||
|
||||
## 5. 运行时交互
|
||||
|
||||
### 5.1 PC(视口宽度 ≥ 768)
|
||||
|
||||
- 使用 `el-tree-select`(或等价树选)。
|
||||
- `multiple` 跟随 `props.multiple`。
|
||||
- 触发条/标签展示全路径;多选以 chip 展示,可删除单条。
|
||||
|
||||
### 5.2 窄屏 / 设计器手机预览(宽度 < 768)
|
||||
|
||||
对齐双端规格:「部门/省市区 → 移动端 **全屏或半屏选择器**,禁止依赖宽屏下拉才能选完」。
|
||||
|
||||
- 表单项为触发条(展示已选全路径或占位「请选择部门」)。
|
||||
- 点击后打开 **抽屉或全屏面板**:搜索 + 组织树 +(多选)勾选 + 取消/确定。
|
||||
- 未点「确定」前不写回 `v-model`。
|
||||
- 与 PC 共用同一 `v-model` 形态,仅交互壳不同。
|
||||
|
||||
### 5.3 只读
|
||||
|
||||
详情 / 审批页:仅展示路径文案(单选一个 `name`;多选多个 `name` 用中文顿号「、」拼接),不可打开选择器。
|
||||
|
||||
### 5.4 条件分支
|
||||
|
||||
`dept` **不**注册「参与流程条件分支」开关;`CONDITION_ELIGIBLE_TYPES` **不**包含 `dept`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 校验
|
||||
|
||||
### 6.1 前端
|
||||
|
||||
- 必填:单选无值或 `null`;多选空数组 → 拦截提示。
|
||||
- 多选且配置了 `max`:达到上限不可再选并提示。
|
||||
- 窄屏面板未确认不算变更。
|
||||
|
||||
### 6.2 后端(发起 / 再次提交)
|
||||
|
||||
- 按 schema 遍历 `type === "dept"`。
|
||||
- 单选:非必填允许缺省/`null`;否则须为 `{ id, name }`。
|
||||
- 多选:须为数组;每项为 `{ id, name }`;必填则 `length >= 1`;有 `max` 则 `length <= max`。
|
||||
- 发布:`ALLOWED_CONTROL_TYPES` 已含 `dept`,保持即可。
|
||||
|
||||
---
|
||||
|
||||
## 7. 打印与详情
|
||||
|
||||
- 单选:输出 `name`。
|
||||
- 多选:多个 `name` 用中文顿号「、」拼接。
|
||||
- **SHALL NOT** 将对象 JSON 原样打印到纸面。
|
||||
- 实现落点:`oa-approval-print`(及详情只读渲染)对 `type === "dept"` 特判。
|
||||
|
||||
---
|
||||
|
||||
## 8. 主要改动面
|
||||
|
||||
| 区域 | 文件(预期) |
|
||||
|------|----------------|
|
||||
| Runtime | `web-admin/src/components/oa-approval/OaDeptSelect.vue` |
|
||||
| DragRule | `web-admin/src/utils/oa-dept-select-rule.ts` |
|
||||
| 注册 | `web-admin/src/utils/oa-form-create-dept.ts`;`oa-fc-host/main.ts`;主应用 form-create 注册点 |
|
||||
| 打印 | `web-admin/src/utils/oa-approval-print.ts` |
|
||||
| 后端校验 | `backend/app/services/oa_publish.py` 或发起校验模块中的 form_data 校验 |
|
||||
| Demo(可选) | `docs/mockups/oa-dept-select-demo.html` 与实现对齐 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
1. 「2 表单设计」左侧组件库出现「部门」,可拖入画布;属性可关闭多选。
|
||||
2. PC:树选可用;设计器切换手机预览(或窄视口)时可用半屏/全屏选完,不必依赖宽屏下拉。
|
||||
3. 提交后 `form_data` 形态符合 §4;组织改名后旧实例仍显示快照路径。
|
||||
4. 含 `dept` 的应用可发布;未知 `type` 仍拒绝。
|
||||
5. 无该控件的旧应用行为不变。
|
||||
6. 详情与打印展示人类可读路径,不出现原始 JSON。
|
||||
|
||||
---
|
||||
|
||||
## 10. 测试建议
|
||||
|
||||
- 单元:`form_data` 形态校验(单选/多选/必填/max)。
|
||||
- 手工:设计器拖入 → 发起多选/单选 → 详情/打印;窄屏或手机预览走完选择;改部门名称后回看历史单。
|
||||
|
||||
---
|
||||
|
||||
## 11. 开放问题
|
||||
|
||||
无。若二期要做「部门字段参与条件分支」或小程序原生页,另开变更,不修改本节契约。
|
||||
Reference in new issue
Block a user