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>
1098 lines
33 KiB
HTML
1098 lines
33 KiB
HTML
<!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>
|