oa-form-kit-tab-demo.html 33 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097
  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>表单设计 · 请假套件 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. --left-w: 290px;
  19. --right-w: 310px;
  20. --menu-w: 44px;
  21. --top-h: 48px;
  22. --radius: 8px;
  23. }
  24. * { box-sizing: border-box; }
  25. html, body {
  26. height: 100%;
  27. margin: 0;
  28. font: 14px/1.5 Inter, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
  29. color: var(--text);
  30. background: var(--bg);
  31. }
  32. .page {
  33. display: flex;
  34. flex-direction: column;
  35. height: 100%;
  36. min-height: 560px;
  37. padding: 12px 16px 16px;
  38. }
  39. .banner {
  40. display: flex;
  41. flex-wrap: wrap;
  42. gap: 8px 16px;
  43. align-items: center;
  44. justify-content: space-between;
  45. margin-bottom: 10px;
  46. padding: 10px 14px;
  47. background: #fff;
  48. border: 1px solid var(--border);
  49. border-radius: var(--radius);
  50. font-size: 13px;
  51. }
  52. .banner b { color: var(--primary-dark); }
  53. .banner span { color: var(--muted); }
  54. .tag {
  55. padding: 2px 8px;
  56. border-radius: 999px;
  57. background: var(--primary-bg);
  58. color: var(--primary-dark);
  59. font-size: 12px;
  60. font-weight: 600;
  61. }
  62. .toolbar {
  63. display: flex;
  64. align-items: center;
  65. justify-content: space-between;
  66. margin-bottom: 8px;
  67. }
  68. .btn {
  69. display: inline-flex;
  70. align-items: center;
  71. gap: 6px;
  72. height: 34px;
  73. padding: 0 12px;
  74. border: 1px solid var(--border);
  75. border-radius: 8px;
  76. background: #fff;
  77. color: var(--text-2);
  78. font-size: 13px;
  79. cursor: pointer;
  80. }
  81. .btn.primary {
  82. border-color: var(--primary);
  83. background: var(--primary);
  84. color: #fff;
  85. }
  86. .tabs {
  87. display: flex;
  88. justify-content: center;
  89. gap: 8px;
  90. margin: 4px 0 12px;
  91. border-bottom: 1px solid var(--border);
  92. }
  93. .tab {
  94. position: relative;
  95. padding: 10px 18px;
  96. border: 0;
  97. background: none;
  98. color: var(--text-2);
  99. font-size: 15px;
  100. font-weight: 600;
  101. cursor: pointer;
  102. }
  103. .tab.active { color: var(--text); }
  104. .tab.active::after {
  105. content: "";
  106. position: absolute;
  107. right: 12px;
  108. bottom: -1px;
  109. left: 12px;
  110. height: 3px;
  111. background: var(--text);
  112. border-radius: 2px;
  113. }
  114. .hint {
  115. margin: 0 0 10px;
  116. color: var(--muted);
  117. font-size: 13px;
  118. }
  119. .hint strong { color: var(--primary-dark); font-weight: 600; }
  120. .designer {
  121. display: flex;
  122. flex: 1;
  123. min-height: 0;
  124. overflow: hidden;
  125. background: #fff;
  126. border: 1px solid var(--border-strong);
  127. border-radius: 10px;
  128. box-shadow: 0 2px 8px rgb(15 23 42 / 8%);
  129. }
  130. .side-menu {
  131. display: flex;
  132. flex: 0 0 var(--menu-w);
  133. flex-direction: column;
  134. align-items: center;
  135. gap: 4px;
  136. padding-top: 8px;
  137. border-right: 1px solid var(--border);
  138. }
  139. .side-menu button {
  140. display: grid;
  141. place-items: center;
  142. width: 36px;
  143. height: 36px;
  144. border: 0;
  145. border-radius: 8px;
  146. background: transparent;
  147. color: #64748b;
  148. cursor: pointer;
  149. }
  150. .side-menu button.active {
  151. color: var(--primary-dark);
  152. background: var(--primary-bg);
  153. }
  154. .pane-left {
  155. display: flex;
  156. flex: 0 0 var(--left-w);
  157. flex-direction: column;
  158. border-right: 1px solid var(--border);
  159. }
  160. .pane-head {
  161. display: flex;
  162. height: 44px;
  163. border-bottom: 1px solid var(--border);
  164. }
  165. .pane-head button {
  166. flex: 1;
  167. border: 0;
  168. background: none;
  169. color: var(--muted);
  170. font-size: 14px;
  171. font-weight: 600;
  172. cursor: pointer;
  173. }
  174. .pane-head button.active {
  175. color: var(--text);
  176. box-shadow: inset 0 -2px 0 var(--primary);
  177. }
  178. .pane-head button .new-dot {
  179. display: inline-block;
  180. width: 6px;
  181. height: 6px;
  182. margin-left: 4px;
  183. vertical-align: middle;
  184. border-radius: 50%;
  185. background: var(--primary);
  186. }
  187. .pane-scroll {
  188. flex: 1;
  189. min-height: 0;
  190. padding: 12px;
  191. overflow: auto;
  192. }
  193. .pane-panel { display: none; }
  194. .pane-panel.active { display: block; }
  195. .group-title {
  196. margin: 12px 0 8px;
  197. color: var(--muted);
  198. font-size: 12px;
  199. font-weight: 600;
  200. }
  201. .group-title:first-child { margin-top: 0; }
  202. .ctrl-grid {
  203. display: grid;
  204. grid-template-columns: 1fr 1fr;
  205. gap: 8px;
  206. }
  207. .ctrl {
  208. display: flex;
  209. gap: 8px;
  210. align-items: center;
  211. padding: 10px;
  212. cursor: grab;
  213. user-select: none;
  214. background: #f8fafc;
  215. border: 1px solid var(--border);
  216. border-radius: 8px;
  217. font-size: 13px;
  218. color: var(--text-2);
  219. }
  220. .ctrl:hover {
  221. background: var(--primary-bg);
  222. border-color: #a7f3d0;
  223. color: var(--primary-dark);
  224. }
  225. .ctrl.coming {
  226. opacity: 0.55;
  227. cursor: not-allowed;
  228. }
  229. .ctrl.coming:hover {
  230. background: #f8fafc;
  231. border-color: var(--border);
  232. color: var(--text-2);
  233. }
  234. .ctrl.inserted {
  235. background: var(--primary-bg);
  236. border-color: #a7f3d0;
  237. color: var(--primary-dark);
  238. }
  239. .ctrl-ico {
  240. display: grid;
  241. place-items: center;
  242. width: 28px;
  243. height: 28px;
  244. border-radius: 6px;
  245. background: #fff;
  246. border: 1px solid var(--border);
  247. font-size: 14px;
  248. flex-shrink: 0;
  249. }
  250. .pane-mid {
  251. display: flex;
  252. flex: 1;
  253. flex-direction: column;
  254. min-width: 0;
  255. background: #f8fafc;
  256. }
  257. .mid-tools {
  258. display: flex;
  259. align-items: center;
  260. justify-content: space-between;
  261. height: var(--top-h);
  262. padding: 0 12px;
  263. background: #fff;
  264. border-bottom: 1px solid var(--border);
  265. }
  266. .mid-tools-l { display: flex; gap: 4px; align-items: center; }
  267. .tool-ico {
  268. display: grid;
  269. place-items: center;
  270. width: 32px;
  271. height: 32px;
  272. border: 0;
  273. border-radius: 6px;
  274. background: transparent;
  275. color: #64748b;
  276. cursor: pointer;
  277. }
  278. .tool-ico.active {
  279. color: var(--primary-dark);
  280. background: var(--primary-bg);
  281. }
  282. .canvas-area {
  283. display: flex;
  284. flex: 1;
  285. justify-content: center;
  286. align-items: flex-start;
  287. min-height: 0;
  288. padding: 20px;
  289. overflow: auto;
  290. }
  291. .phone {
  292. width: min(420px, 100%);
  293. min-height: 480px;
  294. padding: 16px;
  295. background: #fff;
  296. border: 1px solid var(--border-strong);
  297. border-radius: 12px;
  298. box-shadow: 0 4px 16px rgb(15 23 42 / 6%);
  299. }
  300. .phone.wide { width: min(720px, 100%); }
  301. .phone.flash {
  302. box-shadow: 0 0 0 3px rgb(16 185 129 / 35%), 0 4px 16px rgb(15 23 42 / 6%);
  303. }
  304. .kit-shell {
  305. display: none;
  306. margin-bottom: 12px;
  307. padding: 10px 10px 4px;
  308. border: 1px dashed #a7f3d0;
  309. border-radius: 10px;
  310. background: #fafffe;
  311. cursor: pointer;
  312. }
  313. .kit-shell.show { display: block; }
  314. .kit-shell.active {
  315. border-style: solid;
  316. background: var(--primary-bg);
  317. box-shadow: 0 0 0 2px rgb(16 185 129 / 12%);
  318. }
  319. .kit-shell-head {
  320. display: flex;
  321. align-items: center;
  322. justify-content: space-between;
  323. margin-bottom: 8px;
  324. padding: 0 2px 8px;
  325. border-bottom: 1px dashed #a7f3d0;
  326. color: var(--primary-dark);
  327. font-size: 12px;
  328. font-weight: 600;
  329. }
  330. .kit-shell-head span {
  331. color: var(--muted);
  332. font-weight: 500;
  333. font-size: 11px;
  334. }
  335. .field {
  336. margin-bottom: 12px;
  337. padding: 10px 12px;
  338. border: 1px solid transparent;
  339. border-radius: 8px;
  340. cursor: pointer;
  341. }
  342. .field:hover { background: #f8fafc; }
  343. .field.active {
  344. background: var(--primary-bg);
  345. border-color: #a7f3d0;
  346. box-shadow: 0 0 0 2px rgb(16 185 129 / 12%);
  347. }
  348. .field.from-kit { animation: kitIn 0.35s ease; }
  349. @keyframes kitIn {
  350. from { opacity: 0; transform: translateY(8px); }
  351. to { opacity: 1; transform: none; }
  352. }
  353. .field label {
  354. display: flex;
  355. align-items: center;
  356. gap: 6px;
  357. margin-bottom: 6px;
  358. color: var(--text-2);
  359. font-size: 13px;
  360. font-weight: 500;
  361. }
  362. .field .req { color: #ef4444; }
  363. .badge-auto {
  364. padding: 0 6px;
  365. border-radius: 4px;
  366. background: var(--primary-bg);
  367. color: var(--primary-dark);
  368. font-size: 11px;
  369. font-weight: 600;
  370. }
  371. .badge-manual {
  372. padding: 0 6px;
  373. border-radius: 4px;
  374. background: #fff7ed;
  375. color: #d97706;
  376. font-size: 11px;
  377. font-weight: 600;
  378. }
  379. .fake-input {
  380. height: 36px;
  381. padding: 0 12px;
  382. color: var(--muted);
  383. font-size: 13px;
  384. line-height: 36px;
  385. background: #fff;
  386. border: 1px solid var(--border);
  387. border-radius: 8px;
  388. }
  389. .fake-input.filled { color: var(--text); }
  390. .field-tip {
  391. margin-top: 6px;
  392. color: var(--muted);
  393. font-size: 11px;
  394. }
  395. .drop-zone {
  396. margin-top: 8px;
  397. padding: 28px 16px;
  398. color: var(--muted);
  399. font-size: 13px;
  400. text-align: center;
  401. border: 1px dashed #a7f3d0;
  402. border-radius: 8px;
  403. background: #fafffe;
  404. }
  405. .pane-right {
  406. display: flex;
  407. flex: 0 0 var(--right-w);
  408. flex-direction: column;
  409. border-left: 1px solid var(--border);
  410. }
  411. .prop-body {
  412. flex: 1;
  413. min-height: 0;
  414. padding: 14px 16px;
  415. overflow: auto;
  416. }
  417. .prop-body h4 {
  418. margin: 0 0 14px;
  419. font-size: 14px;
  420. font-weight: 600;
  421. }
  422. .prop-item { margin-bottom: 14px; }
  423. .prop-item label {
  424. display: block;
  425. margin-bottom: 6px;
  426. color: var(--muted);
  427. font-size: 12px;
  428. }
  429. .prop-item input,
  430. .prop-item textarea,
  431. .prop-item select {
  432. width: 100%;
  433. height: 36px;
  434. padding: 0 10px;
  435. border: 1px solid var(--border);
  436. border-radius: 8px;
  437. font: inherit;
  438. color: var(--text);
  439. background: #fff;
  440. }
  441. .prop-item textarea {
  442. height: 72px;
  443. padding: 8px 10px;
  444. resize: vertical;
  445. }
  446. .prop-item .row {
  447. display: flex;
  448. gap: 8px;
  449. align-items: center;
  450. }
  451. .time-pair {
  452. display: grid;
  453. grid-template-columns: 1fr auto 1fr;
  454. gap: 6px;
  455. align-items: center;
  456. }
  457. .time-pair span { color: var(--muted); font-size: 12px; }
  458. .switch {
  459. width: 40px;
  460. height: 22px;
  461. border-radius: 999px;
  462. background: var(--primary);
  463. position: relative;
  464. flex-shrink: 0;
  465. }
  466. .switch.off { background: #cbd5e1; }
  467. .switch::after {
  468. content: "";
  469. position: absolute;
  470. top: 2px;
  471. left: 20px;
  472. width: 18px;
  473. height: 18px;
  474. background: #fff;
  475. border-radius: 50%;
  476. }
  477. .switch.off::after { left: 2px; }
  478. .opt-list {
  479. margin: 0;
  480. padding: 0;
  481. list-style: none;
  482. border: 1px solid var(--border);
  483. border-radius: 8px;
  484. overflow: hidden;
  485. }
  486. .opt-list li {
  487. padding: 7px 10px;
  488. border-bottom: 1px solid var(--border);
  489. font-size: 12px;
  490. color: var(--text-2);
  491. }
  492. .opt-list li:last-child { border-bottom: 0; }
  493. .formula-box {
  494. padding: 10px 12px;
  495. border-radius: 8px;
  496. background: #f8fafc;
  497. border: 1px solid var(--border);
  498. font-size: 12px;
  499. color: var(--primary-dark);
  500. line-height: 1.5;
  501. }
  502. .toast {
  503. position: fixed;
  504. left: 50%;
  505. bottom: 28px;
  506. transform: translateX(-50%) translateY(20px);
  507. padding: 10px 16px;
  508. border-radius: 8px;
  509. background: #0f172a;
  510. color: #fff;
  511. font-size: 13px;
  512. opacity: 0;
  513. pointer-events: none;
  514. transition: opacity 0.2s, transform 0.2s;
  515. z-index: 20;
  516. }
  517. .toast.show {
  518. opacity: 1;
  519. transform: translateX(-50%) translateY(0);
  520. }
  521. </style>
  522. </head>
  523. <body>
  524. <div class="page">
  525. <div class="banner">
  526. <div>
  527. <b>请假套件 Demo · 方案 1</b>
  528. <span>套件 Tab 同组件双列;拖入展开 4 字段;双段工时累计小时</span>
  529. </div>
  530. <span class="tag">Fresh Mint</span>
  531. </div>
  532. <div class="toolbar">
  533. <button type="button" class="btn">← 返回</button>
  534. <div style="display:flex;gap:8px">
  535. <button type="button" class="btn" id="btnClear">清空画布</button>
  536. <button type="button" class="btn primary">发布</button>
  537. </div>
  538. </div>
  539. <div class="tabs">
  540. <button type="button" class="tab">1 基础设置</button>
  541. <button type="button" class="tab active">2 表单设计</button>
  542. <button type="button" class="tab">3 流程设计</button>
  543. <button type="button" class="tab">4 高级设置</button>
  544. </div>
  545. <p class="hint">
  546. 点 <strong>请假</strong> 插入套件(4 个基础字段)。点套件标题栏配置上午/下午工时;
  547. 点子字段改属性。双击起止改时间;双击时长可手改。
  548. </p>
  549. <div class="designer">
  550. <aside class="side-menu">
  551. <button type="button" class="active" title="组件库">▦</button>
  552. <button type="button" title="多语言">🌐</button>
  553. <button type="button" title="JSON">&lt;/&gt;</button>
  554. </aside>
  555. <aside class="pane-left">
  556. <div class="pane-head">
  557. <button type="button" data-left-tab="components">组件</button>
  558. <button type="button" class="active" data-left-tab="kits">
  559. 套件<span class="new-dot"></span>
  560. </button>
  561. <button type="button" data-left-tab="outline">大纲</button>
  562. </div>
  563. <div class="pane-scroll">
  564. <div class="pane-panel" data-panel="components">
  565. <div class="group-title">基础</div>
  566. <div class="ctrl-grid">
  567. <div class="ctrl"><span class="ctrl-ico">Aa</span>单行</div>
  568. <div class="ctrl"><span class="ctrl-ico">¶</span>多行</div>
  569. <div class="ctrl"><span class="ctrl-ico">123</span>数字</div>
  570. <div class="ctrl"><span class="ctrl-ico">📅</span>日期</div>
  571. <div class="ctrl"><span class="ctrl-ico">¥</span>金额</div>
  572. <div class="ctrl"><span class="ctrl-ico">◉</span>下拉</div>
  573. </div>
  574. </div>
  575. <div class="pane-panel active" data-panel="kits">
  576. <div class="group-title">人事考勤</div>
  577. <div class="ctrl-grid">
  578. <div class="ctrl" id="kitLeave" title="类型 + 起止 + 时长(h)">
  579. <span class="ctrl-ico">📅</span>请假
  580. </div>
  581. <div class="ctrl coming"><span class="ctrl-ico">✈</span>出差</div>
  582. <div class="ctrl coming"><span class="ctrl-ico">⏰</span>加班</div>
  583. <div class="ctrl coming"><span class="ctrl-ico">🏠</span>外出</div>
  584. </div>
  585. <div class="group-title">财务报销</div>
  586. <div class="ctrl-grid">
  587. <div class="ctrl coming"><span class="ctrl-ico">¥</span>报销金额</div>
  588. <div class="ctrl coming"><span class="ctrl-ico">🧾</span>费用明细</div>
  589. </div>
  590. </div>
  591. <div class="pane-panel" data-panel="outline">
  592. <div class="group-title">字段大纲</div>
  593. <ul id="outlineList" style="margin:0;padding-left:18px;color:var(--text-2);font-size:13px;line-height:1.9"></ul>
  594. <p id="outlineEmpty" style="margin:8px 0 0;color:var(--muted);font-size:12px">画布为空</p>
  595. </div>
  596. </div>
  597. </aside>
  598. <main class="pane-mid">
  599. <div class="mid-tools">
  600. <div class="mid-tools-l">
  601. <button type="button" class="tool-ico" data-device="pc">🖥</button>
  602. <button type="button" class="tool-ico active" data-device="mobile">📱</button>
  603. <span style="margin-left:8px;color:var(--muted);font-size:12px" id="canvasStatus">画布为空</span>
  604. </div>
  605. <button type="button" class="btn">👁 预览</button>
  606. </div>
  607. <div class="canvas-area">
  608. <div class="phone" id="phone">
  609. <div class="kit-shell" id="kitShell">
  610. <div class="kit-shell-head">
  611. 请假套件
  612. <span id="kitWhLabel">工时见右侧</span>
  613. </div>
  614. <div id="fields"></div>
  615. </div>
  616. <div class="drop-zone" id="dropZone">点击左侧「请假」插入套件</div>
  617. </div>
  618. </div>
  619. </main>
  620. <aside class="pane-right">
  621. <div class="pane-head">
  622. <button type="button" class="active">控件</button>
  623. <button type="button">表单</button>
  624. </div>
  625. <div class="prop-body" id="propBody">
  626. <h4>未选中</h4>
  627. <p style="margin:0;color:var(--muted);font-size:13px">选中套件配置工时;选中子字段配置标题/选项等。</p>
  628. </div>
  629. </aside>
  630. </div>
  631. </div>
  632. <div class="toast" id="toast"></div>
  633. <script>
  634. const LEAVE_TYPES = [
  635. '事假', '调休', '病假', '年假', '产假',
  636. '陪产假', '婚假', '例假', '丧假', '哺乳假'
  637. ]
  638. /** 套件实例工时(挂在时长字段 props) */
  639. const workHours = {
  640. morningStart: '08:30',
  641. morningEnd: '12:00',
  642. afternoonStart: '13:00',
  643. afternoonEnd: '17:30'
  644. }
  645. const phone = document.getElementById('phone')
  646. const fieldsEl = document.getElementById('fields')
  647. const dropZone = document.getElementById('dropZone')
  648. const kitShell = document.getElementById('kitShell')
  649. const kitWhLabel = document.getElementById('kitWhLabel')
  650. const propBody = document.getElementById('propBody')
  651. const canvasStatus = document.getElementById('canvasStatus')
  652. const outlineList = document.getElementById('outlineList')
  653. const outlineEmpty = document.getElementById('outlineEmpty')
  654. const toast = document.getElementById('toast')
  655. const kitLeave = document.getElementById('kitLeave')
  656. let fields = []
  657. /** null | 'kit' | fieldId */
  658. let activeId = null
  659. let leaveInserted = false
  660. let durationManual = false
  661. function showToast(msg) {
  662. toast.textContent = msg
  663. toast.classList.add('show')
  664. clearTimeout(showToast._t)
  665. showToast._t = setTimeout(() => toast.classList.remove('show'), 2200)
  666. }
  667. function parseHM(hm) {
  668. const [h, m] = hm.split(':').map(Number)
  669. return h * 60 + m
  670. }
  671. function pad2(n) {
  672. return String(n).padStart(2, '0')
  673. }
  674. function formatDT(d) {
  675. return (
  676. d.getFullYear() +
  677. '-' +
  678. pad2(d.getMonth() + 1) +
  679. '-' +
  680. pad2(d.getDate()) +
  681. ' ' +
  682. pad2(d.getHours()) +
  683. ':' +
  684. pad2(d.getMinutes())
  685. )
  686. }
  687. function parseDT(s) {
  688. if (!s) return null
  689. const m = s.trim().match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$/)
  690. if (!m) return null
  691. const d = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5])
  692. return Number.isNaN(d.getTime()) ? null : d
  693. }
  694. /** 单日 [dayStart, dayEnd) 与上午/下午两段重叠分钟数 */
  695. function overlapMinutesOnDay(day, rangeStart, rangeEnd) {
  696. const y = day.getFullYear()
  697. const mo = day.getMonth()
  698. const da = day.getDate()
  699. const day0 = new Date(y, mo, da, 0, 0, 0, 0).getTime()
  700. const segments = [
  701. [parseHM(workHours.morningStart), parseHM(workHours.morningEnd)],
  702. [parseHM(workHours.afternoonStart), parseHM(workHours.afternoonEnd)]
  703. ]
  704. let total = 0
  705. const rs = rangeStart.getTime()
  706. const re = rangeEnd.getTime()
  707. for (const [aMin, bMin] of segments) {
  708. const a = day0 + aMin * 60000
  709. const b = day0 + bMin * 60000
  710. const lo = Math.max(rs, a)
  711. const hi = Math.min(re, b)
  712. if (hi > lo) total += (hi - lo) / 60000
  713. }
  714. return total
  715. }
  716. /** 请假时长(小时,1 位小数) */
  717. function calcLeaveHours(startStr, endStr) {
  718. const start = parseDT(startStr)
  719. const end = parseDT(endStr)
  720. if (!start || !end || end <= start) return ''
  721. let minutes = 0
  722. const cursor = new Date(start.getFullYear(), start.getMonth(), start.getDate())
  723. const last = new Date(end.getFullYear(), end.getMonth(), end.getDate())
  724. while (cursor <= last) {
  725. minutes += overlapMinutesOnDay(cursor, start, end)
  726. cursor.setDate(cursor.getDate() + 1)
  727. }
  728. return (Math.round((minutes / 60) * 10) / 10).toFixed(1)
  729. }
  730. function recomputeDuration() {
  731. if (durationManual) return
  732. const start = fields.find((f) => f.field === 'leaveStart')
  733. const end = fields.find((f) => f.field === 'leaveEnd')
  734. const dur = fields.find((f) => f.field === 'leaveDuration')
  735. if (!start || !end || !dur) return
  736. dur.value = calcLeaveHours(start.value, end.value)
  737. }
  738. function renderOutline() {
  739. outlineList.innerHTML = ''
  740. fields.forEach((f) => {
  741. const li = document.createElement('li')
  742. let tag = ''
  743. if (f.field === 'leaveDuration') {
  744. tag = durationManual ? '(已手改)' : '(自动)'
  745. }
  746. li.textContent = f.title + tag
  747. outlineList.appendChild(li)
  748. })
  749. outlineEmpty.style.display = fields.length ? 'none' : 'block'
  750. }
  751. function renderProps() {
  752. if (activeId === 'kit') {
  753. propBody.innerHTML = `
  754. <h4>请假套件</h4>
  755. <div class="prop-item">
  756. <label>上午上班时段</label>
  757. <div class="time-pair">
  758. <input id="whMS" type="time" value="${workHours.morningStart}" />
  759. <span>至</span>
  760. <input id="whME" type="time" value="${workHours.morningEnd}" />
  761. </div>
  762. </div>
  763. <div class="prop-item">
  764. <label>下午上班时段</label>
  765. <div class="time-pair">
  766. <input id="whAS" type="time" value="${workHours.afternoonStart}" />
  767. <span>至</span>
  768. <input id="whAE" type="time" value="${workHours.afternoonEnd}" />
  769. </div>
  770. </div>
  771. <div class="prop-item">
  772. <label>计算规则</label>
  773. <div class="formula-box">时长 = 起止与上午/下午时段重叠小时之和<br/>精度 1 位小数 · 间隙自然不计</div>
  774. </div>
  775. <div class="prop-item"><label>说明</label><textarea readonly>套件 = 4 个基础组件拼装。工时属性挂在套件上;子字段各自配置标题/必填/选项。</textarea></div>`
  776. ;['whMS', 'whME', 'whAS', 'whAE'].forEach((id) => {
  777. const el = document.getElementById(id)
  778. if (!el) return
  779. el.addEventListener('change', () => {
  780. workHours.morningStart = document.getElementById('whMS').value
  781. workHours.morningEnd = document.getElementById('whME').value
  782. workHours.afternoonStart = document.getElementById('whAS').value
  783. workHours.afternoonEnd = document.getElementById('whAE').value
  784. updateKitWhLabel()
  785. if (!durationManual) {
  786. recomputeDuration()
  787. renderFields()
  788. showToast('已更新套件工时并重算时长')
  789. } else {
  790. showToast('时长已手改,改工时不会重算')
  791. }
  792. renderProps()
  793. })
  794. })
  795. return
  796. }
  797. const f = fields.find((x) => x.id === activeId)
  798. if (!f) {
  799. propBody.innerHTML =
  800. '<h4>未选中</h4><p style="margin:0;color:var(--muted);font-size:13px">选中套件配置工时;选中子字段配置标题/选项等。</p>'
  801. return
  802. }
  803. if (f.field === 'leaveType') {
  804. propBody.innerHTML = `
  805. <h4>${f.title}</h4>
  806. <div class="prop-item"><label>标题</label><input value="${f.title}" readonly /></div>
  807. <div class="prop-item"><label>字段名</label><input value="${f.field}" readonly /></div>
  808. <div class="prop-item"><label>组件类型</label><input value="基础 · 下拉" readonly /></div>
  809. <div class="prop-item">
  810. <label>选项(可维护 · Demo 只读展示)</label>
  811. <ul class="opt-list">${LEAVE_TYPES.map((t) => `<li>${t}</li>`).join('')}</ul>
  812. </div>`
  813. return
  814. }
  815. if (f.field === 'leaveDuration') {
  816. propBody.innerHTML = `
  817. <h4>${f.title}</h4>
  818. <div class="prop-item"><label>标题</label><input value="${f.title}" readonly /></div>
  819. <div class="prop-item"><label>字段名</label><input value="${f.field}" readonly /></div>
  820. <div class="prop-item"><label>组件类型</label><input value="基础 · 数字" readonly /></div>
  821. <div class="prop-item">
  822. <label>手改状态</label>
  823. <div class="row">
  824. <span class="switch ${durationManual ? '' : 'off'}"></span>
  825. <span style="color:var(--text-2);font-size:13px">${durationManual ? '已手改,不再自动联动' : '跟随起止自动算'}</span>
  826. </div>
  827. </div>
  828. <div class="prop-item">
  829. <button type="button" class="btn" id="btnRestoreAuto" ${durationManual ? '' : 'disabled'}>恢复自动计算</button>
  830. </div>
  831. <div class="prop-item"><label>说明</label><textarea readonly>工时时段在「请假套件」上配置,不在本字段。</textarea></div>`
  832. const btn = document.getElementById('btnRestoreAuto')
  833. if (btn) {
  834. btn.addEventListener('click', () => {
  835. durationManual = false
  836. recomputeDuration()
  837. renderFields()
  838. showToast('已恢复自动计算')
  839. })
  840. }
  841. return
  842. }
  843. propBody.innerHTML = `
  844. <h4>${f.title}</h4>
  845. <div class="prop-item"><label>标题</label><input value="${f.title}" readonly /></div>
  846. <div class="prop-item"><label>字段名</label><input value="${f.field}" readonly /></div>
  847. <div class="prop-item"><label>组件类型</label><input value="基础 · 日期时间(到分)" readonly /></div>
  848. <div class="prop-item">
  849. <label>必填</label>
  850. <div class="row"><span class="switch"></span><span style="color:var(--text-2);font-size:13px">是</span></div>
  851. </div>
  852. <div class="prop-item"><label>说明</label><textarea readonly>${f.tip || ''}</textarea></div>`
  853. }
  854. function updateKitWhLabel() {
  855. kitWhLabel.textContent =
  856. workHours.morningStart +
  857. '–' +
  858. workHours.morningEnd +
  859. ' / ' +
  860. workHours.afternoonStart +
  861. '–' +
  862. workHours.afternoonEnd
  863. }
  864. function displayValue(f) {
  865. if (!f.value) {
  866. if (f.kind === 'select') return '请选择 ▾'
  867. if (f.kind === 'datetime') return '请选择日期时间'
  868. return '请输入'
  869. }
  870. if (f.field === 'leaveDuration') return f.value + ' h'
  871. return f.value
  872. }
  873. function renderFields() {
  874. recomputeDuration()
  875. fieldsEl.innerHTML = ''
  876. fields.forEach((f) => {
  877. const div = document.createElement('div')
  878. div.className =
  879. 'field' + (f.id === activeId ? ' active' : '') + (f.fromKit ? ' from-kit' : '')
  880. div.dataset.id = f.id
  881. const filled = f.value ? ' filled' : ''
  882. const req = f.required ? '<span class="req">*</span>' : ''
  883. let badge = ''
  884. let tip = ''
  885. if (f.field === 'leaveDuration') {
  886. badge = durationManual
  887. ? '<span class="badge-manual">已手改</span>'
  888. : '<span class="badge-auto">自动计算</span>'
  889. tip = durationManual
  890. ? '<div class="field-tip">已手改,改起止时间不再重算</div>'
  891. : '<div class="field-tip">由套件工时与起止时间自动计算</div>'
  892. }
  893. div.innerHTML = `
  894. <label>${f.title}${req}${badge}</label>
  895. <div class="fake-input${filled}">${displayValue(f)}</div>
  896. ${tip}`
  897. div.addEventListener('click', (ev) => {
  898. ev.stopPropagation()
  899. activeId = f.id
  900. renderFields()
  901. renderProps()
  902. })
  903. fieldsEl.appendChild(div)
  904. delete f.fromKit
  905. })
  906. dropZone.style.display = fields.length ? 'none' : 'block'
  907. kitShell.classList.toggle('show', leaveInserted)
  908. kitShell.classList.toggle('active', activeId === 'kit')
  909. updateKitWhLabel()
  910. canvasStatus.textContent = fields.length
  911. ? `已放 ${fields.length} 个基础字段` + (leaveInserted ? '(请假套件)' : '')
  912. : '画布为空'
  913. renderOutline()
  914. renderProps()
  915. }
  916. function insertLeaveKit() {
  917. if (leaveInserted) {
  918. showToast('请假套件已在画布中,请先清空')
  919. return
  920. }
  921. const start = new Date()
  922. start.setHours(9, 0, 0, 0)
  923. const end = new Date()
  924. end.setHours(15, 0, 0, 0)
  925. durationManual = false
  926. fields.push(
  927. {
  928. id: 'f-type',
  929. title: '请假类型',
  930. field: 'leaveType',
  931. kind: 'select',
  932. value: '事假',
  933. required: true,
  934. fromKit: true
  935. },
  936. {
  937. id: 'f-start',
  938. title: '开始时间',
  939. field: 'leaveStart',
  940. kind: 'datetime',
  941. value: formatDT(start),
  942. required: true,
  943. fromKit: true,
  944. tip: '精确到分。与下午/上午时段重叠部分计入时长。'
  945. },
  946. {
  947. id: 'f-end',
  948. title: '结束时间',
  949. field: 'leaveEnd',
  950. kind: 'datetime',
  951. value: formatDT(end),
  952. required: true,
  953. fromKit: true,
  954. tip: '精确到分。须晚于开始时间。'
  955. },
  956. {
  957. id: 'f-dur',
  958. title: '时长(h)',
  959. field: 'leaveDuration',
  960. kind: 'number',
  961. value: '',
  962. required: true,
  963. fromKit: true
  964. }
  965. )
  966. leaveInserted = true
  967. activeId = 'kit'
  968. kitLeave.classList.add('inserted')
  969. phone.classList.add('flash')
  970. setTimeout(() => phone.classList.remove('flash'), 500)
  971. renderFields()
  972. showToast('已插入请假套件(选中套件可配工时)')
  973. }
  974. function clearCanvas() {
  975. fields = []
  976. activeId = null
  977. leaveInserted = false
  978. durationManual = false
  979. kitLeave.classList.remove('inserted')
  980. renderFields()
  981. showToast('画布已清空')
  982. }
  983. document.querySelectorAll('[data-left-tab]').forEach((btn) => {
  984. btn.addEventListener('click', () => {
  985. document.querySelectorAll('[data-left-tab]').forEach((b) => b.classList.remove('active'))
  986. btn.classList.add('active')
  987. const key = btn.dataset.leftTab
  988. document.querySelectorAll('[data-panel]').forEach((p) => {
  989. p.classList.toggle('active', p.dataset.panel === key)
  990. })
  991. })
  992. })
  993. kitLeave.addEventListener('click', () => insertLeaveKit())
  994. document.getElementById('btnClear').addEventListener('click', clearCanvas)
  995. kitShell.addEventListener('click', () => {
  996. if (!leaveInserted) return
  997. activeId = 'kit'
  998. renderFields()
  999. renderProps()
  1000. })
  1001. document.querySelectorAll('.tool-ico[data-device]').forEach((btn) => {
  1002. btn.addEventListener('click', () => {
  1003. document.querySelectorAll('.tool-ico[data-device]').forEach((b) => b.classList.remove('active'))
  1004. btn.classList.add('active')
  1005. phone.classList.toggle('wide', btn.dataset.device === 'pc')
  1006. })
  1007. })
  1008. fieldsEl.addEventListener('dblclick', (e) => {
  1009. const fieldEl = e.target.closest('.field')
  1010. if (!fieldEl) return
  1011. const f = fields.find((x) => x.id === fieldEl.dataset.id)
  1012. if (!f) return
  1013. if (f.field === 'leaveType') {
  1014. const next = window.prompt('请假类型:\n' + LEAVE_TYPES.join(' / '), f.value)
  1015. if (next == null) return
  1016. if (!LEAVE_TYPES.includes(next.trim())) {
  1017. showToast('请从内置类型中选择(Demo)')
  1018. return
  1019. }
  1020. f.value = next.trim()
  1021. activeId = f.id
  1022. renderFields()
  1023. return
  1024. }
  1025. if (f.kind === 'datetime') {
  1026. const next = window.prompt('日期时间 YYYY-MM-DD HH:mm', f.value || '')
  1027. if (next == null) return
  1028. if (!parseDT(next.trim())) {
  1029. showToast('格式需为 YYYY-MM-DD HH:mm')
  1030. return
  1031. }
  1032. f.value = next.trim()
  1033. activeId = f.id
  1034. if (!durationManual) showToast('已更新时间,时长已重算')
  1035. else showToast('时长已手改,未自动重算')
  1036. renderFields()
  1037. return
  1038. }
  1039. if (f.field === 'leaveDuration') {
  1040. const next = window.prompt('手改时长(小时,1 位小数)', f.value || '')
  1041. if (next == null) return
  1042. const n = Number(next)
  1043. if (!Number.isFinite(n) || n < 0) {
  1044. showToast('请输入有效非负数字')
  1045. return
  1046. }
  1047. f.value = (Math.round(n * 10) / 10).toFixed(1)
  1048. durationManual = true
  1049. activeId = f.id
  1050. renderFields()
  1051. showToast('时长已手改,此后不再自动联动')
  1052. }
  1053. })
  1054. renderFields()
  1055. </script>
  1056. </body>
  1057. </html>