Files
zkxw/docs/mockups/oa-form-kit-tab-demo.html
T
zlwuandCursor 06db3bc966 feat(permission): replace OA roles with post-bound roles and assignee posts
Unify effective roles via post-role bindings, remove dedicated OA role UI, and update OA assignee resolution plus related SQL/docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 17:41:04 +08:00

1098 lines
33 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>表单设计 · 请假套件 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">&lt;/&gt;</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>