Files
zkxw/docs/mockups/oa-dept-select-demo.html
T
zlwuandCursor 19c97d148f 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>
2026-07-27 18:17:38 +08:00

923 lines
26 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(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>