oa-dept-select-demo.html 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <meta name="viewport" content="width=device-width, initial-scale=1" />
  6. <title>OA · 部门选择控件 Demo(Fresh Mint)</title>
  7. <style>
  8. :root {
  9. --primary: #10b981;
  10. --primary-dark: #059669;
  11. --primary-bg: #ecfdf5;
  12. --text: #0f172a;
  13. --text-2: #334155;
  14. --muted: #94a3b8;
  15. --border: #e2e8f0;
  16. --border-strong: #cbd5e1;
  17. --bg: #f1f5f9;
  18. --danger: #ef4444;
  19. --radius: 8px;
  20. }
  21. * { box-sizing: border-box; }
  22. html, body {
  23. margin: 0;
  24. min-height: 100%;
  25. font: 14px/1.5 Inter, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
  26. color: var(--text);
  27. background: var(--bg);
  28. }
  29. .page {
  30. max-width: 1180px;
  31. margin: 0 auto;
  32. padding: 16px 20px 40px;
  33. }
  34. .banner {
  35. display: flex;
  36. flex-wrap: wrap;
  37. gap: 8px 16px;
  38. align-items: center;
  39. justify-content: space-between;
  40. margin-bottom: 14px;
  41. padding: 12px 16px;
  42. background: #fff;
  43. border: 1px solid var(--border);
  44. border-radius: var(--radius);
  45. }
  46. .banner b { color: var(--primary-dark); }
  47. .banner span { color: var(--muted); font-size: 13px; }
  48. .tag {
  49. padding: 2px 10px;
  50. border-radius: 999px;
  51. background: var(--primary-bg);
  52. color: var(--primary-dark);
  53. font-size: 12px;
  54. font-weight: 600;
  55. }
  56. .section-title {
  57. margin: 22px 0 10px;
  58. padding-left: 10px;
  59. border-left: 3px solid var(--primary);
  60. color: #1e293b;
  61. font-size: 15px;
  62. font-weight: 600;
  63. }
  64. .section-desc {
  65. margin: -4px 0 12px;
  66. color: var(--muted);
  67. font-size: 13px;
  68. }
  69. /* 方案切换 */
  70. .mode-bar {
  71. display: flex;
  72. flex-wrap: wrap;
  73. gap: 8px;
  74. margin-bottom: 12px;
  75. }
  76. .mode-btn {
  77. height: 34px;
  78. padding: 0 14px;
  79. border: 1px solid var(--border);
  80. border-radius: 8px;
  81. background: #fff;
  82. color: var(--text-2);
  83. font-size: 13px;
  84. font-weight: 600;
  85. cursor: pointer;
  86. }
  87. .mode-btn:hover { border-color: #a7f3d0; background: var(--primary-bg); }
  88. .mode-btn.active {
  89. border-color: var(--primary);
  90. background: var(--primary);
  91. color: #fff;
  92. }
  93. /* ===== 设计器三栏缩略 ===== */
  94. .designer {
  95. display: grid;
  96. grid-template-columns: 220px 1fr 260px;
  97. min-height: 420px;
  98. overflow: hidden;
  99. background: #fff;
  100. border: 1px solid var(--border-strong);
  101. border-radius: 10px;
  102. box-shadow: 0 2px 8px rgb(15 23 42 / 8%);
  103. }
  104. .pane {
  105. display: flex;
  106. flex-direction: column;
  107. min-width: 0;
  108. min-height: 0;
  109. }
  110. .pane + .pane { border-left: 1px solid var(--border); }
  111. .pane-head {
  112. display: flex;
  113. flex-shrink: 0;
  114. height: 42px;
  115. border-bottom: 1px solid var(--border);
  116. }
  117. .pane-head span {
  118. flex: 1;
  119. display: grid;
  120. place-items: center;
  121. color: var(--muted);
  122. font-size: 13px;
  123. font-weight: 600;
  124. }
  125. .pane-head span.active {
  126. color: var(--text);
  127. box-shadow: inset 0 -2px 0 var(--primary);
  128. }
  129. .pane-body {
  130. flex: 1;
  131. padding: 12px;
  132. overflow: auto;
  133. }
  134. .group-title {
  135. margin: 4px 0 8px;
  136. color: var(--muted);
  137. font-size: 12px;
  138. font-weight: 600;
  139. }
  140. .ctrl-grid {
  141. display: grid;
  142. grid-template-columns: 1fr 1fr;
  143. gap: 8px;
  144. margin-bottom: 14px;
  145. }
  146. .ctrl {
  147. display: flex;
  148. align-items: center;
  149. gap: 6px;
  150. height: 36px;
  151. padding: 0 10px;
  152. border: 1px dashed var(--border-strong);
  153. border-radius: 8px;
  154. background: #fafafa;
  155. color: var(--text-2);
  156. font-size: 12px;
  157. cursor: grab;
  158. }
  159. .ctrl.highlight {
  160. border-style: solid;
  161. border-color: var(--primary);
  162. background: var(--primary-bg);
  163. color: var(--primary-dark);
  164. font-weight: 600;
  165. box-shadow: 0 0 0 2px rgb(16 185 129 / 18%);
  166. }
  167. .ctrl-ico {
  168. width: 18px;
  169. text-align: center;
  170. font-size: 13px;
  171. opacity: 0.85;
  172. }
  173. .canvas {
  174. padding: 16px 20px 24px;
  175. background: #f8fafc;
  176. }
  177. .device-toggle {
  178. display: inline-flex;
  179. gap: 4px;
  180. margin-bottom: 12px;
  181. padding: 3px;
  182. border: 1px solid var(--border);
  183. border-radius: 8px;
  184. background: #fff;
  185. }
  186. .device-toggle button {
  187. height: 28px;
  188. padding: 0 10px;
  189. border: 0;
  190. border-radius: 6px;
  191. background: transparent;
  192. color: var(--muted);
  193. font-size: 12px;
  194. cursor: pointer;
  195. }
  196. .device-toggle button.active {
  197. background: var(--primary-bg);
  198. color: var(--primary-dark);
  199. font-weight: 600;
  200. }
  201. .form-card {
  202. max-width: 420px;
  203. margin: 0 auto;
  204. padding: 16px;
  205. background: #fff;
  206. border: 1px solid var(--border);
  207. border-radius: 10px;
  208. }
  209. .field {
  210. margin-bottom: 14px;
  211. padding: 10px;
  212. border: 1px solid transparent;
  213. border-radius: 8px;
  214. }
  215. .field.active {
  216. border-color: var(--primary);
  217. background: rgb(236 253 245 / 45%);
  218. }
  219. .field label {
  220. display: block;
  221. margin-bottom: 6px;
  222. color: var(--text-2);
  223. font-size: 13px;
  224. font-weight: 600;
  225. }
  226. .field label .req {
  227. margin-left: 2px;
  228. color: var(--danger);
  229. }
  230. .fake-input {
  231. display: flex;
  232. align-items: center;
  233. justify-content: space-between;
  234. height: 36px;
  235. padding: 0 12px;
  236. border: 1px solid var(--border-strong);
  237. border-radius: 8px;
  238. background: #fff;
  239. color: var(--muted);
  240. font-size: 13px;
  241. cursor: pointer;
  242. }
  243. .fake-input.has-value { color: var(--text); }
  244. .fake-input .chev { color: var(--muted); font-size: 11px; }
  245. .prop-item { margin-bottom: 14px; }
  246. .prop-item > label {
  247. display: block;
  248. margin-bottom: 6px;
  249. color: var(--muted);
  250. font-size: 12px;
  251. font-weight: 600;
  252. }
  253. .prop-item input,
  254. .prop-item select {
  255. width: 100%;
  256. height: 34px;
  257. padding: 0 10px;
  258. border: 1px solid var(--border-strong);
  259. border-radius: 8px;
  260. font: inherit;
  261. }
  262. .switch-row {
  263. display: flex;
  264. align-items: center;
  265. gap: 8px;
  266. color: var(--text-2);
  267. font-size: 13px;
  268. }
  269. .switch {
  270. position: relative;
  271. width: 36px;
  272. height: 20px;
  273. border-radius: 999px;
  274. background: #cbd5e1;
  275. cursor: pointer;
  276. }
  277. .switch.on { background: var(--primary); }
  278. .switch::after {
  279. content: "";
  280. position: absolute;
  281. top: 2px;
  282. left: 2px;
  283. width: 16px;
  284. height: 16px;
  285. border-radius: 50%;
  286. background: #fff;
  287. transition: left 0.15s;
  288. }
  289. .switch.on::after { left: 18px; }
  290. /* ===== 发起页运行时 ===== */
  291. .runtime {
  292. display: grid;
  293. grid-template-columns: 1fr 320px;
  294. gap: 14px;
  295. }
  296. @media (max-width: 900px) {
  297. .designer { grid-template-columns: 1fr; }
  298. .pane + .pane { border-left: 0; border-top: 1px solid var(--border); }
  299. .runtime { grid-template-columns: 1fr; }
  300. }
  301. .panel {
  302. padding: 16px;
  303. background: #fff;
  304. border: 1px solid var(--border);
  305. border-radius: 10px;
  306. }
  307. .panel h3 {
  308. margin: 0 0 12px;
  309. padding-left: 10px;
  310. border-left: 3px solid var(--primary);
  311. font-size: 14px;
  312. font-weight: 600;
  313. }
  314. .tree-select {
  315. position: relative;
  316. }
  317. .tree-select-trigger {
  318. display: flex;
  319. flex-wrap: wrap;
  320. gap: 6px;
  321. align-items: center;
  322. min-height: 36px;
  323. padding: 4px 10px;
  324. border: 1px solid var(--border-strong);
  325. border-radius: 8px;
  326. background: #fff;
  327. cursor: pointer;
  328. }
  329. .tree-select-trigger:hover,
  330. .tree-select.open .tree-select-trigger {
  331. border-color: var(--primary);
  332. }
  333. .tree-select-trigger .placeholder {
  334. color: var(--muted);
  335. font-size: 13px;
  336. }
  337. .chip {
  338. display: inline-flex;
  339. align-items: center;
  340. gap: 4px;
  341. max-width: 100%;
  342. height: 24px;
  343. padding: 0 8px;
  344. border-radius: 6px;
  345. background: var(--primary-bg);
  346. color: var(--primary-dark);
  347. font-size: 12px;
  348. font-weight: 600;
  349. }
  350. .chip button {
  351. border: 0;
  352. background: none;
  353. color: var(--primary-dark);
  354. font-size: 14px;
  355. line-height: 1;
  356. cursor: pointer;
  357. opacity: 0.7;
  358. }
  359. .chip button:hover { opacity: 1; }
  360. .dropdown {
  361. display: none;
  362. position: absolute;
  363. z-index: 20;
  364. top: calc(100% + 4px);
  365. left: 0;
  366. right: 0;
  367. max-height: 280px;
  368. overflow: auto;
  369. padding: 8px;
  370. background: #fff;
  371. border: 1px solid var(--border-strong);
  372. border-radius: 10px;
  373. box-shadow: 0 8px 24px rgb(15 23 42 / 12%);
  374. }
  375. .tree-select.open .dropdown { display: block; }
  376. .search {
  377. width: 100%;
  378. height: 32px;
  379. margin-bottom: 8px;
  380. padding: 0 10px;
  381. border: 1px solid var(--border);
  382. border-radius: 8px;
  383. font: inherit;
  384. }
  385. .tree-node {
  386. display: flex;
  387. align-items: center;
  388. gap: 6px;
  389. min-height: 32px;
  390. padding: 4px 8px;
  391. border-radius: 6px;
  392. cursor: pointer;
  393. user-select: none;
  394. }
  395. .tree-node:hover { background: #f1f5f9; }
  396. .tree-node.selected {
  397. background: var(--primary-bg);
  398. color: var(--primary-dark);
  399. font-weight: 600;
  400. }
  401. .tree-node .twisty {
  402. width: 14px;
  403. color: var(--muted);
  404. font-size: 10px;
  405. text-align: center;
  406. }
  407. .tree-node .check {
  408. width: 14px;
  409. height: 14px;
  410. border: 1.5px solid var(--border-strong);
  411. border-radius: 3px;
  412. flex-shrink: 0;
  413. }
  414. .tree-node.selected .check {
  415. border-color: var(--primary);
  416. background: var(--primary);
  417. box-shadow: inset 0 0 0 2px #fff;
  418. }
  419. .tree-children { margin-left: 18px; }
  420. .tree-children.collapsed { display: none; }
  421. .kv {
  422. display: grid;
  423. gap: 10px;
  424. font-size: 13px;
  425. }
  426. .kv dt { color: var(--muted); font-size: 12px; }
  427. .kv dd {
  428. margin: 2px 0 0;
  429. padding: 8px 10px;
  430. border-radius: 8px;
  431. background: #f8fafc;
  432. color: var(--text);
  433. word-break: break-all;
  434. }
  435. .kv dd code {
  436. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  437. font-size: 12px;
  438. color: var(--primary-dark);
  439. }
  440. .note {
  441. margin-top: 12px;
  442. padding: 10px 12px;
  443. border-radius: 8px;
  444. background: #fffbeb;
  445. border: 1px solid #fde68a;
  446. color: #92400e;
  447. font-size: 12px;
  448. line-height: 1.55;
  449. }
  450. .footer-actions {
  451. display: flex;
  452. justify-content: flex-end;
  453. gap: 8px;
  454. margin-top: 16px;
  455. }
  456. .btn {
  457. display: inline-flex;
  458. align-items: center;
  459. gap: 6px;
  460. height: 34px;
  461. padding: 0 14px;
  462. border: 1px solid var(--border);
  463. border-radius: 8px;
  464. background: #fff;
  465. color: var(--text-2);
  466. font-size: 13px;
  467. cursor: pointer;
  468. }
  469. .btn.primary {
  470. border-color: var(--primary);
  471. background: var(--primary);
  472. color: #fff;
  473. }
  474. </style>
  475. </head>
  476. <body>
  477. <div class="page">
  478. <div class="banner">
  479. <div>
  480. <b>OA 表单设计 ·「部门选择」控件 Demo</b>
  481. <span>数据源:本租户组织树(sys_dept / fetchDeptTree)· 选中展示全路径</span>
  482. </div>
  483. <span class="tag">Fresh Mint</span>
  484. </div>
  485. <div class="mode-bar" role="tablist" aria-label="选择模式">
  486. <button type="button" class="mode-btn active" data-mode="single">方案 A · 单选部门</button>
  487. <button type="button" class="mode-btn" data-mode="multi">方案 B · 可多选部门</button>
  488. <button type="button" class="mode-btn" data-mode="dialog">方案 C · 弹窗树选择</button>
  489. </div>
  490. <h2 class="section-title">① 设计器:「2 表单设计」左侧组件库</h2>
  491. <p class="section-desc">「部门」出现在增强分组;拖到画布后,右侧可配标题 / 必填 / 单多选等。</p>
  492. <div class="designer">
  493. <aside class="pane">
  494. <div class="pane-head"><span class="active">组件</span><span>大纲</span></div>
  495. <div class="pane-body">
  496. <div class="group-title">基础</div>
  497. <div class="ctrl-grid">
  498. <div class="ctrl"><span class="ctrl-ico">Aa</span>单行</div>
  499. <div class="ctrl"><span class="ctrl-ico">¶</span>多行</div>
  500. <div class="ctrl"><span class="ctrl-ico">123</span>数字</div>
  501. <div class="ctrl"><span class="ctrl-ico">📅</span>日期</div>
  502. </div>
  503. <div class="group-title">增强</div>
  504. <div class="ctrl-grid">
  505. <div class="ctrl"><span class="ctrl-ico">📎</span>附件</div>
  506. <div class="ctrl highlight"><span class="ctrl-ico">🏢</span>部门</div>
  507. <div class="ctrl"><span class="ctrl-ico">📍</span>省市区</div>
  508. <div class="ctrl"><span class="ctrl-ico">ƒ</span>公式</div>
  509. <div class="ctrl"><span class="ctrl-ico">🔗</span>关联审批</div>
  510. </div>
  511. </div>
  512. </aside>
  513. <main class="pane">
  514. <div class="pane-head"><span class="active">画布 · PC</span></div>
  515. <div class="canvas">
  516. <div class="device-toggle">
  517. <button type="button" class="active">🖥 PC</button>
  518. <button type="button">📱 手机</button>
  519. </div>
  520. <div class="form-card">
  521. <div class="field">
  522. <label>申请事由</label>
  523. <div class="fake-input">请输入<span class="chev">▾</span></div>
  524. </div>
  525. <div class="field active">
  526. <label>所属部门<span class="req">*</span></label>
  527. <div class="fake-input has-value" id="canvasPreview">
  528. 中科兴皖 / 研发中心 / 前端组
  529. <span class="chev">▾</span>
  530. </div>
  531. </div>
  532. <div class="field">
  533. <label>说明</label>
  534. <div class="fake-input">请输入<span class="chev">▾</span></div>
  535. </div>
  536. </div>
  537. </div>
  538. </main>
  539. <aside class="pane">
  540. <div class="pane-head"><span class="active">控件</span><span>表单</span></div>
  541. <div class="pane-body">
  542. <div class="prop-item">
  543. <label>标题</label>
  544. <input value="所属部门" />
  545. </div>
  546. <div class="prop-item">
  547. <label>字段名</label>
  548. <input value="deptId" />
  549. </div>
  550. <div class="prop-item">
  551. <label>占位提示</label>
  552. <input value="请选择部门" />
  553. </div>
  554. <div class="prop-item">
  555. <label>必填</label>
  556. <div class="switch-row">
  557. <span class="switch on" id="propRequired"></span>
  558. <span>是</span>
  559. </div>
  560. </div>
  561. <div class="prop-item">
  562. <label>选择模式</label>
  563. <select id="propMode" disabled>
  564. <option value="single">仅单选</option>
  565. <option value="multi">允许多选</option>
  566. </select>
  567. </div>
  568. <div class="note">
  569. 下拉节点只显示本级名称;输入框 / 发起页 / 详情 / 打印显示<strong>全路径</strong>(分隔符 <code> / </code>)。
  570. </div>
  571. </div>
  572. </aside>
  573. </div>
  574. <h2 class="section-title">② 发起页:运行时选择(可点选试用)</h2>
  575. <p class="section-desc">左侧交互;右侧同步展示将写入 <code>form_data</code> 的值形态(建议锁定一种契约)。</p>
  576. <div class="runtime">
  577. <div class="panel">
  578. <h3>填报表单</h3>
  579. <div class="prop-item">
  580. <label style="margin-bottom:8px;color:var(--text-2);font-size:13px;font-weight:600">
  581. 所属部门<span class="req" style="color:var(--danger)">*</span>
  582. </label>
  583. <div class="tree-select" id="deptSelect">
  584. <div class="tree-select-trigger" id="deptTrigger" tabindex="0">
  585. <span class="placeholder">请选择部门</span>
  586. </div>
  587. <div class="dropdown" id="deptDropdown">
  588. <input class="search" id="deptSearch" type="search" placeholder="搜索部门名称" />
  589. <div id="deptTree"></div>
  590. </div>
  591. </div>
  592. </div>
  593. <div class="footer-actions">
  594. <button type="button" class="btn">取消</button>
  595. <button type="button" class="btn primary" id="btnSubmit">✓ 提交</button>
  596. </div>
  597. </div>
  598. <div class="panel">
  599. <h3>form_data 预览</h3>
  600. <dl class="kv">
  601. <div>
  602. <dt>展示文案(全路径)</dt>
  603. <dd id="outLabel">—</dd>
  604. </div>
  605. <div>
  606. <dt>存库值(推荐)</dt>
  607. <dd id="outValue"><code>—</code></dd>
  608. </div>
  609. <div>
  610. <dt>当前方案说明</dt>
  611. <dd id="outNote">单选:值为部门 id 字符串。</dd>
  612. </div>
  613. </dl>
  614. <div class="note">
  615. 规格草案:部门控件 <code>type = "dept"</code>(后端发布白名单已预留)。
  616. 勿存组件实例 / DOM;名称可按 id 在详情侧补全路径。
  617. </div>
  618. </div>
  619. </div>
  620. </div>
  621. <!-- 弹窗方案 C -->
  622. <dialog id="deptDialog" style="
  623. border:0;border-radius:12px;padding:0;width:min(440px,92vw);
  624. box-shadow:0 16px 48px rgb(15 23 42 / 20%);
  625. ">
  626. <form method="dialog" style="padding:16px 18px 18px">
  627. <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
  628. <strong style="font-size:15px">选择部门</strong>
  629. <button value="cancel" class="btn" style="height:28px;padding:0 8px">✕</button>
  630. </div>
  631. <input class="search" id="dialogSearch" type="search" placeholder="搜索部门名称" style="margin-bottom:10px" />
  632. <div id="dialogTree" style="max-height:320px;overflow:auto;border:1px solid var(--border);border-radius:8px;padding:8px"></div>
  633. <div class="footer-actions">
  634. <button value="cancel" class="btn">取消</button>
  635. <button value="ok" class="btn primary" id="dialogOk">确定</button>
  636. </div>
  637. </form>
  638. </dialog>
  639. <script>
  640. /** 模拟本租户组织树 */
  641. const DEPT_TREE = [
  642. {
  643. id: 'd1', name: '中科兴皖',
  644. children: [
  645. {
  646. id: 'd2', name: '研发中心',
  647. children: [
  648. { id: 'd3', name: '前端组' },
  649. { id: 'd4', name: '后端组' }
  650. ]
  651. },
  652. {
  653. id: 'd5', name: '质量中心',
  654. children: [
  655. { id: 'd6', name: '实验室' },
  656. { id: 'd7', name: '体系办' }
  657. ]
  658. },
  659. { id: 'd8', name: '行政人事部' }
  660. ]
  661. }
  662. ]
  663. const pathMap = {}
  664. function walk(nodes, ancestors) {
  665. nodes.forEach((n) => {
  666. const path = [...ancestors, n.name]
  667. pathMap[n.id] = path.join(' / ')
  668. if (n.children) walk(n.children, path)
  669. })
  670. }
  671. walk(DEPT_TREE, [])
  672. let mode = 'single' // single | multi | dialog
  673. let selected = new Set(['d3'])
  674. let dialogPick = new Set()
  675. const modeBtns = document.querySelectorAll('.mode-btn')
  676. const propMode = document.getElementById('propMode')
  677. const canvasPreview = document.getElementById('canvasPreview')
  678. const deptSelect = document.getElementById('deptSelect')
  679. const deptTrigger = document.getElementById('deptTrigger')
  680. const deptTree = document.getElementById('deptTree')
  681. const deptSearch = document.getElementById('deptSearch')
  682. const outLabel = document.getElementById('outLabel')
  683. const outValue = document.getElementById('outValue')
  684. const outNote = document.getElementById('outNote')
  685. const deptDialog = document.getElementById('deptDialog')
  686. const dialogTree = document.getElementById('dialogTree')
  687. const dialogSearch = document.getElementById('dialogSearch')
  688. modeBtns.forEach((btn) => {
  689. btn.addEventListener('click', () => {
  690. modeBtns.forEach((b) => b.classList.remove('active'))
  691. btn.classList.add('active')
  692. mode = btn.dataset.mode
  693. propMode.value = mode === 'multi' ? 'multi' : 'single'
  694. selected = mode === 'multi' ? new Set(['d3', 'd6']) : new Set(['d3'])
  695. deptSelect.classList.remove('open')
  696. renderAll()
  697. })
  698. })
  699. function filterTree(nodes, q) {
  700. if (!q) return nodes
  701. const lower = q.toLowerCase()
  702. function filt(list) {
  703. const out = []
  704. list.forEach((n) => {
  705. const kids = n.children ? filt(n.children) : []
  706. if (n.name.toLowerCase().includes(lower) || kids.length) {
  707. out.push({ ...n, children: kids.length ? kids : n.children })
  708. }
  709. })
  710. return out
  711. }
  712. return filt(nodes)
  713. }
  714. function renderTree(container, nodes, opts) {
  715. const { multi, query, onPick } = opts
  716. container.innerHTML = ''
  717. const data = filterTree(nodes, query)
  718. function renderNodes(list, parentEl) {
  719. list.forEach((n) => {
  720. const row = document.createElement('div')
  721. row.className = 'tree-node' + (selected.has(n.id) || (opts.pickSet && opts.pickSet.has(n.id)) ? ' selected' : '')
  722. row.style.paddingLeft = '8px'
  723. const hasKids = !!(n.children && n.children.length)
  724. row.innerHTML =
  725. `<span class="twisty">${hasKids ? '▾' : ''}</span>` +
  726. (multi ? '<span class="check"></span>' : '') +
  727. `<span>${n.name}</span>`
  728. row.addEventListener('click', (e) => {
  729. e.stopPropagation()
  730. onPick(n.id)
  731. })
  732. parentEl.appendChild(row)
  733. if (hasKids) {
  734. const wrap = document.createElement('div')
  735. wrap.className = 'tree-children'
  736. parentEl.appendChild(wrap)
  737. renderNodes(n.children, wrap)
  738. }
  739. })
  740. }
  741. if (!data.length) {
  742. container.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:13px">无匹配部门</div>'
  743. return
  744. }
  745. renderNodes(data, container)
  746. }
  747. function syncOutput() {
  748. const ids = [...selected]
  749. const labels = ids.map((id) => pathMap[id] || id)
  750. outLabel.textContent = labels.length ? labels.join(';') : '—'
  751. canvasPreview.innerHTML =
  752. (labels[0] || '请选择部门') + '<span class="chev">▾</span>'
  753. canvasPreview.classList.toggle('has-value', !!ids.length)
  754. if (mode === 'multi') {
  755. outValue.innerHTML = `<code>${JSON.stringify(ids)}</code>`
  756. outNote.textContent = '多选:值为 deptId 字符串数组。详情按 id 补全全路径。'
  757. } else {
  758. outValue.innerHTML = `<code>${ids[0] ? JSON.stringify(ids[0]) : '""'}</code>`
  759. outNote.textContent =
  760. mode === 'dialog'
  761. ? '弹窗单选:值为部门 id 字符串;窄屏/手机更友好。'
  762. : '单选:值为部门 id 字符串(与用户管理「所属组织」一致)。'
  763. }
  764. }
  765. function renderTrigger() {
  766. const ids = [...selected]
  767. deptTrigger.innerHTML = ''
  768. if (!ids.length) {
  769. const ph = document.createElement('span')
  770. ph.className = 'placeholder'
  771. ph.textContent = '请选择部门'
  772. deptTrigger.appendChild(ph)
  773. return
  774. }
  775. if (mode === 'multi') {
  776. ids.forEach((id) => {
  777. const chip = document.createElement('span')
  778. chip.className = 'chip'
  779. chip.innerHTML = `${pathMap[id]}<button type="button" aria-label="移除">×</button>`
  780. chip.querySelector('button').addEventListener('click', (e) => {
  781. e.stopPropagation()
  782. selected.delete(id)
  783. renderAll()
  784. })
  785. deptTrigger.appendChild(chip)
  786. })
  787. } else {
  788. const chip = document.createElement('span')
  789. chip.className = 'chip'
  790. chip.textContent = pathMap[ids[0]]
  791. deptTrigger.appendChild(chip)
  792. }
  793. }
  794. function renderAll() {
  795. const multi = mode === 'multi'
  796. renderTree(deptTree, DEPT_TREE, {
  797. multi,
  798. query: deptSearch.value.trim(),
  799. onPick: (id) => {
  800. if (mode === 'dialog') return
  801. if (multi) {
  802. if (selected.has(id)) selected.delete(id)
  803. else selected.add(id)
  804. } else {
  805. selected = new Set([id])
  806. deptSelect.classList.remove('open')
  807. }
  808. renderAll()
  809. }
  810. })
  811. renderTrigger()
  812. syncOutput()
  813. }
  814. deptTrigger.addEventListener('click', () => {
  815. if (mode === 'dialog') {
  816. dialogPick = new Set(selected)
  817. dialogSearch.value = ''
  818. renderDialogTree()
  819. deptDialog.showModal()
  820. return
  821. }
  822. deptSelect.classList.toggle('open')
  823. })
  824. document.addEventListener('click', (e) => {
  825. if (!deptSelect.contains(e.target)) deptSelect.classList.remove('open')
  826. })
  827. deptSearch.addEventListener('input', renderAll)
  828. function renderDialogTree() {
  829. renderTree(dialogTree, DEPT_TREE, {
  830. multi: false,
  831. query: dialogSearch.value.trim(),
  832. pickSet: dialogPick,
  833. onPick: (id) => {
  834. dialogPick = new Set([id])
  835. renderDialogTree()
  836. }
  837. })
  838. // highlight based on dialogPick
  839. dialogTree.querySelectorAll('.tree-node').forEach((el) => {
  840. /* re-render already sets selected from selected; override */
  841. })
  842. // re-render with temporary selected swap
  843. const bak = selected
  844. selected = dialogPick
  845. renderTree(dialogTree, DEPT_TREE, {
  846. multi: false,
  847. query: dialogSearch.value.trim(),
  848. onPick: (id) => {
  849. dialogPick = new Set([id])
  850. selected = dialogPick
  851. renderDialogTree()
  852. }
  853. })
  854. selected = bak
  855. }
  856. dialogSearch.addEventListener('input', renderDialogTree)
  857. document.getElementById('dialogOk').addEventListener('click', (e) => {
  858. e.preventDefault()
  859. selected = new Set(dialogPick)
  860. deptDialog.close()
  861. renderAll()
  862. })
  863. document.getElementById('btnSubmit').addEventListener('click', () => {
  864. if (!selected.size) {
  865. alert('请先选择部门')
  866. return
  867. }
  868. alert('Demo:将提交 form_data →\n' + outValue.textContent)
  869. })
  870. document.getElementById('propRequired').addEventListener('click', function () {
  871. this.classList.toggle('on')
  872. })
  873. renderAll()
  874. </script>
  875. </body>
  876. </html>