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>
923 lines
26 KiB
HTML
923 lines
26 KiB
HTML
<!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>
|