oa-approval-approver-drawer-demo.html 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975
  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. <link rel="preconnect" href="https://fonts.googleapis.com" />
  8. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  9. <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
  10. <style>
  11. :root {
  12. --primary: #10b981;
  13. --primary-dark: #059669;
  14. --primary-bg: #ecfdf5;
  15. --text: #0f172a;
  16. --muted: #64748b;
  17. --border: #e2e8f0;
  18. --bg: #f1f5f9;
  19. --card: #fff;
  20. --radius: 10px;
  21. --drawer-w: 640px;
  22. --warn: #d97706;
  23. }
  24. * { box-sizing: border-box; }
  25. body {
  26. margin: 0;
  27. min-height: 100vh;
  28. font: 14px/1.5 Inter, system-ui, -apple-system, "PingFang SC", sans-serif;
  29. color: var(--text);
  30. background: var(--bg);
  31. }
  32. /* —— 页面壳 —— */
  33. .page {
  34. max-width: 1180px;
  35. margin: 0 auto;
  36. padding: 28px 24px 48px;
  37. }
  38. .page-hd {
  39. margin-bottom: 20px;
  40. }
  41. .page-hd h1 {
  42. margin: 0 0 6px;
  43. font-size: 20px;
  44. font-weight: 700;
  45. }
  46. .page-hd p {
  47. margin: 0;
  48. color: var(--muted);
  49. font-size: 13px;
  50. }
  51. .page-hd code {
  52. padding: 1px 6px;
  53. font-size: 12px;
  54. background: #fff;
  55. border: 1px solid var(--border);
  56. border-radius: 4px;
  57. }
  58. /* —— 方案切换 —— */
  59. .scheme-bar {
  60. display: flex;
  61. flex-wrap: wrap;
  62. gap: 8px;
  63. margin-bottom: 16px;
  64. }
  65. .scheme-btn {
  66. appearance: none;
  67. border: 1px solid var(--border);
  68. background: #fff;
  69. color: var(--text);
  70. padding: 8px 14px;
  71. border-radius: 8px;
  72. cursor: pointer;
  73. font: inherit;
  74. font-weight: 500;
  75. transition: .15s ease;
  76. }
  77. .scheme-btn:hover { border-color: #a7f3d0; background: var(--primary-bg); }
  78. .scheme-btn.is-active {
  79. border-color: var(--primary);
  80. background: var(--primary-bg);
  81. color: var(--primary-dark);
  82. box-shadow: 0 0 0 1px var(--primary);
  83. }
  84. .scheme-btn .tag {
  85. display: inline-block;
  86. margin-left: 6px;
  87. padding: 0 6px;
  88. font-size: 11px;
  89. font-weight: 600;
  90. color: #fff;
  91. background: var(--primary);
  92. border-radius: 999px;
  93. vertical-align: 1px;
  94. }
  95. .scheme-note {
  96. margin: 0 0 20px;
  97. padding: 12px 14px;
  98. background: #fff;
  99. border: 1px solid var(--border);
  100. border-radius: 8px;
  101. color: var(--muted);
  102. font-size: 13px;
  103. }
  104. .scheme-note strong { color: var(--text); font-weight: 600; }
  105. /* —— 舞台:流程背景 + 抽屉 —— */
  106. .stage {
  107. position: relative;
  108. height: min(780px, calc(100vh - 200px));
  109. min-height: 620px;
  110. border-radius: 12px;
  111. overflow: hidden;
  112. border: 1px solid var(--border);
  113. background: #f8fafc;
  114. box-shadow: 0 8px 28px rgba(15, 23, 42, .06);
  115. }
  116. .stage-canvas {
  117. height: 100%;
  118. padding: 40px 24px;
  119. opacity: .55;
  120. filter: grayscale(.15);
  121. pointer-events: none;
  122. display: flex;
  123. flex-direction: column;
  124. align-items: center;
  125. gap: 12px;
  126. }
  127. .flow-node {
  128. width: 200px;
  129. padding: 12px 14px;
  130. background: #fff;
  131. border: 1px solid var(--border);
  132. border-radius: 8px;
  133. text-align: center;
  134. font-size: 13px;
  135. font-weight: 600;
  136. }
  137. .flow-node.approval {
  138. border-color: #a7f3d0;
  139. background: var(--primary-bg);
  140. color: var(--primary-dark);
  141. }
  142. .flow-line {
  143. width: 2px;
  144. height: 20px;
  145. background: #cbd5e1;
  146. }
  147. .stage-mask {
  148. position: absolute;
  149. inset: 0;
  150. background: rgba(15, 23, 42, .28);
  151. }
  152. /* —— 抽屉 —— */
  153. .drawer {
  154. position: absolute;
  155. top: 0;
  156. right: 0;
  157. bottom: 0;
  158. width: var(--drawer-w);
  159. max-width: 100%;
  160. background: #fff;
  161. display: flex;
  162. flex-direction: column;
  163. box-shadow: -8px 0 28px rgba(15, 23, 42, .12);
  164. animation: slideIn .28s ease;
  165. }
  166. @keyframes slideIn {
  167. from { transform: translateX(24px); opacity: .6; }
  168. to { transform: none; opacity: 1; }
  169. }
  170. /* 标题栏美化 */
  171. .drawer-hd {
  172. flex-shrink: 0;
  173. padding: 18px 28px 14px;
  174. border-bottom: 1px solid var(--border);
  175. background: linear-gradient(180deg, #ecfdf5 0%, #fff 72%);
  176. }
  177. .drawer-hd-top {
  178. display: flex;
  179. align-items: flex-start;
  180. gap: 12px;
  181. margin-bottom: 14px;
  182. }
  183. .drawer-icon {
  184. width: 40px;
  185. height: 40px;
  186. border-radius: 10px;
  187. background: var(--primary);
  188. color: #fff;
  189. display: grid;
  190. place-items: center;
  191. flex-shrink: 0;
  192. box-shadow: 0 4px 12px rgba(16, 185, 129, .28);
  193. }
  194. .drawer-icon svg { width: 22px; height: 22px; }
  195. .drawer-titles { flex: 1; min-width: 0; }
  196. .drawer-titles h2 {
  197. margin: 0;
  198. font-size: 16px;
  199. font-weight: 700;
  200. letter-spacing: .01em;
  201. }
  202. .drawer-titles p {
  203. margin: 2px 0 0;
  204. font-size: 12px;
  205. color: var(--muted);
  206. }
  207. .drawer-close {
  208. appearance: none;
  209. width: 32px;
  210. height: 32px;
  211. border: 0;
  212. border-radius: 8px;
  213. background: transparent;
  214. color: var(--muted);
  215. cursor: pointer;
  216. font-size: 18px;
  217. line-height: 1;
  218. }
  219. .drawer-close:hover { background: #f1f5f9; color: var(--text); }
  220. /* 顶部操作按钮 */
  221. .drawer-actions {
  222. display: flex;
  223. justify-content: flex-end;
  224. gap: 8px;
  225. }
  226. .btn {
  227. appearance: none;
  228. display: inline-flex;
  229. align-items: center;
  230. gap: 6px;
  231. height: 36px;
  232. padding: 0 14px;
  233. border-radius: 8px;
  234. font: inherit;
  235. font-weight: 500;
  236. cursor: pointer;
  237. border: 1px solid var(--border);
  238. background: #fff;
  239. color: var(--text);
  240. transition: .15s ease;
  241. }
  242. .btn:hover { background: #f8fafc; }
  243. .btn-primary {
  244. border-color: var(--primary);
  245. background: var(--primary);
  246. color: #fff;
  247. }
  248. .btn-primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
  249. .btn svg { width: 14px; height: 14px; }
  250. /* 正文 */
  251. .drawer-body {
  252. flex: 1;
  253. overflow: auto;
  254. padding: 20px 28px 28px;
  255. }
  256. .field { margin-bottom: 18px; }
  257. .field > label {
  258. display: block;
  259. margin-bottom: 8px;
  260. font-size: 13px;
  261. font-weight: 600;
  262. color: var(--text);
  263. }
  264. .field > label .tip {
  265. margin-left: 6px;
  266. font-weight: 400;
  267. font-size: 12px;
  268. color: var(--muted);
  269. }
  270. .input, .select {
  271. width: 100%;
  272. height: 36px;
  273. padding: 0 12px;
  274. border: 1px solid var(--border);
  275. border-radius: 8px;
  276. font: inherit;
  277. background: #fff;
  278. color: var(--text);
  279. outline: none;
  280. }
  281. .input:focus, .select:focus {
  282. border-color: var(--primary);
  283. box-shadow: 0 0 0 3px rgba(16, 185, 129, .15);
  284. }
  285. /* 选人方式:分段按钮 */
  286. .seg {
  287. display: grid;
  288. grid-template-columns: repeat(3, 1fr);
  289. gap: 8px;
  290. }
  291. .seg.two { grid-template-columns: repeat(2, 1fr); }
  292. .seg-item {
  293. appearance: none;
  294. border: 1px solid var(--border);
  295. background: #fff;
  296. border-radius: 8px;
  297. padding: 10px 8px;
  298. font: inherit;
  299. font-size: 12px;
  300. cursor: pointer;
  301. color: var(--muted);
  302. text-align: center;
  303. transition: .15s ease;
  304. }
  305. .seg-item:hover { border-color: #a7f3d0; color: var(--text); }
  306. .seg-item.is-on {
  307. border-color: var(--primary);
  308. background: var(--primary-bg);
  309. color: var(--primary-dark);
  310. font-weight: 600;
  311. }
  312. /* 或签 / 会签 */
  313. .radio-row {
  314. display: flex;
  315. flex-direction: column;
  316. gap: 8px;
  317. }
  318. .radio-card {
  319. display: flex;
  320. align-items: flex-start;
  321. gap: 10px;
  322. padding: 12px 14px;
  323. border: 1px solid var(--border);
  324. border-radius: 8px;
  325. cursor: pointer;
  326. transition: .15s ease;
  327. }
  328. .radio-card:hover { border-color: #a7f3d0; }
  329. .radio-card.is-on {
  330. border-color: var(--primary);
  331. background: var(--primary-bg);
  332. }
  333. .radio-card input { margin-top: 3px; accent-color: var(--primary); }
  334. .radio-card strong { display: block; font-size: 13px; }
  335. .radio-card span { font-size: 12px; color: var(--muted); }
  336. /* —— 方案 A:头像芯片 + 搜索添加(推荐) —— */
  337. .member-picker-a .search-box {
  338. position: relative;
  339. }
  340. .member-picker-a .search-box input {
  341. padding-left: 36px;
  342. }
  343. .member-picker-a .search-ico {
  344. position: absolute;
  345. left: 12px;
  346. top: 50%;
  347. transform: translateY(-50%);
  348. color: var(--muted);
  349. pointer-events: none;
  350. }
  351. .dropdown {
  352. position: absolute;
  353. left: 0;
  354. right: 0;
  355. top: calc(100% + 4px);
  356. z-index: 5;
  357. max-height: 220px;
  358. overflow: auto;
  359. background: #fff;
  360. border: 1px solid var(--border);
  361. border-radius: 8px;
  362. box-shadow: 0 8px 24px rgba(15, 23, 42, .1);
  363. display: none;
  364. }
  365. .dropdown.is-open { display: block; }
  366. .dropdown-item {
  367. display: flex;
  368. align-items: center;
  369. gap: 10px;
  370. width: 100%;
  371. padding: 10px 12px;
  372. border: 0;
  373. background: transparent;
  374. cursor: pointer;
  375. font: inherit;
  376. text-align: left;
  377. }
  378. .dropdown-item:hover { background: var(--primary-bg); }
  379. .dropdown-item.is-picked { opacity: .45; pointer-events: none; }
  380. .dropdown-empty {
  381. padding: 16px;
  382. text-align: center;
  383. color: var(--muted);
  384. font-size: 13px;
  385. }
  386. .avatar {
  387. width: 32px;
  388. height: 32px;
  389. border-radius: 50%;
  390. display: grid;
  391. place-items: center;
  392. font-size: 12px;
  393. font-weight: 700;
  394. color: #fff;
  395. flex-shrink: 0;
  396. }
  397. .avatar.sm { width: 28px; height: 28px; font-size: 11px; }
  398. .u-meta { min-width: 0; }
  399. .u-meta .name { font-weight: 600; font-size: 13px; }
  400. .u-meta .sub { font-size: 12px; color: var(--muted); }
  401. .chip-list {
  402. display: flex;
  403. flex-wrap: wrap;
  404. gap: 8px;
  405. margin-top: 12px;
  406. min-height: 40px;
  407. padding: 12px;
  408. background: #f8fafc;
  409. border: 1px dashed #cbd5e1;
  410. border-radius: 8px;
  411. }
  412. .chip-list.is-empty {
  413. align-items: center;
  414. justify-content: center;
  415. color: var(--muted);
  416. font-size: 13px;
  417. }
  418. .chip {
  419. display: inline-flex;
  420. align-items: center;
  421. gap: 8px;
  422. padding: 4px 8px 4px 4px;
  423. background: #fff;
  424. border: 1px solid var(--border);
  425. border-radius: 999px;
  426. max-width: 100%;
  427. }
  428. .chip .name {
  429. font-size: 13px;
  430. font-weight: 500;
  431. white-space: nowrap;
  432. overflow: hidden;
  433. text-overflow: ellipsis;
  434. max-width: 120px;
  435. }
  436. .chip .rm {
  437. appearance: none;
  438. width: 20px;
  439. height: 20px;
  440. border: 0;
  441. border-radius: 50%;
  442. background: #f1f5f9;
  443. color: var(--muted);
  444. cursor: pointer;
  445. font-size: 14px;
  446. line-height: 1;
  447. flex-shrink: 0;
  448. }
  449. .chip .rm:hover { background: #fee2e2; color: #dc2626; }
  450. .picked-count {
  451. margin-top: 8px;
  452. font-size: 12px;
  453. color: var(--muted);
  454. }
  455. /* —— 方案 B:双栏勾选 —— */
  456. .picker-b {
  457. display: grid;
  458. grid-template-columns: 1fr 1fr;
  459. gap: 12px;
  460. border: 1px solid var(--border);
  461. border-radius: 10px;
  462. overflow: hidden;
  463. min-height: 280px;
  464. }
  465. .picker-b-col {
  466. display: flex;
  467. flex-direction: column;
  468. min-height: 0;
  469. }
  470. .picker-b-col + .picker-b-col {
  471. border-left: 1px solid var(--border);
  472. }
  473. .picker-b-col-hd {
  474. flex-shrink: 0;
  475. padding: 10px 12px;
  476. background: #f8fafc;
  477. border-bottom: 1px solid var(--border);
  478. font-size: 12px;
  479. font-weight: 600;
  480. color: var(--muted);
  481. display: flex;
  482. justify-content: space-between;
  483. align-items: center;
  484. }
  485. .picker-b-col-hd strong { color: var(--primary-dark); }
  486. .picker-b-search {
  487. padding: 8px 10px;
  488. border-bottom: 1px solid var(--border);
  489. }
  490. .picker-b-list {
  491. flex: 1;
  492. overflow: auto;
  493. max-height: 240px;
  494. }
  495. .picker-b-item {
  496. display: flex;
  497. align-items: center;
  498. gap: 10px;
  499. width: 100%;
  500. padding: 9px 12px;
  501. border: 0;
  502. background: transparent;
  503. cursor: pointer;
  504. font: inherit;
  505. text-align: left;
  506. }
  507. .picker-b-item:hover { background: var(--primary-bg); }
  508. .picker-b-item input { accent-color: var(--primary); }
  509. /* —— 方案 C:穿梭框风格 —— */
  510. .picker-c {
  511. display: grid;
  512. grid-template-columns: 1fr 40px 1fr;
  513. gap: 8px;
  514. align-items: stretch;
  515. min-height: 280px;
  516. }
  517. .picker-c-panel {
  518. border: 1px solid var(--border);
  519. border-radius: 8px;
  520. display: flex;
  521. flex-direction: column;
  522. overflow: hidden;
  523. }
  524. .picker-c-panel-hd {
  525. padding: 10px 12px;
  526. background: #f8fafc;
  527. border-bottom: 1px solid var(--border);
  528. font-size: 12px;
  529. font-weight: 600;
  530. }
  531. .picker-c-list {
  532. flex: 1;
  533. overflow: auto;
  534. max-height: 260px;
  535. }
  536. .picker-c-mid {
  537. display: flex;
  538. flex-direction: column;
  539. justify-content: center;
  540. gap: 8px;
  541. align-items: center;
  542. }
  543. .picker-c-mid .btn {
  544. width: 32px;
  545. height: 32px;
  546. padding: 0;
  547. justify-content: center;
  548. }
  549. .hidden { display: none !important; }
  550. .footer-tip {
  551. margin-top: 16px;
  552. font-size: 12px;
  553. color: var(--muted);
  554. }
  555. </style>
  556. </head>
  557. <body>
  558. <div class="page">
  559. <header class="page-hd">
  560. <h1>审批人设置 · 右侧抽屉 Demo</h1>
  561. <p>
  562. Fresh Mint · 对照选型页。文件:
  563. <code>docs/mockups/oa-approval-approver-drawer-demo.html</code>
  564. · 浏览器直接打开即可(推荐 Chrome)。
  565. </p>
  566. </header>
  567. <div class="scheme-bar" role="tablist" aria-label="指定成员展示方案">
  568. <button type="button" class="scheme-btn is-active" data-scheme="a" role="tab" aria-selected="true">
  569. 方案 A · 头像芯片 + 搜索<span class="tag">推荐</span>
  570. </button>
  571. <button type="button" class="scheme-btn" data-scheme="b" role="tab" aria-selected="false">
  572. 方案 B · 双栏勾选
  573. </button>
  574. <button type="button" class="scheme-btn" data-scheme="c" role="tab" aria-selected="false">
  575. 方案 C · 穿梭框
  576. </button>
  577. </div>
  578. <p class="scheme-note" id="schemeNote">
  579. <strong>方案 A(推荐):</strong>搜索添加 + 已选人员以头像芯片展示,可单颗移除。接近钉钉/飞书体验,占高小、多选一眼可读。
  580. </p>
  581. <div class="stage">
  582. <div class="stage-canvas" aria-hidden="true">
  583. <div class="flow-node">发起人</div>
  584. <div class="flow-line"></div>
  585. <div class="flow-node approval">审批人 · 指定成员</div>
  586. <div class="flow-line"></div>
  587. <div class="flow-node">抄送人</div>
  588. <div class="flow-line"></div>
  589. <div class="flow-node">结束</div>
  590. </div>
  591. <div class="stage-mask" aria-hidden="true"></div>
  592. <aside class="drawer" aria-label="审批人设置">
  593. <div class="drawer-hd">
  594. <div class="drawer-hd-top">
  595. <div class="drawer-icon" aria-hidden="true">
  596. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  597. <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
  598. <circle cx="9" cy="7" r="4" />
  599. <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
  600. <path d="M16 3.13a4 4 0 0 1 0 7.75" />
  601. </svg>
  602. </div>
  603. <div class="drawer-titles">
  604. <h2>审批人设置</h2>
  605. <p>配置本节点审批人与多人会签规则</p>
  606. </div>
  607. <button type="button" class="drawer-close" title="关闭" aria-label="关闭">×</button>
  608. </div>
  609. <div class="drawer-actions">
  610. <button type="button" class="btn">
  611. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>
  612. 取消
  613. </button>
  614. <button type="button" class="btn btn-primary">
  615. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6 9 17l-5-5"/></svg>
  616. 确定
  617. </button>
  618. </div>
  619. </div>
  620. <div class="drawer-body">
  621. <div class="field">
  622. <label for="nodeName">节点名称</label>
  623. <input id="nodeName" class="input" value="审批人" maxlength="50" />
  624. </div>
  625. <div class="field">
  626. <label>选人方式</label>
  627. <div class="seg" id="assigneeSeg">
  628. <button type="button" class="seg-item is-on" data-type="users">指定成员</button>
  629. <button type="button" class="seg-item" data-type="oa_roles">工作流角色</button>
  630. <button type="button" class="seg-item" data-type="dept_leader">部门负责人</button>
  631. <button type="button" class="seg-item" data-type="direct_supervisor">发起人主管</button>
  632. <button type="button" class="seg-item" data-type="initiator_select">发起人自选</button>
  633. </div>
  634. </div>
  635. <!-- 指定成员:三方案切换 -->
  636. <div class="field" id="usersField">
  637. <label>指定成员 <span class="tip">可多选,已选将作为本节点审批人</span></label>
  638. <!-- A -->
  639. <div class="member-picker-a" data-panel="a">
  640. <div class="search-box">
  641. <svg class="search-ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  642. <circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
  643. </svg>
  644. <input class="input" id="searchA" type="search" placeholder="搜索姓名 / 工号,回车或点击添加" autocomplete="off" />
  645. <div class="dropdown" id="dropA" role="listbox"></div>
  646. </div>
  647. <div class="chip-list is-empty" id="chipList">暂未选择成员,请从上方搜索添加</div>
  648. <div class="picked-count" id="pickedCountA">已选 0 人</div>
  649. </div>
  650. <!-- B -->
  651. <div class="picker-b hidden" data-panel="b">
  652. <div class="picker-b-col">
  653. <div class="picker-b-col-hd">可选成员</div>
  654. <div class="picker-b-search">
  655. <input class="input" id="searchB" type="search" placeholder="筛选…" />
  656. </div>
  657. <div class="picker-b-list" id="listBLeft"></div>
  658. </div>
  659. <div class="picker-b-col">
  660. <div class="picker-b-col-hd">已选 <strong id="countB">0</strong> 人</div>
  661. <div class="picker-b-list" id="listBRight"></div>
  662. </div>
  663. </div>
  664. <!-- C -->
  665. <div class="picker-c hidden" data-panel="c">
  666. <div class="picker-c-panel">
  667. <div class="picker-c-panel-hd">待选</div>
  668. <div class="picker-c-list" id="listCLeft"></div>
  669. </div>
  670. <div class="picker-c-mid">
  671. <button type="button" class="btn" id="cToRight" title="添加">›</button>
  672. <button type="button" class="btn" id="cToLeft" title="移除">‹</button>
  673. </div>
  674. <div class="picker-c-panel">
  675. <div class="picker-c-panel-hd">已选</div>
  676. <div class="picker-c-list" id="listCRight"></div>
  677. </div>
  678. </div>
  679. </div>
  680. <div class="field">
  681. <label>多人审批方式</label>
  682. <div class="radio-row" id="signMode">
  683. <label class="radio-card is-on">
  684. <input type="radio" name="sign" value="or" checked />
  685. <div>
  686. <strong>或签</strong>
  687. <span>一人通过即可进入下一节点</span>
  688. </div>
  689. </label>
  690. <label class="radio-card">
  691. <input type="radio" name="sign" value="and" />
  692. <div>
  693. <strong>会签</strong>
  694. <span>所选成员全部通过后进入下一节点</span>
  695. </div>
  696. </label>
  697. </div>
  698. </div>
  699. <p class="footer-tip">交互说明:切换顶部方案 Tab 可对比指定成员的三种呈现;选人数据为 Demo 假数据。</p>
  700. </div>
  701. </aside>
  702. </div>
  703. </div>
  704. <script>
  705. const USERS = [
  706. { id: '1', nickName: '张伟', userNo: 'U1001', dept: '研发中心', color: '#059669' },
  707. { id: '2', nickName: '李娜', userNo: 'U1002', dept: '质量部', color: '#0ea5e9' },
  708. { id: '3', nickName: '王芳', userNo: 'U1003', dept: '市场部', color: '#8b5cf6' },
  709. { id: '4', nickName: '刘强', userNo: 'U1004', dept: '财务部', color: '#d97706' },
  710. { id: '5', nickName: '陈明', userNo: 'U1005', dept: '人事部', color: '#ef4444' },
  711. { id: '6', nickName: '赵敏', userNo: 'U1006', dept: '研发中心', color: '#14b8a6' },
  712. { id: '7', nickName: '周杰', userNo: 'U1007', dept: '仓储中心', color: '#6366f1' },
  713. { id: '8', nickName: '吴倩', userNo: 'U1008', dept: '客服部', color: '#ec4899' },
  714. ]
  715. const selected = new Set(['1', '2'])
  716. const notes = {
  717. a: '<strong>方案 A(推荐):</strong>搜索添加 + 已选人员以头像芯片展示,可单颗移除。接近钉钉/飞书体验,占高小、多选一眼可读。',
  718. b: '<strong>方案 B:</strong>左栏可选、右栏已选,勾选即时同步。适合一次挑很多人、需要对照两侧清单的场景;抽屉较宽时更舒服。',
  719. c: '<strong>方案 C:</strong>经典穿梭框(Transfer)。规则清晰,但控件偏重、在抽屉里略挤,且不如芯片直观展示「是谁」。',
  720. }
  721. let scheme = 'a'
  722. let cPendingLeft = new Set()
  723. let cPendingRight = new Set()
  724. function initials(name) {
  725. return (name || '?').slice(0, 1)
  726. }
  727. function userById(id) {
  728. return USERS.find((u) => u.id === id)
  729. }
  730. /* —— 方案 A —— */
  731. function renderChips() {
  732. const box = document.getElementById('chipList')
  733. const ids = [...selected]
  734. document.getElementById('pickedCountA').textContent = `已选 ${ids.length} 人`
  735. if (!ids.length) {
  736. box.className = 'chip-list is-empty'
  737. box.textContent = '暂未选择成员,请从上方搜索添加'
  738. return
  739. }
  740. box.className = 'chip-list'
  741. box.innerHTML = ids.map((id) => {
  742. const u = userById(id)
  743. if (!u) return ''
  744. return `
  745. <span class="chip" data-id="${u.id}">
  746. <span class="avatar sm" style="background:${u.color}">${initials(u.nickName)}</span>
  747. <span class="name" title="${u.nickName}(${u.userNo})">${u.nickName}</span>
  748. <button type="button" class="rm" aria-label="移除 ${u.nickName}">×</button>
  749. </span>`
  750. }).join('')
  751. box.querySelectorAll('.rm').forEach((btn) => {
  752. btn.addEventListener('click', () => {
  753. selected.delete(btn.closest('.chip').dataset.id)
  754. syncAll()
  755. })
  756. })
  757. }
  758. function renderDropA(q) {
  759. const drop = document.getElementById('dropA')
  760. const kw = (q || '').trim().toLowerCase()
  761. const list = USERS.filter((u) => {
  762. if (!kw) return true
  763. return (
  764. u.nickName.toLowerCase().includes(kw) ||
  765. u.userNo.toLowerCase().includes(kw) ||
  766. u.dept.toLowerCase().includes(kw)
  767. )
  768. })
  769. if (!list.length) {
  770. drop.innerHTML = '<div class="dropdown-empty">无匹配成员</div>'
  771. drop.classList.add('is-open')
  772. return
  773. }
  774. drop.innerHTML = list.map((u) => `
  775. <button type="button" class="dropdown-item${selected.has(u.id) ? ' is-picked' : ''}" data-id="${u.id}">
  776. <span class="avatar" style="background:${u.color}">${initials(u.nickName)}</span>
  777. <span class="u-meta">
  778. <div class="name">${u.nickName}</div>
  779. <div class="sub">${u.userNo} · ${u.dept}</div>
  780. </span>
  781. </button>`).join('')
  782. drop.classList.add('is-open')
  783. drop.querySelectorAll('.dropdown-item:not(.is-picked)').forEach((el) => {
  784. el.addEventListener('click', () => {
  785. selected.add(el.dataset.id)
  786. document.getElementById('searchA').value = ''
  787. drop.classList.remove('is-open')
  788. syncAll()
  789. })
  790. })
  791. }
  792. /* —— 方案 B —— */
  793. function renderB() {
  794. const kw = (document.getElementById('searchB').value || '').trim().toLowerCase()
  795. const left = document.getElementById('listBLeft')
  796. const right = document.getElementById('listBRight')
  797. document.getElementById('countB').textContent = String(selected.size)
  798. left.innerHTML = USERS.filter((u) => {
  799. if (selected.has(u.id)) return false
  800. if (!kw) return true
  801. return u.nickName.toLowerCase().includes(kw) || u.userNo.toLowerCase().includes(kw)
  802. }).map((u) => `
  803. <button type="button" class="picker-b-item" data-id="${u.id}">
  804. <input type="checkbox" tabindex="-1" />
  805. <span class="avatar sm" style="background:${u.color}">${initials(u.nickName)}</span>
  806. <span class="u-meta">
  807. <div class="name">${u.nickName}</div>
  808. <div class="sub">${u.userNo}</div>
  809. </span>
  810. </button>`).join('') || '<div class="dropdown-empty">无更多可选</div>'
  811. right.innerHTML = [...selected].map((id) => {
  812. const u = userById(id)
  813. if (!u) return ''
  814. return `
  815. <button type="button" class="picker-b-item" data-id="${u.id}">
  816. <input type="checkbox" checked tabindex="-1" />
  817. <span class="avatar sm" style="background:${u.color}">${initials(u.nickName)}</span>
  818. <span class="u-meta">
  819. <div class="name">${u.nickName}</div>
  820. <div class="sub">${u.userNo}</div>
  821. </span>
  822. </button>`
  823. }).join('') || '<div class="dropdown-empty">尚未选择</div>'
  824. left.querySelectorAll('.picker-b-item').forEach((el) => {
  825. el.addEventListener('click', () => { selected.add(el.dataset.id); syncAll() })
  826. })
  827. right.querySelectorAll('.picker-b-item').forEach((el) => {
  828. el.addEventListener('click', () => { selected.delete(el.dataset.id); syncAll() })
  829. })
  830. }
  831. /* —— 方案 C —— */
  832. function renderC() {
  833. const left = document.getElementById('listCLeft')
  834. const right = document.getElementById('listCRight')
  835. left.innerHTML = USERS.filter((u) => !selected.has(u.id)).map((u) => `
  836. <label class="picker-b-item">
  837. <input type="checkbox" data-side="left" value="${u.id}" ${cPendingLeft.has(u.id) ? 'checked' : ''} />
  838. <span class="avatar sm" style="background:${u.color}">${initials(u.nickName)}</span>
  839. <span class="u-meta"><div class="name">${u.nickName}</div><div class="sub">${u.userNo}</div></span>
  840. </label>`).join('') || '<div class="dropdown-empty">无</div>'
  841. right.innerHTML = [...selected].map((id) => {
  842. const u = userById(id)
  843. if (!u) return ''
  844. return `
  845. <label class="picker-b-item">
  846. <input type="checkbox" data-side="right" value="${u.id}" ${cPendingRight.has(u.id) ? 'checked' : ''} />
  847. <span class="avatar sm" style="background:${u.color}">${initials(u.nickName)}</span>
  848. <span class="u-meta"><div class="name">${u.nickName}</div><div class="sub">${u.userNo}</div></span>
  849. </label>`
  850. }).join('') || '<div class="dropdown-empty">无</div>'
  851. left.querySelectorAll('input').forEach((inp) => {
  852. inp.addEventListener('change', () => {
  853. if (inp.checked) cPendingLeft.add(inp.value)
  854. else cPendingLeft.delete(inp.value)
  855. })
  856. })
  857. right.querySelectorAll('input').forEach((inp) => {
  858. inp.addEventListener('change', () => {
  859. if (inp.checked) cPendingRight.add(inp.value)
  860. else cPendingRight.delete(inp.value)
  861. })
  862. })
  863. }
  864. function syncAll() {
  865. renderChips()
  866. renderB()
  867. renderC()
  868. }
  869. function setScheme(next) {
  870. scheme = next
  871. document.querySelectorAll('.scheme-btn').forEach((btn) => {
  872. const on = btn.dataset.scheme === next
  873. btn.classList.toggle('is-active', on)
  874. btn.setAttribute('aria-selected', on ? 'true' : 'false')
  875. })
  876. document.getElementById('schemeNote').innerHTML = notes[next]
  877. document.querySelectorAll('[data-panel]').forEach((el) => {
  878. el.classList.toggle('hidden', el.dataset.panel !== next)
  879. })
  880. syncAll()
  881. }
  882. /* 事件绑定 */
  883. document.querySelectorAll('.scheme-btn').forEach((btn) => {
  884. btn.addEventListener('click', () => setScheme(btn.dataset.scheme))
  885. })
  886. const searchA = document.getElementById('searchA')
  887. const dropA = document.getElementById('dropA')
  888. searchA.addEventListener('focus', () => renderDropA(searchA.value))
  889. searchA.addEventListener('input', () => renderDropA(searchA.value))
  890. searchA.addEventListener('keydown', (e) => {
  891. if (e.key === 'Escape') dropA.classList.remove('is-open')
  892. })
  893. document.addEventListener('click', (e) => {
  894. if (!e.target.closest('.member-picker-a .search-box')) {
  895. dropA.classList.remove('is-open')
  896. }
  897. })
  898. document.getElementById('searchB').addEventListener('input', renderB)
  899. document.getElementById('cToRight').addEventListener('click', () => {
  900. cPendingLeft.forEach((id) => selected.add(id))
  901. cPendingLeft.clear()
  902. syncAll()
  903. })
  904. document.getElementById('cToLeft').addEventListener('click', () => {
  905. cPendingRight.forEach((id) => selected.delete(id))
  906. cPendingRight.clear()
  907. syncAll()
  908. })
  909. document.getElementById('assigneeSeg').addEventListener('click', (e) => {
  910. const btn = e.target.closest('.seg-item')
  911. if (!btn) return
  912. document.querySelectorAll('#assigneeSeg .seg-item').forEach((b) => b.classList.remove('is-on'))
  913. btn.classList.add('is-on')
  914. document.getElementById('usersField').classList.toggle('hidden', btn.dataset.type !== 'users')
  915. })
  916. document.getElementById('signMode').addEventListener('change', (e) => {
  917. document.querySelectorAll('#signMode .radio-card').forEach((card) => {
  918. card.classList.toggle('is-on', card.querySelector('input').checked)
  919. })
  920. })
  921. setScheme('a')
  922. </script>
  923. </body>
  924. </html>