| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1" />
- <title>表单设计 · 请假套件 Demo(Fresh Mint)</title>
- <style>
- :root {
- --primary: #10b981;
- --primary-dark: #059669;
- --primary-bg: #ecfdf5;
- --text: #0f172a;
- --text-2: #334155;
- --muted: #94a3b8;
- --border: #e2e8f0;
- --border-strong: #cbd5e1;
- --bg: #f1f5f9;
- --left-w: 290px;
- --right-w: 310px;
- --menu-w: 44px;
- --top-h: 48px;
- --radius: 8px;
- }
- * { box-sizing: border-box; }
- html, body {
- height: 100%;
- margin: 0;
- font: 14px/1.5 Inter, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
- color: var(--text);
- background: var(--bg);
- }
- .page {
- display: flex;
- flex-direction: column;
- height: 100%;
- min-height: 560px;
- padding: 12px 16px 16px;
- }
- .banner {
- display: flex;
- flex-wrap: wrap;
- gap: 8px 16px;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 10px;
- padding: 10px 14px;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- font-size: 13px;
- }
- .banner b { color: var(--primary-dark); }
- .banner span { color: var(--muted); }
- .tag {
- padding: 2px 8px;
- border-radius: 999px;
- background: var(--primary-bg);
- color: var(--primary-dark);
- font-size: 12px;
- font-weight: 600;
- }
- .toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 8px;
- }
- .btn {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- height: 34px;
- padding: 0 12px;
- border: 1px solid var(--border);
- border-radius: 8px;
- background: #fff;
- color: var(--text-2);
- font-size: 13px;
- cursor: pointer;
- }
- .btn.primary {
- border-color: var(--primary);
- background: var(--primary);
- color: #fff;
- }
- .tabs {
- display: flex;
- justify-content: center;
- gap: 8px;
- margin: 4px 0 12px;
- border-bottom: 1px solid var(--border);
- }
- .tab {
- position: relative;
- padding: 10px 18px;
- border: 0;
- background: none;
- color: var(--text-2);
- font-size: 15px;
- font-weight: 600;
- cursor: pointer;
- }
- .tab.active { color: var(--text); }
- .tab.active::after {
- content: "";
- position: absolute;
- right: 12px;
- bottom: -1px;
- left: 12px;
- height: 3px;
- background: var(--text);
- border-radius: 2px;
- }
- .hint {
- margin: 0 0 10px;
- color: var(--muted);
- font-size: 13px;
- }
- .hint strong { color: var(--primary-dark); font-weight: 600; }
- .designer {
- display: flex;
- flex: 1;
- min-height: 0;
- overflow: hidden;
- background: #fff;
- border: 1px solid var(--border-strong);
- border-radius: 10px;
- box-shadow: 0 2px 8px rgb(15 23 42 / 8%);
- }
- .side-menu {
- display: flex;
- flex: 0 0 var(--menu-w);
- flex-direction: column;
- align-items: center;
- gap: 4px;
- padding-top: 8px;
- border-right: 1px solid var(--border);
- }
- .side-menu button {
- display: grid;
- place-items: center;
- width: 36px;
- height: 36px;
- border: 0;
- border-radius: 8px;
- background: transparent;
- color: #64748b;
- cursor: pointer;
- }
- .side-menu button.active {
- color: var(--primary-dark);
- background: var(--primary-bg);
- }
- .pane-left {
- display: flex;
- flex: 0 0 var(--left-w);
- flex-direction: column;
- border-right: 1px solid var(--border);
- }
- .pane-head {
- display: flex;
- height: 44px;
- border-bottom: 1px solid var(--border);
- }
- .pane-head button {
- flex: 1;
- border: 0;
- background: none;
- color: var(--muted);
- font-size: 14px;
- font-weight: 600;
- cursor: pointer;
- }
- .pane-head button.active {
- color: var(--text);
- box-shadow: inset 0 -2px 0 var(--primary);
- }
- .pane-head button .new-dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- margin-left: 4px;
- vertical-align: middle;
- border-radius: 50%;
- background: var(--primary);
- }
- .pane-scroll {
- flex: 1;
- min-height: 0;
- padding: 12px;
- overflow: auto;
- }
- .pane-panel { display: none; }
- .pane-panel.active { display: block; }
- .group-title {
- margin: 12px 0 8px;
- color: var(--muted);
- font-size: 12px;
- font-weight: 600;
- }
- .group-title:first-child { margin-top: 0; }
- .ctrl-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 8px;
- }
- .ctrl {
- display: flex;
- gap: 8px;
- align-items: center;
- padding: 10px;
- cursor: grab;
- user-select: none;
- background: #f8fafc;
- border: 1px solid var(--border);
- border-radius: 8px;
- font-size: 13px;
- color: var(--text-2);
- }
- .ctrl:hover {
- background: var(--primary-bg);
- border-color: #a7f3d0;
- color: var(--primary-dark);
- }
- .ctrl.coming {
- opacity: 0.55;
- cursor: not-allowed;
- }
- .ctrl.coming:hover {
- background: #f8fafc;
- border-color: var(--border);
- color: var(--text-2);
- }
- .ctrl.inserted {
- background: var(--primary-bg);
- border-color: #a7f3d0;
- color: var(--primary-dark);
- }
- .ctrl-ico {
- display: grid;
- place-items: center;
- width: 28px;
- height: 28px;
- border-radius: 6px;
- background: #fff;
- border: 1px solid var(--border);
- font-size: 14px;
- flex-shrink: 0;
- }
- .pane-mid {
- display: flex;
- flex: 1;
- flex-direction: column;
- min-width: 0;
- background: #f8fafc;
- }
- .mid-tools {
- display: flex;
- align-items: center;
- justify-content: space-between;
- height: var(--top-h);
- padding: 0 12px;
- background: #fff;
- border-bottom: 1px solid var(--border);
- }
- .mid-tools-l { display: flex; gap: 4px; align-items: center; }
- .tool-ico {
- display: grid;
- place-items: center;
- width: 32px;
- height: 32px;
- border: 0;
- border-radius: 6px;
- background: transparent;
- color: #64748b;
- cursor: pointer;
- }
- .tool-ico.active {
- color: var(--primary-dark);
- background: var(--primary-bg);
- }
- .canvas-area {
- display: flex;
- flex: 1;
- justify-content: center;
- align-items: flex-start;
- min-height: 0;
- padding: 20px;
- overflow: auto;
- }
- .phone {
- width: min(420px, 100%);
- min-height: 480px;
- padding: 16px;
- background: #fff;
- border: 1px solid var(--border-strong);
- border-radius: 12px;
- box-shadow: 0 4px 16px rgb(15 23 42 / 6%);
- }
- .phone.wide { width: min(720px, 100%); }
- .phone.flash {
- box-shadow: 0 0 0 3px rgb(16 185 129 / 35%), 0 4px 16px rgb(15 23 42 / 6%);
- }
- .kit-shell {
- display: none;
- margin-bottom: 12px;
- padding: 10px 10px 4px;
- border: 1px dashed #a7f3d0;
- border-radius: 10px;
- background: #fafffe;
- cursor: pointer;
- }
- .kit-shell.show { display: block; }
- .kit-shell.active {
- border-style: solid;
- background: var(--primary-bg);
- box-shadow: 0 0 0 2px rgb(16 185 129 / 12%);
- }
- .kit-shell-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 8px;
- padding: 0 2px 8px;
- border-bottom: 1px dashed #a7f3d0;
- color: var(--primary-dark);
- font-size: 12px;
- font-weight: 600;
- }
- .kit-shell-head span {
- color: var(--muted);
- font-weight: 500;
- font-size: 11px;
- }
- .field {
- margin-bottom: 12px;
- padding: 10px 12px;
- border: 1px solid transparent;
- border-radius: 8px;
- cursor: pointer;
- }
- .field:hover { background: #f8fafc; }
- .field.active {
- background: var(--primary-bg);
- border-color: #a7f3d0;
- box-shadow: 0 0 0 2px rgb(16 185 129 / 12%);
- }
- .field.from-kit { animation: kitIn 0.35s ease; }
- @keyframes kitIn {
- from { opacity: 0; transform: translateY(8px); }
- to { opacity: 1; transform: none; }
- }
- .field label {
- display: flex;
- align-items: center;
- gap: 6px;
- margin-bottom: 6px;
- color: var(--text-2);
- font-size: 13px;
- font-weight: 500;
- }
- .field .req { color: #ef4444; }
- .badge-auto {
- padding: 0 6px;
- border-radius: 4px;
- background: var(--primary-bg);
- color: var(--primary-dark);
- font-size: 11px;
- font-weight: 600;
- }
- .badge-manual {
- padding: 0 6px;
- border-radius: 4px;
- background: #fff7ed;
- color: #d97706;
- font-size: 11px;
- font-weight: 600;
- }
- .fake-input {
- height: 36px;
- padding: 0 12px;
- color: var(--muted);
- font-size: 13px;
- line-height: 36px;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 8px;
- }
- .fake-input.filled { color: var(--text); }
- .field-tip {
- margin-top: 6px;
- color: var(--muted);
- font-size: 11px;
- }
- .drop-zone {
- margin-top: 8px;
- padding: 28px 16px;
- color: var(--muted);
- font-size: 13px;
- text-align: center;
- border: 1px dashed #a7f3d0;
- border-radius: 8px;
- background: #fafffe;
- }
- .pane-right {
- display: flex;
- flex: 0 0 var(--right-w);
- flex-direction: column;
- border-left: 1px solid var(--border);
- }
- .prop-body {
- flex: 1;
- min-height: 0;
- padding: 14px 16px;
- overflow: auto;
- }
- .prop-body h4 {
- margin: 0 0 14px;
- font-size: 14px;
- font-weight: 600;
- }
- .prop-item { margin-bottom: 14px; }
- .prop-item label {
- display: block;
- margin-bottom: 6px;
- color: var(--muted);
- font-size: 12px;
- }
- .prop-item input,
- .prop-item textarea,
- .prop-item select {
- width: 100%;
- height: 36px;
- padding: 0 10px;
- border: 1px solid var(--border);
- border-radius: 8px;
- font: inherit;
- color: var(--text);
- background: #fff;
- }
- .prop-item textarea {
- height: 72px;
- padding: 8px 10px;
- resize: vertical;
- }
- .prop-item .row {
- display: flex;
- gap: 8px;
- align-items: center;
- }
- .time-pair {
- display: grid;
- grid-template-columns: 1fr auto 1fr;
- gap: 6px;
- align-items: center;
- }
- .time-pair span { color: var(--muted); font-size: 12px; }
- .switch {
- width: 40px;
- height: 22px;
- border-radius: 999px;
- background: var(--primary);
- position: relative;
- flex-shrink: 0;
- }
- .switch.off { background: #cbd5e1; }
- .switch::after {
- content: "";
- position: absolute;
- top: 2px;
- left: 20px;
- width: 18px;
- height: 18px;
- background: #fff;
- border-radius: 50%;
- }
- .switch.off::after { left: 2px; }
- .opt-list {
- margin: 0;
- padding: 0;
- list-style: none;
- border: 1px solid var(--border);
- border-radius: 8px;
- overflow: hidden;
- }
- .opt-list li {
- padding: 7px 10px;
- border-bottom: 1px solid var(--border);
- font-size: 12px;
- color: var(--text-2);
- }
- .opt-list li:last-child { border-bottom: 0; }
- .formula-box {
- padding: 10px 12px;
- border-radius: 8px;
- background: #f8fafc;
- border: 1px solid var(--border);
- font-size: 12px;
- color: var(--primary-dark);
- line-height: 1.5;
- }
- .toast {
- position: fixed;
- left: 50%;
- bottom: 28px;
- transform: translateX(-50%) translateY(20px);
- padding: 10px 16px;
- border-radius: 8px;
- background: #0f172a;
- color: #fff;
- font-size: 13px;
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.2s, transform 0.2s;
- z-index: 20;
- }
- .toast.show {
- opacity: 1;
- transform: translateX(-50%) translateY(0);
- }
- </style>
- </head>
- <body>
- <div class="page">
- <div class="banner">
- <div>
- <b>请假套件 Demo · 方案 1</b>
- <span>套件 Tab 同组件双列;拖入展开 4 字段;双段工时累计小时</span>
- </div>
- <span class="tag">Fresh Mint</span>
- </div>
- <div class="toolbar">
- <button type="button" class="btn">← 返回</button>
- <div style="display:flex;gap:8px">
- <button type="button" class="btn" id="btnClear">清空画布</button>
- <button type="button" class="btn primary">发布</button>
- </div>
- </div>
- <div class="tabs">
- <button type="button" class="tab">1 基础设置</button>
- <button type="button" class="tab active">2 表单设计</button>
- <button type="button" class="tab">3 流程设计</button>
- <button type="button" class="tab">4 高级设置</button>
- </div>
- <p class="hint">
- 点 <strong>请假</strong> 插入套件(4 个基础字段)。点套件标题栏配置上午/下午工时;
- 点子字段改属性。双击起止改时间;双击时长可手改。
- </p>
- <div class="designer">
- <aside class="side-menu">
- <button type="button" class="active" title="组件库">▦</button>
- <button type="button" title="多语言">🌐</button>
- <button type="button" title="JSON"></></button>
- </aside>
- <aside class="pane-left">
- <div class="pane-head">
- <button type="button" data-left-tab="components">组件</button>
- <button type="button" class="active" data-left-tab="kits">
- 套件<span class="new-dot"></span>
- </button>
- <button type="button" data-left-tab="outline">大纲</button>
- </div>
- <div class="pane-scroll">
- <div class="pane-panel" data-panel="components">
- <div class="group-title">基础</div>
- <div class="ctrl-grid">
- <div class="ctrl"><span class="ctrl-ico">Aa</span>单行</div>
- <div class="ctrl"><span class="ctrl-ico">¶</span>多行</div>
- <div class="ctrl"><span class="ctrl-ico">123</span>数字</div>
- <div class="ctrl"><span class="ctrl-ico">📅</span>日期</div>
- <div class="ctrl"><span class="ctrl-ico">¥</span>金额</div>
- <div class="ctrl"><span class="ctrl-ico">◉</span>下拉</div>
- </div>
- </div>
- <div class="pane-panel active" data-panel="kits">
- <div class="group-title">人事考勤</div>
- <div class="ctrl-grid">
- <div class="ctrl" id="kitLeave" title="类型 + 起止 + 时长(h)">
- <span class="ctrl-ico">📅</span>请假
- </div>
- <div class="ctrl coming"><span class="ctrl-ico">✈</span>出差</div>
- <div class="ctrl coming"><span class="ctrl-ico">⏰</span>加班</div>
- <div class="ctrl coming"><span class="ctrl-ico">🏠</span>外出</div>
- </div>
- <div class="group-title">财务报销</div>
- <div class="ctrl-grid">
- <div class="ctrl coming"><span class="ctrl-ico">¥</span>报销金额</div>
- <div class="ctrl coming"><span class="ctrl-ico">🧾</span>费用明细</div>
- </div>
- </div>
- <div class="pane-panel" data-panel="outline">
- <div class="group-title">字段大纲</div>
- <ul id="outlineList" style="margin:0;padding-left:18px;color:var(--text-2);font-size:13px;line-height:1.9"></ul>
- <p id="outlineEmpty" style="margin:8px 0 0;color:var(--muted);font-size:12px">画布为空</p>
- </div>
- </div>
- </aside>
- <main class="pane-mid">
- <div class="mid-tools">
- <div class="mid-tools-l">
- <button type="button" class="tool-ico" data-device="pc">🖥</button>
- <button type="button" class="tool-ico active" data-device="mobile">📱</button>
- <span style="margin-left:8px;color:var(--muted);font-size:12px" id="canvasStatus">画布为空</span>
- </div>
- <button type="button" class="btn">👁 预览</button>
- </div>
- <div class="canvas-area">
- <div class="phone" id="phone">
- <div class="kit-shell" id="kitShell">
- <div class="kit-shell-head">
- 请假套件
- <span id="kitWhLabel">工时见右侧</span>
- </div>
- <div id="fields"></div>
- </div>
- <div class="drop-zone" id="dropZone">点击左侧「请假」插入套件</div>
- </div>
- </div>
- </main>
- <aside class="pane-right">
- <div class="pane-head">
- <button type="button" class="active">控件</button>
- <button type="button">表单</button>
- </div>
- <div class="prop-body" id="propBody">
- <h4>未选中</h4>
- <p style="margin:0;color:var(--muted);font-size:13px">选中套件配置工时;选中子字段配置标题/选项等。</p>
- </div>
- </aside>
- </div>
- </div>
- <div class="toast" id="toast"></div>
- <script>
- const LEAVE_TYPES = [
- '事假', '调休', '病假', '年假', '产假',
- '陪产假', '婚假', '例假', '丧假', '哺乳假'
- ]
- /** 套件实例工时(挂在时长字段 props) */
- const workHours = {
- morningStart: '08:30',
- morningEnd: '12:00',
- afternoonStart: '13:00',
- afternoonEnd: '17:30'
- }
- const phone = document.getElementById('phone')
- const fieldsEl = document.getElementById('fields')
- const dropZone = document.getElementById('dropZone')
- const kitShell = document.getElementById('kitShell')
- const kitWhLabel = document.getElementById('kitWhLabel')
- const propBody = document.getElementById('propBody')
- const canvasStatus = document.getElementById('canvasStatus')
- const outlineList = document.getElementById('outlineList')
- const outlineEmpty = document.getElementById('outlineEmpty')
- const toast = document.getElementById('toast')
- const kitLeave = document.getElementById('kitLeave')
- let fields = []
- /** null | 'kit' | fieldId */
- let activeId = null
- let leaveInserted = false
- let durationManual = false
- function showToast(msg) {
- toast.textContent = msg
- toast.classList.add('show')
- clearTimeout(showToast._t)
- showToast._t = setTimeout(() => toast.classList.remove('show'), 2200)
- }
- function parseHM(hm) {
- const [h, m] = hm.split(':').map(Number)
- return h * 60 + m
- }
- function pad2(n) {
- return String(n).padStart(2, '0')
- }
- function formatDT(d) {
- return (
- d.getFullYear() +
- '-' +
- pad2(d.getMonth() + 1) +
- '-' +
- pad2(d.getDate()) +
- ' ' +
- pad2(d.getHours()) +
- ':' +
- pad2(d.getMinutes())
- )
- }
- function parseDT(s) {
- if (!s) return null
- const m = s.trim().match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$/)
- if (!m) return null
- const d = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5])
- return Number.isNaN(d.getTime()) ? null : d
- }
- /** 单日 [dayStart, dayEnd) 与上午/下午两段重叠分钟数 */
- function overlapMinutesOnDay(day, rangeStart, rangeEnd) {
- const y = day.getFullYear()
- const mo = day.getMonth()
- const da = day.getDate()
- const day0 = new Date(y, mo, da, 0, 0, 0, 0).getTime()
- const segments = [
- [parseHM(workHours.morningStart), parseHM(workHours.morningEnd)],
- [parseHM(workHours.afternoonStart), parseHM(workHours.afternoonEnd)]
- ]
- let total = 0
- const rs = rangeStart.getTime()
- const re = rangeEnd.getTime()
- for (const [aMin, bMin] of segments) {
- const a = day0 + aMin * 60000
- const b = day0 + bMin * 60000
- const lo = Math.max(rs, a)
- const hi = Math.min(re, b)
- if (hi > lo) total += (hi - lo) / 60000
- }
- return total
- }
- /** 请假时长(小时,1 位小数) */
- function calcLeaveHours(startStr, endStr) {
- const start = parseDT(startStr)
- const end = parseDT(endStr)
- if (!start || !end || end <= start) return ''
- let minutes = 0
- const cursor = new Date(start.getFullYear(), start.getMonth(), start.getDate())
- const last = new Date(end.getFullYear(), end.getMonth(), end.getDate())
- while (cursor <= last) {
- minutes += overlapMinutesOnDay(cursor, start, end)
- cursor.setDate(cursor.getDate() + 1)
- }
- return (Math.round((minutes / 60) * 10) / 10).toFixed(1)
- }
- function recomputeDuration() {
- if (durationManual) return
- const start = fields.find((f) => f.field === 'leaveStart')
- const end = fields.find((f) => f.field === 'leaveEnd')
- const dur = fields.find((f) => f.field === 'leaveDuration')
- if (!start || !end || !dur) return
- dur.value = calcLeaveHours(start.value, end.value)
- }
- function renderOutline() {
- outlineList.innerHTML = ''
- fields.forEach((f) => {
- const li = document.createElement('li')
- let tag = ''
- if (f.field === 'leaveDuration') {
- tag = durationManual ? '(已手改)' : '(自动)'
- }
- li.textContent = f.title + tag
- outlineList.appendChild(li)
- })
- outlineEmpty.style.display = fields.length ? 'none' : 'block'
- }
- function renderProps() {
- if (activeId === 'kit') {
- propBody.innerHTML = `
- <h4>请假套件</h4>
- <div class="prop-item">
- <label>上午上班时段</label>
- <div class="time-pair">
- <input id="whMS" type="time" value="${workHours.morningStart}" />
- <span>至</span>
- <input id="whME" type="time" value="${workHours.morningEnd}" />
- </div>
- </div>
- <div class="prop-item">
- <label>下午上班时段</label>
- <div class="time-pair">
- <input id="whAS" type="time" value="${workHours.afternoonStart}" />
- <span>至</span>
- <input id="whAE" type="time" value="${workHours.afternoonEnd}" />
- </div>
- </div>
- <div class="prop-item">
- <label>计算规则</label>
- <div class="formula-box">时长 = 起止与上午/下午时段重叠小时之和<br/>精度 1 位小数 · 间隙自然不计</div>
- </div>
- <div class="prop-item"><label>说明</label><textarea readonly>套件 = 4 个基础组件拼装。工时属性挂在套件上;子字段各自配置标题/必填/选项。</textarea></div>`
- ;['whMS', 'whME', 'whAS', 'whAE'].forEach((id) => {
- const el = document.getElementById(id)
- if (!el) return
- el.addEventListener('change', () => {
- workHours.morningStart = document.getElementById('whMS').value
- workHours.morningEnd = document.getElementById('whME').value
- workHours.afternoonStart = document.getElementById('whAS').value
- workHours.afternoonEnd = document.getElementById('whAE').value
- updateKitWhLabel()
- if (!durationManual) {
- recomputeDuration()
- renderFields()
- showToast('已更新套件工时并重算时长')
- } else {
- showToast('时长已手改,改工时不会重算')
- }
- renderProps()
- })
- })
- return
- }
- const f = fields.find((x) => x.id === activeId)
- if (!f) {
- propBody.innerHTML =
- '<h4>未选中</h4><p style="margin:0;color:var(--muted);font-size:13px">选中套件配置工时;选中子字段配置标题/选项等。</p>'
- return
- }
- if (f.field === 'leaveType') {
- propBody.innerHTML = `
- <h4>${f.title}</h4>
- <div class="prop-item"><label>标题</label><input value="${f.title}" readonly /></div>
- <div class="prop-item"><label>字段名</label><input value="${f.field}" readonly /></div>
- <div class="prop-item"><label>组件类型</label><input value="基础 · 下拉" readonly /></div>
- <div class="prop-item">
- <label>选项(可维护 · Demo 只读展示)</label>
- <ul class="opt-list">${LEAVE_TYPES.map((t) => `<li>${t}</li>`).join('')}</ul>
- </div>`
- return
- }
- if (f.field === 'leaveDuration') {
- propBody.innerHTML = `
- <h4>${f.title}</h4>
- <div class="prop-item"><label>标题</label><input value="${f.title}" readonly /></div>
- <div class="prop-item"><label>字段名</label><input value="${f.field}" readonly /></div>
- <div class="prop-item"><label>组件类型</label><input value="基础 · 数字" readonly /></div>
- <div class="prop-item">
- <label>手改状态</label>
- <div class="row">
- <span class="switch ${durationManual ? '' : 'off'}"></span>
- <span style="color:var(--text-2);font-size:13px">${durationManual ? '已手改,不再自动联动' : '跟随起止自动算'}</span>
- </div>
- </div>
- <div class="prop-item">
- <button type="button" class="btn" id="btnRestoreAuto" ${durationManual ? '' : 'disabled'}>恢复自动计算</button>
- </div>
- <div class="prop-item"><label>说明</label><textarea readonly>工时时段在「请假套件」上配置,不在本字段。</textarea></div>`
- const btn = document.getElementById('btnRestoreAuto')
- if (btn) {
- btn.addEventListener('click', () => {
- durationManual = false
- recomputeDuration()
- renderFields()
- showToast('已恢复自动计算')
- })
- }
- return
- }
- propBody.innerHTML = `
- <h4>${f.title}</h4>
- <div class="prop-item"><label>标题</label><input value="${f.title}" readonly /></div>
- <div class="prop-item"><label>字段名</label><input value="${f.field}" readonly /></div>
- <div class="prop-item"><label>组件类型</label><input value="基础 · 日期时间(到分)" readonly /></div>
- <div class="prop-item">
- <label>必填</label>
- <div class="row"><span class="switch"></span><span style="color:var(--text-2);font-size:13px">是</span></div>
- </div>
- <div class="prop-item"><label>说明</label><textarea readonly>${f.tip || ''}</textarea></div>`
- }
- function updateKitWhLabel() {
- kitWhLabel.textContent =
- workHours.morningStart +
- '–' +
- workHours.morningEnd +
- ' / ' +
- workHours.afternoonStart +
- '–' +
- workHours.afternoonEnd
- }
- function displayValue(f) {
- if (!f.value) {
- if (f.kind === 'select') return '请选择 ▾'
- if (f.kind === 'datetime') return '请选择日期时间'
- return '请输入'
- }
- if (f.field === 'leaveDuration') return f.value + ' h'
- return f.value
- }
- function renderFields() {
- recomputeDuration()
- fieldsEl.innerHTML = ''
- fields.forEach((f) => {
- const div = document.createElement('div')
- div.className =
- 'field' + (f.id === activeId ? ' active' : '') + (f.fromKit ? ' from-kit' : '')
- div.dataset.id = f.id
- const filled = f.value ? ' filled' : ''
- const req = f.required ? '<span class="req">*</span>' : ''
- let badge = ''
- let tip = ''
- if (f.field === 'leaveDuration') {
- badge = durationManual
- ? '<span class="badge-manual">已手改</span>'
- : '<span class="badge-auto">自动计算</span>'
- tip = durationManual
- ? '<div class="field-tip">已手改,改起止时间不再重算</div>'
- : '<div class="field-tip">由套件工时与起止时间自动计算</div>'
- }
- div.innerHTML = `
- <label>${f.title}${req}${badge}</label>
- <div class="fake-input${filled}">${displayValue(f)}</div>
- ${tip}`
- div.addEventListener('click', (ev) => {
- ev.stopPropagation()
- activeId = f.id
- renderFields()
- renderProps()
- })
- fieldsEl.appendChild(div)
- delete f.fromKit
- })
- dropZone.style.display = fields.length ? 'none' : 'block'
- kitShell.classList.toggle('show', leaveInserted)
- kitShell.classList.toggle('active', activeId === 'kit')
- updateKitWhLabel()
- canvasStatus.textContent = fields.length
- ? `已放 ${fields.length} 个基础字段` + (leaveInserted ? '(请假套件)' : '')
- : '画布为空'
- renderOutline()
- renderProps()
- }
- function insertLeaveKit() {
- if (leaveInserted) {
- showToast('请假套件已在画布中,请先清空')
- return
- }
- const start = new Date()
- start.setHours(9, 0, 0, 0)
- const end = new Date()
- end.setHours(15, 0, 0, 0)
- durationManual = false
- fields.push(
- {
- id: 'f-type',
- title: '请假类型',
- field: 'leaveType',
- kind: 'select',
- value: '事假',
- required: true,
- fromKit: true
- },
- {
- id: 'f-start',
- title: '开始时间',
- field: 'leaveStart',
- kind: 'datetime',
- value: formatDT(start),
- required: true,
- fromKit: true,
- tip: '精确到分。与下午/上午时段重叠部分计入时长。'
- },
- {
- id: 'f-end',
- title: '结束时间',
- field: 'leaveEnd',
- kind: 'datetime',
- value: formatDT(end),
- required: true,
- fromKit: true,
- tip: '精确到分。须晚于开始时间。'
- },
- {
- id: 'f-dur',
- title: '时长(h)',
- field: 'leaveDuration',
- kind: 'number',
- value: '',
- required: true,
- fromKit: true
- }
- )
- leaveInserted = true
- activeId = 'kit'
- kitLeave.classList.add('inserted')
- phone.classList.add('flash')
- setTimeout(() => phone.classList.remove('flash'), 500)
- renderFields()
- showToast('已插入请假套件(选中套件可配工时)')
- }
- function clearCanvas() {
- fields = []
- activeId = null
- leaveInserted = false
- durationManual = false
- kitLeave.classList.remove('inserted')
- renderFields()
- showToast('画布已清空')
- }
- document.querySelectorAll('[data-left-tab]').forEach((btn) => {
- btn.addEventListener('click', () => {
- document.querySelectorAll('[data-left-tab]').forEach((b) => b.classList.remove('active'))
- btn.classList.add('active')
- const key = btn.dataset.leftTab
- document.querySelectorAll('[data-panel]').forEach((p) => {
- p.classList.toggle('active', p.dataset.panel === key)
- })
- })
- })
- kitLeave.addEventListener('click', () => insertLeaveKit())
- document.getElementById('btnClear').addEventListener('click', clearCanvas)
- kitShell.addEventListener('click', () => {
- if (!leaveInserted) return
- activeId = 'kit'
- renderFields()
- renderProps()
- })
- document.querySelectorAll('.tool-ico[data-device]').forEach((btn) => {
- btn.addEventListener('click', () => {
- document.querySelectorAll('.tool-ico[data-device]').forEach((b) => b.classList.remove('active'))
- btn.classList.add('active')
- phone.classList.toggle('wide', btn.dataset.device === 'pc')
- })
- })
- fieldsEl.addEventListener('dblclick', (e) => {
- const fieldEl = e.target.closest('.field')
- if (!fieldEl) return
- const f = fields.find((x) => x.id === fieldEl.dataset.id)
- if (!f) return
- if (f.field === 'leaveType') {
- const next = window.prompt('请假类型:\n' + LEAVE_TYPES.join(' / '), f.value)
- if (next == null) return
- if (!LEAVE_TYPES.includes(next.trim())) {
- showToast('请从内置类型中选择(Demo)')
- return
- }
- f.value = next.trim()
- activeId = f.id
- renderFields()
- return
- }
- if (f.kind === 'datetime') {
- const next = window.prompt('日期时间 YYYY-MM-DD HH:mm', f.value || '')
- if (next == null) return
- if (!parseDT(next.trim())) {
- showToast('格式需为 YYYY-MM-DD HH:mm')
- return
- }
- f.value = next.trim()
- activeId = f.id
- if (!durationManual) showToast('已更新时间,时长已重算')
- else showToast('时长已手改,未自动重算')
- renderFields()
- return
- }
- if (f.field === 'leaveDuration') {
- const next = window.prompt('手改时长(小时,1 位小数)', f.value || '')
- if (next == null) return
- const n = Number(next)
- if (!Number.isFinite(n) || n < 0) {
- showToast('请输入有效非负数字')
- return
- }
- f.value = (Math.round(n * 10) / 10).toFixed(1)
- durationManual = true
- activeId = f.id
- renderFields()
- showToast('时长已手改,此后不再自动联动')
- }
- })
- renderFields()
- </script>
- </body>
- </html>
|