commit
build / build-push (push) Successful in 50s

This commit is contained in:
wanghu
2026-10-10 15:42:52 +08:00
parent c738fd9339
commit 25147ec74e
181 changed files with 13549 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
target/
.idea/
*.iml
*.log
.DS_Store
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"w":3056,"h":1298,"ch":3}
+543
View File
@@ -0,0 +1,543 @@
<!DOCTYPE html>
<html lang="zh-CN" data-page-node-id="kKD2zGHaUO9U8u7IkrNWoA">
<head data-page-node-id="Dla59qTcmtkSQPQHpk2rXm">
<meta charset="UTF-8" data-page-node-id="gYv6D6nA0dbg3IvkENsNxP">
<meta name="viewport" content="width=device-width, initial-scale=1.0" data-page-node-id="CXN5L7TMr7y3USos7buZPt">
<title>教师请假 · 我要请假(美化版)</title>
<style>
:root {
--primary: #2f6bff;
--primary-dark: #1e54d6;
--primary-light: #eef3ff;
--bg: #f4f6fa;
--card: #ffffff;
--text-1: #1c2333;
--text-2: #5a6478;
--text-3: #9aa3b5;
--border: #e6eaf2;
--danger: #e5484d;
--success: #18a058;
--radius: 12px;
--shadow: 0 1px 3px rgba(28, 35, 51, .06), 0 8px 24px rgba(28, 35, 51, .05);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text-1);
font-size: 14px;
display: flex;
min-height: 100vh;
}
/* ---------- 侧边栏 ---------- */
.sidebar {
width: 220px;
background: #ffffff;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 20px 18px;
font-size: 16px;
font-weight: 600;
}
.brand-logo {
width: 32px; height: 32px;
border-radius: 9px;
background: var(--primary);
color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 15px;
box-shadow: 0 4px 10px rgba(47, 107, 255, .35);
}
.nav { flex: 1; padding: 6px 12px; overflow-y: auto; }
.nav a {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px;
margin-bottom: 2px;
border-radius: 8px;
color: var(--text-2);
text-decoration: none;
transition: all .15s;
}
.nav a:hover { background: #f4f6fb; color: var(--text-1); }
.nav a.active {
background: var(--primary-light);
color: var(--primary);
font-weight: 600;
}
.nav a svg { width: 16px; height: 16px; flex-shrink: 0; }
.nav-section {
font-size: 12px;
color: var(--text-3);
padding: 14px 12px 6px;
}
/* ---------- 主区域 ---------- */
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
height: 56px;
background: #ffffff;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
padding: 0 24px;
position: sticky; top: 0; z-index: 10;
}
.breadcrumb { display: flex; align-items: center; gap: 8px; color: var(--text-3); font-size: 13px; }
.breadcrumb .current { color: var(--text-1); font-weight: 600; }
.user-chip { display: flex; align-items: center; gap: 10px; }
.avatar {
width: 30px; height: 30px; border-radius: 50%;
background: linear-gradient(135deg, #5b8cff, #2f6bff);
color: #fff; font-size: 12px;
display: flex; align-items: center; justify-content: center;
}
.content { max-width: 860px; width: 100%; margin: 0 auto; padding: 24px 24px 120px; }
/* ---------- 页头卡片 ---------- */
.page-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
}
.page-head h1 { font-size: 20px; font-weight: 600; }
.page-head .sub { color: var(--text-3); font-size: 13px; margin-top: 4px; }
.duration-banner {
display: flex; align-items: center; gap: 10px;
background: var(--primary-light);
border: 1px solid #d8e3ff;
color: var(--primary-dark);
border-radius: 10px;
padding: 10px 14px;
font-size: 13px;
margin-bottom: 16px;
}
.duration-banner svg { width: 16px; height: 16px; flex-shrink: 0; }
/* ---------- 表单卡片 ---------- */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 16px;
overflow: hidden;
}
.card-head {
display: flex; align-items: center; gap: 10px;
padding: 16px 24px;
border-bottom: 1px solid var(--border);
background: #fafbfe;
}
.card-head .bar { width: 4px; height: 16px; border-radius: 2px; background: var(--primary); }
.card-head h2 { font-size: 15px; font-weight: 600; }
.card-head .hint { margin-left: auto; font-size: 12px; color: var(--text-3); }
.card-body { padding: 24px; }
.form-row { display: grid; grid-template-columns: 112px 1fr; gap: 16px; margin-bottom: 22px; align-items: start; }
.form-row:last-child { margin-bottom: 0; }
.form-row > label { padding-top: 9px; font-size: 14px; color: var(--text-2); text-align: right; }
.req::before { content: "*"; color: var(--danger); margin-right: 2px; }
.field-control { min-width: 0; }
/* 输入控件 */
.input, .select, .textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 12px;
font-size: 14px;
font-family: inherit;
color: var(--text-1);
background: #fff;
transition: border-color .15s, box-shadow .15s;
outline: none;
}
.input::placeholder, .textarea::placeholder { color: var(--text-3); }
.input:hover, .select:hover, .textarea:hover { border-color: #c3cede; }
.input:focus, .select:focus, .textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(47, 107, 255, .12);
}
.select {
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239aa3b5' stroke-width='2.5'><path d='M6 9l6 6 6-6'/></svg>");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 34px;
cursor: pointer;
}
.textarea { resize: vertical; min-height: 108px; line-height: 1.6; }
.char-count { text-align: right; font-size: 12px; color: var(--text-3); margin-top: 6px; }
.help-text { font-size: 12px; color: var(--text-3); margin-top: 6px; }
/* 分段选择器(替代原生单选) */
.segmented {
display: inline-flex;
background: #f1f3f9;
border-radius: 8px;
padding: 3px;
gap: 2px;
}
.segmented button {
border: none; background: transparent;
padding: 7px 18px;
border-radius: 6px;
font-size: 13px; font-family: inherit;
color: var(--text-2);
cursor: pointer;
transition: all .15s;
}
.segmented button.on {
background: #fff;
color: var(--primary);
font-weight: 600;
box-shadow: 0 1px 4px rgba(28, 35, 51, .12);
}
.segmented.mini button { padding: 6px 14px; }
.inline-group { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.date-wrap { position: relative; width: 170px; }
.date-wrap .input { padding-right: 34px; }
.date-wrap svg {
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
width: 14px; height: 14px; color: var(--text-3); pointer-events: none;
}
.arrow { color: var(--text-3); font-size: 13px; }
.auto-length {
display: inline-flex; align-items: center; gap: 8px;
background: #f0faf4;
border: 1px solid #cdeeda;
color: var(--success);
border-radius: 8px;
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
}
.auto-length .big { font-size: 16px; }
/* 上传区 */
.upload {
display: flex; align-items: center; gap: 16px;
border: 1.5px dashed #c9d4ea;
border-radius: 10px;
padding: 18px 20px;
cursor: pointer;
transition: all .15s;
background: #fafbff;
}
.upload:hover { border-color: var(--primary); background: var(--primary-light); }
.upload-icon {
width: 44px; height: 44px; border-radius: 10px;
background: var(--primary-light);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.upload-icon svg { width: 20px; height: 20px; color: var(--primary); }
.upload .t { font-size: 14px; color: var(--text-1); font-weight: 500; }
.upload .t em { color: var(--primary); font-style: normal; }
.upload .d { font-size: 12px; color: var(--text-3); margin-top: 3px; }
/* 抄送人 chips */
.cc-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.cc-chip {
display: inline-flex; align-items: center; gap: 7px;
background: #f4f6fb;
border: 1px solid var(--border);
border-radius: 999px;
padding: 5px 12px 5px 5px;
font-size: 13px;
}
.cc-chip .av {
width: 24px; height: 24px; border-radius: 50%;
background: linear-gradient(135deg, #7fa4ff, #4f7dff);
color: #fff; font-size: 11px;
display: flex; align-items: center; justify-content: center;
}
.cc-chip .x { color: var(--text-3); cursor: pointer; font-size: 12px; }
.cc-chip .x:hover { color: var(--danger); }
.cc-add {
display: inline-flex; align-items: center; gap: 6px;
border: 1px dashed #c9d4ea;
background: #fff;
border-radius: 999px;
padding: 6px 14px;
font-size: 13px;
color: var(--text-2);
cursor: pointer;
transition: all .15s;
}
.cc-add:hover { color: var(--primary); border-color: var(--primary); }
/* 底部操作栏 */
.action-bar {
position: fixed;
bottom: 0; left: 220px; right: 0;
background: rgba(255, 255, 255, .92);
backdrop-filter: blur(8px);
border-top: 1px solid var(--border);
padding: 14px 24px;
display: flex; justify-content: center; gap: 12px;
z-index: 20;
}
.btn {
border: none; border-radius: 8px;
padding: 10px 28px;
font-size: 14px; font-family: inherit;
cursor: pointer;
transition: all .15s;
}
.btn-primary {
background: var(--primary); color: #fff;
box-shadow: 0 4px 12px rgba(47, 107, 255, .3);
}
.btn-primary:hover { background: var(--primary-dark); transform: translateY(-1px); }
.btn-ghost { background: #fff; color: var(--text-2); border: 1px solid var(--border); }
.btn-ghost:hover { color: var(--primary); border-color: var(--primary); background: var(--primary-light); }
@media (max-width: 900px) {
.sidebar { display: none; }
.action-bar { left: 0; }
.form-row { grid-template-columns: 1fr; gap: 8px; }
.form-row > label { text-align: left; padding-top: 0; }
}
</style>
</head>
<body data-page-node-id="pEuvYe0F7z7IRACLMAJH5X">
<!-- 侧边栏 -->
<aside class="sidebar" data-page-node-id="KxKa3n00u4ulOQOXzW3uMQ">
<div class="brand" data-page-node-id="VzcmXzjTNBqyPqyVjfC6om">
<div class="brand-logo" data-page-node-id="1X5pxZgYm6LMoLl2c9FYCM">假</div>
教师请假
</div>
<nav class="nav" data-page-node-id="cjnkCzt9ERGD5HUBPowDyR">
<a class="active" href="#" data-page-node-id="Va1SYAGt6sOdSJegvz726T">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" data-page-node-id="ulsRZBFqExsBrMsKiUidpD"><path d="M3 12l9-8 9 8" data-page-node-id="oInS7JOZVNoB70QHqOU8au"/><path d="M5 10v10h14V10" data-page-node-id="s85v5eHLxfIQrCdLE4igW3"/></svg>
我要请假
</a>
<a href="#" data-page-node-id="dbHc0a7VHf4n6GtA1azGbs">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" data-page-node-id="IdUSndES0wv3tZuFkhONUv"><rect x="3" y="4" width="18" height="17" rx="2" data-page-node-id="UdHIDsgJuiujc9qVX7s8Fb"/><path d="M3 9h18M8 2v4M16 2v4" data-page-node-id="a0wpFLvqAIDQOWFzuCuvmt"/></svg>
我的请假
</a>
<a href="#" data-page-node-id="71xEYRC8LRGtHeQY6NDQOz">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" data-page-node-id="KoB6xEYlMHWF9EriepZg1T"><path d="M9 11l3 3 8-8" data-page-node-id="sAuu92W4pSErD1OxtLQZcI"/><path d="M20 12v6a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h9" data-page-node-id="810bvLwnB2UtwXIRCZaKbM"/></svg>
审批列表
</a>
<a href="#" data-page-node-id="tTu2zRCjdncO67183BOFN3">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" data-page-node-id="7xaYewKgpOUXPraaDOw09S"><path d="M4 4h16v12H7l-3 3z" data-page-node-id="mCQ6ySlxaiBlbkkeMcL51m"/></svg>
抄送列表
</a>
<a href="#" data-page-node-id="Gs6GSHth07CrYFLJ5o60Ih">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" data-page-node-id="rnvZzs2XUuaYGWqAmdC5bP"><circle cx="12" cy="12" r="9" data-page-node-id="OBeF2bYsozNJur04Bw7G3H"/><path d="M12 7v5l3 3" data-page-node-id="H0dRAUZKl3qm0DN10cNIJZ"/></svg>
全部请假
</a>
<div class="nav-section" data-page-node-id="JCwkmbaLM5TweGEcJUs6W8">系统管理</div>
<a href="#" data-page-node-id="hSXfr0wCRB8DkbJ73RKWrw">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" data-page-node-id="Cum3smV6ZijGAg79gVMDKD"><circle cx="6" cy="6" r="2.5" data-page-node-id="z14t9hJa8FAt2wvPQXgYBm"/><circle cx="6" cy="18" r="2.5" data-page-node-id="1igtdCB3rheNVDrLPK0mtn"/><circle cx="18" cy="12" r="2.5" data-page-node-id="41A0s7EkwjAmkIfKsmB5qt"/><path d="M8 7l8 4M8 17l8-4" data-page-node-id="b90DRkfc6OQr6Ng55Y6Lyd"/></svg>
流程管理
</a>
<a href="#" data-page-node-id="rGTpqu5DeYdCvYXgQ81MWW">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" data-page-node-id="CBSqMxi7TuCnpE8st3XH6B"><circle cx="12" cy="12" r="3" data-page-node-id="TdGoOsqI7u14xZ79HaN5aD"/><path d="M19 12a7 7 0 00-.2-1.6l2-1.5-2-3.4-2.3.9a7 7 0 00-2.8-1.6L13 2h-4l-.7 2.8a7 7 0 00-2.8 1.6l-2.3-.9-2 3.4 2 1.5A7 7 0 005 12c0 .5.1 1.1.2 1.6l-2 1.5 2 3.4 2.3-.9a7 7 0 002.8 1.6L11 22h4l.7-2.8a7 7 0 002.8-1.6l2.3.9 2-3.4-2-1.5c.1-.5.2-1.1.2-1.6z" data-page-node-id="SqgFM1A88nYkkJtU4AuEuB"/></svg>
请假设置
</a>
<a href="#" data-page-node-id="s91aGwCnjwTsqj2A7s54xN">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" data-page-node-id="b5cA0cUXWODHQkrTIV4ZIe"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2" data-page-node-id="LoYAVhjFTXPibADPv31BD8"/></svg>
时长日志
</a>
<a href="#" data-page-node-id="yhzjmHJncnQrOPvpdD3BiF">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" data-page-node-id="awX52Tr5qvDCXDbD8KELcN"><path d="M21 12a9 9 0 11-9-9" data-page-node-id="nSDfCui4uPQiDsUk0gUbQU"/><path d="M21 3l-9 9" data-page-node-id="F0gLfIp8UkNL5hVPdDZfqw"/></svg>
统计分析
</a>
</nav>
</aside>
<!-- 主区域 -->
<div class="main" data-page-node-id="sMV0VdZBGSodsH6qwlDCf1">
<header class="topbar" data-page-node-id="EUigeyFAoCR9CEkqV85MCL">
<div class="breadcrumb" data-page-node-id="ZAKRQ3iCsCyuE8oHQfnSve">
<span data-page-node-id="Ayu9ndzrCH6xHNgDH5i8q3">首页</span><span data-page-node-id="LmTzuZOBp5w5HOVLhA0krW">/</span><span data-page-node-id="E8NJ91gnT4qxt3c7LVx1QV">教师请假</span><span data-page-node-id="kz4Aszy3nhe8WzQrBFedxF">/</span><span class="current" data-page-node-id="gIzqk9uFZT0LJZcqSO4QKT">我要请假</span>
</div>
<div class="user-chip" data-page-node-id="siJRD2l0dxOjNQkzOLqbbJ">
<div class="avatar" data-page-node-id="QEct8lAiU3XQ4bBNXHuJhw">李</div>
<span style="font-size:13px;color:var(--text-2)" data-page-node-id="xSsq81vrQKNcHfvbzWwKcl">李晶</span>
</div>
</header>
<main class="content" data-page-node-id="b3qFT1d5MbfLz1MWBoQHWb">
<div class="page-head" data-page-node-id="EYQoa9nU7Y6CnSpBTIqUBm">
<div data-page-node-id="afVHkaWg2h9ECBzpDkzhhU">
<h1 data-page-node-id="ixJbTpAJuugIv7If5HmJmc">请假申请</h1>
<div class="sub" data-page-node-id="jihbsdZhsWChtVO6krHgte">提交后将进入审批流程,审批结果会通过消息通知您</div>
</div>
</div>
<div class="duration-banner" data-page-node-id="NuMwuvJBQQSC1kwUhEJOei">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" data-page-node-id="gHR7TvdetFVkT1EPRHLVlt"><circle cx="12" cy="12" r="9" data-page-node-id="yD6ZRVO91u1UMflIj2cS78"/><path d="M12 8v4M12 16h.01" data-page-node-id="3FiynVNRF9hbQHn0tAkDPu"/></svg>
时间说明:上午(08:00–12:00)– 下午(13:00–17:00),固定 3 小时工作制,按半天为单位计费时长
</div>
<form onsubmit="return false" data-page-node-id="lM5rDi8YBXazoQmj2xRVYJ">
<!-- 请假信息 -->
<section class="card" data-page-node-id="ldXTzuvrIHdwdYLOmKrvXw">
<div class="card-head" data-page-node-id="jfBksYP1p6jlOHovaVLKnC">
<span class="bar" data-page-node-id="Ae8GrrHH2KB1zFXrlkLDmF"></span>
<h2 data-page-node-id="mTOLZasDNFB8cOC3UEnkHB">请假信息</h2>
<span class="hint" data-page-node-id="u15G14hbxqxPA23NONBMPr">带 * 为必填项</span>
</div>
<div class="card-body" data-page-node-id="uB0cUq8E7NcEwA0KbcVgfS">
<div class="form-row" data-page-node-id="XuguJUjkgUkjSJJ0P9uEeP">
<label class="req" data-page-node-id="34aLY6Q8cxk0ahXoQVBaGJ">请假类型</label>
<div class="field-control" data-page-node-id="CKLpKzQSySrTZ5zYviP6AF">
<div class="segmented" data-page-node-id="c5sryFi9RHrfgtKuCM6BtV">
<button type="button" class="on" data-page-node-id="qvFXFmXWkCYQ8xqIR8Yf2z">事假</button>
<button type="button" data-page-node-id="MRr8xFyPnvbpHoYUMM7649">病假</button>
<button type="button" data-page-node-id="Y25dmgoQGO7fXtB6YddSfE">婚假</button>
<button type="button" data-page-node-id="Zwqv2c06roJgSS6s1FDYoS">产假</button>
<button type="button" data-page-node-id="WTteUcAAA4AqS5vqCcgQ9P">其他</button>
</div>
<div class="help-text" data-page-node-id="MRptvR6jjGk9D1arZ40esi">不同类型可能需要附加不同的证明材料</div>
</div>
</div>
<div class="form-row" data-page-node-id="hN63ZRXjqMJQpD3xXOouYi">
<label class="req" data-page-node-id="R143QpoTjkGIzkpR2AzqJ2">起止时间</label>
<div class="field-control" data-page-node-id="Xi0VxxTy0Bv2XGNZ0FDbTz">
<div class="inline-group" data-page-node-id="R5moEYc0CgMHEqoTulXFwY">
<div class="segmented mini" data-page-node-id="jjyPX3fb7jGvasHchRoWy4">
<button type="button" class="on" data-page-node-id="gKpIGCohbkQXDFyFOU4MdS">半天</button>
<button type="button" data-page-node-id="0rtjA9ABbaYiUUHbCPivlR">半天以上</button>
</div>
</div>
<div class="inline-group" style="margin-top:12px" data-page-node-id="S07l3479yHAQekvgpAEGSf">
<div class="date-wrap" data-page-node-id="XpWVG57eBS6I6XqF4F0ZLb">
<input class="input" type="text" placeholder="开始日期" value="2026-10-12" data-page-node-id="IYSLOFh1EM0Mqf8MT5UQQT">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" data-page-node-id="4fz42ERqratTWNZM6LWrhI"><rect x="3" y="4" width="18" height="17" rx="2" data-page-node-id="iaPtKo2tHABJgKW7T3dFTz"/><path d="M3 9h18M8 2v4M16 2v4" data-page-node-id="bBMfb0djLcpOZasbPdXIut"/></svg>
</div>
<span class="arrow" data-page-node-id="DxL3MF49N3ExzXbEQutH9d">至</span>
<div class="date-wrap" data-page-node-id="2KRaxUjqirMcWuMqxQRd0k">
<input class="input" type="text" placeholder="结束日期" value="2026-10-12" data-page-node-id="7jBUHBHAZwVIaA9DMFFRoY">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" data-page-node-id="Akv8tEVe2B6KGI2rCquErM"><rect x="3" y="4" width="18" height="17" rx="2" data-page-node-id="X35kk0y5sR2A5Tnpf5HfC1"/><path d="M3 9h18M8 2v4M16 2v4" data-page-node-id="3gGsKjfVBpvHPyNlkQt426"/></svg>
</div>
<div class="segmented mini" data-page-node-id="99sq4CQ74B4HmE4aExVt9S">
<button type="button" class="on" data-page-node-id="ZaJXegt7BQVfQzBxYsR4wT">上午</button>
<button type="button" data-page-node-id="QZWxPZhCmnEJMxl5cotFKV">下午</button>
</div>
</div>
</div>
</div>
<div class="form-row" data-page-node-id="R5Gvc1SQ83ujS4rLZ9AHf3">
<label data-page-node-id="9XdzSMTiqlRl3rD9cPbFta">时长(天)</label>
<div class="field-control" data-page-node-id="GAFjTSwPQuEmC9RWeDqrPM">
<div class="auto-length" data-page-node-id="lEoDmiF9jaYSI2WvAW2D0G">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" data-page-node-id="ZXnqn8CedYbenu9o8NAu3F"><path d="M20 6L9 17l-5-5" data-page-node-id="EVwPtbhwgQ2qC8EOb7abtL"/></svg>
<span class="big" id="len" data-page-node-id="vgwQ3unWYE8McDZebzxBQS">0.5</span> 天(按选择自动计算)
</div>
</div>
</div>
<div class="form-row" data-page-node-id="C1PM1qOPV2z6oAbsd5JuuR">
<label data-page-node-id="1dBrOAqidP5ZFKz6FxhMUM">课务安排</label>
<div class="field-control" data-page-node-id="8DCGf8LVPImLJDHB9nZDcM">
<div class="segmented" data-page-node-id="NxGgrvEpNrDliW5u7tUQJ4">
<button type="button" class="on" data-page-node-id="Wjc0C3bX6c1VH5ZTAjnaLf">无课务安排</button>
<button type="button" data-page-node-id="IqNGMXfXHzor4DcfdLLn1W">自行安排</button>
<button type="button" data-page-node-id="aiNkqxmQHLjSR2SrYdfHtb">教务安排</button>
</div>
</div>
</div>
<div class="form-row" data-page-node-id="k1M4sxumnqLNMCjc1Kq49J">
<label class="req" data-page-node-id="wS0SJtUbLrykrIAPDjhfz5">请假事由</label>
<div class="field-control" data-page-node-id="o8ZA7twmpBVwnlNZ8EGrlk">
<textarea class="textarea" maxlength="50" placeholder="请输入请假事由,如:因家中急事需请假半天处理…" oninput="document.getElementById('cnt').textContent=this.value.length+'/50'"></textarea>
<div class="char-count" id="cnt" data-page-node-id="P9RJV4Wh8j1H7nJxrYhbFX">0/50</div>
</div>
</div>
</div>
</section>
<!-- 证明材料 -->
<section class="card" data-page-node-id="0nBpDIAQYCPPsKK3V6Dcdf">
<div class="card-head" data-page-node-id="OGbHxmvAXt5qc7792De287">
<span class="bar" data-page-node-id="h7OOjcknF1mUCC7zh2tUJB"></span>
<h2 data-page-node-id="l5cbaC8S3XpGjKOfVXtT2o">证明材料</h2>
<span class="hint" data-page-node-id="rYBk78Ey7v1IZ8Zw6oJPlD">选填</span>
</div>
<div class="card-body" data-page-node-id="YRZq0roOiODObFtB7EFfyQ">
<div class="form-row" data-page-node-id="lPh9hGa5dUJJceCXbcFOsc">
<label data-page-node-id="ADJZDgRPUTBOiFUc9khFDy">图片上传</label>
<div class="field-control" data-page-node-id="ovpkFVnIsdCZWUcBN21EuO">
<label class="upload" data-page-node-id="sHylpiEVUllqifYR5Rjnux">
<div class="upload-icon" data-page-node-id="9jA1234YfjzKAqGtEXdfxv">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" data-page-node-id="jpo7HN1U8dyb10eTMZXWmJ"><path d="M12 16V4M7 9l5-5 5 5" data-page-node-id="jCYvVZ9bqQ3eWhd0JGCn6P"/><path d="M4 15v3a2 2 0 002 2h12a2 2 0 002-2v-3" data-page-node-id="mGAuau1yyJJ8rPVkHsc80i"/></svg>
</div>
<div data-page-node-id="pOYMzudSilp8anfhdX6NoT">
<div class="t" data-page-node-id="7270wGAv0zvuovLNixYv0F">点击或拖拽图片到此处<em data-page-node-id="4SBpaJ9F1SQmgcF4h1TXMQ">上传</em></div>
<div class="d" data-page-node-id="VWiqsopOKlryVzUEyNDV1N">支持 .jpg / .bmp / .png 格式,单张不超过 20MB,可多选</div>
</div>
</label>
</div>
</div>
<div class="form-row" data-page-node-id="g2rqW2Pg1mV7kxU5pjhc6A">
<label data-page-node-id="HaMeoFJEFIxuExlSEd5vdt">抄送人</label>
<div class="field-control" data-page-node-id="hPrQi3dsNAoaIbHYH12r7W">
<div class="cc-row" data-page-node-id="JYP4lZiwZHPvAxOSGfyPyT">
<span class="cc-chip" data-page-node-id="7Dm8zeNpe3f9cqzSHWmhgc"><span class="av" data-page-node-id="7mtBNgDqn01CMyOOYdqySn">王</span>王主任<span class="x" data-page-node-id="46ZNU30heTGESUTHBXl8VR">✕</span></span>
<span class="cc-chip" data-page-node-id="pGp1TbwLQQBtkMezEdPxIL"><span class="av" style="background:linear-gradient(135deg,#8fd3a8,#4caf7d)" data-page-node-id="Jo33PdZrLUHNLRd8Xcx491">张</span>张老师<span class="x" data-page-node-id="Nfqm6fh8IBpnqh8YxdSvi5">✕</span></span>
<button type="button" class="cc-add" data-page-node-id="kJjoQnQQqIGSW9RnjMssnA">+ 选择抄送人</button>
</div>
<div class="help-text" data-page-node-id="3I1len510FzvXASsKgVwL5">审批通过后,请假单将自动抄送给以上人员</div>
</div>
</div>
</div>
</section>
</form>
</main>
<!-- 底部操作栏 -->
<div class="action-bar" data-page-node-id="sHkY1QqGjh30YMgwDkGxyx">
<button class="btn btn-primary" type="submit" data-page-node-id="rEzE7N1bIe6Y4NKyeBKi6g">提交申请</button>
<button class="btn btn-ghost" type="button" data-page-node-id="fwFH5H3nWV0Q04QtHFZJTp">返回</button>
<button class="btn btn-ghost" type="button" data-page-node-id="ICHmTkrxmEPVAguA6wmbUo">重置</button>
</div>
</div>
<script>
// 分段选择器通用交互
document.querySelectorAll('.segmented').forEach(function(group){
group.addEventListener('click', function(e){
var btn = e.target.closest('button');
if (!btn) return;
group.querySelectorAll('button').forEach(function(b){ b.classList.remove('on'); });
btn.classList.add('on');
// 半天 / 半天以上 联动结束日期与时长
if (btn.textContent === '半天') {
document.querySelectorAll('.date-wrap .input')[1].value = document.querySelectorAll('.date-wrap .input')[0].value;
document.getElementById('len').textContent = '0.5';
} else if (btn.textContent === '半天以上') {
document.getElementById('len').textContent = '1.0';
}
});
});
</script>
</body>
</html>
+189
View File
@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="zh-CN" data-page-node-id="refVnJgepgGX7ooSSw1oNl">
<head data-page-node-id="CVAXxq86IWt6duxnFeTLA0">
<meta charset="UTF-8" data-page-node-id="79IZgCK1mSNOJYhwo4Fqxs">
<meta name="viewport" content="width=device-width, initial-scale=1" data-page-node-id="xfrrrb0wcshQP1YGHHoPEF">
<title>查询条件区(.search-wrap)视觉规范</title>
<style>
:root{
--brand:#577afb; --brand-hover:#3f63e8; --brand-bg:#eef3ff; --brand-ring:rgba(87,122,251,.12);
--text-main:#1c2333; --text-sub:#5b6478; --text-hint:#99a2b5;
--border:#e6ebf4; --border-hover:#c8d4ee; --panel-border:#e3eaf6; --divider:#ecedf3;
--page:#f5f7fa; --radius:8px; --radius-panel:10px;
}
*{box-sizing:border-box}
body{margin:0;padding:32px 28px 72px;background:var(--page);color:var(--text-main);
font:14px/1.65 "Microsoft YaHei","PingFang SC",-apple-system,Arial,sans-serif}
.wrap{max-width:1000px;margin:0 auto}
h1{font-size:22px;font-weight:600;margin:0 0 6px}
h2{font-size:16px;font-weight:600;margin:36px 0 14px;padding-left:11px;position:relative}
h2::before{content:"";position:absolute;left:0;top:4px;width:3px;height:16px;border-radius:2px;background:var(--brand)}
h3{font-size:14px;font-weight:600;margin:22px 0 10px;color:var(--text-sub)}
p.lede{margin:0 0 4px;color:var(--text-sub)}
p.note{color:var(--text-sub);font-size:13px;margin:8px 0}
code{font-family:Consolas,"Courier New",monospace;font-size:12.5px;background:#eef1f7;
color:#31405c;padding:1px 5px;border-radius:4px}
.card{background:#fff;border:1px solid var(--panel-border);border-radius:12px;padding:20px 22px;
box-shadow:0 2px 8px rgba(23,35,61,.05)}
.stage{background:#fff;border:1px solid var(--divider);border-radius:12px;padding:20px;margin-top:12px}
/* ---------- 真实组件复刻 ---------- */
.sw{display:flex;align-items:center;gap:12px;background:#fff;border:1px solid var(--panel-border);
border-radius:var(--radius-panel);height:56px;padding:10px 16px;box-shadow:0 2px 8px rgba(23,35,61,.05)}
.sw.form-only{gap:12px}
.sw .grp{display:flex;align-items:center;gap:12px;flex:1;min-width:0}
.ctl{height:32px;border:1px solid var(--border);border-radius:var(--radius);background:#fff;
display:flex;align-items:center;padding:0 10px;font-size:13px;color:var(--text-hint);
transition:border-color .18s,box-shadow .18s;white-space:nowrap}
.ctl.sel{width:150px;justify-content:space-between;padding-right:8px}
.dates{display:flex;align-items:center;gap:8px;flex:none}
.ctl.dt{width:140px;justify-content:space-between;padding-right:6px}
.tilde{color:var(--text-hint);font-size:13px;line-height:1;flex:none;user-select:none}
.ctl.kw{width:240px;padding-right:0;overflow:hidden}
.ctl.kw .ph{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}
.ctl.kw .mag{width:34px;text-align:center;color:var(--text-hint);font-size:16px;transition:color .18s;flex:none}
.ctl.hover{border-color:var(--border-hover)}
.ctl.focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-ring);color:var(--text-main)}
.ctl .val{color:var(--text-main)}
.caret{width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;
border-top:5px solid #b9c1d1;margin-left:8px;flex:none}
.btn{height:32px;padding:0 14px;border:1px solid var(--border);border-radius:var(--radius);background:#fff;
color:var(--text-sub);font-size:13px;display:inline-flex;align-items:center;gap:5px;cursor:pointer;transition:all .18s}
.btn.hover{border-color:#bccbf5;background:var(--brand-bg);color:var(--brand)}
.btn.primary{background:var(--brand);border-color:var(--brand);color:#fff}
.reset{margin-left:auto}
.tip{font-size:13px;color:var(--text-hint);white-space:nowrap}
.cal{color:var(--text-hint);font-size:14px}
.hintline{margin:8px 0 0;font-size:12.5px;color:var(--text-hint)}
table{border-collapse:collapse;width:100%;margin-top:12px;font-size:13px;background:#fff;
border:1px solid var(--panel-border);border-radius:10px;overflow:hidden}
th,td{padding:9px 12px;text-align:left;border-bottom:1px solid var(--divider);vertical-align:top}
th{background:#f7f9fc;font-weight:600;color:var(--text-sub);font-size:12.5px}
tr:last-child td{border-bottom:none}
td.k{white-space:nowrap}
.swatch{display:inline-block;width:22px;height:14px;border-radius:3px;border:1px solid rgba(0,0,0,.08);
vertical-align:-2px;margin-right:6px}
ul{margin:8px 0;padding-left:20px;color:var(--text-sub);font-size:13px}
ul li{margin:5px 0}
.ok::before{content:"✓ ";color:#18a058;font-weight:700}
.no::before{content:"✕ ";color:#e5484d;font-weight:700}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:12px}
@media (max-width:760px){.grid2{grid-template-columns:1fr}}
</style>
</head>
<body data-page-node-id="DbU6KA9kcHZ5A6UxgKvMfI">
<div class="wrap" data-page-node-id="rW64T9TP4LiTFfDjrquu1W">
<h1 data-page-node-id="XBvI0zZupskdTOnaDMGbrU">查询条件区 <code data-page-node-id="GPkR2fhCaNFbYKyyBT3IgV">.search-wrap</code> 视觉规范</h1>
<p class="lede" data-page-node-id="hONYABIgTfq5bSVWxG6tEc">请假模块 6 个列表页统一筛选条 · 全局面板 · 纯 CSS 实现(<code data-page-node-id="cDJHHzzpxTv1CxLDRNBMH9">layout.css</code>)</p>
<p class="note" data-page-node-id="ZMLNaHf0zr4syQVPove17Z">适用范围:我的请假 / 全部请假 / 抄送列表 / 审批列表 / 时长日志 / 统计分析</p>
<h2 data-page-node-id="IPlypUJTJtHiuL0BSfBMwf">一、容器(面板)</h2>
<p class="note" data-page-node-id="1EbMzEPnq7o6Fk67TWD9Lo">原先是一条通栏灰带(<code data-page-node-id="Yn76yVNJxLYwTVHI3LfS9C">#f7f7f7</code>、无圆角、<code data-page-node-id="bkxIcOD4CaGvpjQZQdHLLE">52px</code>),与白色页面板对比生硬、边界模糊。改为<b data-page-node-id="DIfqVVtuXcLQSYD69QsEU2">白底卡片</b>:靠细边框分层,靠极淡投影浮起。高度 <code data-page-node-id="4AXM8wEzUIBHl6aE1D21OV">56px</code> 与 <code data-page-node-id="vnbcG1k0YR5p6oy2TbV2PC">padding 10px</code> 是为了让 32px 控件上下各留 10px 余量,同时消化 1px 边框与聚焦光圈,不顶破容器。</p>
<div class="stage" data-page-node-id="uPGFVFQyeBY4mjVX8ag1eJ">
<div class="sw" data-page-node-id="7uhW2SibiLEn5DmZnGXosA">
<div class="grp" data-page-node-id="vzF3ugJNZRHwrP8phMkFrZ">
<div class="dates" data-page-node-id="datesPair1">
<div class="ctl dt" data-page-node-id="dtStart"><span class="ph" data-page-node-id="dtStartPh">开始时间</span><span class="cal" data-page-node-id="dtStartCal">▦</span></div>
<span class="tilde" data-page-node-id="dtTilde">至</span>
<div class="ctl dt" data-page-node-id="dtEnd"><span class="ph" data-page-node-id="dtEndPh">结束时间</span><span class="cal" data-page-node-id="dtEndCal">▦</span></div>
</div>
<div class="ctl sel" data-page-node-id="lQtAxbP9AOugipVbdv3vNF"><span data-page-node-id="fKHg0mqzTN0DK9TayNC2pB">请假类型</span><i class="caret" data-page-node-id="9h7WgGW5aqLb8IbBh99NEH"></i></div>
</div>
<button class="btn" data-page-node-id="cUwL5UCpHwSPcKwN8urCGf">↻ 重置</button>
<div class="ctl kw" data-page-node-id="UGXtduSh70kS1eWwRMcsr9"><span class="ph" data-page-node-id="9rIBncREbH2Qd7l4uqKeSZ">搜索申请人 / 事由</span><span class="mag" data-page-node-id="Iy8Cbt71L0a0P8IM8MQhX9">⌕</span></div>
</div>
<p class="hintline" data-page-node-id="uxMlxiBdRxgqtLqHoCtGjn">默认态:起止两个日期框等宽等距、同一基线,中间以弱化的「至」连接;右侧「重置 + 搜索」固定靠右</p>
</div>
<table data-page-node-id="tG3RfjQHTjxdVkIDxtLxOU">
<thead data-page-node-id="fpuTHBgahdVVQX7ZBGzfB5"><tr data-page-node-id="ascah5lsrAHODWkDiOWFqA"><th data-page-node-id="DlcWpzxi06FzRSzOVzFn3f">属性</th><th data-page-node-id="hdYdehWo113jtXi72Y9Fju">取值</th><th data-page-node-id="HGKv0IYBDZcop9a861xxum">说明</th></tr></thead>
<tbody data-page-node-id="h2KaDn1y1VIBiC7UGCo1J2">
<tr data-page-node-id="QoeUBBda9u7X0u5GOZ0EmA"><td class="k" data-page-node-id="wEKMQDoufZI6TdbKKOjiO8">background</td><td class="k" data-page-node-id="KUbufawueFV0Gw3WLXROMg"><span class="swatch" style="background:#fff" data-page-node-id="iSFbVJroPdgy8qEjzCPtWd"></span><code data-page-node-id="s3I3p54StlNATFY7sZ0zco">#fff</code></td><td data-page-node-id="CCAY5U4t1xu63XivNSvSdZ">与页面底板同色,靠边框与投影分层</td></tr>
<tr data-page-node-id="7IJlrVTuA4RF87nb8LEFNz"><td class="k" data-page-node-id="b51sfORV9f0LSEI7x9l5WF">border</td><td class="k" data-page-node-id="FyoC9KkCq9QDrZ2Iql7t3s"><span class="swatch" style="background:#e3eaf6" data-page-node-id="aTaWC50R3fj6YUWGueV5gQ"></span><code data-page-node-id="gny0RckX6yXzlKPaWOfUwA">1px solid #e3eaf6</code></td><td data-page-node-id="fWTRVSSV8pe1Cverwgoan1">比表格分隔线 <code data-page-node-id="NAzRLSTz96anmLZnRcIipH">#ecedf3</code> 略深偏冷,保证纯白底上仍看得见</td></tr>
<tr data-page-node-id="GREKnCOi6eUEBOyHFCyud8"><td class="k" data-page-node-id="htDrVwze4Hc6D7z8a4hto6">border-radius</td><td class="k" data-page-node-id="iSBFyfD7Q9JG4FrYdnIU2k"><code data-page-node-id="Fp9T3x2su9EVAaYeCdYyaq">10px</code></td><td data-page-node-id="ja0OWDCMjH5crX0vH6vtVv">面板用 10,内部控件用 8,形成层次差</td></tr>
<tr data-page-node-id="qP0jFXthyXTMeXCSl9Y2Bk"><td class="k" data-page-node-id="Oag8DX4Ri8hcesumZOD0zv">height</td><td class="k" data-page-node-id="w9Gq4DbHD2bTHg4JZnTTcR"><code data-page-node-id="1hI0axPpv0cHo2800tsxSW">56px</code>(含 <code data-page-node-id="VUMFCbZDSYyq8Isi5VxQpm">box-sizing:border-box</code>)</td><td data-page-node-id="C167BYbQGLDmzOLTyMRZ4n">10 + 32 + 10 + 边框,与改造前 <code data-page-node-id="qRsVpTyCB4uIBliM0QUrmW">52px</code> 竖向占位接近,不影响表格高度预算</td></tr>
<tr data-page-node-id="OmFqTMtu2J2HBS2JPVY7YB"><td class="k" data-page-node-id="pzRWQ8CJqXXfoFPG7wmMDH">box-shadow</td><td class="k" data-page-node-id="WLhJMrOWOwm3ut20oySaiF"><code data-page-node-id="62N46BysqNdTCHByrCdL5W">0 2px 8px rgba(23,35,61,.05)</code></td><td data-page-node-id="dENDS9SjxxB9BiggU3SmEJ">极淡,只做「浮起」暗示,不做视觉噪音</td></tr>
<tr data-page-node-id="QRbdfJ4rBDglARB8zfsJlX"><td class="k" data-page-node-id="2Rh3v7SM3oIGPrdeuCkmIF">layout</td><td class="k" data-page-node-id="sr0aAhzjNQXPitBcHeYFQ5"><code data-page-node-id="sIy1r0ScmNYUEnMKNQi4jN">display:flex; align-items:center</code></td><td data-page-node-id="kCVWFEsoXraH9EtVVUoK23">弃用 <code data-page-node-id="ni7xh9bVcC3AePrK23YUsZ">.fl/.fr</code> 浮动:条件项增减(抄送页无日期、统计页无搜索框)都不会挤在一起</td></tr>
</tbody>
</table>
<h2 data-page-node-id="tN7BpuYOc4k9gKCWYUyXCd">二、控件</h2>
<p class="note" data-page-node-id="VgPvVEPO3zcbTiwZaDFIOx">输入框、下拉、起止日期统一 <b data-page-node-id="CTfmtDSgg7hku0s0AjBvv3">32px 高 / 8px 圆角</b>,边框比面板边框略深一档,让控件在面板内可辨。同一对起止日期之间只允许「宽度、高度、边框、圆角、字号、内边距」逐项相同。</p>
<div class="stage" data-page-node-id="vqsuvAaSEDm70q4kp9gb2D">
<h3 data-page-node-id="we8dUQQAaK2RsCoU7Cim2X">交互状态</h3>
<div class="grid2" data-page-node-id="BHfPWJtg7YAT8ZyHcVCwsx">
<div data-page-node-id="kY3AnQtm20foUDMPBziuZX">
<div class="ctl" data-page-node-id="xDIqRQlyUqx16F0piMqjVY">默认态 · 占位文字 #99a2b5</div>
<p class="hintline" data-page-node-id="t0iRCmRAhXWOsQFGWOqJ35">border <code data-page-node-id="gwII8Ms08EVkb7CD4zEzbz">#e6ebf4</code></p>
</div>
<div data-page-node-id="gm7VJ6nfqat1awqIuFzoJ4">
<div class="ctl hover" data-page-node-id="BpSsuitTxy2TK69AzUIRLS">悬停态 · 边框加深提示可交互</div>
<p class="hintline" data-page-node-id="7FXiJo28SywvPbg1kbDUT8">border <code data-page-node-id="obJ4kh5DULF2IardAXJFwV">#c8d4ee</code></p>
</div>
<div data-page-node-id="oPPYXCPMVQRXqzXd5ZADMV">
<div class="ctl focus" data-page-node-id="LkBYO3E7n9Z41ERoLV0ABv"><span class="val" data-page-node-id="t7JlI9R1GzXdJTnP1tAJfk">聚焦态 · 品牌色描边</span></div>
<p class="hintline" data-page-node-id="bNAKsF3axHXEvYT1ucOSSy">border <code data-page-node-id="0v8vhsPHswGqS1QEE54qtf">#577afb</code> + <code data-page-node-id="tpDrSi5v4wAs2831bXuuNj">0 0 0 3px rgba(87,122,251,.12)</code></p>
</div>
<div data-page-node-id="d4tU7pcrUA5WmICoPIG9xB">
<div class="ctl kw" data-page-node-id="RoWvDBPMdj7oGoEiy0Rpg4"><span class="val" data-page-node-id="shQC4K4lKzdJUBaYMuyNE9">已输入内容</span><span class="mag" style="color:var(--brand)" data-page-node-id="VCHBO1FZhxRma8pj0P44iG">⌕</span><span style="width:22px;text-align:center;color:#b9c1d1" data-page-node-id="Bc6B6IEbM0qHcFNNeRLCI4">✕</span></div>
<p class="hintline" data-page-node-id="US54C6xCYvIKDykPdispN1">悬停整框时放大镜转品牌蓝;清空键位于其左侧,两个图标不叠在同一槽位</p>
</div>
</div>
</div>
<table data-page-node-id="fdYpbbuKVh1HSHLWCJMPr1">
<thead data-page-node-id="iX0QXUEgc713psvXTByFtq"><tr data-page-node-id="DcybrruoNoIrdIcLVfhxwr"><th data-page-node-id="4ojzKLjg0qDtFitCBTp5UA">控件</th><th data-page-node-id="NXsaQxFChbhCpOCvxZr8P2">宽 / 高</th><th data-page-node-id="1pEdK1Nh92gepb6Lg9JsGc">关键样式</th></tr></thead>
<tbody data-page-node-id="xtDxu6E8ONtrdSogSeFQjo">
<tr data-page-node-id="ckuGGk80I979kHjubl5vus"><td class="k" data-page-node-id="zXN5Co1t865AF9XbULrljr">起止日期对 <code data-page-node-id="jurMf0m45zZBAE81EIafRB">.search-wrap_dates</code></td><td class="k" data-page-node-id="aeFd8JgEeYlRkIVjVrzpm7">140 × 32(各)</td><td data-page-node-id="a1niDhil4ZLNI8ruDbxSfD"><code data-page-node-id="CS4A681EeynXQQwcTxvZQO">type="date" transfer</code>,两个独立输入框;容器为 <code data-page-node-id="dts1">display:flex; gap:8px</code>,保证等宽、等高、同一基线;中间 <code data-page-node-id="dts2">.search-wrap_tilde</code>(「至」)仅作语义连接</td></tr>
<tr data-page-node-id="dzzVQtgO9rYRNiunYSOIKK"><td class="k" data-page-node-id="jX4yLwQ81B699vGsQWBNnw">类型下拉 <code data-page-node-id="D4lFDrXSZRw21JGjTctHNP">.search-wrap_sel</code></td><td class="k" data-page-node-id="lkDd5DOzy1XRE5W8dHom1j">150 × 32</td><td data-page-node-id="g4wFN52eDCnodEMeKlpjdh">选中值与占位文字左右内边距 10px,行高 30px 视觉居中</td></tr>
<tr data-page-node-id="GRiNJGWHQYEFtkPte4ZwsX"><td class="k" data-page-node-id="v7jagjj1HIBM5oEdJQt1tV">关键词 <code data-page-node-id="oQmswz3zQvSq4ChR6jZYH0">.search-wrap_kw</code></td><td class="k" data-page-node-id="43LP4mEOBzrduWOonFW9N3">240 × 32</td><td data-page-node-id="Wr0beTVBEo4KwR9WMkMwtB"><code data-page-node-id="vjZ7l6MNXxOziZarU8ZTAW">search</code> 输入框;右内边距 40px 给放大镜让位</td></tr>
<tr data-page-node-id="SESuTlrknBIdUIONWrZ3DZ"><td class="k" data-page-node-id="YIkZYaVc3LA23nTIaxHImK">重置 <code data-page-node-id="GCjPasKYyawgDKOHnpPhE0">.search-wrap_reset</code></td><td class="k" data-page-node-id="FsgMCqu15fX04EIPShBCSG">自动 × 32</td><td data-page-node-id="VQe5JJ3AgmFJUA9Eflv3uI">幽灵按钮:白底 + 灰字,hover 才染品牌色,避免与查询按钮抢注意力</td></tr>
<tr data-page-node-id="i8u7uGTax2ULeF6LApAVYn"><td class="k" data-page-node-id="ADzFaW4ZOMcxHayHVE7DFB">查询(统计页)</td><td class="k" data-page-node-id="XOIQoUHhFHnGkfisxEE43P">自动 × 32</td><td data-page-node-id="nEXjPdOMT3JmYCCCdfUICc">沿用 <code data-page-node-id="9CgJHDFmFpezJCb4bBT6vU">type="primary"</code> 品牌蓝实心,全页仅此一处主按钮</td></tr>
</tbody>
</table>
<h2 data-page-node-id="PZSeLthAli8jssX38NAkjs">三、色板(与「我要请假」页同源)</h2>
<p class="note" data-page-node-id="GlOD7qtfB0GikmTLGJrryW">不引入第二套品牌色。所有取值复用 ApplyLeave 已有 token,保证跨页一致。</p>
<table data-page-node-id="Omhc5xvDwCdeeHzD6tVH2E">
<thead data-page-node-id="jYpqQDFNYzK1ZXHt8OXpG2"><tr data-page-node-id="SABlBVHK15IiJnvsp9FE5H"><th data-page-node-id="BsMHcO7vs5TyGdXZ7xHU55">用途</th><th data-page-node-id="yzUej8Ww4KLDKzn3RWmnKf">色值</th><th data-page-node-id="esUpjczOtzl3HEswAt27dD">Token</th></tr></thead>
<tbody data-page-node-id="BZpsl85mrd5HhjAhXBHCjm">
<tr data-page-node-id="jFplq0ZreBVbnWnFdWpvzf"><td class="k" data-page-node-id="xgPjcs6bYWfhx6v2WjkjUG">品牌主色</td><td class="k" data-page-node-id="LvdOiq7YOE5hBd5eK9Hnzq"><span class="swatch" style="background:#577afb" data-page-node-id="LywILAEbYRdhMwICQrTpsX"></span><code data-page-node-id="kpzFkB1qPzHercu3VEWuAk">#577afb</code></td><td class="k" data-page-node-id="KIPRGBT7YBgFkzONgDXQgk">$primary</td></tr>
<tr data-page-node-id="uZlgMOcPZLYXR8NvBtwDlW"><td class="k" data-page-node-id="ZbBeBCoS7hyopCRi04iVJQ">浅主色底</td><td class="k" data-page-node-id="WJ9nV2PeAoOTfcyxjhqEPt"><span class="swatch" style="background:#eef3ff" data-page-node-id="8tXIvaOZZZ6sjQ2316Zl47"></span><code data-page-node-id="TvGOsZ5PqXHXvuaQ3EigUQ">#eef3ff</code></td><td class="k" data-page-node-id="sFK3vjbPxLpv5YGwm09sDx">$primary-bg</td></tr>
<tr data-page-node-id="LagiXmsxYXua7IYHQhKdhF"><td class="k" data-page-node-id="alRRC7PePsJVUporRHh0bD">主文字</td><td class="k" data-page-node-id="XhGbNVau1JGxZYv6L54Fdm"><span class="swatch" style="background:#1c2333" data-page-node-id="KmlBLptF06Z6hOgkUgWPMe"></span><code data-page-node-id="Kzu8PjhN8lnpjB1mymxMDB">#1c2333</code></td><td class="k" data-page-node-id="BeyOno2BPhOwluzGRjCZQJ">$text-main</td></tr>
<tr data-page-node-id="iLyAGeZgMUirhZxUZcikNA"><td class="k" data-page-node-id="o6ff0IWdvIigtnKTqjdVIN">次文字</td><td class="k" data-page-node-id="FeXK1nHGTnCkTAl2kteTe3"><span class="swatch" style="background:#5b6478" data-page-node-id="2gtM0rIGhtBhfBqpoKJKm7"></span><code data-page-node-id="xUmfBbsDANtMQctONHON3Y">#5b6478</code></td><td class="k" data-page-node-id="FeDI9cpFZaQYkUUqTLLomf">$text-sub</td></tr>
<tr data-page-node-id="XaN85L6cGGg7zVkwaO4B2o"><td class="k" data-page-node-id="0sZyt0rgBvsZXG19nKP17T">提示 / 占位</td><td class="k" data-page-node-id="PBcV0Bi2hySCqD1y4mHzhi"><span class="swatch" style="background:#99a2b5" data-page-node-id="HBz1olJw1h53kuE9d7HKnM"></span><code data-page-node-id="kyuPJAtngFX8ZDupmIrcD7">#99a2b5</code></td><td class="k" data-page-node-id="UnXH0vZRNU4iZnlHyrGKH8">$text-hint</td></tr>
<tr data-page-node-id="18te0UZwqUBEH7eXzTx3xv"><td class="k" data-page-node-id="uF086wMjQTZCNFWQIhoJDf">控件边框</td><td class="k" data-page-node-id="fKR3N6FI1kQoOoFiPzL7HY"><span class="swatch" style="background:#e6ebf4" data-page-node-id="jGtNVoNvPjsUM9nNpNOhFR"></span><code data-page-node-id="894kOUWEF92GRE9DVyKmNn">#e6ebf4</code></td><td class="k" data-page-node-id="Hs2ErCvJcsL8vbPQZ2TjPo">$border</td></tr>
<tr data-page-node-id="ayQAFnpkLws9R0BTN7GB0D"><td class="k" data-page-node-id="Of2ra8ljr5dXMaHz5rurM3">面板边框</td><td class="k" data-page-node-id="pzwXEGGT2gJ4tkdl7FF6Hp"><span class="swatch" style="background:#e3eaf6" data-page-node-id="KdhHhXEIYK0ZShgBkd1C7R"></span><code data-page-node-id="NLB2kuodccqIjGgEnYYNhe">#e3eaf6</code></td><td class="k" data-page-node-id="n4rHgUEXYxIsKuL7bKo7I9">—</td></tr>
</tbody>
</table>
<h2 data-page-node-id="KvlGBmLwf3q6MKa3IxTiEY">四、无障碍与可用性</h2>
<ul data-page-node-id="JFMuZFMsHXI1WtNHzPlPTf">
<li class="ok" data-page-node-id="lBDmUg2zds2YtDCnuYvaOp">所有可交互控件带 <code data-page-node-id="5mPwrHQ3DBqwkwjFs3S7bh">:focus-visible</code> 外描边(<code data-page-node-id="fAFrcsfRsClGzSggj7XHKQ">2px #577afb</code>),键盘 Tab 可完整走通「日期 → 类型 → 重置 → 搜索」。</li>
<li class="ok" data-page-node-id="IA8EGii0NGaioFWIN7DaTZ">聚焦态用「描边 + 3px 柔光圈」双重提示,不依赖单一颜色差异。</li>
<li class="ok" data-page-node-id="ooZ4ilRHSDV3DhqtBdFa7u">控件高度 32px;表单页输入框沿用 32px,跨页命中区域一致。</li>
<li class="ok" data-page-node-id="vSLBkXGIukoX5jGz2FC3i9">占位文字统一 <code data-page-node-id="5Q53VBBac6wWZAHtB9nEQg">#99a2b5</code>,与其上白底对比度约 2.9:1 —— 仅用于占位(WCAG 对占位文字不作 4.5:1 强制),实际输入值走 <code data-page-node-id="zF0Z2UdHvPOgCMWttFVcyU">#1c2333</code>(≈15:1)。</li>
<li class="ok" data-page-node-id="JF61H7vs5WXIIaAFfALeiF">「重置」清空起止时间 + 关键词 + 状态页签并回到第 1 页,行为可预期。</li>
<li class="ok" data-page-node-id="dtsAcc1">起止两个日期框:宽度差 &lt; 0.5px、高度差 &lt; 0.5px、顶边与垂直中心线偏差 &lt; 0.5px,且边框/圆角/字号/内边距逐项一致 —— 已纳入自动化断言。</li>
<li class="ok" data-page-node-id="dtsAcc2">两个日期框均为可编辑态,点击输入框本体即弹出月历(无需只点右侧图标),两框交互完全一致。</li>
</ul>
<h2 data-page-node-id="3HJ08rehnOnSEbse9GKrUn">五、实现要点(易错处)</h2>
<ul data-page-node-id="4PuBZIUS3mLGEmE2ANvjSK">
<li class="no" data-page-node-id="Gx72dYzZGUw9HBWWEKLh7u">iView 的 <code data-page-node-id="azAUwrONE05RIp1Qvwn9qG">search</code> 输入框放大镜类名是 <code data-page-node-id="JGBezSukH1uowmPKnIEOqa">.ivu-input-search-icon</code>(绝对定位),<b data-page-node-id="2UAo766Cul5Mwslo9QMEHP">没有</b> <code data-page-node-id="l7iVcajhxV6dJuM1cAHkjN">.ivu-input-suffix</code> 包裹层;按 suffix 写样式会全部失效。</li>
<li class="no" data-page-node-id="oSaQCdSI1q2xfqFwZ5FLWV">日期面板的根类名是 <code data-page-node-id="HLufqTlSLAPXWTol7qaDik">.ivu-picker-panel-body-wrapper</code>,不是 <code data-page-node-id="I9EGDshkHcX3svaXdAbksO">.ivu-picker-panel</code>;且 <code data-page-node-id="dtTrans">transfer</code> 会把所有 picker 的面板都挂到 body,统计可见面板前必须先按 <code data-page-node-id="dtOffPar">offsetParent !== null</code> 过滤。</li>
<li class="no" data-page-node-id="9pZxdEQrVEz39xi1B4XwT7"><code data-page-node-id="Prz1tPH0UovAfl1AUmllKB">:editable="false"</code> 会让内部 input 变 readonly,<b data-page-node-id="RbnqMod3IXv6WHZEFKYNFF">点输入框本体会失效</b>(只有日历图标可用)。因此起止日期框保持默认可编辑,保证「点框即弹出」的一致体验。</li>
<li class="ok" data-page-node-id="DkPAGzfOgk64BZJY0gwzfX">全部新规则以 <code data-page-node-id="w4qcyGyTSfTJYDQIMcb3xH">.search-wrap</code> 为前缀 → 作用域隔离;实测仅 6 个列表页使用该容器,其它页面零影响。</li>
<li class="no" data-page-node-id="dtSrvNote">业务 dev 端口(如 8081)的 vite 对<b data-page-node-id="dtSrvNote2">任何浏览器导航</b>都返回 <code data-page-node-id="dtSrvNote3">index.html</code>(curl 用 <code data-page-node-id="dtSrvNote4">Accept: */*</code> 才能拿到真实文件),自定义预览页在浏览器里会直接启动真实 SPA 并被 CAS 跳去 SSO。故「渲染验收」在独立预览服务上做,「该端口代码是否已生效」用直接抓取模块源码确认。</li>
<li class="no" data-page-node-id="9pZxdEQrVEz39xi1B4XwT7"><code data-page-node-id="Prz1tPH0UovAfl1AUmllKB">:editable="false"</code> 会让内部 input 变 readonly,<b data-page-node-id="RbnqMod3IXv6WHZEFKYNFF">点输入框本体会失效</b>(只有日历图标可用)。因此起止日期框保持默认可编辑,保证「点框即弹出」的一致体验。</li>
<li class="ok" data-page-node-id="DkPAGzfOgk64BZJY0gwzfX">全部新规则以 <code data-page-node-id="w4qcyGyTSfTJYDQIMcb3xH">.search-wrap</code> 为前缀 → 作用域隔离;实测仅 6 个列表页使用该容器,其它页面零影响。</li>
</ul>
</div>
</body>
</html>
+154
View File
@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="zh-CN" data-page-node-id="c8jJRN3qgiN5kfAt3vgZYD">
<head data-page-node-id="WMNABuuAvIt65s6192UA4K">
<meta charset="UTF-8" data-page-node-id="Yk6AGtVnERulInfIlfo8Ck" />
<title>左侧导航栏视觉升级 · 效果预览</title>
<style>
/* ===== 复制自 src/assets/styles/layout.css 的真实规则 ===== */
*{font-family:"微软雅黑","Microsoft YaHei","Helvetica Neue",Helvetica,"PingFang SC","Hiragino Sans GB",Arial,sans-serif}
body{margin:0;background:#fff}
.left-menu{
position:fixed;background:#fff;color:#272829;width:216px;left:0;top:0;bottom:0px;
display:flex;flex-direction:column;z-index:9;overflow:auto;
border-right:1px solid #EFF1F5;
padding:10px 0 20px;
box-sizing:border-box;
}
.left-menu .menu-top-box,.left-menu .clear-box{border-right:none;height:0;flex:none}
.left-menu ul{list-style:none;margin:0;padding:0}
.left-menu li{
height:44px;margin:2px 12px;position:relative;border:none;border-radius:8px;
background:transparent;transition:background-color .2s ease,color .2s ease;
}
.left-menu li a{
display:flex;align-items:center;height:44px;width:100%;padding:0 12px;line-height:1;
box-sizing:border-box;border:none;color:#4b5568;font-size:14px;letter-spacing:.2px;
outline:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:none;
cursor:pointer;user-select:none;
}
.left-menu li a .menu-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.left-menu li a .menu-icon{font-size:18px;line-height:1;margin-right:10px;flex:none;color:#98a2b3;transition:color .2s ease}
.left-menu li:hover{background:#f5f7fb}
.left-menu li:hover a{color:#1f2a44}
.left-menu li:hover a .menu-icon{color:#577afb}
.left-menu li.active{background:#eff4ff;border:none}
.left-menu li.active::before{
content:'';position:absolute;left:0;top:50%;transform:translateY(-50%);
width:3px;height:18px;border-radius:0 3px 3px 0;background:#577afb;
}
.left-menu li.active a,.left-menu li.active a:hover{color:#3a63e0;font-weight:600}
.left-menu li.active a .menu-icon{color:#3a63e0}
.left-menu li.menu-group{margin-top:10px}
.left-menu li.menu-group > a{color:#667085;font-weight:600}
.left-menu li.menu-group > a .menu-icon{font-size:17px}
.child-item-wrap{transition:all .3s ease-in-out;overflow:hidden}
.left-menu li.child-item{height:38px;margin:2px 12px}
.left-menu li.child-item a{margin-left:0;padding-left:14px;color:#6b7280;font-size:13px}
.left-menu li.child-item a .child-dot{
position:relative;width:5px;height:5px;margin-right:10px;flex:none;border-radius:50%;
background:#d0d5dd;transition:background-color .2s ease,transform .2s ease;
}
.left-menu li.child-item:hover a .child-dot{background:#9db4f7;transform:scale(1.2)}
.left-menu li.child-item.active a{color:#3a63e0;font-weight:600}
.left-menu li.child-item.active a .child-dot{background:#577afb;box-shadow:0 0 0 3px rgba(87,122,251,.18)}
.left-menu::-webkit-scrollbar{width:6px}
.left-menu::-webkit-scrollbar-thumb{background:#e2e6ef;border-radius:3px}
.left-menu::-webkit-scrollbar-track{background:transparent}
/* 占位图标(无 iconfont 字体文件时用方块示意真实 18px 图形) */
.ph{flex:none;width:18px;height:18px;margin-right:10px;border-radius:5px;
background:#dfe4ee;display:flex;align-items:center;justify-content:center}
.ph svg{width:12px;height:12px;stroke:#98a2b3;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.left-menu li:hover .ph svg,.left-menu li.active .ph svg{stroke:#577afb}
.left-menu li.menu-group .ph{width:17px;height:17px}
.menu-arrow{
position:absolute;right:14px;top:50%;width:7px;height:7px;
border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;
background:none;transform-origin:center;transition:transform .25s ease;margin-top:-5px;opacity:.55;
}
.menu-arrow.arrow-up{transform:rotate(225deg)}
.menu-arrow.arrow-down{transform:rotate(45deg)}
.stage{margin-left:216px;padding:28px 32px}
.stage h2{font-size:15px;color:#17233d;margin:0 0 6px}
.stage p{font-size:13px;color:#808695;margin:0;line-height:1.9}
.tag{display:inline-block;background:#f2f5ff;color:#3a63e0;font-size:12px;
padding:3px 9px;border-radius:11px;margin:0 6px 6px 0}
</style>
</head>
<body data-page-node-id="3h7DE4QC674Nnv72KJMbX0">
<div class="left-menu" data-page-node-id="vabeCjHmuEHr2IaWKMTE6S">
<ul data-page-node-id="aTKRGUyuILFdYCwefWUzoi">
<li class="menu-item" data-page-node-id="pR24sUgCtatpckTYmXe3we"><a href="javascript:;" data-page-node-id="tgXpBVqM3EATjswoGGHS7H">
<i class="ph" data-page-node-id="tAKj2fjiNrwddnGqCxArvQ"><svg viewBox="0 0 24 24" data-page-node-id="jxz7O7CtBup4o32mfQEEiy"><path d="M4 4h16v16H4z" data-page-node-id="uGb3wfRZSxUM2pbmg2OEiZ"/><path d="M8 9h8M8 13h5" data-page-node-id="DJaWHf3WkzWRR67pOrByur"/></svg></i>
<span class="menu-text" data-page-node-id="WXmF7gJBXMBQ1N3joLEvvO">我要请假</span></a></li>
<li class="menu-item" data-page-node-id="oXDXp7oyU7UPkudPnM4PM7"><a href="javascript:;" data-page-node-id="88dBWBfxk83WmuzU9NhydP">
<i class="ph" data-page-node-id="eoMgHd82AW7JktRn4JwfdX"><svg viewBox="0 0 24 24" data-page-node-id="A1pcU27FJzu1AbUsqzIqVf"><rect x="4" y="4" width="16" height="16" rx="2" data-page-node-id="VFPTbMHfjLRJAQOZ7n241p"/><path d="M8 9h8M8 13h8M8 17h5" data-page-node-id="wzmPhFWvYKXC6IUcWeuRO8"/></svg></i>
<span class="menu-text" data-page-node-id="a3Z2s2BpAL4LvBpJhKOjmT">我的请假</span></a></li>
<li class="menu-item" data-page-node-id="sIvEaACTM1PMS0H9JbWF5m"><a href="javascript:;" data-page-node-id="2h1QUpErVCz6qhjz8Nyoux">
<i class="ph" data-page-node-id="OgOJGh7zrPBZgG9tsmZOW6"><svg viewBox="0 0 24 24" data-page-node-id="ezV60F6HRKYpYmke4FEZq2"><path d="M9 11l3 3 8-8" data-page-node-id="SNP8F4GeGQSIY2kNGdRyHn"/><path d="M20 12v7H4V5h11" data-page-node-id="dq9mg7vdSQgcOXNceWutCB"/></svg></i>
<span class="menu-text" data-page-node-id="gumzYNtFnRuxWTwuV0bBFh">审批列表</span></a></li>
<li class="menu-item" data-page-node-id="PJGxm3sQlT5h2UdzgRA7Vw"><a href="javascript:;" data-page-node-id="F9YkIlNR3Qp4rMJZTuvIXc">
<i class="ph" data-page-node-id="mnBQDL5W34ZEyRRiRNqJOV"><svg viewBox="0 0 24 24" data-page-node-id="ZoE3ViWjgzdRR8T3zTJ2af"><path d="M3 11l18-8-8 18-2-8z" data-page-node-id="oiSGCEHHQBql0vGsKrj42A"/></svg></i>
<span class="menu-text" data-page-node-id="y0COJXTTgACj9foNSo38di">抄送列表</span></a></li>
<li class="menu-item" data-page-node-id="phsM2T0lQ3y8wW6msFYHnU"><a href="javascript:;" data-page-node-id="s0aPHxAKM91LdbE4Un0nsS">
<i class="ph" data-page-node-id="JUYuXRqZXdeS1mcym3lpzh"><svg viewBox="0 0 24 24" data-page-node-id="gTYhHKmOcYRsUbfN9utE3C"><rect x="3" y="4" width="18" height="16" rx="2" data-page-node-id="mNnoNfYbDK8nNzoWytGRCw"/><path d="M3 9h18M8 13h8" data-page-node-id="8lPJSF8kH0RkWzOZzQi6iK"/></svg></i>
<span class="menu-text" data-page-node-id="KppZsMkJhdN5dWPWO29B0p">全部请假</span></a></li>
<li class="menu-item" data-page-node-id="IEO5ewQPGUBEUVlLDH9vXl"><a href="javascript:;" data-page-node-id="M92YdKNLMUYwSLCj2f2rQb">
<i class="ph" data-page-node-id="9CYJ7bW7QdVAgimyElAZMm"><svg viewBox="0 0 24 24" data-page-node-id="VscXa3xrz5TUYXp0wCBQ3d"><path d="M4 20l16-16" data-page-node-id="MnVc30edhOZoEiHoUvKHFF"/><circle cx="8" cy="8" r="3" data-page-node-id="DJwnbswxrv1OSVXnG2jcKY"/><path d="M14 14l4 4" data-page-node-id="jYqP7D6B7mblk6qMJ60h9i"/></svg></i>
<span class="menu-text" data-page-node-id="eLZVIN95qaofw6MSBqDaa7">指派代课</span></a></li>
<li class="menu-group" data-page-node-id="V4si42iDLHug7ceQLQgEXC">
<a href="javascript:;" data-page-node-id="ivZ7JBNQGaLqorfPkh9pwY">
<i class="ph" data-page-node-id="iy6lh8HGmHmEJtZwosHjBt"><svg viewBox="0 0 24 24" data-page-node-id="5dDzVscj2TCMGXHrSDL6NV"><rect x="3" y="3" width="7" height="7" rx="1" data-page-node-id="npPqa6NxdIbfo7T40hByZ4"/><rect x="14" y="3" width="7" height="7" rx="1" data-page-node-id="hJv7jqMLxtFiXi1sL5WAeu"/><rect x="3" y="14" width="7" height="7" rx="1" data-page-node-id="injDikerKDZNRMEFc4jWDO"/><path d="M17.5 14v7M14 17.5h7" data-page-node-id="Kwe7pIYmWMI9bAf19SDWlL"/></svg></i>
<span class="menu-text" data-page-node-id="25sokshNMmWhyWw8z8jR7i">流程管理</span>
<i class="menu-arrow arrow-down" data-page-node-id="nrb1S6BJHKnjDiFD4Iq1qm"></i>
</a>
</li>
<div class="child-item-wrap" style="max-height:none" data-page-node-id="jfzvi3DtJtGhTUrll4vwF3">
<li class="child-item" data-page-node-id="0PTYj1y32gzIAEdfitLJkK"><a href="javascript:;" data-page-node-id="wQgGo2UjhG6xLhhHZ9PrUn"><i class="child-dot" data-page-node-id="wYtfFYu9fCvpWAh3i0tk8y"></i><span class="menu-text" data-page-node-id="3aYROZTDN0J8fwxPF30LoG">流程配置</span></a></li>
<li class="child-item active" data-page-node-id="9ERi4TshqkBopLMjhOAA9W"><a href="javascript:;" data-page-node-id="AOLoECfunwtLmP3FOE2FjK"><i class="child-dot" data-page-node-id="u0KnAqW981QLBkOJosnHB8"></i><span class="menu-text" data-page-node-id="reRq6ZLvNdhZdjaQ5WjYyW">审批节点</span></a></li>
<li class="child-item" data-page-node-id="4Ecn91IMUtiCrmka8QsGHe"><a href="javascript:;" data-page-node-id="RUD5TjLsDxuShMn19XQG50"><i class="child-dot" data-page-node-id="1g5xWpTHXOFdg85cllnN21"></i><span class="menu-text" data-page-node-id="VKd9Ch47JAHmLsJSbIVRSv">代理人设置</span></a></li>
</div>
<li class="menu-item active" data-page-node-id="KtQC3tV7nO2Kx2Jsqr2wQQ"><a href="javascript:;" data-page-node-id="GpE9yU3keYBWFwBOQrxKca">
<i class="ph" data-page-node-id="B4QP1pM6mUY8seklb9vkI8"><svg viewBox="0 0 24 24" data-page-node-id="9LDP9MlN4xsKtlJ3X5SSmH"><circle cx="12" cy="12" r="9" data-page-node-id="lcBfpbb9V1Nkg1AGUsoGa8"/><path d="M12 7v5l3 2" data-page-node-id="0iGRTaaf9ot1pCATgHoMjS"/></svg></i>
<span class="menu-text" data-page-node-id="QkBL0DhBIlTzULvISU2PES">请假设置</span></a></li>
<li class="menu-item" data-page-node-id="cEQEBjCOQFrl7EuBTkYTKF"><a href="javascript:;" data-page-node-id="41CEH2TvHevASbeXtC6r5J">
<i class="ph" data-page-node-id="w5o5sGUrMTchDYDE0KfFDC"><svg viewBox="0 0 24 24" data-page-node-id="4MZS8wqRTnOmEUjfPl2Oao"><rect x="3" y="4" width="18" height="17" rx="2" data-page-node-id="3rFDH9IV09tfkquR63MERi"/><path d="M8 2v4M16 2v4M3 9h18" data-page-node-id="XcRzuCbuHl5P6kZ5wwIg8o"/></svg></i>
<span class="menu-text" data-page-node-id="kqQev70pCcovhIvosXodiI">时长日志</span></a></li>
<li class="menu-item" data-page-node-id="EfJ26ZtRvVERXkvYY8rfdZ"><a href="javascript:;" data-page-node-id="TZvSBwSVjLmMqJOqSA0jfD">
<i class="ph" data-page-node-id="5hKf0Rhfvl4MhFxkcipyYo"><svg viewBox="0 0 24 24" data-page-node-id="pkrRH2F6yvoS5j7rGSfhl9"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2" data-page-node-id="c8lYmIueRvwMcNjKYKEt7T"/></svg></i>
<span class="menu-text" data-page-node-id="BxJzeNPXAmGEiymbZ58zi8">统计分析</span></a></li>
</ul>
<div class="clear-box" data-page-node-id="jtANDCzzW5qwaFn5cWfHC0"></div>
</div>
<div class="stage" data-page-node-id="uhFczKbBxjardMiksRvArY">
<h2 data-page-node-id="CjgddVGOLTFAwLRgqSi0rk">主内容区(216px 偏移)</h2>
<p data-page-node-id="CY3hnDE5BHZ4lBhC7UDL02">
<span class="tag" data-page-node-id="iTjNhLrL0yQJ4eZWinfFFE">216px 侧栏</span><span class="tag" data-page-node-id="KCobNeHbQht7wOmHGEJKzh">44px 行高</span><span class="tag" data-page-node-id="Z0VUB03L72jPcYx3H6Cnm9">8px 圆角</span>
<span class="tag" data-page-node-id="KtZgxywfpuBBocYHFkRQFI">12px 侧边留白</span><span class="tag" data-page-node-id="GCCOb5yw34rKBF5U5w1H44">胶囊选中态</span><span class="tag" data-page-node-id="hM2VNDxm7XXYesXxQtiXAK">左侧指示条</span>
<span class="tag" data-page-node-id="NeMaYeSBIJMcHWp8TcIYqV">图标随状态变色</span><span class="tag" data-page-node-id="NphJ4aZFHCEajbIQGA0Zyw">子级圆点层级</span>
</p>
<p style="margin-top:14px;color:#98a2b3" data-page-node-id="hCu6JMbkjWLPQAI8lt7fcB">
鼠标悬停任一菜单项可查看 hover 态(浅灰底 #f5f7fb + 图标转品牌蓝)。
</p>
</div>
</body>
</html>
+180
View File
@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>图片上传样式预览</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f5f7fa;
padding: 32px 24px 56px;
color: #1c2333;
}
.wrap { max-width: 1080px; margin: 0 auto; }
h2 { font-size: 16px; font-weight: 600; margin-bottom: 6px; }
.desc { font-size: 13px; color: #8b93a7; margin-bottom: 20px; }
.card {
background: #fff; border-radius: 10px; padding: 24px 28px;
margin-bottom: 28px; box-shadow: 0 1px 3px rgba(28,35,51,.06);
}
.label { font-size: 14px; color: #5b6478; margin-bottom: 12px; }
/* ===== 与 AttachmentUpload.vue imageOnly 模式样式保持一致 ===== */
$primary: #577afb;
.img-drag-upload { display: block; width: 100%; }
.img-drag-upload .ivu-upload-drag {
display: block; width: 100%; padding: 26px 28px;
background: #fbfcfe; border: 1px dashed #e6ebf4; border-radius: 8px;
transition: border-color .2s ease, background .2s ease; cursor: pointer;
}
.img-drag-upload .ivu-upload-drag:hover { background: #f8faff; border: 1px dashed rgba(87,122,251,.5); }
.img-drag-upload .ivu-upload-dragOver { background: #eef3ff; border: 1px dashed #577afb; }
.drag-inner { display: flex; align-items: center; gap: 16px; user-select: none; }
.drag-icon {
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
width: 56px; height: 56px; border-radius: 8px; background: #eef3ff; color: #577afb;
}
.drag-icon svg { width: 26px; height: 26px; }
.drag-text { min-width: 0; }
.drag-title { font-size: 15px; line-height: 22px; color: #5b6478; }
.drag-link { color: #577afb; }
.drag-sub { margin-top: 4px; font-size: 13px; line-height: 20px; color: #99a2b5; }
/* ===== 缩略图网格(上传成功后) ===== */
.img-preview-list { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; }
.img-preview-item {
width: 108px; border: 1px solid #e6ebf4; border-radius: 6px;
overflow: hidden; background: #fff; transition: box-shadow .2s;
}
.img-preview-item:hover { box-shadow: 0 2px 10px rgba(87,122,251,.18); border-color: #577afb; }
.img-thumb-box { position: relative; width: 100%; height: 80px; cursor: pointer; background: #f6f8fc; }
.img-thumb { width: 100%; height: 80px; object-fit: cover; display: block; }
.img-mask {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 2px; color: #fff;
font-size: 12px; background: rgba(28,35,51,.45); opacity: 0; transition: opacity .2s;
}
.img-preview-item:hover .img-mask { opacity: 1; }
.img-meta { display: flex; align-items: center; padding: 4px 6px; border-top: 1px solid #f0f3f9; }
.img-name {
flex: 1; font-size: 12px; color: #5b6478; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.img-del { color: #c5c8ce; cursor: pointer; font-size: 18px; line-height: 1; }
.img-del:hover { color: #e5484d; }
.tag {
display: inline-block; font-size: 12px; padding: 2px 8px; border-radius: 4px;
margin-bottom: 12px; background: #eef3ff; color: #577afb;
}
.thumb-holder { width: 108px; height: 80px; background: linear-gradient(135deg,#c7d7ff,#9fb6f7); }
</style>
</head>
<body>
<div class="wrap">
<h2>图片上传样式预览</h2>
<p class="desc">对应组件:src/components/AttachmentUpload.vue(imageOnly 模式)— 应用页 src/views/leave/ApplyLeave.vue</p>
<!-- 状态1:默认 -->
<div class="card">
<span class="tag">默认态</span>
<div class="label">图片</div>
<div class="img-drag-upload">
<div class="ivu-upload-drag">
<div class="drag-inner">
<div class="drag-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><path d="M16 16l-4-4-4 4"/><path d="M12 12v9"/>
</svg>
</div>
<div class="drag-text">
<div class="drag-title">点击或拖拽图片到此处<span class="drag-link">上传</span></div>
<div class="drag-sub">支持 .jpg / .bmp / .png 格式,单张不超过 20MB,可多选</div>
</div>
</div>
</div>
</div>
</div>
<!-- 状态2:hover -->
<div class="card">
<span class="tag">悬停态</span>
<div class="label">图片</div>
<div class="img-drag-upload">
<div class="ivu-upload-drag" style="background:#f8faff; border:1px dashed rgba(87,122,251,.5);">
<div class="drag-inner">
<div class="drag-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><path d="M16 16l-4-4-4 4"/><path d="M12 12v9"/>
</svg>
</div>
<div class="drag-text">
<div class="drag-title">点击或拖拽图片到此处<span class="drag-link">上传</span></div>
<div class="drag-sub">支持 .jpg / .bmp / .png 格式,单张不超过 20MB,可多选</div>
</div>
</div>
</div>
</div>
</div>
<!-- 状态3:拖拽经过 -->
<div class="card">
<span class="tag">拖拽经过态(dragOver)</span>
<div class="label">图片</div>
<div class="img-drag-upload">
<div class="ivu-upload-drag" style="background:#eef3ff; border:1px dashed #577afb;">
<div class="drag-inner">
<div class="drag-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><path d="M16 16l-4-4-4 4"/><path d="M12 12v9"/>
</svg>
</div>
<div class="drag-text">
<div class="drag-title">点击或拖拽图片到此处<span class="drag-link">上传</span></div>
<div class="drag-sub">松开鼠标即可上传</div>
</div>
</div>
</div>
</div>
</div>
<!-- 状态4:已上传 -->
<div class="card">
<span class="tag">已上传态(缩略图 + 点击放大)</span>
<div class="label">图片</div>
<div class="img-drag-upload">
<div class="ivu-upload-drag">
<div class="drag-inner">
<div class="drag-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><path d="M16 16l-4-4-4 4"/><path d="M12 12v9"/>
</svg>
</div>
<div class="drag-text">
<div class="drag-title">点击或拖拽图片到此处<span class="drag-link">上传</span></div>
<div class="drag-sub">支持 .jpg / .bmp / .png 格式,单张不超过 20MB,可多选</div>
</div>
</div>
</div>
</div>
<div class="img-preview-list">
<div class="img-preview-item">
<div class="img-thumb-box">
<div class="img-thumb thumb-holder"></div>
<div class="img-mask"><span>查看大图</span></div>
</div>
<div class="img-meta"><span class="img-name">病历单.jpg</span><span class="img-del">✕</span></div>
</div>
<div class="img-preview-item">
<div class="img-thumb-box">
<div class="img-thumb" style="background:linear-gradient(135deg,#d5e8d0,#9ec9a1);"></div>
<div class="img-mask"><span>查看大图</span></div>
</div>
<div class="img-meta"><span class="img-name">证明材料_01.png</span><span class="img-del">✕</span></div>
</div>
</div>
</div>
</div>
</body>
</html>
+349
View File
@@ -0,0 +1,349 @@
<!DOCTYPE html>
<html lang="zh-CN" data-page-node-id="GiF1pjpTSsFZcToUsJOSy3">
<head data-page-node-id="nOsb0mCEhfByiN5FJCR7C8">
<meta charset="UTF-8" data-page-node-id="gNADgqtE6orZYZd4BDzNbc" />
<meta name="viewport" content="width=device-width, initial-scale=1" data-page-node-id="KVNGXCPSRCxEOcgHtQ1mjy" />
<title>教师时长 · 页面设计预览</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
padding: 24px;
background: #f2f4f8;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #17233d;
font-size: 14px;
}
.wrap { max-width: 1440px; margin: 0 auto; }
.crumb { color: #808695; font-size: 13px; margin-bottom: 10px; }
.crumb b { color: #17233d; }
.card { background: #fff; border-radius: 6px; box-shadow: 0 1px 4px rgba(0,21,41,.08); }
/* tabs */
.tabs { display: flex; gap: 34px; padding: 0 20px; border-bottom: 1px solid #eef0f5; background: #fff;
border-radius: 6px 6px 0 0; }
.tab { padding: 12px 2px; color: #515a6e; position: relative; font-size: 14px; }
.tab.on { color: #577afb; font-weight: 600; }
.tab.on::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px;
background: #577afb; border-radius: 2px; }
.body { padding: 16px 20px 20px; }
/* managed */
.managed {
padding: 14px 16px;
background: linear-gradient(135deg,#f7f9ff 0%,#fbfcff 100%);
border: 1px solid #e4ebff; border-radius: 8px;
}
.managed-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
.mhl { display: flex; align-items: flex-start; gap: 10px; }
.mh-icon { color: #577afb; font-size: 19px; line-height: 22px; }
.managed-title { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 3px; font-size: 11px; font-weight: 400; line-height: 17px; }
.tag.success { background: #f0f9eb; color: #19be6b; border: 1px solid #e1f3d8; }
.managed-tip { margin-top: 3px; color: #808695; font-size: 12px; line-height: 18px; }
.btn {
display: inline-flex; align-items: center; gap: 5px; height: 32px; padding: 0 15px;
border-radius: 4px; border: 1px solid transparent; font-size: 14px; cursor: pointer; background: #fff;
}
.btn.ghost { color: #577afb; border-color: #b4c4fb; background: #f7f9ff; }
.btn.primary { background: #577afb; color: #fff; }
.btn.plain { border-color: #dcdee2; color: #515a6e; }
.btn.sm { height: 28px; padding: 0 12px; font-size: 13px; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.managed-types { padding: 10px 12px; background: #fff; border: 1px solid #eef1f7; border-radius: 6px; }
.cbx { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 4px; font-size: 13px;
color: #515a6e; margin-right: 8px; }
.cbx:hover { background: #f0f4ff; }
.box { width: 14px; height: 14px; border: 1px solid #c5c8ce; border-radius: 3px; background: #fff;
display: inline-flex; align-items: center; justify-content: center; color: #fff; font-size: 10px; }
.cbx.on .box { background: #577afb; border-color: #577afb; }
.cbx.on { color: #577afb; font-weight: 600; }
/* stats */
.stat-row { display: grid; grid-template-columns: repeat(4,1fr); gap: 12px; margin: 14px 0; }
.stat-card { display: flex; align-items: center; gap: 12px; padding: 14px 16px;
background: #fff; border: 1px solid #eef1f5; border-radius: 8px; transition: .2s; }
.stat-card:hover { border-color: #c9d8ef; box-shadow: 0 4px 14px rgba(87,122,251,.12); transform: translateY(-1px); }
.stat-card.warn { background: #fffbfb; border-color: #ffe0e0; }
.sc-icon { flex: none; width: 40px; height: 40px; border-radius: 10px; display: flex;
align-items: center; justify-content: center; font-size: 20px; }
.ic-blue { background:#eef2ff; color:#577afb } .ic-green { background:#f0f9eb; color:#19be6b }
.ic-purple { background:#f5f0ff; color:#7b61ff } .ic-red { background:#fff0f0; color:#ed4014 }
.sc-num { font-size: 21px; font-weight: 700; line-height: 26px; letter-spacing: -.4px; }
.sc-num i { margin-left: 3px; font-size: 12px; font-weight: 400; font-style: normal; color: #a0a6b0; }
.sc-label { font-size: 12px; color: #808695; }
/* layout */
.cols { display: flex; gap: 14px; align-items: flex-start; }
.tree-panel { flex: none; width: 216px; padding: 12px 10px; background: #fff;
border: 1px solid #eef1f5; border-radius: 8px; }
.side-title { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; padding: 0 6px 8px;
border-bottom: 1px solid #f2f4f8; font-size: 13px; font-weight: 600; }
.side-title i { color:#577afb; font-style: normal; font-size: 15px; }
.side-title em { margin-left: auto; font-size: 11px; font-weight: 400; font-style: normal; color: #c5c8ce; }
.tree { max-height: 470px; overflow: auto; }
.tnode { display: flex; align-items: center; height: 32px; padding: 0 6px 0 22px; border-radius: 4px;
font-size: 13px; color: #3c4b5f; }
.tnode.sub { padding-left: 38px; }
.tnode.sel { background: #eef2ff; }
.tnode.sel .nm { color: #577afb; font-weight: 600; }
.tnode .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tnode .cnt { flex: none; margin-left: 6px; min-width: 20px; padding: 0 5px; border-radius: 9px;
background: #f2f4f8; color: #a0a6b0; font-size: 11px; line-height: 16px; text-align: center; }
.tnode.sel .cnt { background:#dfe7ff; color:#577afb; }
.caret { color: #808695; font-size: 10px; width: 14px; }
/* table */
.table-panel { flex: 1; min-width: 0; padding: 12px 14px 0; background: #fff;
border: 1px solid #eef1f5; border-radius: 8px; }
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.tt-left { display: flex; align-items: center; gap: 12px; }
.search { display: flex; align-items: center; gap: 6px; width: 230px; height: 32px; padding: 0 10px;
border: 1px solid #dcdee2; border-radius: 4px; color: #c5c8ce; font-size: 13px; }
.search input { flex: 1; border: none; outline: none; font-size: 13px; color: #17233d; background: transparent; }
.tt-count { color: #808695; font-size: 12px; } .tt-count b { color:#577afb; font-size:13px; margin:0 2px; }
.tt-right { display: flex; gap: 8px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th { background: #f8f8f9; color: #17233d; font-weight: 600; text-align: left; padding: 10px 12px;
border-bottom: 1px solid #e8eaec; white-space: nowrap; }
tbody td { padding: 9px 12px; border-bottom: 1px solid #e8eaec; vertical-align: middle; }
tbody tr:hover td { background: #f7f9fc; }
tr.edited td { background: #fffaf0; } tr.edited:hover td { background: #fff5e1; }
.c-idx { color:#808695; width:52px; text-align:center }
.c-act { width:70px; text-align:center }
.c-mid { text-align:center }
.u-cell { display: flex; align-items: center; gap: 8px; }
.avatar { flex: none; width: 26px; height: 26px; border-radius: 50%; background: #eef2ff; color: #577afb;
font-size: 12px; font-weight: 600; display: flex; align-items: center; justify-content: center; }
.u-name { display: flex; flex-direction: column; line-height: 16px; min-width: 0; }
.u-main { color: #17233d; font-size: 13px; }
.u-rem { color: #a0a6b0; font-size: 11px; }
.h-cell { line-height: 17px; }
.h-num { font-size: 15px; font-weight: 700; } .h-num i { margin-left:3px; font-size:11px; font-weight:400;
font-style: normal; opacity:.75; }
.h-day { font-size: 11px; opacity: .7; }
.ok .h-num { color:#19a465 } .warn .h-num { color:#e6a23c } .danger .h-num { color:#ed4014 }
.edit-cell { display: flex; align-items: center; justify-content: center; gap: 6px; }
.num { width: 108px; height: 30px; border: 1px solid #dfe6fb; border-radius: 4px; background: #f7f9ff;
display: flex; align-items: center; justify-content: flex-end; padding: 0 8px; color:#c5c8ce; font-size:13px; }
.num.filled { color: #577afb; font-weight: 600; justify-content: center; }
.qbtn { width: 26px; height: 24px; border: 1px solid #dcdee2; border-radius: 4px; background: #fff;
color: #515a6e; font-size: 12px; cursor: pointer; }
.qbtn:hover { border-color:#577afb; color:#577afb; background:#f5f8ff }
.qbtn.d:hover { border-color:#ed4014; color:#ed4014; background:#fff5f5 }
.rem-input { width: 100%; min-width: 130px; height: 30px; border: 1px solid #dcdee2; border-radius: 4px;
padding: 0 9px; font-size: 13px; color: #17233d; outline: none; }
.rem-input::placeholder { color:#c5c8ce; }
.pf-cell { display: inline-flex; align-items: baseline; gap: 5px; }
.pf-num { font-size: 14px; font-weight: 700; color: #17233d; }
.pf-delta { padding: 1px 5px; border-radius: 3px; font-size: 11px; }
.pf-delta.up { color:#19a465; background:#f0f9eb } .pf-delta.down { color:#ed4014; background:#fff0f0 }
.pf-empty { color: #dcdee2; }
.lk { color:#808695; font-size:12px; cursor:pointer } .lk:hover { color:#ed4014 }
.footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between;
margin-top: 12px; padding: 12px 4px; border-top: 1px solid #f2f4f8; font-size: 13px; color:#515a6e; }
.footer.active { border-top-color: #ffe0b0; }
.foot-info { display: flex; align-items: center; gap: 10px; }
.foot-info b { margin: 0 3px; font-size: 15px; }
.fi-sep { width:1px; height:12px; background:#e8eaec; }
.hl-blue{color:#577afb} .hl-orange{color:#e6a23c} .hl-green{color:#19a465} .hl-red{color:#ed4014}
.foot-btns { display: flex; gap: 8px; }
.note { margin-top: 18px; padding: 14px 16px; background: #fff; border: 1px solid #eef1f5;
border-radius: 8px; font-size: 13px; color: #515a6e; line-height: 22px; }
.note h4 { margin: 0 0 8px; font-size: 14px; color: #17233d; }
.note li { margin-bottom: 4px; }
</style>
</head>
<body data-page-node-id="Yg8oh4VZpbLXhqPjtBLC0R">
<div class="wrap" data-page-node-id="smRaZ4eOz02yCstEXVKMlM">
<div class="crumb" data-page-node-id="CnDBvTn1W11CUoZ1xvSidl">请假管理 /<b data-page-node-id="HSjP0AD1rcFqOdHVPqmGYl"> 请假设置</b></div>
<div class="card" data-page-node-id="2gwjetzwzWI75VOduiULNa">
<div class="tabs" data-page-node-id="q0DaNpn3b7Z26I7vjLMxIr">
<div class="tab" data-page-node-id="dAZK4ykcWhUQRtp1UVBj83">作息时间</div>
<div class="tab" data-page-node-id="1FCNiSHQ4BnD32U5xi5Wj4">请假类型</div>
<div class="tab on" data-page-node-id="gLQw6QTHCBwJUAd6FS8qHu">教师时长</div>
<div class="tab" data-page-node-id="CIYZqHRXQj8kBJqCoE4yDc">节次</div>
<div class="tab" data-page-node-id="W7TYACclLIpEcwCGFLcBbD">其他设置</div>
</div>
<div class="body" data-page-node-id="kSrP8PXRQtAE8GdhKtCFNo">
<!-- ① 管控类型 -->
<div class="managed" data-page-node-id="GdToG8fhRGmLhrcecVOSbD">
<div class="managed-head" data-page-node-id="kTMy9FhiUGAFGFyOs0nsTP">
<div class="mhl" data-page-node-id="kmFNoziZtumVBG7L7S6mRJ">
<div class="mh-icon" data-page-node-id="FSOtYnPlGC1jj3RFQR7z2e">&#9632;</div>
<div data-page-node-id="SSVHbzQIch3OUHqbZZDoUy">
<div class="managed-title" data-page-node-id="eo1IZdUU3sYf56WJNHG5RR">
需扣减时长的请假类型
<span class="tag success" data-page-node-id="JN2mBcJWvdqV8qNrjzaYLE">已启用 3 项</span>
</div>
<div class="managed-tip" data-page-node-id="qMJDwwURpWaLbN0wEqa1mr">勾选后,该类型请假审批通过将自动扣减教师时长;取消勾选即停止扣减。变更即时保存。</div>
</div>
</div>
<button class="btn ghost" data-page-node-id="TzmAHdZe9hiVokzlevPbdm">&#8681; 批量导入</button>
</div>
<div class="managed-types" data-page-node-id="otTvoH7txoxhG4BnaK7Dki">
<span class="cbx on" data-page-node-id="2raoIDbKt4QirU0pZNetVc"><span class="box" data-page-node-id="fmdhFqBf4LUJnUqm6iH3mZ">&#10003;</span>事假</span>
<span class="cbx on" data-page-node-id="GhyiRhbPZAWU4tfRAUthyl"><span class="box" data-page-node-id="xNicqTyQ6wWWEzCd9DPx9p">&#10003;</span>病假</span>
<span class="cbx on" data-page-node-id="08EAErdsyuJ2dEPxklNdCS"><span class="box" data-page-node-id="ZtPmmPxRRqaIAzemlquHB2">&#10003;</span>调休</span>
<span class="cbx" data-page-node-id="xHnWEGsHdJuRFdcETNzBuC"><span class="box" data-page-node-id="UhN0egzCQGlhV6jWZtTMjp"></span>婚假</span>
<span class="cbx" data-page-node-id="6OIHjmkOOyCvjklqgGHWAL"><span class="box" data-page-node-id="YT4FsGGmD7eM5cveIzyZNp"></span>产假</span>
<span class="cbx" data-page-node-id="piudnGIUpAwSpn4kXAMoEQ"><span class="box" data-page-node-id="2IpxCxG7ppqrBs83CcE1Uh"></span>丧假</span>
</div>
</div>
<!-- ② 统计 -->
<div class="stat-row" data-page-node-id="XnwOMIbSJgXDBGRV89JYIz">
<div class="stat-card" data-page-node-id="vixBBo6pYXDlAiFEWdnJYw">
<div class="sc-icon ic-blue" data-page-node-id="YyfAmgGtRXfPGdcMcyUkqq">&#9734;</div>
<div data-page-node-id="rPBdyyX7uWSXHsIiJgB7qs"><div class="sc-num" data-page-node-id="rSwvKT4l0XyDgg9Qd4IJSU">48<i data-page-node-id="AM5AdwhqzaIzezMDuSRi8H">人</i></div><div class="sc-label" data-page-node-id="8R1jm13gN3NDartp6AOjw7">当前范围教师</div></div>
</div>
<div class="stat-card" data-page-node-id="zMkhLVlrONaWkqfCswlJbR">
<div class="sc-icon ic-green" data-page-node-id="zGTkgLPS0WGabr9ui8CTRv">&#9201;</div>
<div data-page-node-id="Y4ZZvqH2n0g2cTRDgDmrxT"><div class="sc-num" data-page-node-id="JHMkZq2XqblNOV3evbzpKG">612.5<i data-page-node-id="Hz5nJ0aMb0VPiJ8bquOvzq">小时</i></div><div class="sc-label" data-page-node-id="ESdpYrUNT4S05Cs0HRXLLx">剩余时长合计</div></div>
</div>
<div class="stat-card" data-page-node-id="Ro8a8GmcUVwZhtWr8zqjis">
<div class="sc-icon ic-purple" data-page-node-id="5JCiVemLVfCm1YKszehFCm">&#9707;</div>
<div data-page-node-id="Eq263XTX07CWRcK3CjbDyH"><div class="sc-num" data-page-node-id="8BPF17J3CMuC2Ur7RjfpWd">12.76<i data-page-node-id="InITh8jpiZcSKHWTfDNXZ6">小时</i></div><div class="sc-label" data-page-node-id="xJBcC2bqvec0CS6dVhJDur">人均可用时长</div></div>
</div>
<div class="stat-card warn" data-page-node-id="waq2xPo4cCqZlJ6D5GPWY0">
<div class="sc-icon ic-red" data-page-node-id="fe8vdeyGIHlyFvTAGrnEsr">&#9888;</div>
<div data-page-node-id="ajZmbWRBJZ2nA9mVZN1qDa"><div class="sc-num" data-page-node-id="1EVAH1R3Em6orc0VuwpOGC">3<i data-page-node-id="aL0cHGFZtbEpKfiOmgOkPX">人</i></div><div class="sc-label" data-page-node-id="6anrkZFeKH5ouJQFfUQ2Wr">时长已用尽</div></div>
</div>
</div>
<!-- ③ 双栏 -->
<div class="cols" data-page-node-id="IvNnGaSrboYS4EzhPUF5Fj">
<div class="tree-panel" data-page-node-id="pxta51iElsYMpdpXi7PyS2">
<div class="side-title" data-page-node-id="RFu5O3vFrFHXXopyF3oE8i"><i data-page-node-id="MdLYZeIWsJnO12A4iku6J2">&#9707;</i><span data-page-node-id="BQjpGd8byJKPFHLC6N82Bc">教师分组</span><em data-page-node-id="m6M8LvkBoaU4QYw0Vsp0FE">点击筛选</em></div>
<div class="tree" data-page-node-id="Xy0m69vz2FG0HcjUZB366W">
<div class="tnode sel" data-page-node-id="BF9LwKxAYfWOLxzSk1PVMs"><span class="caret" data-page-node-id="Yo0wAiWBPvBIfkpddycDrK">&#9660;</span><span class="nm" data-page-node-id="0AEkXURFJQFlJGgTne4Nw2">全部教师</span><span class="cnt" data-page-node-id="xz3IIVnVa8IxVZLqLl3ePp">48</span></div>
<div class="tnode sub" data-page-node-id="9ht2SH9CUnigy4D303LP5I"><span class="caret" data-page-node-id="Gr0sfv46hUZSuAcEulcFhO">&#9654;</span><span class="nm" data-page-node-id="rVhfXSGFO8OJOYipR4AqHu">语文组</span><span class="cnt" data-page-node-id="uYNYzxjHkHW6rypdKxl4Iz">12</span></div>
<div class="tnode sub" data-page-node-id="2RcW28uJKsRegMBhhv6s2h"><span class="caret" data-page-node-id="RfOWSdKgQhVzMRwvF3aCew">&#9654;</span><span class="nm" data-page-node-id="4Er9Gka2cLzBZf1HZJ1UE9">数学组</span><span class="cnt" data-page-node-id="cAFG5ehEbcdzBmD7R0FBEE">10</span></div>
<div class="tnode sub" data-page-node-id="NLdjFPvGcqTDufVrXXbnNC"><span class="caret" data-page-node-id="JSxvn56HxB8ebsWuYabgDV">&#9654;</span><span class="nm" data-page-node-id="GCPDHD3U9mC9mYgoAfqNHV">英语组</span><span class="cnt" data-page-node-id="XsDnGcyWbgoqdAWAOcC9ho">9</span></div>
<div class="tnode sub" data-page-node-id="E14QvMFEfcM7N1OfMaJS84"><span class="caret" data-page-node-id="637VKqgnnYJ0hVY6EvuTFE">&#9654;</span><span class="nm" data-page-node-id="dXB32dRcQW8XqccdenFImo">综合组</span><span class="cnt" data-page-node-id="tsw9dDb43zGNFFA6A9u8nj">17</span></div>
</div>
</div>
<div class="table-panel" data-page-node-id="YdIfYn3jc4DeSIiBDhmZ5N">
<div class="toolbar" data-page-node-id="KtbNrcNwSdwJ7ppqsP7gw9">
<div class="tt-left" data-page-node-id="YEaanxALXsfHIZFx3tQ72g">
<div class="search" data-page-node-id="jvPnVtWdMpEmCDtqxyFBPF"><span data-page-node-id="O6D1yTafHWwzhoQTFfwprL">&#9906;</span><input placeholder="搜索教师姓名 / 备注" data-page-node-id="xJwEk7zRoaIem4TUhypVq7" /></div>
<span class="tt-count" data-page-node-id="iLCii0Qp2nbEOTm3kLuDCH">共 <b data-page-node-id="SXjN4j1QQwcXq8zFfMmA2B">48</b> 位教师</span>
</div>
<div class="tt-right" data-page-node-id="VDQ9xzeyye0J4F7GRkKM8g">
<button class="btn plain sm" data-page-node-id="h88RDcKeQ9xfQjG4BedCCB">&#8635; 刷新</button>
<button class="btn plain sm" data-page-node-id="dtBsngp8IgMktay3x86YKS">&#8630; 清空改动</button>
</div>
</div>
<table data-page-node-id="gBZy1PsAfjjOsaHklGS5tE">
<thead data-page-node-id="V0L4JqaDgFBMRaCzuwEX79">
<tr data-page-node-id="2GnK0D7NAQgHL2dlZPmsF4">
<th class="c-idx" data-page-node-id="DMrVIj68vWgacpVdkcVOUe">序号</th>
<th style="width:150px" data-page-node-id="A6RoMDtzT9YGgBpczbVCBx">教师</th>
<th style="width:130px" data-page-node-id="NGcTLSLbGC8aAPTfLyGw4s">剩余时长</th>
<th style="width:200px" class="c-mid" data-page-node-id="smUVAd6RjPCGLF2konjIYu">本次新加(小时)</th>
<th style="width:120px" class="c-mid" data-page-node-id="0uk3VBwed4YHphKgiHFHwP">调整后</th>
<th data-page-node-id="UKVXCq64ipyptCmRIYcZ1I">情况备注</th>
<th class="c-act" data-page-node-id="FkRSLNsGdHMIIoHpwXFmC4">操作</th>
</tr>
</thead>
<tbody data-page-node-id="weMZ2dPaaJ6iVZULJub8cQ">
<tr class="edited" data-page-node-id="n4zUHTBKF8V6KKCgEYTEeu">
<td class="c-idx" data-page-node-id="IOYGxau8x3j2I2pueqStNX">1</td>
<td data-page-node-id="yjW3NTQzowbwcxRwVTQhaU"><div class="u-cell" data-page-node-id="MXFp6i2Vy88UkN8OtD7TKU"><span class="avatar" data-page-node-id="HafGklgRUXzjASBTRbEUKO">王</span><span class="u-name" data-page-node-id="7ly9nuQqN9O3DAATZRX6GP"><span class="u-main" data-page-node-id="2TTCywrdRNJGoNFw9RSI8Z">王雅静</span><span class="u-rem" data-page-node-id="Tvy9Bt2NRg1ayNmMexd9lG">语文组 · 教研组长</span></span></div></td>
<td data-page-node-id="agY1GW42c4N4raG4X9XYla"><div class="h-cell lv-ok" data-page-node-id="9E6XJXCCgm2KzQh131aap1"><span class="h-num" data-page-node-id="3tsBwWeXQh8RoyGgGEjR0T">24<i data-page-node-id="svdWIbd34Hg9zGieMzlGo5">小时</i></span><div class="h-day" data-page-node-id="XaoSSy39I5l92FsOSYp63h">≈ 3 天</div></div></td>
<td class="c-mid" data-page-node-id="dDEYRbcHIZW36tv4FA6bde"><div class="edit-cell" data-page-node-id="WaTqQmt9F1o51lf9v6djr1"><div class="num filled" data-page-node-id="6af1HJFan01l0zHEsHbFFI">4</div>
<button class="qbtn" data-page-node-id="9ewCYHMnHxwrXOXdoiEFeD">+1</button><button class="qbtn" data-page-node-id="lNgiYxODTzFdV5EMW3pDZA">+4</button><button class="qbtn d" data-page-node-id="0wr8x8SCF393GBUPeQPhw5">-1</button></div></td>
<td class="c-mid" data-page-node-id="8HTmTRLufxFZoe7BRHC7HQ"><div class="pf-cell" data-page-node-id="XYCeldaBD0j7L7cDMfA1Uk"><span class="pf-num" data-page-node-id="sA1bC0HcGYLH6RDh7EZd38">28</span><span class="pf-delta up" data-page-node-id="eeEKS9fvOtDLCJRHlVv7jd">+4</span></div></td>
<td data-page-node-id="CiOJv3L94HpMfpevyWm9fC"><input class="rem-input" value="周末补课补时" data-page-node-id="4zpswltDOIzhw9J2oGzkJp" /></td>
<td class="c-act" data-page-node-id="W76HNv8oGn1DxCiIiT6GNx"><span class="lk" data-page-node-id="hOF4sRM9ioGcZmRN7oFv13">撤销</span></td>
</tr>
<tr data-page-node-id="lcQaRTXtIIOZAglJwr39De">
<td class="c-idx" data-page-node-id="zeWCHKMSJFqopt6MqZYRhl">2</td>
<td data-page-node-id="240iFFZK23rRBuFieN9pd6"><div class="u-cell" data-page-node-id="YTH0tBLrGBao2xe7B4rwRm"><span class="avatar" data-page-node-id="TSPBFT4eQpf9m9wzQJXVrq">李</span><span class="u-name" data-page-node-id="EmRoDctTCR4teOeNy6vBGO"><span class="u-main" data-page-node-id="n8lAjx6tQnD3njLs69p6xz">李国栋</span><span class="u-rem" data-page-node-id="7L3vYWSlCG5Z5LsDflcBfL">数学组</span></span></div></td>
<td data-page-node-id="smjJuTvFDDKCtzI44Vak21"><div class="h-cell lv-ok" data-page-node-id="0GzBhR2Z8o9Ww1hoxdPJWp"><span class="h-num" data-page-node-id="3sWlYSi5FxuSOVBPtq6BGt">16<i data-page-node-id="cXBoPcjVA49eEcDCSrseg8">小时</i></span><div class="h-day" data-page-node-id="7uhHwhjTrGi62kT6HAOvtJ">≈ 2 天</div></div></td>
<td class="c-mid" data-page-node-id="yEfKPm4fDkvNs82QBNX5v5"><div class="edit-cell" data-page-node-id="kvtIGGfLaha6I4viKvDGmh"><div class="num" data-page-node-id="EtGniqOwFl5Fcmd1N8GhxO">可填负数</div>
<button class="qbtn" data-page-node-id="EAfFeHFCyYAe7x5cj8CBps">+1</button><button class="qbtn" data-page-node-id="U0EJwBzXsv007Vz5pXVDwW">+4</button><button class="qbtn d" data-page-node-id="C7Mb0d3QC47mPpcQ53aMIg">-1</button></div></td>
<td class="c-mid" data-page-node-id="aVwepCNnSIM4Fu2Qm4RPFt"><span class="pf-empty" data-page-node-id="AQi93C376ZPDCKUi6Z7X58">&mdash;</span></td>
<td data-page-node-id="eovnFpWevUcNJTVFbZKOBn"><input class="rem-input" placeholder="如:补课补时" data-page-node-id="T2KYZCfs61ow7KCyGuF8sd" /></td>
<td class="c-act" data-page-node-id="YH0pXgqc5y0gpxXcj7RT9g"><span class="pf-empty" data-page-node-id="HuZWNFpNHuAZGNGLqO1pPZ">&mdash;</span></td>
</tr>
<tr data-page-node-id="8PrKIvk80rV2US4WjyyA3w">
<td class="c-idx" data-page-node-id="sOFf9zYy594uUZzrnJpdn6">3</td>
<td data-page-node-id="NssfV9fjCGuJf7QLEpMEIc"><div class="u-cell" data-page-node-id="73fvXHiwv6i2CeJKsS0skO"><span class="avatar" data-page-node-id="vrHN4L37LafVY3jxyN3b1f">张</span><span class="u-name" data-page-node-id="9doL7oGNSr9LcHJs7QQvVO"><span class="u-main" data-page-node-id="SRDgRi5YkDCGSl1mOfA27h">张明远</span><span class="u-rem" data-page-node-id="7vld9Fc3z4u6jHZWoQLNZK">英语组</span></span></div></td>
<td data-page-node-id="jVJdSx17tXmeCGUVk3AOFX"><div class="h-cell lv-warn" data-page-node-id="fAjevOuMLWJSHwBJdxEAnY"><span class="h-num" data-page-node-id="XR33xGPOlTREGZfBKL66XR">6<i data-page-node-id="Cn690wifz000tKfHoohJ7X">小时</i></span><div class="h-day" data-page-node-id="4qXNRN7z1In4LLqSmvBoLb">≈ 0.75 天</div></div></td>
<td class="c-mid" data-page-node-id="DE1HAH4Dv7Y27ZFMkS0fiY"><div class="edit-cell" data-page-node-id="QsQjUCaGaIAdfB9cpEKlSN"><div class="num filled" data-page-node-id="i8LjkqfljhcQmrZUjTeBvo">-6</div>
<button class="qbtn" data-page-node-id="oy6BYrQwbqACzeXjkwG71z">+1</button><button class="qbtn" data-page-node-id="SHNsIIbaW09Fk7K8wDEH8h">+4</button><button class="qbtn d" data-page-node-id="pKDmO0f4QfUUThho7Vdv1F">-1</button></div></td>
<td class="c-mid" data-page-node-id="0AF1AtzMPcAYcwl7Fgmi9j"><div class="pf-cell" data-page-node-id="SPnL9VsE0FKnItIGunUVgK"><span class="pf-num" data-page-node-id="OaMnfKdifqidVCgLySvGSl">0</span><span class="pf-delta down" data-page-node-id="ZjI016mxLv8Wlzp6WQsArt">-6</span></div></td>
<td data-page-node-id="gSPXFIBGqlNP3PCyiFJmLS"><input class="rem-input" placeholder="如:事假扣减" data-page-node-id="dpEO43c6SDI8fgOhlKLB9q" /></td>
<td class="c-act" data-page-node-id="rH4035FGPQqYP5ibGjCHhp"><span class="lk" data-page-node-id="F2RiqFdT56NHm3omYJcHa7">撤销</span></td>
</tr>
<tr data-page-node-id="lPS6KNy5IAeuqto5v5dcFA">
<td class="c-idx" data-page-node-id="2xrk0GsDTKKZPWGdyZIzMn">4</td>
<td data-page-node-id="9tekiXvEGIYxAxOAdD2I84"><div class="u-cell" data-page-node-id="HqZkK48QG50ZHe0VRsrtNp"><span class="avatar" data-page-node-id="8eMeC3i7kz53mI9DPhPXeu">陈</span><span class="u-name" data-page-node-id="hgdxwDGaAguIrCoEdeaQNd"><span class="u-main" data-page-node-id="oNlPt4b1PRoTfVe8aJaKBD">陈思彤</span><span class="u-rem" data-page-node-id="P0NPwpkHD4xDpqTJ6g4hgd">综合组</span></span></div></td>
<td data-page-node-id="FWfuMCbjzZDUirWPsPhwF9"><div class="h-cell lv-danger" data-page-node-id="v1UZMeYTnvOEBaApaTbvUV"><span class="h-num" data-page-node-id="YGn70DTNEv4O1OETby3vB9">0<i data-page-node-id="dHfbNsUnlYAE9UubqX1z5u">小时</i></span><div class="h-day" data-page-node-id="dLSCdYHQzP48wDj47ErRfO">需及时补充</div></div></td>
<td class="c-mid" data-page-node-id="wdSUThppNFrNH4rA9Q0jW9"><div class="edit-cell" data-page-node-id="wwYqFb78MEdU0khxSitQM0"><div class="num" data-page-node-id="SqVhjUY6rDrfMnv7uLBnkp">可填负数</div>
<button class="qbtn" data-page-node-id="GLPCkhgIEGqcF1HTRsiegm">+1</button><button class="qbtn" data-page-node-id="zS5VrDWPk5WrolJlNI3Grv">+4</button><button class="qbtn d" data-page-node-id="nm9iiCwzGXvbQgt5HTfTy5">-1</button></div></td>
<td class="c-mid" data-page-node-id="RDmjE12Xua8lwabZlKUPIt"><span class="pf-empty" data-page-node-id="tQRcpLE4LGakMF6Wc6JkgL">&mdash;</span></td>
<td data-page-node-id="e4WpknJiXuSjdE6q7CBJNu"><input class="rem-input" placeholder="如:补课补时" data-page-node-id="PpE6F43exgUjCeBWpwXx2w" /></td>
<td class="c-act" data-page-node-id="BGL37oPVVQZcZOQiGwGarZ"><span class="pf-empty" data-page-node-id="i1PK5LWZDi4Gjbv3AEohHw">&mdash;</span></td>
</tr>
<tr data-page-node-id="4uNpYUUqjqA6faCWm5zhit">
<td class="c-idx" data-page-node-id="tqMI0VVviTnRG3mGbY7EAQ">5</td>
<td data-page-node-id="aG3WwudhmJPHEPwG1xpvoQ"><div class="u-cell" data-page-node-id="TYpgIuHpslchHfGJWhzt6B"><span class="avatar" data-page-node-id="hGNKIZhWjz6OHhlC3ujWL1">周</span><span class="u-name" data-page-node-id="jLbQwXBAO2JqBeFQsLIYKb"><span class="u-main" data-page-node-id="3evVB0aAx2MbwARlRz7p1i">周建华</span><span class="u-rem" data-page-node-id="QZJD5y8R22LduL0r0s5IwH">语文组</span></span></div></td>
<td data-page-node-id="3WdZC6sF6Q3OnnKPdIMTz0"><div class="h-cell lv-ok" data-page-node-id="MtrF3rVjbIaQ85OXaYvAaU"><span class="h-num" data-page-node-id="fGArtWAWICS6GtVRtEsMMa">40<i data-page-node-id="2fAQOsbmBr17ZpvgWLLwbM">小时</i></span><div class="h-day" data-page-node-id="suU8zJcxzDlp3Ui115nFp4">≈ 5 天</div></div></td>
<td class="c-mid" data-page-node-id="r03YnFQTFN2ihDesRQHE1W"><div class="edit-cell" data-page-node-id="0kDAzw4ULgyGjSCbG7uXu5"><div class="num" data-page-node-id="Xzcr9CFrW5iAocnoTvtQZL">可填负数</div>
<button class="qbtn" data-page-node-id="e2ECXvyU5T1FgFXQER6gtu">+1</button><button class="qbtn" data-page-node-id="VfwpFExThsm9d8IIDczcxC">+4</button><button class="qbtn d" data-page-node-id="ZyxPi4zqokE6JBO2krMUn1">-1</button></div></td>
<td class="c-mid" data-page-node-id="kRbBOJgb7wFiyLOMlBcSxE"><span class="pf-empty" data-page-node-id="BuXWUKVHI6BFAEJwESEtqd">&mdash;</span></td>
<td data-page-node-id="BF6Lcx42WikoiZAETvewVt"><input class="rem-input" placeholder="如:补课补时" data-page-node-id="27h1h6428GqEihJm5qUNHU" /></td>
<td class="c-act" data-page-node-id="2Vjiqs3tMujdPksBodU8oq"><span class="pf-empty" data-page-node-id="Ut00QTvaziprxKziTnv8i6">&mdash;</span></td>
</tr>
</tbody>
</table>
<div class="footer active" data-page-node-id="auQWEPsZlBcDk90ZPBapAu">
<div class="foot-info" data-page-node-id="fWOscVlC6r3wB9VRAeA74N">
<span data-page-node-id="TzcUOvMKxs6WUXOHzdZ0zr">范围剩余合计<b class="hl-blue" data-page-node-id="MwwoP0w02y8oQFLt8cAQ6V">612.5</b>小时</span>
<span class="fi-sep" data-page-node-id="azQIoyW96y6VANNHPrxqJ9"></span>
<span data-page-node-id="zSOHxsgROLQztbvST7IufM">待提交<b class="hl-orange" data-page-node-id="zkE4HfBMkzY1PVn1fwVDEM">2</b>人,净增减<b class="hl-green" data-page-node-id="6uuXUU1jnYqiUoR3QbsMlq">-2</b>小时</span>
</div>
<div class="foot-btns" data-page-node-id="FV7MtMkxjEOPC97PUFnHt6">
<button class="btn plain" data-page-node-id="3D7nbKIHCY1l7D8I8w3wI8">取消</button>
<button class="btn primary" data-page-node-id="9PSeF3mhggVLnX8TJyWGHG">&#10003; 提交本次调整</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="note" data-page-node-id="DPJWMQB0PixGQW2FqHSWpL">
<h4 data-page-node-id="YBeKgcD9lyzRI7ITc9GbA4">本次设计要点</h4>
<ul data-page-node-id="RFzrvh2UZJ4hOnvZdFkex4">
<li data-page-node-id="W86NOru5YmX64EwwbacgAm"><b data-page-node-id="l4aCUx6e5fz0snTgNpLMcM">顶部概览四卡</b>:教师人数 / 剩余合计 / 人均时长 / 时长用尽人数,随左侧分组与搜索实时联动,异常数据(用尽)自动红底提示。</li>
<li data-page-node-id="zrRdlecBGJXndHc7MZrElr"><b data-page-node-id="OyuwEIBZBQoJfcrSaIKd86">时长水位分级</b>:≥8 小时绿色、&lt;8 小时橙色、≤0 红色,并附「≈ X 天」折算(按 8 小时/天),比单一数字更容易判断余额。</li>
<li data-page-node-id="r7DmnwSDagBf9wX9lRbOBD"><b data-page-node-id="BEb2BG4xwqmmDtLjuJBrYw">快捷加减按钮</b>:每行 +1 / +4 / -1 三个小按钮,免去输入数字;扣减后若会变负会即时拦截提示。</li>
<li data-page-node-id="vdBvpkv6P8NoZGDha472or"><b data-page-node-id="LGATWlAKsYGa8pbpUjziHT">调整后预览列</b>:填写即预览「调整后时长 + 增减徽标」(绿加红减),提交前不必刷新核对。</li>
<li data-page-node-id="BAyJ8l5ZHAqpzj8GDc7B0o"><b data-page-node-id="tj04sVhMxLPbGm2vpTJrER">编辑痕迹可视化</b>:已编辑行淡黄高亮 + 底部粘性条显示「待提交 N 人 / 净增减」,未改动时提交按钮禁用,杜绝误提交。</li>
<li data-page-node-id="MsvP4QweVKqokyRGetyqpN"><b data-page-node-id="6hbH4D1PSBExseDuS0UjpI">教师头像 + 备注</b>:姓名旁以首字头像区分,原备注降级为次要信息,不再被表格覆盖。</li>
<li data-page-node-id="QREACCSASfesiqJdFsxOo9"><b data-page-node-id="nA0pk9cGvdDIT3U9FZPDl3">分组树增强</b>:新增「全部教师」虚拟根节点、每组显示人数徽标,与统计卡片口径一致。</li>
<li data-page-node-id="ZeW3hwy4xFB8jzIcHEXkCH"><b data-page-node-id="5dTH3ouJmocn9XGJGPAslB">撤销 / 清空改动</b>:单行可撤销,顶部可一键清空全部未提交编辑,减少误操作。</li>
<li data-page-node-id="rQwC7Mm3JnZMIvhXeFBzEE"><b data-page-node-id="dgYPaNW2WhsZm4lyPyivKc">导入入口收敛</b>:「批量导入」并入管控类型区标题栏,与 Excel 导入能力语义相关联。</li>
</ul>
</div>
</div>
</body>
</html>
+542
View File
@@ -0,0 +1,542 @@
# 教师请假(WorkLeave)模块改造计划 v1
> 目标:把当前「宿舍通知(DormNotice)」模板工程整体改造为**教师请假**子应用。
> 业务闭环:**教师提交请假申请 → 平台流程审核器(流程中心)流转审批 → 回调推进单据状态 → 扣减教师时长**。
> 本文件是执行依据:按 P0~P7 逐阶段落地,每阶段附验收标准。
- 当前后端:`E:\YHXT\java\WorkLeave`(现为 com.yunhai.dormnotice,Spring Boot 2.7 + JPA + 基础平台 yh-spring-boot-starter)
- 当前前端:`E:\YHXT\view\WorkLeave`(现为 dorm-notice,Vue3 + Vite + view-ui-plus + alova)
- 历史前端(Vue2,**只参考页面/菜单节点逻辑**):`H:\工作区\MYFile\备份\请假前端`
- 历史后端(C#/EF,参考实体字段与业务规则):`H:\工作区\MYFile\备份\请假申请2.2`
- 流程对接手册:`E:\xwechat_files\wxid_psk2hommdhtq22_310d\msg\file\2026-09\子应用流程与消息对接手册-20260929.md`
- 平台参考实现:`E:\YHXT\java\UserclassService`(UserContext / SysOperationLogAspect)、`E:\YHXT\java\HomeLeave`(同平台请假姊妹工程,风格参照)
---
## 1. 参考资产盘点(已勘察结论)
### 1.1 历史左侧菜单节点(请假前端 router,modId 100~190)
| modId | 页面 | 历史路径 | 新方案 |
|---|---|---|---|
| 100 | 我要请假(申请表单) | `/leave/` | 保留 → `/apply` |
| 110 | 我的请假 | `/leave/myleave` | 保留 → `/myleave` |
| 111 | 请假编辑(子页) | `/leave/edit` | 合并进 `/apply?serid=`(隐藏路由) |
| 120 | 审核列表(待我审核) | `/leave/shenhe` | 保留 → `/audit`(改用流程中心待办) |
| 130 | 抄送列表 | `/leave/leave_send` | 保留 → `/cc` |
| 140 | 全部请假(管理端) | `/leave/totallist` | 保留 → `/all` |
| 150 | 指派代课 | `/leave/assignment` | **P7 可选**(依赖排课/课务数据,v1 暂缓) |
| 160 | 流程管理(固定流程配置) | `/leave/liucheng` | **删除**:新平台由「流程中心-流程编排」承担(管理员配置) |
| 170 | 请假设置(6 个 tab) | `/leave/leave_timeSetting` | 保留 → `/setting`(作息/类型/教师时长/节次/其他) |
| 180 | 时长日志 | `/leave/leave_amendant` | 保留 → `/timelog` |
| 190 | 统计分析 | `/leave/tongji` | 保留 → `/stats`(本地实现,旧版跳外部站点不沿用) |
### 1.2 历史状态机(C# 口径,新方案做了流程化收敛)
历史:`0 待审核 / 1 通过 / 2 未通过 / 3 撤销(通过) / 4 撤销待审 / 5 通过(撤销被拒) / 6 修改待审`,
并配套自建流程引擎(`Common_flowinfo/flowstep/freeflowinfo/flowtranctInfo/Groupuser`)实现固定流程与自由流程。
新方案:**审核流转全部交给平台流程审核器**,本地只保留单据状态;4/5/6 三个依赖自建引擎的状态取消:
| 新状态 | 枚举 | 进入事件 |
|---|---|---|
| 0 | APPROVING 审批中 | 提交(flow.submitted / flow.task.created / flow.task.handled) |
| 1 | PASSED 已通过 | flow.completed(**扣减时长**) |
| 2 | REJECTED 已拒绝 | flow.rejected |
| 3 | CANCELED 已销假 | 已通过后教师发起销假(本模块动作,**返还时长**) |
| 4 | WITHDRAWN 已撤回 | flow.withdrawn(提交人撤回,需无任务被办理) |
### 1.3 历史核心业务规则(重写要点,详见 §6)
- 请假类型:字典(历史 `Common_Dictionary typeName='请假类别'`),学校开关 + 排序 → 新方案 `sys_dict(dict_type='leaveType')`。
- 时长(调休余额)池:类型进入「时长管控清单」时校验余额;在途占用 = 审批中单据时长之和;通过扣减、销假返还;加减流水留痕。
- 时间/时长计算:按学校作息(上午 F1-F2、下午 A1-A2)折算小时,同日去午休、跨天按整天 8h;小时制(`IsCalculateWay=true`)为唯一口径,按天模式取消。
- 冲突校验:同一申请人、状态 ∈ {0,1} 的时间区间不得重叠(历史含 5,新方案无该状态)。
- 编辑:仅「审批中」「已拒绝」可编辑并重新提交(重新发起流程,bizId 追加尝试序号);已通过只能销假,不能改。
- 详情快照:终态时把回调事件 data(含审批意见)序列化落库,详情页免二次查询。
- 抄送:流程中心 NOTIFY 节点自动发消息;本模块落 `leave_cc` 留痕表供「抄送列表」渲染。
- 删除:物理删除(历史口径),仅限审批中/已拒绝/已撤回,且仅本人。
### 1.4 历史缺陷清单(重写时修正,不复制)
1. 半天换算 `JsAPm` 判成 `KsAPm`(C# 源码 bug)→ 新方案按天模式整体取消,不涉及。
2. 拒绝时返还时长传错类型 → 新方案拒绝不返还(未扣过),无此分支。
3. 附件绑定不落库 → 新方案仓储显式 save。
4. 时长池新建记录 `null + x = null` → 新方案 `default 0`。
5. 时间段校验用字符串拼 SQL → 新方案参数化 JPA + LocalDateTime。
6. 列表时间过滤口径不一(重叠 vs 包含)→ 统一为**重叠**语义。
---
## 2. 关键决策
| # | 决策 | 理由 |
|---|---|---|
| D1 | 后端包名 `com.yunhai.workleave`,artifactId/finalName=`workleave`,应用名`WorkLeave` | 需求 1;与旧模板彻底切割 |
| D2 | 删除宿舍业务(Activity/ActivityRead/ActivitySignup 及全部 Model/Repository/Service/Controller),**保留** `sys_operation_log`(含切面)、`sys_dict`、`up_file`、contacts(自定义组)、base/dict/file/log 平台代理控制器 | 需求 2(删实体保留日志表);保留件是请假的通用底座 |
| D3 | 业务人员字段**只存平台数字 ID**(userId),姓名/头像/类型名做冗余展示字段;`BaseEntity.create_by/update_by` 改存**操作人 userId**,不存账号 | 需求 3(不保存师生账号) |
| D4 | 审核流转对接**平台流程审核器**(yh-flow-callback-starter + YschFlowOpenApi),bizType=`leave` 全局唯一 | 需求 7 对接手册;替代历史自建流程引擎 |
| D5 | 申请发起 `bizId = {leaveId}-{attempt}`(attempt 为第几次提交,从 1 起);回调按 `-` 前缀解析 leaveId | 流程中心按 bizType+bizId 幂等,拒绝后重新提交必须换 bizId |
| D6 | 消息跳转 `JumpTarget.page("detail").param("leaveId", id)`,前端提供 `/detail?leaveId=` 落地页 | 手册 §3.1 红线(query 形态、页面名两端一致) |
| D7 | 时长计算做成工具类前后端同规则(前端实时算、后端复核),小时制保留 2 位小数 | 需求 3 业务逻辑;历史算法在前端算、后端只校验,重写为双端一致 |
| D8 | 前端保留现有技术栈与壳层(Vue3+Vite+view-ui-plus+alova),仅替换业务域与菜单契约 | 与当前前端工程一致,降低风险;"只参考页面逻辑"不搬 Vue2 代码 |
| D9 | 节点 160 流程管理删除;审批意见/流程图/进度时间线用流程中心能力(progress/diagramUrl) | 平台已提供,避免重复建设 |
---
## 3. 前置依赖(需平台管理员配合,先登记后联调)
| # | 事项 | 说明 | 状态 |
|---|---|---|---|
| A1 | projman 建应用 | 部署名称 `leave`(= app_code,逐字一致;公共组件勾 Redis) | ☐ 待确认 |
| A2 | UserBase 注册应用 | app_code=`leave`、app_key/app_secret、pc_url=`http://<域名>/leave/front/index.html#/` | ☐ 待确认 |
| A3 | 流程中心编排 | 注册流程记 flowGuid(建议 `leave-apply-v1`;如需销假审批再加 `leave-cancel-v1`) | ☐ 待确认 |
| A4 | 白名单登记 | flow-center `trusted-apps` 加 `appKey:secret`;UserMsg/UserBase 密钥链 | ☐ 待确认 |
| A5 | 菜单树配置 | 基础平台按 §4.3 的 modId/path 配置 9 个节点(对齐 GetUserMod) | ☐ 待确认 |
| A6 | Nacos 配置 | `workleave.yaml`(app-key/secret/flow-guid/open.base-host),本地用 application.yml 兜底 | ☐ 待确认 |
> 本地开发不需要 A4;`open-host` 指向 `http://open.yunhaixingtong.com`(hosts 加 `192.168.10.160 open.yunhaixingtong.com`)可直连真实服务(手册 §5.0)。
---
## 4. 目标架构
### 4.1 后端目录结构(com.yunhai.workleave)
```
src/main/java/com/yunhai/workleave/
├── WorkLeaveApplication.java
├── aspect/ ControllerUserContextAspect(Order1)· SysOperationLogAspect(Order3)
├── common/ listener/BaseAuditListener · validation/ValidationGroups
├── config/ Config(API_PATH) · JacksonConfig · SwaggerConfig
├── entity/ BaseEntity · SysDict · UpFile · SysOperationLog
│ LeaveApply · LeaveSetting · LeaveTimeSet · LeaveUserTime · LeaveUserTimeRecord
│ LeaveSection · LeaveCc · (P7) LeaveCourse · LeaveCourseExchange · LeaveApplyOld
├── enums/ LeaveStatusEnum · LeaveActionEnum · UserType
├── flow/ LeaveFlowService(apply/withdraw/todo/approve/reject/progress/diagram)
│ LeaveFlowCallbackHandler(事件状态机 + 终态守卫 + 快照 + 扣减/返还 + 抄送留痕)
├── model/ BasePageQuery/BasePageResult · bsch(SchTeaList/ClassTreeNodeVo) · file(UpFileVo/Result)
│ └── leave/ LeaveVo · LeaveDetailVo · LeaveQuery · LeaveRequest · LeaveCancelRequest
│ LeaveAuditRequest · LeaveProgressVo · LeaveStatsVo · TimeSetVo/TimeSetRequest
│ LeaveSettingVo · UserTimeVo · UserTimeRecordVo · UserTimeAdjustRequest · SectionVo ...
├── repository/ LeaveApplyRepository · LeaveSettingRepository · LeaveTimeSetRepository
│ LeaveUserTimeRepository · LeaveUserTimeRecordRepository · LeaveSectionRepository
│ LeaveCcRepository · SysDictRepository · UpFileRepository · SysOperationLogRepository
├── service/ I/DictService · I/FileService · I/ContactsService · I/UserCenterService · I/ClassService
│ I/LeaveApplyService · I/LeaveSettingService · I/LeaveUserTimeService
│ I/LeaveStatsService · I/SysOperationLogService
│ └── impl/ 同名实现 + LeaveTimeCalculator(时长算法)
├── controller/api/ BaseApiController · DictController · FileController · LogController · ContactsController
│ LeaveController(申请域)· LeaveAuditController(待办/审核/进度)
│ LeaveSettingController(设置域)· LeaveTimeController(时长域)· LeaveStatsController
└── utils/ UserContext · PageUtils · GzipUtil · IpUtils · LeaveTimeText
```
### 4.2 数据表(`sql/workleave_schema.sql`;ddl-auto=update 兜底建表,脚本供人工核对)
| 表 | 说明 | 关键字段 |
|---|---|---|
| `leave_apply` | 请假申请主表 | id, sch_id, user_id, user_name, user_photo, leave_type(字典key), leave_type_name, start_time, end_time, total_hours, reason, file_ids, cc_user_ids, status, biz_id, instance_id, instance_version, flow_guid, flow_attempt, snapshot(终态JSON), apply_time + BaseEntity 审计列 |
| `leave_setting` | 学校设置(一校一行) | sch_id, reason_must, show_explain, enable_course, allow_exchange, time_managed_types(CSV 字典key), time_limit_enabled |
| `leave_time_set` | 作息时间(一校一行) | sch_id, forenoon1, forenoon2, afternoon1, afternoon2, interval_minutes(默认 08:00/12:00/13:00/17:00/10) |
| `leave_user_time` | 教师时长池 | sch_id, user_id, user_name, usable_hours, remark;唯一键 (sch_id,user_id) |
| `leave_user_time_record` | 时长流水 | sch_id, user_id, user_name, leave_id, leave_type, edit_hours(±), old_hours, new_hours, describe, operator_id, create_time |
| `leave_section` | 节次表(P7 课务用,设置页可维护) | sch_id, name, rank, begin_time, end_time |
| `leave_cc` | 抄送留痕 | sch_id, leave_id, cc_user_id, cc_user_name, instance_id, create_time |
| `sys_dict` | 字典(保留):新增 `leaveType` | (sch_id, dict_type='leaveType', dict_key, dict_value, sort_order, enable_flag) |
| `up_file` / `sys_operation_log` | 附件 / 审计日志(保留) | 现状不动 |
| `flow_callback_event` / `flow_callback_reg` | 回调 starter 自动建表 | 不手工维护 |
### 4.3 前端路由与 modId 契约(对齐平台菜单树)
| modId | path | 页面 | 说明 |
|---|---|---|---|
| 100 | `/apply` | 我要请假 | 新建/编辑(`?serid=`),含时长试算、附件、抄送人 |
| 110 | `/myleave` | 我的请假 | 本人列表:查看/编辑/销假/撤回/删除 |
| 111 | `/detail` | 请假详情 | `?leaveId=`,**流程消息跳转落地页**;meta.parentId=110(菜单不单列) |
| 120 | `/audit` | 审核列表 | tab:待我审核(流程待办)/ 我已审核;同意/拒绝/流程图 |
| 130 | `/cc` | 抄送列表 | leave_cc 渲染 |
| 140 | `/all` | 全部请假 | 管理端全量 + 导出 CSV |
| 170 | `/setting` | 请假设置 | tab:作息时间/请假类型/教师时长/节次/其他设置 |
| 180 | `/timelog` | 时长日志 | 时长流水 + 合计(新加/剩余),支持加减时长 |
| 190 | `/stats` | 统计分析 | 数量/时长按类型与月份、明细 |
| (150) | `/assignment` | 指派代课 | **P7 可选** |
| — | `/` → `/apply` 重定向 | — | |
> 平台菜单若分配了不同 modId:优先按 `path` 解析(menuRoute.ts 已具备),仅需把上表 modId 改为实际值即可。
### 4.4 前端文件结构(E:\YHXT\view\WorkLeave)
```
src/
├── api/module/leave.ts # 全部接口封装(沿用 get/postForm/postJSON 约定)
├── types/leave.ts # 类型契约(枚举、VO/DTO、请求入参)
├── router/routes.ts # 上表路由 + meta.modId/parentId
├── utils/leaveTime.ts # 时长计算(与后端同规则)
├── views/leave/
│ ├── ApplyLeave.vue # 我要请假(新建/编辑)
│ ├── MyLeave.vue # 我的请假
│ ├── LeaveDetail.vue # 详情(信息+进度时间线+附件+抄送人)
│ ├── LeaveAudit.vue # 审核列表
│ ├── LeaveCc.vue # 抄送列表
│ ├── AllLeave.vue # 全部请假
│ ├── LeaveSetting.vue # 请假设置(多 tab)
│ ├── TimeLog.vue # 时长日志
│ └── LeaveStats.vue # 统计分析
└── (保留)components/、utils/、assets/ 等基础设施;删除 views/dorm/*
```
---
## 5. 接口契约(前后端对齐)
统一前缀:`/{platform}/api`(前端 `serviceHost` + `/api/...`;平台值 api/manage/mobiapi)。响应由平台 `ApiResponseAdvice` 包装 `{code,msg,data}`,alova 解包 `data`。
### 5.1 申请域 `leave/`
| 方法 | 接口 | 入参 | 出参 |
|---|---|---|---|
| GET | `/leave/List` | scope(user=我的/all=全部), status, keyword, startTime, endTime, pageIndex, pageSize | `{content:[LeaveVo], total, ...}` |
| GET | `/leave/Detail` | leaveId | LeaveDetailVo(含进度、抄送、附件、快照、当前用户能否审核) |
| POST | `/leave/Create` | LeaveRequest(JSON) | leaveId(内部:落库→发起流程→回写 bizId/instanceId) |
| POST | `/leave/Edit` | LeaveRequest + id(JSON) | void(仅 0/2/4 状态;重新发起流程 attempt+1) |
| POST | `/leave/Delete` | ids(JSON 数组) | void |
| POST | `/leave/Withdraw` | id | void(调流程中心 withdraw) |
| POST | `/leave/Cancel` | id + reason(JSON) | void(销假:状态 1 → 3,返还时长,落流水) |
| GET | `/leave/Progress` | leaveId | `{steps:[...], tasks:[...]}`(流程中心 progress 包装) |
| GET | `/leave/DiagramUrl` | leaveId | `{url}`(流程图 10 分钟 token) |
| GET | `/leave/MyTime` | leaveType | `{usable, inTransit, remaining}`(表单页余额提示) |
### 5.2 审核域 `leave/`
| 方法 | 接口 | 入参 | 出参 |
|---|---|---|---|
| GET | `/leave/TodoList` | pageIndex, pageSize, keyword | `{content:[TodoVo], total}`(todoTasks(bizType=leave) + 本地单号补全) |
| GET | `/leave/AuditedList` | pageIndex, pageSize, keyword | 已办:本地按 instance 与审核记录反查 |
| POST | `/leave/Audit` | taskId, action(approve/reject), opinion(JSON) | void(approveTask/rejectTask;状态由回调推进) |
### 5.3 抄送 / 统计 / 设置 / 时长
| 方法 | 接口 | 说明 |
|---|---|---|
| GET | `/leave/CcList` | 抄送给我的列表(leave_cc 关联 leave_apply) |
| GET | `/leave/Statis` | dateStart/dateEnd → 总数量、总时长、按类型分布、按月趋势、明细 Top |
| GET | `/leave/setting/GetTimeSet` / `SetTimeSet` | 学校作息 |
| GET | `/leave/setting/GetOption` / `SetOption` | 其他设置(含时长管控类型) |
| GET | `/leave/setting/SectionList` | 节次列表(P7 预览,设置页可维护) |
| POST | `/leave/setting/SectionSave` / `SectionDelete` / `SectionSort` | 节次维护 |
| GET | `/leave/time/UserTimeList` | 教师时长列表(按组/关键字;可用 `base/GetSchoolAll` 组树过滤) |
| POST | `/leave/time/Adjust` | 加减时长(单人或批量,JSON;写流水) |
| GET | `/leave/time/Records` | 时长流水(rqType 0今日/1本周/2本月/3累计 + 关键字 + 分页) |
| GET | `/leave/time/Sum` | 合计:新加总时长、剩余总时长 |
| GET/POST | `/api/dict/*`(复用) | type=leaveType 的字典增删改查排序(请假类型 tab) |
### 5.4 平台代理接口(保留,改包名)
`base/GetUserInfo`、`base/GetUserMod`、`base/GetSchoolAll`(选人树)、`base/SetRole`、`base/FileUpload`、`base/Logout`、
`dict/*`、`file/*`、`contacts/*`(自定义组)、`log/*`(审计日志查询)。
---
## 6. 业务规则明细
### 6.1 时长计算(前后端同规则,工具类 `LeaveTimeCalculator` / `leaveTime.ts`)
输入:起止时间(yyyy-MM-dd HH:mm)、作息(F1/F2/A1/A2)。输出:小时(四舍五入 2 位)。
- 同日:start `clamp` 到工作时段(早于 F1→F1;F1~F2 之间保持;F2~A1 之间→A1;晚于 A2→A2;A1~A2 保持);
end 同理(F2~A1 之间→F2);`minutes = end - start`;若 `start<=F2 && end>=A1` 再减午休 `(A1-F2)`。
- 跨天:首日 = `A2 - start`(start 落在 F1~F2 时再减午休);末日 = `end - F1`(end 落在 A1~A2 时再减午休);
中间整天按 `8h/天`(= `A2-A1 + F2-F1`);合计取小时。
- 校验:结束必须晚于开始;单条时长 > 0;跨天视为按天折算(不再区分上下午字段)。
### 6.2 申请与校验(Create/Edit)
1. 类型必须为启用的 leaveType 字典项;事由在 `reason_must=1` 时必填且 ≤50 字。
2. 时间冲突:同人、`status in (0,1)`、`(start_time < 已有.end_time) && (已有.start_time < end_time)` → 报"与已有请假时间冲突"。
3. 时长管控:类型 ∈ `time_managed_types` 时,`在途(0状态且管控类型) + 本次 <= usable`(无记录按 0),否则报"可申请 X 小时,时间不足"。
4. `total_hours` 后端按 §6.1 复核(与前端不一致以服务端为准落库)。
5. 提交成功 → 组装 `FlowApplyBody`:bizType=`leave`、bizId=`{id}-{attempt}`、bizTitle=`{姓名}的{类型}申请`、bizJump=page("detail").param("leaveId",id)、bizSnapshot=摘要(类型/起止/时长/事由)、submitterId/Name、formData(类型、时长、天数)。
6. 发起成功回写 `biz_id/instance_id/flow_guid/status=0`;`duplicated=true` 时视为幂等成功(不新建实例)。
### 6.3 回调状态机(LeaveFlowCallbackHandler)
| 事件 | 处理 |
|---|---|
| `flow.submitted` / `flow.task.created` / `flow.task.handled` | status→0(审批中);**终态不回退**:已 1/2/3/4 则忽略 |
| `flow.completed` | status→1;扣时长(管控类型才执行)+ 流水"扣除";落终态快照 |
| `flow.rejected` | status→2;不扣不返还;落终态快照 |
| `flow.withdrawn` | status→4(仅当时为 0 时) |
| `flow.cc.sent` | 写 `leave_cc` 留痕(幂等:同 leaveId+ccUserId+instanceId 不重复落) |
- 乱序守卫:事件带 `instanceVersion`,小于单据已记录版本的事件忽略(终态快照之外)。
- 事务:回调方法内异常 → 整体回滚 + 流程中心重推;确定性脏消息(bizId 解析失败等)记录 warn 后正常 return,不抛异常。
- 注册:首次发起前 `callbackRegistrar.ensureRegistered(schId, flowGuid)`(结果落 flow_callback_reg 镜像表)。
### 6.4 销假(Cancel)与撤回(Withdraw)
- 销假:仅 `status=1`;置 3;管控类型返还时长 + 流水"销假返还";记 `cancel_time/reason`(快照字段)。已通过 50 天时效沿用(可配置为 50 天常量)。
- 撤回:仅 `status=0`;调 `YschFlowOpenApi.withdraw(schId, instanceId, submitterId)`;等回调置 4(不本地直改,避免与流程中心不一致)。
### 6.5 时长池管理
- 列表:`leave_user_time` LEFT JOIN 教师名单(`base/GetSchoolAll` 同源,按组过滤)。
- 调整:`edit_hours` 可正可负,`now = old + edit`(不低于 0),写流水(describe=备注或"新加"),operator 取当前用户。
- 流水统计:`Sum` 返回 `新加总=Σ(±)`、`剩余总=Σ usable`(按过滤区间口径)。
---
## 7. 流程审核器对接要点(照手册执行)
1. **pom**:`yh-flow-callback-spring-boot-starter` + `spring-boot-starter-data-redis`(验签密钥链必需,optional 不传递)。
2. **配置**:
```yaml
yh:
app-auth: { enabled: true, app-key: "1000xx", app-secret: "******" }
flow-callback:
enabled: true
biz-types: [leave]
open-host: ${yh.open.base-host} # 未配 callback-url 时用于拼回调地址
management.health.redis.enabled: false
leave.flow.guid: leave-apply-v1 # 编排所得
```
回调地址默认 `/oapi/flow/callback`(starter 自证 context-path 生成);`yh.sso.url-pattern=/api/*` 不含 oapi,无需放行改造。
3. **发起**:`YschFlowOpenApi.apply(schId, body, FlowApplyResult.class)`。
4. **回调**:实现 `FlowCallbackHandler#onEvent`(§6.3);`schId` 从事件取,不信任本地上下文。
5. **待办/审核**:`todoTasks(schId, assigneeId, "leave", page, size)`、`approveTask(schId, taskId, operatorId, operatorName, opinion[, edgeName])`、`rejectTask(...)`。
6. **进度/流程图**:`progress(null, "leave", bizId, schId, FlowProgress.class)`、`diagramUrl(instanceId, flowGuid, "track", schId, FlowDiagramUrl.class)`。
7. **自检**(手册 §5.1):本地单测(mock 回调事件 + apply 出参断言)→ 160 环境全链路 9 项验收。
---
## 8. 分阶段任务与验收
### P0 骨架重整
- [x] 后端:包 `com.yunhai.dormnotice` → `com.yunhai.workleave`(目录迁移 + 全量 import 替换);`DormNoticeApplication` → `WorkLeaveApplication`;pom `artifactId/name/finalName` → `workleave`;`bootstrap.yml spring.application.name` → `workleave`;日志/库名对齐
- [x] 后端:删除宿舍业务(ActivityController、Activity 系实体/模型/仓储/服务);保留 log/dict/file/base/contacts 底座
- [x] 前端:`package.json` name、`vite.config` 标题、`index.html`、README、App.vue appName → 教师请假
- **验收**:后端 `mvn -q compile` 通过;前端构建通过;全局搜索无 `dormnotice` 残留
### P1 实体与数据层
- [x] `BaseEntity` 审计人改存 userId(`create_by/update_by`)
- [x] 新实体 + Repository(LeaveApply/LeaveSetting/LeaveTimeSet/LeaveUserTime/LeaveUserTimeRecord/LeaveSection/LeaveCc/LeaveAuditTrace)
- [x] `sql/workleave_schema.sql`(建表 + leaveType 字典种子:事假/病假/调休/公假/婚假/产假)
- **验收**:编译通过;启动自动建表成功;`leaveType` 字典种子可查
### P2 设置域 + 时长域
- [x] `LeaveSettingController/Service`:作息时间、其他设置、节次
- [x] `LeaveTimeController/Service`:时长列表/调整/流水/合计;`LeaveTimeCalculator`
- **验收**:单测覆盖时长算法(同日/跨天/边界 clamp/午休)—— 16 例全绿
### P3 申请域 + 流程对接
- [x] `LeaveController/Service`:Create/Edit/Delete/Withdraw/Cancel/List/Detail/MyTime/Statis/TodoList/AuditedList/Audit
- [x] `flow/LeaveFlowService` + `flow/LeaveFlowCallbackHandler`(§6.3)
- [x] pom/application.yml 流程依赖与配置(§7)
- **验收**:编译通过;回调单测(mock FlowCallbackEvent 覆盖 5 类事件 + 终态不回退 + 乱序门 + 抄送幂等)—— 12 例全绿
### P4 前端壳层与路由
- [x] `api/module/leave.ts`、`types/leave.ts`、`router/routes.ts`(§4.3)、`menuRoute.ts` 别名更新
- [x] 删除 `views/dorm/*`、`api/module/dorm.ts`、`types/dorm.ts` 及依赖其的组件
- **验收**:`npm run build` 通过;菜单解析器对新节点 path 命中
### P5 前端页面
- [x] ApplyLeave:表单 + 时长实时试算 + 余额提示 + 附件 + 抄送人 + 提交/重新提交
- [x] MyLeave / LeaveDetail / LeaveAudit / LeaveCc / AllLeave
- [x] LeaveSetting(5 个 tab)/ TimeLog / LeaveStats
- **验收**:构建通过(联调需本地后端 + DB 环境,见 P6 说明)
### P6 联调与验收
- [x] 后端 `mvn -o compile` / 单测全绿;前端 `npm run build`
- [ ] 160 环境按手册 §5.1 九项清单验收(含消息卡片跳转 `/detail?leaveId=`)—— **需管理员提供 A1~A6 前置项**
- **验收**:清单全过;`proj34.sys_message` 最新行 `jump_path=/detail?leaveId=xxx`、`jump_app=leave`
### P6.5 本地冒烟测试(2026-10-09 新增)
> 目标:在不依赖平台管理员前置项(A1~A6)的前提下,对已交付代码做**本地全链路冒烟**,
> 验证后端服务、数据库、核心业务接口、前端构建与页面渲染的可运行性。
> 160 全链路(真实 SSO 登录 + 流程审批闭环)仍归 P6,等管理员补齐前置项。
**冒烟辅助件(开发专用,生产零影响)**:
- 新增 `config/DevMockUserFilter`:仅当 `smoke.auth.enabled=true` 时注册,向 `BoaUserContext`
注入可配置的模拟用户(`smoke.auth.user-id/user-name/sch-id/user-type`),默认关闭。
- 本地启动时用 `--yh.sso.url-pattern=/__smoke_off__/*` 关闭 CAS 拦截,使 `/api/*` 可直连
(模拟用户由上述过滤器提供,业务代码无需感知)。
**冒烟清单(S1~S6)**:
| # | 项 | 内容 | 通过标准 | 结果 |
|---|---|---|---|---|
| S1 | 构建 | `mvn -o test`(含 28 例单测) | BUILD SUCCESS,0 失败 | ✅ 28 例全绿(修复后复跑仍全绿) |
| S2 | 启动 | smoke 模式 `java -jar` 起 8105 | 日志无 ERROR,`/actuator/health` 200 | ✅ |
| S3 | 数据 | DB `work_leave` 13 张表 + leaveType 种子 | 表齐全,LT001~LT006 可查 | ✅ 13 表 + 6 种子 |
| S4 | 设置域 API | GetTimeSet/SetTimeSet、GetOption/SetOption、dict 全链路 | 读默认值→写入→回读一致 | ✅(修复 2 个 bug 后通过,见 §10) |
| S5 | 申请/时长域 API | List 空态、time/Adjust+Sum+Records、MyTime、Statis、Create(流程中心链路) | 只读接口正常;Adjust 后 Sum/Records 联动;Create 失败时**事务回滚无孤儿单**(flowGuid 占位期预期),若流程中心可用则全链路通过 | ✅ Adjust +8h→Sum/Records/MyTime 联动一致;Create 报"流程失败 404"且回滚(leave_apply=0);操作日志记录失败尝试 |
| S6 | 前端 | `npm run build` + dev server 浏览器访问核心页面 | 构建通过;页面壳层渲染、控制台无阻断性报错 | ✅ 9 页面全渲染;申请表单可交互(类型下拉 6 项/时长试算 4h/事由 50 字计数/提交按钮联动);提交失败 toast 正确透传后端错误;"我的请假"空态表格/统计卡正常;设置页 5 tab 正常 |
**执行结论随做随记入 §10。**
### P7 可选扩展(v1 后)
- [ ] 指派代课/调课(课务安排表 + 教务安排人 + 通知);节次/班级/科目数据源接入
- [ ] 修改待审流程(快照表 `leave_apply_old` + 二次流程)
- [ ] 发起销假审批(第二 flowGuid `leave-cancel-v1`)
- [ ] 教师时长 Excel 导入导出;统计图表增强
### P8 对齐旧版生产行为(2026-10-09,参考 oa.bestsch.com/leave 实地勘察)
> 背景:登录旧版生产系统(lwj 账号)实地核对,发现原计划 v1 裁剪掉的两个行为在生产实际在用,
> 且旧版"其他设置"里有**请假计算(按小时/按天)**开关(该学校当前为按天)。本阶段把这两块补齐。
> 旧版算法结论:后端永远存**小时**(leave_totaltime),"按天"只是输入/显示模式(显示=小时/8),
> 与新架构小时制天然兼容,改造聚焦在输入侧与配置面。
**后端**:
- `leave_setting.calc_way`(1 按小时[默认] / 0 按天,历史 IsCalculateWay 同义)
- `leave_time_set.min_minutes`(最少请假时间,分钟,0 不限制;历史"学校时间设置"项)
- `leave_apply.course_arrange`(0 无课务安排 / 1 自行安排 / 2 教务安排;不传默认 0)
- Create/Edit 校验:时长分钟 < min_minutes → 拒绝"请假时长不能少于 X 分钟"(边界=值本身放行)
- VO:TimeSetVo+minMinutes、LeaveSettingVo+calcWay、LeaveRequest/LeaveVo/LeaveDetailVo+courseArrange(+Name)
**前端**:
- ApplyLeave:calcWay=0 时表单切换为「半天/半天以上 + 日期 + 上午/下午」输入(按作息组合起止
datetime 后复用既有小时试算),时长主显**天**(=小时/8,同旧版);calcWay=1 保持 datetime 输入。
新增「时间说明」行(showExplain=1 时):`上午(F1-F2)-下午(A1-A2),固定X小时工作制`。
新增「课务安排」三选一(enableCourse=1 时,默认无课务安排)。
- LeaveSetting:作息 tab + 「最少请假时间(分钟)」;其他设置 tab 置顶 + 「请假计算:按小时/按天」
radio;课务安排/允许换课去掉 P7 标注(课务字段已落地输入侧,代课执行仍属 P7)。
- LeaveDetail:显示课务安排。
**验证(2026-10-09)**:28 例单测全绿;smoke API 实测 calcWay/minMinutes 读写回显一致、
20 分钟申请被拒(30 下限)、4h 半天口径(08:00-12:00)正确、courseArrange 落库字段就绪(流程 404
回滚期无孤儿单);浏览器(真实账号李晶 + 8108 IDEA 实例 + devtools 热载):时间说明行、按小时表单、
左侧 9 菜单节点渲染正常。
**端口变更**:application.yml `server.port` 8105 → **8108**(IDEA debug 实例),vite 代理同步改 8108。
用户另起 dev server 于 **8079**(与 8078 同源,均为 vite --mode dev)。
**学校 3(李晶,对齐旧版生产)已切按天**:`calc_way=0`、`enable_course=1`(2026-10-09 经 DB 设置)。
8079 实测:半天=0.5 天(合 4 小时)、半天以上 10-12 上午~10-14 下午=3 天(合 24 小时),与旧版算法一致。
**按小时模式对齐旧版(2026-10-09 二次勘察旧系统按小时形态后)**:
起止时间改为旧版同款四控件一行(开始日期+开始时间 至 结束日期+结束时间),TimePicker 分钟按
`最短请假间隔` 步进(`:steps=[1,intervalMinutes,1]`,对应旧版分钟过滤);时长标签「时长(小时)」
纯小时显示(去掉天副注)。8079 实测 10-12 08:00~12:00 = 4 小时。验证后学校 3 已切回按天。
**附件 → 图片上传(2026-10-09,对齐旧版)**:`AttachmentUpload` 组件加 `imageOnly` 模式
(仅 jpg/jpeg/bmp/png、单张 ≤20MB、accept 过滤、错误文案"只能上传 jpg/bmp/png 格式的图片!");
申请页 FormItem「附件」→「图片」,按钮「上传图片」+ 提示
「(上传图片格式:*.jpg、*.bmp、*.png 图片大小不要超过20MB)」逐字对齐旧版;
详情页「附件」→「图片」,图片文件渲染 60×60 缩略图(点击预览直链打开)。
后端 FileUpload 保持通用(格式限制在前端,P6 联调时可评估是否服务端加固)。
**申请页样式对齐旧版(2026-10-09 四轮,末轮按旧版真实 CSS 值精调)**:① label 统一加全角「:」
(CSS `::after`,不逐个改);② `label-width` 100px、标签色 #272829(旧版实测值);
③ **表单铺满内容区**(取消窄卡片约束,旧版控件实测宽 ~946px);④ 控件宽度按旧版实测:
类型下拉/事由文本框**满宽**、日期 190px(旧版 193px)、时间 130px、时长 180px(旧版 180px);
⑤ 行距 20px(旧版实测);⑥ 时长由蓝色文字改为**禁用 Input**(占位「自动获取时长」);
⑦ 按天模式「起止时间」合并为一行:半天/半天以上 + 开始日期 + 上午/下午(半天以上再接
「至」+ 结束日期 + 上午/下午);按小时模式同为四控件一行;⑧ 事由占位「请输入请假事由……」(旧版原文);
⑨ 图片上传按钮改旧版同款方形图标按钮;⑩ 抄送人未选时显示灰色提示
「请选择抄送人,审批通过后通知抄送人」;⑪ 时间说明文字色 #515a6e。
8079 浏览器复核:满宽下拉/事由、100px 冒号标签、180px 时长框、一行起止时间、方形图片按钮均与旧版一致。
### P9 流程一致性与安全加固(2026-10-09 现状审计新增)
> 审计范围:以《子应用流程与消息对接手册-20260929》§2.3、§2.4、§5.1 为准,复核
> `LeaveFlowService`、`LeaveFlowCallbackHandler`、申请身份边界及现有单测。P0~P8 已交付功能不重做。
**审计发现**:
1. 流程发起返回 `null` 或缺少 `instanceId` 时,当前代码仍可能提交为“审批中”,形成无真实流程实例的孤儿单。
2. 拒绝/撤回后重新提交会复用同一请假主键;旧实例迟到事件若按主键兜底定位,可能误推进新 attempt。
3. `LeaveRequest.userId` 来自客户端请求体,普通教师可构造该字段替其他用户发起申请;申请人必须只信任登录态。
4. 手册 §5.1 要求 apply 请求契约断言;现有 12 例流程测试只覆盖回调状态机,没有覆盖
`bizType/bizId/bizJump/bizSnapshot/formData/submitter`。
**实施任务**:
- [x] P9.1 发起结果强校验:`FlowApplyResult` 及 `instanceId` 必须非空,否则抛业务异常并由事务回滚。
- [x] P9.2 回调实例隔离:事件 `bizId/instanceId` 必须与当前 attempt 一致;旧实例迟到事件正常忽略,不触发重推。
- [x] P9.3 回调终态幂等:重复终态事件不重复产生副作用,冲突终态事件不得覆盖已落终态。
- [x] P9.4 申请身份收口:Create 始终使用登录态 `currentUserId/userName`,删除前后端请求契约中的 `userId`。
- [x] P9.5 补齐流程发起请求构造测试、空实例回滚边界测试、旧 attempt/终态冲突回调测试。
- [x] P9.6 验证:后端 `mvn test` 34 例全绿;前端 `npm run typecheck:leave` 通过;`npm run build` 通过。
全工程 `vue-tsc --noEmit` 已执行,另发现 74 个模板历史错误(知识树/年级班级/Vant 等未接入请假路由的遗留件),
与本次请假业务文件无关,登记为 R7,不混入本阶段改造。
**验收标准**:
- 流程发起无实例 ID 时接口失败且事务不留下申请记录;正常结果完整回写 `bizId/instanceId`。
- `100-1` 的迟到回调不能修改当前 `bizId=100-2` 的单据;重复 `completed` 只扣减一次。
- 请求体即使伪造 `userId` 也不进入后端 DTO,申请人只取登录态。
- 单测显式断言跳转为 `/detail?leaveId=xxx`、`bizType=leave`,以及快照/表单/提交人字段。
---
## 9. 风险与未决项
| # | 风险/未决 | 处置 |
|---|---|---|
| R1 | app_key/app_secret、flowGuid、菜单 modId 实际值未定(依赖管理员) | 全部走配置项;本地先以占位值 + mock 单测开发;P6 前与管理员确认 |
| R2 | 回调在本地收不到(flow-center 不能推回本机) | 回调逻辑以单测 mock 覆盖;真链路 160 验收 |
| R3 | Redis 不可达导致启动/健康检查异常 | `management.health.redis.enabled=false`;starter 只在回调验签用 Redis,本地联调可用 docker 起一个 |
| R4 | 历史「按天/半天」模式与「课务安排」不影响 v1 核心闭环 | 已在决策中裁剪,P7 按需恢复 |
| R5 | 教师名单来源(bschclass `GetSchteaList`)不可达时选人/时长列表降级 | 沿用现有 ClassService 缓存 + 空列表降级策略 |
| R6 | 时长算法双端实现漂移 | 同一份规则单测用例(P2 后端 + P4 前端各一份对照) |
| R7 | 前端模板遗留 74 个全量类型错误(知识树/年级班级/移动端 Vant 等未使用模块) | 新增 `tsconfig.leave.json` + `npm run typecheck:leave` 作为请假模块严格门禁;遗留模板另立清理任务,不影响当前构建 |
---
## 10. 执行记录(随实施更新)
| 日期 | 阶段 | 说明 |
|---|---|---|
| 2026-10-08 | 计划 | 本文档创建,勘察完成 |
| 2026-10-08 | P0 完成 | 后端包名 `com.yunhai.workleave`、宿舍业务全删、工程标识更名 workleave;前端标识更名教师请假 |
| 2026-10-08 | P1 完成 | 8 个业务实体 + 仓储 + 建库脚本(含 leaveType 字典种子);审计人改存 userId |
| 2026-10-08 | P2 完成 | 设置域(作息/其他/节次)+ 时长域(列表/调整/流水/合计)+ LeaveTimeCalculator;16 例单测通过 |
| 2026-10-08 | P3 完成 | 申请域(申请/编辑/删除/撤回/销假/待办/审核/统计)+ 流程门面 + 回调状态机;12 例单测通过(共 28 例) |
| 2026-10-08 | P4/P5 完成 | 前端 types/api/routes/壳层重建;9 个业务页面落地;宿舍遗留全删 |
| 2026-10-08 | P6 完成 | 后端 `mvn package` 出包 `target/workleave.jar`(105MB fat jar);28 例单测 + 前端 `npm run build` + `vue-tsc` 新增文件全绿;**启动验证**:`java -jar` 起 8105,Hibernate 自动建 13 张表(leave_apply/leave_setting/leave_time_set/leave_user_time/leave_user_time_record/leave_section/leave_cc/leave_audit_trace + 底座表),`/actuator/health` 返回 200,`leaveType` 字典种子可查(LT001~LT006) |
| 2026-10-08 | 待办 | 160 环境全链路验收依赖 §3 前置项 A1~A6(app_key/flowGuid/菜单 modId 由管理员分配);`/api/*` 请求按平台 CAS 约定 302 跳 SSO 登录(浏览器带票据后正常,与改造前行为一致) |
| 2026-10-09 | P6.5 冒烟 | S1~S6 全部执行完毕(结果见 §8 P6.5 表)。**新增冒烟辅助件** `config/DevMockUserFilter`(`smoke.auth.enabled=true` + `--yh.sso.url-pattern=/__smoke_off__/*` 启动即可本地免 CAS 冒烟)。**修复 3 个 bug**:① `DictService.normalizeType` 大小写比对错误(`"leaveType".equals("leavetype")` 恒 false)导致字典写接口全挂 → 改为忽略大小写比对;② `LeaveSettingService.saveOption` 显式 null 无法清空 `timeManagedTypes` → 改为无条件写入(null 视为清空);③ **`LeaveRequest.userId` 多余的 `@NotNull` 与前端契约冲突**(前端从不传 userId、后端本就从 UserContext 取)导致前端 Create/Edit 100% 被 Bean Validation 拦截报"不能为null" → 删除该注解。**前端修复**:ApplyLeave/LeaveSetting 共 6 处裸 `required` FormItem 补 `:model`/`prop`/`rules`,消除常驻 "undefined is required" 错误文案。修复后 28 例单测复跑全绿、前端重建通过、浏览器复验通过(提交失败 toast 正确透传"发起审批流程失败:OpenAPI 404",DB 无孤儿单)。流程中心 404 为 flowGuid 占位预期(待 A3) |
| 2026-10-09 | P8 完成 | 对照旧版生产系统(oa.bestsch.com/leave,lwj 登录勘察)补齐:按天模式(calc_way + 半天/半天以上表单 + 天显示)、最少请假时间(min_minutes + 校验)、课务安排三选一(course_arrange)、申请页时间说明行;详见 §8 P8。真实账号(李晶)浏览器全链路验证通过;application.yml 端口改 8108(IDEA 实例),vite 代理同步 |
| 2026-10-09 | P8 微调 | ① 移除申请页顶部「我要请假/编辑请假申请」标题(页面更贴近旧版留白);② **时长开放编辑**:申请页时长输入框由禁用改为可编辑(默认自动试算值、后缀带单位,起止时间变化后恢复自动值),提交携带手工值;后端 `resolveHours`:请求 totalHours>0 时以手工值为准(原逻辑一律服务端重算覆盖),最少请假时间/余额校验按最终落库值执行;后端编译 + 前端构建通过(后端需重启生效) |
| 2026-10-09 | P8 教师时长设置对齐旧版 | 按旧版 oa.bestsch.com/leave/leave_timeSetting「教师时长设置」tab 实地截图 + 历史源码(请假前端 TeaTimeImport.vue、C# leave_System.cs ImportQJSchUserTime/ExportQJSchUserTime)重做:① 管控类型勾选迁入教师时长 tab(「请选择需要使用时长的请假类型」勾选即存,timeLimitEnabled 随勾选联动;其他设置中原两项移除);② 左侧教师组树过滤(base/GetSchoolAll 组节点);③ 表格逐行「新加时长(小时)InputNumber + 情况备注」,底部组内合计剩余时长 + 统一提交(复用 Adjust 批量接口),替代原逐人弹窗;④ 新增 Excel 导入:GET leave/time/ImportTemplate(easyexcel 出模板:部门/教师名称/新加时长(小时)/备注,教师按组预填)+ POST leave/time/Import(表头校验、按部门+姓名定位教师、任一行错整体不入账并返回行级错误明细,语义同旧版 C#);前端 LeaveSetting.vue 重构该 tab + 导入弹窗;mvn test 28 例全绿 + 前端 build 通过(后端需重启) |
| 2026-10-09 | P9 完成 | 流程发起空实例强制失败回滚;回调增加当前 attempt/instance 隔离、重复终态幂等与冲突终态保护;Create 申请人只取登录态并移除前后端 `LeaveRequest.userId`;新增 6 例流程契约/边界测试(总计 34 例全绿),`npm run typecheck:leave` 与生产构建通过。全工程类型检查另检出 74 个未使用模板历史错误,见 R7。 |
### 交付物清单
**后端**(`E:\YHXT\java\WorkLeave`)
- `docs/教师请假模块-改造计划-v1.md`(本文件)
- `src/main/resources/sql/workleave_schema.sql`(建表 + 字典种子)
- `src/main/java/com/yunhai/workleave/`(8 实体 + 8 仓储 + 4 控制器 + 5 服务 + 2 门面/回调)
- `src/test/java/com/yunhai/workleave/`(28 例单测)
- `target/workleave.jar`(可执行 fat jar)
**前端**(`E:\YHXT\view\WorkLeave`)
- `src/types/leave.ts`、`src/api/module/leave.ts`、`src/router/routes.ts`
- `src/utils/leaveTime.ts`(与后端同规则的时长试算)
- `src/views/leave/*.vue`(9 个页面)
- `dist/`(构建产物,vite build 直出)
### 启动与联调说明
```bash
# 后端(本地库 work_leave,root/123123,见 application.yml)
cd E:\YHXT\java\WorkLeave
mvn -o -DskipTests package
java -jar target/workleave.jar # 首启自动建表;8108 端口
# 前端
cd E:\YHXT\view\WorkLeave
npm install && npm run dev # 8078,/api 代理到 8108
npm run typecheck:leave # 请假模块严格类型检查
```
> 本地未起 Redis / 未配 flow-callback 时 `yh.flow-callback.enabled=false`,
> 申请提交会因流程中心不可达而整体回滚(设计如此:不留"不回流"的孤儿单)。
> 需要本地跑通提交链路时,按手册 §5.0 配 `yh.open.base-host` 指向 open 子域名并联通 160。
```bash
# 本地冒烟模式(免 CAS + 模拟用户 1001/冒烟测试教师/schId=1,见 P6.5)
java -jar target/workleave.jar --smoke.auth.enabled=true --yh.sso.url-pattern=/__smoke_off__/*
```
+140
View File
@@ -0,0 +1,140 @@
# 流程管理页面 · 开发说明
> 依据《子应用流程与消息对接手册 v1.0》(2026-09-29)实现。
> 日期:2026-10-09
## 一、做了什么
在「请假设置」(路由 `/setting`,菜单 modId=170)中新增第 6 个 Tab **流程管理**,提供:
1. **流程列表** —— 实时拉取流程中心「流程编排」里已注册的流程(名称 / flowGuid / 版本 / 节点摘要)。
2. **流程绑定** —— 选中流程后绑定到本校,支持两级作用域;绑定即触发回调登记。
之所以做成设置页的 Tab 而非新路由:左侧菜单来自平台 `GetUserMod`,新增 modId 需平台侧配菜单才会出现;挂在已有的「请假设置」下可立即访问。后续若要在菜单单列,只需新增 `/flow` 路由(meta.modId 取新值)+ 平台配一条菜单,页面逻辑可直接复用。
## 二、手册要点落地对照
| 手册条款 | 实现 |
|---|---|
| §1.3 流程中心编排注册流程,记下 flowGuid;多套流程 = 多个 flowGuid | 流程列表来自 `YschFlowOpenApi.listFlows`;绑定表按 scope+scopeKey 存多条 |
| §3.2 bizType 恒等于 app_code,不可点分档 | 页面顶部明示 `bizType = leave`;发起时 `FlowApplyBody.bizType` 仍取 `LeaveFlowService.BIZ_TYPE` |
| §2.4 首次发起前完成回调登记 | `bind()` 保存绑定后立刻调用 `flowService.ensureRegistered(schId, flowGuid)` |
| §2.4 登记失败不要阻断业务 | 登记异常只 warn,绑定照常落库 |
| §6.4 别用异常过滤脏消息 | 流程中心不可达时不抛错,降级为 `centerReachable=false`,页面只展示已绑定关系 |
## 三、后端改动
### 新增文件
| 文件 | 作用 |
|---|---|
| `entity/LeaveFlowBind.java` | 流程绑定实体,表 `leave_flow_bind` |
| `repository/LeaveFlowBindRepository.java` | 按 (sch_id, bind_scope, scope_key) 查询 |
| `model/leave/FlowItemVo.java` | 流程列表项 + 绑定标记(是否默认 / 绑定到哪些类型) |
| `model/leave/FlowBindVo.java` | 已绑定关系行 |
| `model/leave/FlowManageVo.java` | 页面聚合视图(列表 + 绑定 + 请假类型 + 可达性) |
| `model/leave/LeaveTypeOptionVo.java` | 请假类型选项 |
| `model/leave/FlowBindRequest.java` | 绑定入参 |
| `service/ILeaveFlowBindService.java`<br>`service/impl/LeaveFlowBindService.java` | 列表 / 绑定 / 解绑 / 发起时解析 flowGuid |
| `controller/api/LeaveFlowController.java` | `/api/leave/flow/List`、`/Bind`、`/Unbind` |
### 修改文件
- `flow/ILeaveFlowService.java` + `flow/LeaveFlowService.java`:新增 `listFlows(schId)`,封装 `YschFlowOpenApi.listFlows(schId, FlowSummary.class)`。
- `service/impl/LeaveApplyService.java`:`startFlow()` 的 flowGuid 由「配置默认值」改为「绑定解析」:
```
单据已定(重提沿用) → 按请假类型绑定 → 学校默认绑定 → 配置 leave.flow.guid
```
- `resources/sql/workleave_schema.sql`:补充 `leave_flow_bind` 建表脚本(线上以 `ddl-auto=update` 自动建表,该脚本用于人工建库/核对)。
### 数据表
```sql
leave_flow_bind(
sch_id, bind_scope(0=全校默认 / 1=按请假类型), scope_key,
flow_guid, flow_name, flow_version, node_summary, ...
)
```
`scope=0` 时 `scope_key` 固定为 `-`。未建物理唯一索引(避免不同环境 `ddl-auto=update` 追加约束失败),唯一性由服务层 upsert 保证。
## 四、前端改动
- `types/leave.ts`:`FlowBindScope` / `FlowItemVo` / `FlowBindVo` / `LeaveTypeOptionVo` / `FlowManageVo` / `FlowBindRequest`。
- `api/module/leave.ts`:新增 `leaveFlowApi`(`List` / `Bind` / `Unbind`),导出 `leaveFlowApi`。
- `views/leave/LeaveSetting.vue`:新增「流程管理」Tab,含
- 全校默认流程卡片(流程名 / 版本 / flowGuid / 节点摘要 + 更换 / 解除)
- 流程列表表格(设为默认、按类型绑定;名称列以徽标展示绑定的请假类型名称,超过 2 个折叠为 +N、悬停看全量)
- 绑定弹窗(范围单选 + 请假类型多选下拉 + 流程下拉 + 节点摘要预览;按类型绑定时预选该流程已绑类型,提交为替换语义——未勾选的原绑定类型自动解除)
- 流程中心不可达时的告警条
- 样式沿用页面既有 CSS 变量(`--set-*`),并补了 `prefers-color-scheme: dark` 深色覆写
> 2026-10-10 调整:移除独立的「按请假类型指定」表格卡片(类型绑定统一从流程列表「按类型绑定」入口操作);
> 绑定弹窗请假类型由单选改为多选(后端 `FlowBindRequest` 新增 `scopeKeys` 列表,一次把多个类型绑到同一流程);
> 流程列表名称列直接展示绑定的类型名称(原先只显示「N 个类型」计数)。
>
> 2026-10-10 二次调整:列表去掉「流程标识(flowGuid)」列,绑定类型从名称列徽标拆为独立
> 「绑定的请假类型」列(超 2 个折叠 +N、悬停看全量,未绑定显示灰字);页面不再裸露 flowGuid——
> 默认流程卡片、绑定弹窗预览、默认流程空态(回落值改为显示流程名,中心查不到才显示配置值)同步收敛,
> 顶部提示删去 flowGuid 说明语句。
## 五、验收情况
- 后端 `mvn -o compile`(JDK8)通过。
- 前端 `vue-tsc --noEmit -p tsconfig.leave.json` 通过;`vite build --mode base` 通过(154 modules transformed)。
- **未做真链路联调**:本地不出站(手册 §5.0),流程中心不可达时页面走降级分支。需在 160 环境用真实 flowGuid 验证「列表拉取 → 绑定 → 发起 → 回调」。
## 六、遗留事项
1. 本地 `yh.flow-callback.enabled=false`,回调登记会跳过并 warn——属预期,上环境后由公共配置覆盖打开。
2. 前端仓库根目录残留一个构建校验产物目录 `E:\YHXT\view\WorkLeave\dist-verify`(沙箱回收站不可用未能删除),可手动删除。
## 七、条件路由判断开放接口 `/oapi/flow/dept-judge`(2026-10-10)
流程中心 CONDITION 节点(`routerKey=work-leave.dept-judge`,路由注册表 path=`/oapi/flow/dept-judge`,
serviceKey=work-leave)激活时**同步**回调本端点,按请假时长选出口:**小于3天 / 大于等于3天**。
已发布流程 `d2237a5b…`(v4)/ `c3e9c6ee…`(v7)「请假申请(小于3天或大于等于3天)」的两条出边
name 即出口契约:`小于3天`、`大于等于3天`。
**契约**(对齐 noticeservice `OapiFlowRouteController` 参考实现,收在 `/oapi/*` 下由 AppAuthFilter
验 flow-center 签名):
- 入参:POST JSON `{schId, instanceId, instanceVersion, bizType, bizId, nodeId, nodeName, routerKey, outlets, variables}`
- `bizId` 形态 `{leaveId}-{attempt}`,取首段定位 `leave_apply`;
- `outlets`:流程定义的出口名集合;`variables`:发起时透传的 formData(含 `totalHours`/`days`)。
- 出参:`{"outlet": "小于3天" | "大于等于3天"}`。
- 时长口径:**3天 = 24小时**(一天按作息 8 小时折算,同 `LeaveFlowService#buildFormData` 的
`days=totalHours/8`);恰好 24h 归「大于等于3天」。
- 免查库:`variables.totalHours` 存在则直接用(与发起口径同源),缺失回落按 bizId 查表。
- 契约纪律:计算出的 outlet 不在 `outlets` 集合内 → 抛业务错让 flow-center 挂起,**绝不默认放行**;
单据不存在 / 无时长同样挂起。
**鉴权**:`yh.app-auth.server.enforce-patterns: [/oapi/*]`(本次新增,对齐 noticeservice 生产姿势)——
`/oapi/**` 无签名一律 401,有签名走中央密钥链校验;`application-local.yml` 已用永不匹配 pattern
`[/__local_no_match__]` 关闭(不能直接置空:base 的 `enforce-patterns[0]` 索引键仍会使条件命中,
而空 pattern 列表会让 FilterRegistration 启动报错),本地无签名联调不受影响。
**代码**:`controller/oapi/FlowJudgeController.java`(端点)+ `FlowJudgeControllerTest`(9 项单测:
出口两分支 / 恰好3天边界 / variables 免查库与回落查表 / bizId 带重提后缀解析 / 出口不命中与缺时长挂起)。
**响应包装**:端点裸返回 `{"outlet":...}`,由 yh-api-std `ApiResponseAdvice` 统一包成
`{code:0, msg, data:{outlet}}` —— notice 参考实现(生产在用)同受此包装,flow-center 读 `data.outlet`,行为一致。
**病假绑定与真链路测试(2026-10-10)**:
- 病假 = LT002(全局字典,schId=0),已在「流程管理」把 LT001/LT002/LT003/LT004/LT005 五类绑到
c3e9c6ee(请假申请(小于3天或大于等于3天) v7,启用中)。v7 结构:CONDITION →「小于3天」审批人
10007 /「大于等于3天」审批人 10008(lijing),两分支汇合后抄送 10008 → END。
- 本地直连契约测试(模拟 flow-center 请求体)全过:16h→`小于3天`;24h 整→`大于等于3天`;
variables 缺 totalHours→回落查表;出口不命中→业务错挂起(`code=2` + 明确 message)。
- ⚠️ **平台侧前置缺口:流程中心的判断调用走开放网关 `{gateway}{basePath}{path}`
(=`open.yunhaixingtong.com/work-leave/oapi/flow/dept-judge`),网关当前无 `/work-leave` 路由
(404;对照 `/notice` 路由存在、401=已到达 notice 服务)⇒ 网关出不了 160,调用永远到不了本机,
CONDITION 实例按设计**挂起在 u1c**。需平台部署 work-leave / 注册网关路由后真链路才能闭环。
- 挂起实例可正常**撤回**(flow.withdrawn 正常回调,单据回已撤回)——是卡单的恢复路径。
- 遗留:病假测试单 id=18(10-19 一天)在绑定生效前提交,走了兜底流程 d2237a5b 并卡在 u1c,
可在「我的请假」撤回后重提(重提将按绑定走 c3e9c6ee)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+63
View File
@@ -0,0 +1,63 @@
import fs from "node:fs";
import zlib from "node:zlib";
const file = process.argv[2];
const buf = fs.readFileSync(file);
// ---- 解析 PNG ----
let pos = 8;
let W = 0, H = 0, bitDepth = 0, colorType = 0, idat = [];
while (pos < buf.length) {
const len = buf.readUInt32BE(pos);
const type = buf.toString("ascii", pos + 4, pos + 8);
const data = buf.subarray(pos + 8, pos + 8 + len);
if (type === "IHDR") {
W = data.readUInt32BE(0); H = data.readUInt32BE(4);
bitDepth = data[8]; colorType = data[9];
} else if (type === "IDAT") idat.push(data);
else if (type === "IEND") break;
pos += 12 + len;
}
console.log(`size=${W}x${H} bitDepth=${bitDepth} colorType=${colorType}`);
const raw = zlib.inflateSync(Buffer.concat(idat));
const ch = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 0 ? 1 : 0;
if (!ch || bitDepth !== 8) { console.log("unsupported format"); process.exit(0); }
const stride = W * ch;
const px = Buffer.alloc(H * stride);
// ---- 反 filter ----
let p = 0;
for (let y = 0; y < H; y++) {
const ft = raw[p++];
const line = raw.subarray(p, p + stride); p += stride;
const cur = px.subarray(y * stride, (y + 1) * stride);
const prev = y > 0 ? px.subarray((y - 1) * stride, y * stride) : null;
for (let x = 0; x < stride; x++) {
const a = x >= ch ? cur[x - ch] : 0;
const b = prev ? prev[x] : 0;
const c = prev && x >= ch ? prev[x - ch] : 0;
let v = line[x];
if (ft === 1) v += a;
else if (ft === 2) v += b;
else if (ft === 3) v += (a + b) >> 1;
else if (ft === 4) {
const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
v += (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c);
}
cur[x] = v & 255;
}
}
const at = (x, y) => { const o = (y * W + x) * ch; return [px[o], px[o + 1], px[o + 2]]; };
const near = (c, t, tol) => Math.abs(c[0]-t[0])<=tol && Math.abs(c[1]-t[1])<=tol && Math.abs(c[2]-t[2])<=tol;
// ---- 直方图:统计出现最多的颜色 ----
const hist = new Map();
for (let y = 0; y < H; y += 2) for (let x = 0; x < W; x += 2) {
const c = at(x, y); const k = `${c[0]},${c[1]},${c[2]}`;
hist.set(k, (hist.get(k) || 0) + 1);
}
const top = [...hist.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12);
console.log("主色 Top12(含占比):");
for (const [k, v] of top) console.log(` #${k.split(",").map(n=>Number(n).toString(16).padStart(2,"0")).join("")} ${(v/((W/2)*(H/2))*100).toFixed(1)}% (${k})`);
fs.writeFileSync(process.argv[3] || "png-px.bin", Buffer.from(px));
+51
View File
@@ -0,0 +1,51 @@
import fs from "node:fs";
const meta = JSON.parse(fs.readFileSync("clip.rgba.json", "utf8"));
const px = fs.readFileSync("clip.rgba");
const { w, ch, h } = meta;
const at = (x, y) => { const o = (y * w + x) * ch; return [px[o], px[o + 1], px[o + 2]]; };
const D = (a, b) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]);
const hex = (c) => c.map((v) => v.toString(16).padStart(2, "0")).join("");
const Y0 = Number(process.argv[2] || 372);
const Y1 = Number(process.argv[3] || 500);
const isInk = (x, y) => {
const c = at(x, y);
if (D(c, [255, 255, 255]) <= 20) return false;
if (D(c, [227, 234, 246]) <= 14) return false;
if (D(c, [230, 235, 244]) <= 14) return false;
if (D(c, [236, 237, 243]) <= 10) return false;
return true;
};
const seen = new Uint8Array(w * h);
const comps = [];
for (let y = Y0; y < Y1; y++) {
for (let x = 0; x < w; x++) {
if (seen[y * w + x] || !isInk(x, y)) continue;
let minX = x, maxX = x, minY = y, maxY = y, n = 0, sr = 0, sg = 0, sb = 0;
const st = [x, y];
seen[y * w + x] = 1;
while (st.length) {
const cy = st.pop(), cx = st.pop();
n++;
const c = at(cx, cy);
sr += c[0]; sg += c[1]; sb += c[2];
if (cx < minX) minX = cx; if (cx > maxX) maxX = cx;
if (cy < minY) minY = cy; if (cy > maxY) maxY = cy;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || nx >= w || ny < Y0 || ny >= Y1) continue;
if (!seen[ny * w + nx] && isInk(nx, ny)) { seen[ny * w + nx] = 1; st.push(nx, ny); }
}
}
if (n > 25) comps.push({ x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1, n, c: [Math.round(sr / n), Math.round(sg / n), Math.round(sb / n)] });
}
}
comps.sort((a, b) => a.x - b.x);
console.log(`区域 y${Y0}-${Y1} 内「墨迹」连通域 ${comps.length} 个(按 x 排序,CSS 坐标):`);
for (const b of comps) {
console.log(` x${Math.round(b.x / 2)} y${Math.round(b.y / 2)} ${Math.round(b.w / 2)}x${Math.round(b.h / 2)} px${b.n} avg#${hex(b.c)}`);
}
+48
View File
@@ -0,0 +1,48 @@
import fs from "node:fs";
const meta = JSON.parse(fs.readFileSync("clip.rgba.json", "utf8"));
const px = fs.readFileSync("clip.rgba");
const { w, ch } = meta, h = meta.h;
const at = (x, y) => { const o = (y * w + x) * ch; return [px[o], px[o+1], px[o+2]]; };
const D = (a,b) => Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1])+Math.abs(a[2]-b[2]);
// 非白二值图(看整体结构)
const CW = 130, CHh = 56;
let map = "";
for (let gy = 0; gy < CHh; gy++) {
let line = "";
for (let gx = 0; gx < CW; gx++) {
const x0 = Math.floor(gx*w/CW), x1 = Math.max(x0+1, Math.floor((gx+1)*w/CW));
const y0 = Math.floor(gy*h/CHh), y1 = Math.max(y0+1, Math.floor((gy+1)*h/CHh));
let n = 0, tot = 0;
for (let y = y0; y < y1; y += 2) for (let x = x0; x < x1; x += 2) { tot++; if (D(at(x,y), [255,255,255]) > 24) n++; }
const r = n / Math.max(1, tot);
line += r > 0.5 ? "#" : r > 0.18 ? "+" : r > 0.04 ? "." : " ";
}
map += String(gy).padStart(2) + "|" + line + "\n";
}
console.log("=== 非白结构图(# 密集 / + 中等 / . 稀疏)===");
console.log(map);
// 连通域:找边框色(#e3eaf6 面板边框 / #e6ebf4 控件边框 / #ecedf3 分隔线)的矩形
const TARGETS = { panelBorder: [227,234,246], ctrlBorder: [230,235,244], divider: [236,237,243] };
for (const [name, t] of Object.entries(TARGETS)) {
const seen = new Uint8Array(w*h); const boxes = [];
const match = (x,y) => D(at(x,y), t) <= 12;
for (let y = 0; y < h; y += 1) for (let x = 0; x < w; x += 1) {
if (seen[y*w+x] || !match(x,y)) continue;
let minX=x,maxX=x,minY=y,maxY=y,n=0; const st=[x,y]; seen[y*w+x]=1;
while (st.length) { const cy=st.pop(), cx=st.pop(); n++;
if(cx<minX)minX=cx; if(cx>maxX)maxX=cx; if(cy<minY)minY=cy; if(cy>maxY)maxY=cy;
for (const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const nx=cx+dx, ny=cy+dy;
if (nx<0||ny<0||nx>=w||ny>=h) continue;
if (!seen[ny*w+nx] && match(nx,ny)) { seen[ny*w+nx]=1; st.push(nx,ny); }
}
}
if (n > 60) boxes.push({x:minX,y:minY,w:maxX-minX+1,h:maxY-minY+1,n});
}
boxes.sort((a,b)=>a.y-b.y || a.x-b.x);
console.log(`\n=== ${name} #${t.join(",")} 连通域 ${boxes.length} 个 ===`);
for (const b of boxes.slice(0, 26))
console.log(` x${b.x} y${b.y} ${b.w}x${b.h} px${b.n} (CSS≈ x${Math.round(b.x/2)} y${Math.round(b.y/2)} ${Math.round(b.w/2)}x${Math.round(b.h/2)})`);
}
+34
View File
@@ -0,0 +1,34 @@
import fs from "node:fs";
const W = Number(process.argv[3] || 110), H = Number(process.argv[4] || 46);
const meta = JSON.parse(fs.readFileSync(process.argv[2] + ".json", "utf8"));
const px = fs.readFileSync(process.argv[2]);
const { w, h, ch } = meta;
const at = (x, y) => { const o = (y * w + x) * ch; return [px[o], px[o+1], px[o+2]]; };
const D = (a,b) => Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1])+Math.abs(a[2]-b[2]);
const CLASSES = [
[" ", [255,255,255]], ["b", [235,247,255]], ["B", [87,122,251]],
["-", [227,234,246]], ["=", [236,237,243]], ["#", [81,90,110]],
["R", [250,81,81]], ["g", [69,185,114]], ["o", [232,165,76]],
["f", [246,248,249]], [".", [239,244,255]],
];
const cls = (c) => { let best = ".", bd = 1e9;
for (const [ch2, t] of CLASSES) { const d = D(c, t); if (d < bd) { bd = d; best = ch2; } }
return bd < 40 ? best : "?"; };
let out = "";
for (let gy = 0; gy < H; gy++) {
let line = "";
for (let gx = 0; gx < W; gx++) {
const x0 = Math.floor(gx * w / W), x1 = Math.max(x0+1, Math.floor((gx+1) * w / W));
const y0 = Math.floor(gy * h / H), y1 = Math.max(y0+1, Math.floor((gy+1) * h / H));
const cnt = new Map();
for (let y = y0; y < y1; y += 2) for (let x = x0; x < x1; x += 2) {
const k = cls(at(x, y)); cnt.set(k, (cnt.get(k)||0)+1);
}
let best = " ", bc = -1;
for (const [k, v] of cnt) if (v > bc) { bc = v; best = k; }
line += best;
}
out += String(gy).padStart(2) + "|" + line + "\n";
}
console.log(`图 ${w}x${h} → ${W}x${H} 字符`);
console.log(out);
+216
View File
@@ -0,0 +1,216 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yunhai</groupId>
<artifactId>workleave</artifactId>
<version>0.0.1</version>
<name>WorkLeave</name>
<description>请假申请子应用</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencyManagement>
<!-- 基础平台版本管理(含 yh-* 架包与 spring-boot-dependencies 对齐) -->
<dependencies>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-platform-dependencies</artifactId>
<version>1.0.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring Cloud Alibaba(Nacos 配置中心,对齐 Boot 2.7) -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2021.0.5.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- 仅引入 Spring Security 的加密模块(BCryptPasswordEncoder),不引入整套安全框架 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- AOP(UserContext 填充切面 + 审计日志切面) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- Boot 2.4+ 默认不再处理 bootstrap.yml,需显式引入以继续从 Nacos 拉取配置 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
<version>3.1.8</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JSON 序列化(fastjson,主类开启安全模式) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.80</version>
</dependency>
<!-- Swagger / OpenAPI 文档(springdoc,兼容 Boot 2.7) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.7.0</version>
</dependency>
<!-- 基础平台全家桶(聚合 starter):认证(CAS/会话/SSO)+ 应用间签名鉴权 + 用户中心静态门面
+ 统一响应 + actuator 健康探活,一行带齐(模块构成见 BasePlatform/后端项目知识库.md)。
约定:标准业务服务一律引聚合 starter;平台能力提供方(如 UserBase)按需单引。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-spring-boot-starter</artifactId>
</dependency>
<!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。
版本 3.5.1 已在本地 .m2 仓库。 -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.5.1</version>
</dependency>
<!-- Excel 导入导出(EasyExcel 3.x,注解驱动、流式写入内存友好) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.2</version>
</dependency>
<!-- 流程中心回调接收端 starter:入站验签 + 幂等 + 乱序门 + 回调登记注册器;
传递带入 openapi-client(flow/msg 门面)。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-flow-callback-spring-boot-starter</artifactId>
</dependency>
<!-- 验签密钥链需要 Redis 连接(optional 依赖不传递,必须显式引)。
management.health.redis.enabled=false 防抖动拉垮健康检查;本地未起 Redis 时
回调验签相关装配失败会阻断启动,本地开发可临时关闭 yh.flow-callback.enabled。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
<build>
<finalName>workleave</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- 关闭增量编译,避免 Windows 下 maven-status 状态文件被锁导致
"Error while storing the mojo status / 拒绝访问" -->
<useIncrementalCompilation>false</useIncrementalCompilation>
</configuration>
</plugin>
<!-- 打可执行 fat jar(Docker 镜像直接 java -jar 启动) -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.yunhai.workleave.WorkLeaveApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>nexus-public</id>
<name>bsch/nexus public</name>
<url>http://nexus.yunhaixingtong.com/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
<!--
ci 构建档案:将构建产物重定向到 IDE(IntelliJ/Eclipse)索引目录之外,
避免 IDE 后台进程持续锁定 target/maven-status/.../createdFiles.lst 导致
"Error while storing the mojo status / 拒绝访问"。
输出目录需从未被 IDE 打开过。常规本地构建无需激活此 profile。
-->
<profiles>
<profile>
<id>ci</id>
<build>
<directory>C:/notice-build/workleave</directory>
</build>
</profile>
</profiles>
</project>
@@ -0,0 +1,20 @@
package com.yunhai.workleave;
import com.alibaba.fastjson.parser.ParserConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication(scanBasePackages = "com.yunhai.workleave")
@EnableTransactionManagement
@EnableAsync
public class WorkLeaveApplication {
public static void main(String[] args) {
// 开启 fastjson 安全模式
ParserConfig.getGlobalInstance().setSafeMode(true);
SpringApplication.run(WorkLeaveApplication.class, args);
}
}
@@ -0,0 +1,154 @@
package com.yunhai.workleave.aspect;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.workleave.enums.UserType;
import com.yunhai.workleave.model.UserSysRole;
import com.yunhai.workleave.utils.IpUtils;
import com.yunhai.workleave.utils.UserContext;
import com.yunhai.platform.auth.BoaUserContext;
import com.yunhai.platform.utils.model.BoaUser;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
/**
* 当前用户上下文填充切面(移植自 UserclassService ControllerUserContextAspect)。
* <p>@Order(1) 先于审计日志切面(Order 3)执行,保证日志能取到 UserContext。
* 取用户优先级:方法参数 BoaUser → BoaUserContext 静态持有者 → request attribute;
* 角色切换信息从 currentrole(Header/Query,URL 编码 JSON)解析。</p>
*/
@Aspect
@Component
@Order(1)
public class ControllerUserContextAspect {
private static final Logger log = LoggerFactory.getLogger(ControllerUserContextAspect.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 外部认证框架把解析好的 BoaUser 放进 request attribute 的候选 key */
private static final List<String> BOA_USER_ATTR_KEYS = Arrays.asList(
"boaUser", "boauser", "currentUser", "user", "authUser", "loginUser"
);
@Around("execution(* com.yunhai.workleave.controller..*.*(..))")
public Object setUserContext(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
BoaUser boaUser = null;
UserSysRole userSysRole = null;
for (Object arg : args) {
if (arg instanceof BoaUser) {
boaUser = (BoaUser) arg;
} else if (arg instanceof UserSysRole) {
userSysRole = (UserSysRole) arg;
}
}
if (boaUser == null) {
try {
boaUser = BoaUserContext.get();
} catch (Throwable ignore) {
boaUser = null;
}
}
HttpServletRequest request = getCurrentHttpRequest();
if (request != null) {
if (boaUser == null) {
boaUser = resolveBoaUserFromRequest(request);
}
if (userSysRole == null) {
userSysRole = resolveUserSysRoleFromRequest(request);
}
}
populateUserContext(boaUser, userSysRole);
try {
return joinPoint.proceed();
} finally {
UserContext.clear();
log.debug("UserContext cleared for {}", joinPoint.getSignature().toShortString());
}
}
/** 从 currentrole(URL 编码,Header 或 Query 参数)解析 UserSysRole */
private UserSysRole resolveUserSysRoleFromRequest(HttpServletRequest request) {
String raw = request.getHeader("currentrole");
boolean fromQuery = false;
if (raw == null) {
raw = request.getParameter("currentrole");
fromQuery = true;
}
if (raw == null) {
return null;
}
try {
String decoded = fromQuery ? raw : URLDecoder.decode(raw, StandardCharsets.UTF_8.name());
return MAPPER.readValue(decoded, UserSysRole.class);
} catch (Exception e) {
log.warn("解析 currentrole 失败: {}", e.getMessage());
return null;
}
}
/** 从 request attribute(外部框架注入的 BoaUser)回退解析 */
private BoaUser resolveBoaUserFromRequest(HttpServletRequest request) {
for (String key : BOA_USER_ATTR_KEYS) {
Object attr = request.getAttribute(key);
if (attr instanceof BoaUser) {
return (BoaUser) attr;
}
}
return null;
}
private HttpServletRequest getCurrentHttpRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attributes != null ? attributes.getRequest() : null;
}
private void populateUserContext(BoaUser boaUser, UserSysRole userSysRole) {
if (boaUser != null) {
UserContext.setUserId(boaUser.getId());
UserContext.setUserName(boaUser.getName() != null ? boaUser.getName() : "unknown");
Integer schId = boaUser.getSchool() == null ? null : boaUser.getSchool().getId();
if (schId == null && userSysRole != null) {
schId = userSysRole.getSchId();
}
UserContext.setSchId(schId);
} else {
UserContext.setUserName("anonymous");
if (userSysRole != null && userSysRole.getSchId() != null) {
UserContext.setSchId(userSysRole.getSchId());
}
log.warn("No BoaUser found, using anonymous");
}
if (userSysRole != null) {
UserContext.setRoleId(userSysRole.getRoleId());
UserContext.setChildId(userSysRole.getChildId());
}
UserContext.setType(boaUser != null ? boaUser.getType() : null);
// 超级管理员(TYPE=1)不绑定具体学校,schId 统一置 0
if (UserType.SUPER_ADMINISTRATOR.getId().equals(UserContext.getType())) {
UserContext.setSchId(0);
}
UserContext.setIp(IpUtils.getClientIp());
}
}
@@ -0,0 +1,223 @@
package com.yunhai.workleave.aspect;
import com.alibaba.fastjson.JSON;
import com.yunhai.workleave.entity.SysOperationLog;
import com.yunhai.workleave.service.ISysOperationLogService;
import com.yunhai.workleave.utils.GzipUtil;
import com.yunhai.workleave.utils.IpUtils;
import com.yunhai.workleave.utils.UserContext;
import com.yunhai.platform.apistd.ApiResponse;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.exception.IErrorCode;
import com.yunhai.platform.utils.http.ApiResult;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 系统审计日志切面(移植自 UserclassService SysOperationLogAspect)。
* <p>全量拦截 controller 包所有方法,不依赖注解;排除日志查询控制器自身(防递归);
* 用户信息来自 {@link ControllerUserContextAspect}(Order 1)填充的 UserContext;
* 响应体 GZIP 压缩落库,落库走 @Async 不阻塞请求。</p>
*/
@Aspect
@Component
@Order(3)
public class SysOperationLogAspect {
private static final Logger log = LoggerFactory.getLogger(SysOperationLogAspect.class);
/** 排除的控制器(日志查询自身,防递归刷屏) */
private static final List<String> EXCLUDE_CONTROLLERS = java.util.Arrays.asList(
"com.yunhai.workleave.controller.api.LogController"
);
private static final int DEFAULT_MAX_RESPONSE_SIZE = 10240;
private static final int MAX_ERROR_MSG = 8000;
private static final int MAX_PARAMS = 2000;
@Autowired
private ISysOperationLogService sysOperationLogService;
@Around("execution(* com.yunhai.workleave.controller..*.*(..))")
public Object around(ProceedingJoinPoint point) throws Throwable {
String declType = point.getSignature().getDeclaringTypeName();
if (EXCLUDE_CONTROLLERS.contains(declType)) {
return point.proceed();
}
HttpServletRequest request = getRequest();
String httpMethod = request != null ? request.getMethod() : "";
long start = System.currentTimeMillis();
SysOperationLog entity = buildEntity(point, request, httpMethod);
Object result;
try {
result = point.proceed();
entity.setStatusCode(resolveSuccessCode(result));
fillResponse(entity, result, DEFAULT_MAX_RESPONSE_SIZE);
return result;
} catch (Throwable e) {
entity.setStatusCode(resolveErrorCode(e));
entity.setErrorMsg(buildErrorMsg(e));
throw e;
} finally {
entity.setCostTime(System.currentTimeMillis() - start);
sysOperationLogService.saveAsync(entity);
}
}
private SysOperationLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request, String httpMethod) {
SysOperationLog e = new SysOperationLog();
e.setCreateTime(LocalDateTime.now());
// 用户信息(ControllerUserContextAspect Order 1 已填充)
e.setUserId(UserContext.getUserId());
e.setUserName(StringUtils.hasText(UserContext.getUserName()) ? UserContext.getUserName() : "anonymous");
e.setSchId(UserContext.getSchId());
e.setRequestIp(StringUtils.hasText(UserContext.getIp()) ? UserContext.getIp() : IpUtils.getClientIp());
String uri = request != null ? request.getRequestURI() : "";
String methodName = point.getSignature().getName();
e.setRequestUri(uri);
e.setRequestMethod(httpMethod);
e.setClassMethod(point.getTarget().getClass().getName() + "." + methodName);
e.setLogLevel(resolveLevel(httpMethod));
e.setOperationType(resolveOperationType(httpMethod, methodName));
if (request != null) {
e.setUserAgent(request.getHeader("User-Agent"));
}
e.setRequestParams(truncate(buildRequestParams(point, request), MAX_PARAMS));
return e;
}
private String buildRequestParams(ProceedingJoinPoint point, HttpServletRequest request) {
try {
if (request != null && "GET".equalsIgnoreCase(request.getMethod())) {
String qs = request.getQueryString();
if (StringUtils.hasText(qs)) {
return qs;
}
}
Object[] args = point.getArgs();
if (args == null || args.length == 0) {
return "";
}
List<Object> filtered = new ArrayList<>();
for (Object arg : args) {
if (arg == null) {
continue;
}
if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse
|| arg instanceof HttpSession || arg instanceof MultipartFile
|| arg instanceof MultipartFile[]) {
continue;
}
// BoaUser 等平台对象跳过序列化(无 toString 意义且可能循环引用)
if (arg.getClass().getName().startsWith("com.yunhai.platform.")) {
continue;
}
filtered.add(arg);
}
return filtered.isEmpty() ? "" : JSON.toJSONString(filtered);
} catch (Exception ex) {
return "";
}
}
private void fillResponse(SysOperationLog e, Object result, int max) {
if (result == null) {
return;
}
try {
String resp = JSON.toJSONString(result);
if (resp.length() > max) {
resp = resp.substring(0, max) + "...[截断]";
}
e.setResponseData(GzipUtil.compress(resp));
} catch (Exception ex) {
log.warn("审计日志响应序列化失败, uri={}", e.getRequestUri());
}
}
/** 成功路径:Controller 返回 ApiResponse/ApiResult 时取其 code,其余视为 SUCCESS(0) */
private int resolveSuccessCode(Object result) {
if (result instanceof ApiResponse) {
return ((ApiResponse<?>) result).getCode();
}
if (result instanceof ApiResult) {
return ((ApiResult<?>) result).getCode();
}
return ErrorCode.SUCCESS.getCode();
}
/** 异常路径:从业务异常提取错误码,兜底 INTERNAL_SERVER_ERROR */
private int resolveErrorCode(Throwable e) {
if (e instanceof DException) {
IErrorCode ec = ((DException) e).getCode();
if (ec != null) {
return ec.getCode();
}
}
return ErrorCode.INTERNAL_SERVER_ERROR.getCode();
}
private HttpServletRequest getRequest() {
ServletRequestAttributes a = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return a != null ? a.getRequest() : null;
}
private String buildErrorMsg(Throwable e) {
String msg = e.getMessage();
String head = (msg == null || msg.trim().isEmpty()) ? e.getClass().getName() : msg;
return truncate(head + System.lineSeparator() + ExceptionUtils.getStackTrace(e), MAX_ERROR_MSG);
}
private String truncate(String s, int max) {
return s != null && s.length() > max ? s.substring(0, max) : s;
}
/** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */
private int resolveLevel(String httpMethod) {
if ("DELETE".equalsIgnoreCase(httpMethod)) {
return 1;
}
if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod)) {
return 2;
}
return 3;
}
private String resolveOperationType(String httpMethod, String methodName) {
String lower = methodName == null ? "" : methodName.toLowerCase();
if ("DELETE".equalsIgnoreCase(httpMethod) || lower.startsWith("delete") || lower.startsWith("del")) {
return "DELETE";
}
if (lower.startsWith("create") || lower.startsWith("add")) {
return "CREATE";
}
if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod)
|| lower.startsWith("edit") || lower.startsWith("update") || lower.startsWith("set")) {
return "UPDATE";
}
return "QUERY";
}
}
@@ -0,0 +1,36 @@
package com.yunhai.workleave.common.listener;
import com.yunhai.workleave.entity.BaseEntity;
import com.yunhai.workleave.utils.UserContext;
import org.springframework.stereotype.Component;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import java.time.LocalDateTime;
/**
* JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人与时间戳。
* <p>操作人取 {@link UserContext#getUserId()}——只存平台用户ID,不保存账号;
* 无用户上下文的线程(如流程回调、定时任务)为 null,不影响落库。</p>
*/
@Component
public class BaseAuditListener {
@PrePersist
public void prePersist(BaseEntity entity) {
LocalDateTime now = LocalDateTime.now();
entity.setCreateTime(now);
entity.setUpdateTime(now);
Integer userId = UserContext.getUserId();
if (entity.getCreateBy() == null) {
entity.setCreateBy(userId);
}
entity.setUpdateBy(userId);
}
@PreUpdate
public void preUpdate(BaseEntity entity) {
entity.setUpdateTime(LocalDateTime.now());
entity.setUpdateBy(UserContext.getUserId());
}
}
@@ -0,0 +1,10 @@
package com.yunhai.workleave.common.validation;
/**
* JSR-303 分组校验标记接口(对齐 UserBase 规范)。
* Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。
*/
public interface ValidationGroups {
interface Create {}
interface Update {}
}
@@ -0,0 +1,11 @@
package com.yunhai.workleave.config;
/**
* 全局路径常量。与 UserBase 一致:API 统一挂在 /{platform}/ 之下(platform = api / manage / mobiapi)。
*/
public enum Config {
Inst;
public static final String API_PATH = "/{platform}"; // manage / api / mobiapi,前面有/后面无/,拼接子路径时用"/"分隔
public static final String OPEN_API_PATH = "/oapi";
}
@@ -0,0 +1,83 @@
package com.yunhai.workleave.config;
import com.yunhai.platform.auth.BoaUserContext;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* 本地冒烟/联调辅助:向 BoaUserContext 注入模拟登录用户。
* <p>仅当 smoke.auth.enabled=true 时注册(默认关闭,线上不受影响)。
* 需配合关闭 CAS 拦截启动:--yh.sso.url-pattern=/__smoke_off__/*</p>
*/
@Configuration
@ConditionalOnProperty(prefix = "smoke.auth", name = "enabled", havingValue = "true")
public class DevMockUserFilter {
@Bean
public FilterRegistrationBean<Filter> mockUserFilter(DevMockUserProperties props) {
FilterRegistrationBean<Filter> registration = new FilterRegistrationBean<>();
registration.setFilter((request, response, chain) -> doFilter(props, request, response, chain));
registration.addUrlPatterns("/api/*", "/manage/*", "/mobiapi/*");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
registration.setName("devMockUserFilter");
return registration;
}
private void doFilter(DevMockUserProperties props, ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
BoaUser user = new BoaUser();
user.setId(props.getUserId());
user.setName(props.getUserName());
user.setType(props.getUserType());
BoaGroup school = new BoaGroup();
school.setId(props.getSchId());
school.setName("冒烟测试学校");
user.setSchool(school);
BoaUserContext.set(user);
try {
chain.doFilter(request, response);
} finally {
BoaUserContext.clear();
}
if (request instanceof HttpServletRequest) {
org.slf4j.LoggerFactory.getLogger(DevMockUserFilter.class)
.debug("mock user {} applied for {}", props.getUserId(), ((HttpServletRequest) request).getRequestURI());
}
}
@org.springframework.boot.context.properties.ConfigurationProperties(prefix = "smoke.auth")
@Bean
public DevMockUserProperties devMockUserProperties() {
return new DevMockUserProperties();
}
/** 模拟用户参数(默认值见 application.yml smoke 段) */
public static class DevMockUserProperties {
private Integer userId = 1001;
private String userName = "冒烟测试教师";
private Integer schId = 1;
private Integer userType = 3;
public Integer getUserId() { return userId; }
public void setUserId(Integer userId) { this.userId = userId; }
public String getUserName() { return userName; }
public void setUserName(String userName) { this.userName = userName; }
public Integer getSchId() { return schId; }
public void setSchId(Integer schId) { this.schId = schId; }
public Integer getUserType() { return userType; }
public void setUserType(Integer userType) { this.userType = userType; }
}
}
@@ -0,0 +1,35 @@
package com.yunhai.workleave.config;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import java.time.format.DateTimeFormatter;
/**
* Jackson 时间序列化全局约定(对齐旧 .NET 版 "yyyy-MM-dd HH:mm:ss"):
* 接口出入参中的 LocalDateTime/LocalDate 统一使用该格式,避免 ISO 的 "T" 分隔符
* (如 2026-09-01T16:38:40)直接暴露给前端表格。
*/
@Configuration
public class JacksonConfig {
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> builder
.serializers(
new LocalDateTimeSerializer(DATE_TIME),
new LocalDateSerializer(DATE))
.deserializers(
new LocalDateTimeDeserializer(DATE_TIME),
new LocalDateDeserializer(DATE));
}
}
@@ -0,0 +1,83 @@
package com.yunhai.workleave.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 本地联调辅助:流程中心入站 oapi 调用(appKey=100019 签名)被 AppAuthFilter 401 拦截的绕行。
* <p>背景:应用间签名的密钥链在 160 infra Redis(容器网内),开发机不可达,本地无法校验
* 流程中心的 X-App-Key/X-Timestamp/X-Signature,覆盖两个入站端点:</p>
* <ul>
* <li>/oapi/flow/callback——回调接收端(starter),剥头后另有 yh-flow-callback 的
* X-Flw 验签(登记下发的 secret)兜底;</li>
* <li>/oapi/flow/dept-judge——条件路由判断端点(CONDITION 节点同步回调),纯读无副作用,
* 无第二层验签,仅本地开放(base 配置 enforce-patterns=[/oapi/*],线上签名强校验不受影响)。</li>
* </ul>
* <p>仅当 yh.app-auth.local-callback-bypass=true 时注册(默认关闭,线上不配即不生效)。</p>
*/
@Configuration
@ConditionalOnProperty(prefix = "yh.app-auth", name = "local-callback-bypass", havingValue = "true")
public class LocalCallbackBypassConfig {
private static final Set<String> STRIPPED = Collections.unmodifiableSet(
new HashSet<>(Arrays.asList("x-app-key", "x-timestamp", "x-signature")));
/** AppAuthFilter 的注册序是 HIGHEST_PRECEDENCE+10,本过滤器须更靠前才能先剥头 */
@Bean
public FilterRegistrationBean<Filter> localCallbackHeaderStripFilter() {
FilterRegistrationBean<Filter> registration = new FilterRegistrationBean<>();
registration.setFilter((request, response, chain) -> {
if (request instanceof HttpServletRequest) {
chain.doFilter(new HeaderStrippingRequest((HttpServletRequest) request), response);
} else {
chain.doFilter(request, response);
}
});
registration.addUrlPatterns("/oapi/flow/callback", "/oapi/flow/dept-judge");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 5);
registration.setName("localCallbackHeaderStripFilter");
return registration;
}
/** 剥掉应用间签名三头后,AppAuthFilter 视为无签名请求直接放行 */
private static class HeaderStrippingRequest extends HttpServletRequestWrapper {
HeaderStrippingRequest(HttpServletRequest request) {
super(request);
}
private boolean stripped(String name) {
return name != null && STRIPPED.contains(name.toLowerCase());
}
@Override
public String getHeader(String name) {
return stripped(name) ? null : super.getHeader(name);
}
@Override
public Enumeration<String> getHeaders(String name) {
return stripped(name) ? Collections.emptyEnumeration() : super.getHeaders(name);
}
@Override
public Enumeration<String> getHeaderNames() {
List<String> names = Collections.list(super.getHeaderNames());
names.removeIf(this::stripped);
return Collections.enumeration(names);
}
}
}
@@ -0,0 +1,22 @@
package com.yunhai.workleave.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 接口文档(springdoc-openapi,替代旧 Springfox)。
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI noticeOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("请假申请接口文档")
.description("请假申请子应用:申请、审核(流程中心)、抄送、时长、统计(springdoc-openapi)")
.version("1.0"));
}
}
@@ -0,0 +1,177 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.model.UserSysRole;
import com.yunhai.workleave.model.bsch.ClassTreeNodeVo;
import com.yunhai.workleave.model.base.SetRoleRequest;
import com.yunhai.workleave.model.base.SetRoleResult;
import com.yunhai.workleave.model.file.UpFileResultVo;
import com.yunhai.workleave.service.IClassService;
import com.yunhai.workleave.service.IFileService;
import com.yunhai.workleave.service.IUserCenterService;
import com.yunhai.workleave.utils.UserContext;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
import com.yunhai.platform.utils.model.BoaUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 平台代理域接口(base/)。
* <p>承载:GetUserInfo / GetUserMod / GetSchoolAll / GetClassTree / SetRole / FileUpload / Logout。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/base/")
public class BaseApiController {
private static final Logger log = LoggerFactory.getLogger(BaseApiController.class);
/** 家长角色ID(UserRole.PATRIARCH_USER),用于 GetUserInfo 的 role 判定 */
private static final Integer ROLE_PARENT = 4;
@Autowired
private IUserCenterService userCenterService;
@Autowired
private IFileService fileService;
@Autowired
private IClassService classService;
// ============================ FileUpload ============================
/**
* 附件上传(单文件)。
* 返回: { fileid, host, fileurl, name, type, size }
*/
@PostMapping("FileUpload")
public UpFileResultVo fileUpload(@RequestParam("file") MultipartFile file) {
return fileService.uploadToResult(file, "leave");
}
// ============================ GetUserInfo ============================
/**
* 获取当前登录用户信息。
* 返回 {id, name, role: teacher|parent, profilePicture, school: {id, name}, currentRole}
*/
@GetMapping("GetUserInfo")
public Map<String, Object> getUserInfo(BoaUser authUserWithDept) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", authUserWithDept.getId());
result.put("name", authUserWithDept.getName());
UserSysRole role = currentRole(authUserWithDept);
boolean parent = isParent(role);
result.put("role", parent ? "parent" : "teacher");
result.put("profilePicture", "");
Map<String, Object> school = new LinkedHashMap<>();
if (authUserWithDept.getSchool() != null) {
school.put("id", authUserWithDept.getSchool().getId());
school.put("name", authUserWithDept.getSchool().getName());
}
result.put("school", school);
result.put("currentRole", role);
return result;
}
// ============================ GetUserMod ============================
/**
* 获取当前用户已授权的模块列表(左侧菜单树)。
*/
@GetMapping("GetUserMod")
public List<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
Integer schId = schIdOf(authUserWithDept);
return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId);
}
// ============================ GetSchoolAll ============================
/**
* 获取当前用户学校组及教师(树形结构,选人数据源)。
*/
@GetMapping("GetSchoolAll")
public List<Map<String, Object>> getSchoolAll(BoaUser authUserWithDept) {
return userCenterService.getSchoolAllTree(schIdOf(authUserWithDept));
}
// ============================ GetClassTree ============================
/**
* 年级→班级树(叶子=班级,附学生数)。数据来自班级服务学生树(5 分钟缓存)。
*/
@GetMapping("GetClassTree")
public List<ClassTreeNodeVo> getClassTree(BoaUser authUserWithDept) {
return classService.getClassTree(schIdOf(authUserWithDept));
}
// ============================ SetRole ============================
/**
* 角色切换(teacher / parent)。会话身份由平台 CAS 管理,此处回显确认。
*/
@PostMapping("SetRole")
public SetRoleResult setRole(@RequestBody SetRoleRequest request) {
String role = request != null && "parent".equalsIgnoreCase(request.getAs()) ? "parent" : "teacher";
return new SetRoleResult(role);
}
// ============================ Logout ============================
/**
* 退出登录。认证由平台网关处理,此处仅返回成功。
*/
@PostMapping("Logout")
public void logout() {
LoginUser user = UserContextHolder.get();
log.info("[logout] 用户退出登录: {}", user == null ? null : user.getLoginName());
}
// ============================ 内部方法 ============================
private Integer schIdOf(BoaUser user) {
if (user.getSchool() != null) {
return user.getSchool().getId();
}
return UserContext.getSchId();
}
private UserSysRole currentRole(BoaUser user) {
UserSysRole role = new UserSysRole();
if (user.getSchool() != null) {
role.setSchId(user.getSchool().getId());
}
role.setUserId(user.getId());
if (user.getRoleIds() != null && !user.getRoleIds().isEmpty()) {
role.setRoleId(user.getRoleIds().get(0));
}
// childId 由 ControllerUserContextAspect 填充进 UserContext
Integer childId = UserContext.getChildId();
role.setChildId(childId == null ? 0 : childId);
return role;
}
/** 家长判定:角色=家长(4) 或带有效 childId */
private boolean isParent(UserSysRole role) {
if (role == null) {
return false;
}
if (ROLE_PARENT.equals(role.getRoleId())) {
return true;
}
return role.getChildId() != null && role.getChildId() > 0;
}
}
@@ -0,0 +1,71 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.service.IContactsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 自定义联系组接口(contacts/)。
* <p>路径与方法名对齐前端 settingApi;POST form 参数只从 query 接收;
* 成员只存教师平台用户ID,不存账号。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/contacts/")
public class ContactsController {
@Autowired
private IContactsService contactsService;
/** 联系组分页:{list:[{serid, GroupName, count}], total} */
@GetMapping("GetGroup")
public Map<String, Object> getGroup(@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "count", defaultValue = "10") int count) {
return contactsService.getGroups(page, count);
}
/** 新增组,返回组ID */
@PostMapping("AddGroup")
public Integer addGroup(@RequestParam("name") String name) {
return contactsService.addGroup(name);
}
/** 编辑组名 */
@PostMapping("EditGroup")
public void editGroup(@RequestParam("id") Integer id, @RequestParam("name") String name) {
contactsService.editGroup(id, name);
}
/** 批量删除组(ids 逗号串) */
@PostMapping("DelGroup")
public void delGroup(@RequestParam("ids") String ids) {
contactsService.deleteGroups(ids);
}
/** 组成员分页(page=0 取全部):{list:[{id, name}], total} */
@GetMapping("GetContactsTea")
public Map<String, Object> getContactsTea(@RequestParam("gid") Integer gid,
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "count", defaultValue = "10") int count) {
return contactsService.getContactsTea(gid, page, count);
}
/** 设置组成员(全量覆盖,ids 逗号串) */
@PostMapping("SetContactsTea")
public void setContactsTea(@RequestParam("gid") Integer gid, @RequestParam("ids") String ids) {
contactsService.setContactsTea(gid, ids);
}
/** 学校组织树(选人数据源) */
@GetMapping("GetGroupTree")
public List<Map<String, Object>> getGroupTree(@RequestParam(value = "type", required = false) Integer type) {
return contactsService.getGroupTree();
}
}
@@ -0,0 +1,89 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.service.IDictService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 系统字典接口(请假类型 leaveType)。
* <p>List 兼容 type=1/2 数字与 leaveType 字符串两种入参(历史前端沿用数字);
* POST form 参数只从 query 接收(与前端 postForm 契约一致)。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/dict/")
public class DictController {
@Autowired
private IDictService dictService;
/** 请假类型下拉(启用项),每项 {k, v} */
@GetMapping("List")
public List<Map<String, Object>> list(@RequestParam(value = "type", defaultValue = "leaveType") String type) {
return dictService.getSysDic(parseType(type));
}
/** 字典管理列表(全量含禁用),每项 {SerID, CodeID, Name, Des, Enable} */
@GetMapping("ManageList")
public List<Map<String, Object>> manageList(@RequestParam(value = "type", defaultValue = "leaveType") String type) {
return dictService.getDictMangeList(type);
}
/** 新增字典项(服务端按 (schId, type, name) 查重,防文字重复) */
@PostMapping("Add")
public void add(@RequestParam("name") String name,
@RequestParam(value = "type", defaultValue = "leaveType") String type) {
dictService.addDict(name, type);
}
/** 更新字典项(名称/启停用) */
@PostMapping("Update")
public void update(@RequestParam("SerID") Integer serID,
@RequestParam("Name") String name,
@RequestParam(value = "Enable", required = false, defaultValue = "1") Integer enable,
@RequestParam(value = "type", defaultValue = "leaveType") String type) {
dictService.updateDict(serID, name, enable, type);
}
/** 删除字典项(service 侧校验被请假单引用时拒绝) */
@PostMapping("Delete")
public void delete(@RequestParam("serid") Integer serID,
@RequestParam(value = "type", defaultValue = "leaveType") String type) {
dictService.deleteDict(serID, type);
}
/** 排序(ids = 前端顺序的 SerID 逗号串) */
@PostMapping("Sort")
public void sort(@RequestParam("ids") String ids,
@RequestParam(value = "type", defaultValue = "leaveType") String type) {
dictService.sortDict(ids, type);
}
/** 短信模块开关(前端 getSchModuleIsSms) */
@GetMapping("Sync")
public Boolean sync() {
return dictService.getSchModuleIsSms();
}
/** 数字入参一律归一为 leaveType(兼容历史 1/2) */
private Integer parseType(String type) {
if (type == null) {
return null;
}
switch (type) {
case "1":
case "2":
case "leaveType":
return 1;
default:
return -1;
}
}
}
@@ -0,0 +1,74 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.model.file.UpFileResultVo;
import com.yunhai.workleave.model.file.UpFileVo;
import com.yunhai.workleave.service.IFileService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 附件域接口(file/)。topicType 统一挂 topicType=leave。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/file/")
public class FileController {
private static final String TOPIC_TYPE = "leave";
@Autowired
private IFileService fileService;
/** 附件上传(单文件),返回 { fileid, host, fileurl, name, type, size } */
@PostMapping("Upload")
public UpFileResultVo upload(@RequestParam("file") MultipartFile file) {
return fileService.uploadToResult(file, TOPIC_TYPE);
}
/** 图片上传(多文件) */
@PostMapping("UploadImage")
public List<UpFileVo> uploadImage(@RequestParam("file") MultipartFile[] files) {
return fileService.uploadImages(files, TOPIC_TYPE);
}
/** 文件下载(浏览器直接 location.href 跳转) */
@GetMapping("Down")
public void down(@RequestParam("id") Integer id, HttpServletResponse response) {
fileService.download(id, response);
}
/** 文件在线预览(inline 输出) */
@GetMapping("Preview")
public void preview(@RequestParam("id") Integer id,
@RequestParam(value = "type", required = false) Integer type,
HttpServletResponse response) {
fileService.preview(id, response);
}
/** 删除文件 */
@PostMapping("Delete")
public void delete(@RequestParam("fileId") Integer fileId) {
fileService.delete(fileId);
}
/** 查询某请假单的附件列表 */
@GetMapping("List")
public List<UpFileVo> list(@RequestParam("topicId") Integer topicId) {
return fileService.listByTopic(TOPIC_TYPE, topicId);
}
/** 批量绑定附件到活动 */
@PostMapping("Bind")
public void bind(@RequestParam("fileIds") List<Integer> fileIds,
@RequestParam("topicId") Integer topicId) {
fileService.bindToTopic(fileIds, topicId);
}
}
@@ -0,0 +1,154 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.model.BasePageResult;
import com.yunhai.workleave.model.leave.LeaveAuditRequest;
import com.yunhai.workleave.model.leave.LeaveCancelRequest;
import com.yunhai.workleave.model.leave.LeaveDetailVo;
import com.yunhai.workleave.model.leave.LeaveQuery;
import com.yunhai.workleave.model.leave.LeaveRequest;
import com.yunhai.workleave.model.leave.LeaveStatsVo;
import com.yunhai.workleave.model.leave.LeaveVo;
import com.yunhai.workleave.model.leave.TodoVo;
import com.yunhai.workleave.model.leave.UserTimeSumVo;
import com.yunhai.workleave.service.ILeaveApplyService;
import com.yunhai.workleave.utils.UserContext;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List;
/**
* 请假申请域接口(leave/):申请 / 列表 / 详情 / 撤回 / 销假 / 待办 / 审核 / 统计。
* <p>审核流转全部由平台流程审核器承担;本控制器不直接改单据状态,
* 状态由 {@link com.yunhai.workleave.flow.LeaveFlowCallbackHandler} 按回调推进。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/leave/")
public class LeaveController {
@Autowired
private ILeaveApplyService leaveApplyService;
// ============================ 列表 / 详情 ============================
/** 请假列表:scope=user 我的 / all 全部 / cc 抄送我的 */
@GetMapping("List")
public BasePageResult<LeaveVo> list(LeaveQuery query) {
return leaveApplyService.list(schId(), userId(), query);
}
/** 请假详情(消息跳转落地页/详情弹窗) */
@GetMapping("Detail")
public LeaveDetailVo detail(@RequestParam("leaveId") Integer leaveId) {
return leaveApplyService.detail(schId(), userId(), leaveId);
}
// ============================ 申请 / 编辑 / 删除 ============================
/** 新建申请(JSON):返回单据ID */
@PostMapping("Create")
public Integer create(@RequestBody @Valid LeaveRequest request) {
return leaveApplyService.create(schId(), userId(), userName(), request);
}
/** 编辑/重新提交(JSON,仅审批中/已拒绝/已撤回) */
@PostMapping("Edit")
public void edit(@RequestBody @Valid LeaveRequest request) {
leaveApplyService.edit(schId(), userId(), userName(), request);
}
/** 删除(JSON 数组:仅本人,已通过不能删) */
@PostMapping("Delete")
public void delete(@RequestBody List<Integer> ids) {
leaveApplyService.delete(schId(), userId(), ids);
}
// ============================ 撤回 / 销假 ============================
/** 撤回(审批中) */
@PostMapping("Withdraw")
public void withdraw(@RequestParam("id") Integer id) {
leaveApplyService.withdraw(schId(), userId(), id);
}
/** 销假(已通过 → 已销假,返还时长) */
@PostMapping("Cancel")
public void cancel(@RequestBody @Valid LeaveCancelRequest request) {
leaveApplyService.cancel(schId(), userId(), request);
}
// ============================ 待办 / 审核 ============================
/** 待我审核列表 */
@GetMapping("TodoList")
public BasePageResult<TodoVo> todoList(@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "startTime", required = false) String startTime,
@RequestParam(value = "endTime", required = false) String endTime,
@RequestParam(value = "pageIndex", defaultValue = "1") int pageIndex,
@RequestParam(value = "pageSize", defaultValue = "10") int pageSize) {
return leaveApplyService.todoList(schId(), userId(), keyword, startTime, endTime, pageIndex, pageSize);
}
/** 我已审核列表 */
@GetMapping("AuditedList")
public BasePageResult<LeaveVo> auditedList(@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "startTime", required = false) String startTime,
@RequestParam(value = "endTime", required = false) String endTime,
@RequestParam(value = "pageIndex", defaultValue = "1") int pageIndex,
@RequestParam(value = "pageSize", defaultValue = "10") int pageSize) {
return leaveApplyService.auditedList(schId(), userId(), keyword, startTime, endTime, pageIndex, pageSize);
}
/** 审核(同意/拒绝,JSON);状态由回调推进 */
@PostMapping("Audit")
public void audit(@RequestBody @Valid LeaveAuditRequest request) {
leaveApplyService.audit(schId(), userId(), userName(), request);
}
// ============================ 余额 / 统计 ============================
/** 表单页余额提示(可用 - 在途) */
@GetMapping("MyTime")
public UserTimeSumVo myTime() {
return leaveApplyService.myTime(schId(), userId());
}
/** 统计分析(区间按申请时间,口径:已通过) */
@GetMapping("Statis")
public LeaveStatsVo statis(@RequestParam(value = "startTime", required = false) String startTime,
@RequestParam(value = "endTime", required = false) String endTime) {
return leaveApplyService.statistics(schId(), startTime, endTime);
}
// ============================ 内部方法 ============================
private Integer schId() {
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败");
}
return schId;
}
private Integer userId() {
Integer userId = UserContext.getUserId();
if (userId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "获取当前用户信息失败");
}
return userId;
}
private String userName() {
String userName = UserContext.getUserName();
return userName == null ? "" : userName;
}
}
@@ -0,0 +1,76 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.model.leave.FlowBindRequest;
import com.yunhai.workleave.model.leave.FlowEnableRequest;
import com.yunhai.workleave.model.leave.FlowManageVo;
import com.yunhai.workleave.service.ILeaveFlowBindService;
import com.yunhai.workleave.utils.UserContext;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
/**
* 流程管理域接口(leave/flow/):流程列表 / 绑定 / 解绑。
* <p>对齐《子应用流程与消息对接手册 v1.0》:
* 流程在流程中心「流程编排」注册,本应用只记录选用哪条 flowGuid(bizType 恒为 leave)。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/leave/flow/")
public class LeaveFlowController {
@Autowired
private ILeaveFlowBindService flowBindService;
/** 流程管理页数据:流程中心列表 + 本校绑定关系 + 请假类型 */
@GetMapping("List")
public FlowManageVo list() {
return flowBindService.manage(schId());
}
/** 绑定流程(按 scope + scopeKey upsert,并触发回调登记) */
@PostMapping("Bind")
public void bind(@RequestBody @Valid FlowBindRequest request) {
flowBindService.bind(schId(), request);
}
/**
* 启用绑定:把流程标记为本应用可用(并回调登记到流程中心)。
* <p>启用后才允许设为默认 / 按请假类型绑定。与绑定范围无关,不校验 scope。</p>
*/
@PostMapping("Enable")
public void enable(@RequestBody @Valid FlowEnableRequest request) {
flowBindService.enable(schId(), request);
}
/**
* 关闭启用:移除启用标记;若该流程已被设为默认 / 按类型绑定,一并解除。
*/
@PostMapping("Disable")
public void disable(@RequestParam("flowGuid") String flowGuid) {
flowBindService.disable(schId(), flowGuid);
}
/** 解除绑定:scope=0 学校默认(scopeKey 可空)/ scope=1 按请假类型(scopeKey 必填) */
@PostMapping("Unbind")
public void unbind(@RequestParam("scope") Integer scope,
@RequestParam(value = "scopeKey", required = false) String scopeKey) {
flowBindService.unbind(schId(), scope, scopeKey);
}
private Integer schId() {
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败");
}
return schId;
}
}
@@ -0,0 +1,92 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.model.leave.LeaveSettingVo;
import com.yunhai.workleave.model.leave.SectionVo;
import com.yunhai.workleave.model.leave.TimeSetVo;
import com.yunhai.workleave.service.ILeaveSettingService;
import com.yunhai.workleave.utils.UserContext;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 请假设置域接口(setting/):作息时间 / 其他设置 / 节次。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/leave/setting/")
public class LeaveSettingController {
@Autowired
private ILeaveSettingService settingService;
// ============================ 作息时间 ============================
/** 学校作息时间 */
@GetMapping("GetTimeSet")
public TimeSetVo getTimeSet() {
return settingService.getTimeSet(schId());
}
/** 保存作息时间(JSON) */
@PostMapping("SetTimeSet")
public void setTimeSet(@RequestBody TimeSetVo vo) {
settingService.saveTimeSet(schId(), vo);
}
// ============================ 其他设置 ============================
/** 其他设置 */
@GetMapping("GetOption")
public LeaveSettingVo getOption() {
return settingService.getOption(schId());
}
/** 保存其他设置(JSON) */
@PostMapping("SetOption")
public void setOption(@RequestBody LeaveSettingVo vo) {
settingService.saveOption(schId(), vo);
}
// ============================ 节次 ============================
/** 节次列表 */
@GetMapping("SectionList")
public List<SectionVo> sectionList() {
return settingService.sectionList(schId());
}
/** 新增/编辑节次(JSON) */
@PostMapping("SectionSave")
public void sectionSave(@RequestBody SectionVo vo) {
settingService.sectionSave(schId(), vo);
}
/** 删除节次 */
@PostMapping("SectionDelete")
public void sectionDelete(@RequestParam("id") Integer id) {
settingService.sectionDelete(schId(), id);
}
/** 节次排序(ids 逗号串,按前端展示顺序) */
@PostMapping("SectionSort")
public void sectionSort(@RequestParam("ids") String ids) {
settingService.sectionSort(schId(), ids);
}
private Integer schId() {
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败");
}
return schId;
}
}
@@ -0,0 +1,100 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.model.BasePageResult;
import com.yunhai.workleave.model.leave.UserTimeAdjustRequest;
import com.yunhai.workleave.model.leave.UserTimeImportResult;
import com.yunhai.workleave.model.leave.UserTimeRecordVo;
import com.yunhai.workleave.model.leave.UserTimeSumVo;
import com.yunhai.workleave.model.leave.UserTimeVo;
import com.yunhai.workleave.service.ILeaveUserTimeService;
import com.yunhai.workleave.utils.UserContext;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.net.URLEncoder;
import java.util.List;
/**
* 教师时长域接口(leave/time/):列表 / 调整 / 流水 / 合计。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/leave/time/")
public class LeaveTimeController {
@Autowired
private ILeaveUserTimeService userTimeService;
/** 教师时长列表 */
@GetMapping("UserTimeList")
public List<UserTimeVo> userTimeList(@RequestParam(value = "keyword", required = false) String keyword) {
return userTimeService.list(schId(), keyword);
}
/** 批量加减时长(JSON) */
@PostMapping("Adjust")
public void adjust(@RequestBody @Valid UserTimeAdjustRequest request) {
userTimeService.adjust(schId(), UserContext.getUserId(), request.getItems());
}
/** 下载「教师时长导入模板」(浏览器 location.href 直接跳转) */
@GetMapping("ImportTemplate")
public void importTemplate(HttpServletResponse response) {
byte[] data = userTimeService.importTemplate(schId());
try {
response.setContentType("application/octet-stream");
String name = URLEncoder.encode("教师时长导入模板.xlsx", "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-Disposition", "attachment; filename=\"" + name + "\"");
response.getOutputStream().write(data);
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "模板下载失败");
}
}
/** 教师时长 Excel 导入(任一行出错整体不入账,返回错误明细) */
@PostMapping("Import")
public UserTimeImportResult importExcel(@RequestParam("file") MultipartFile file) throws java.io.IOException {
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "请选择要上传的文件");
}
return userTimeService.importExcel(schId(), UserContext.getUserId(), file.getInputStream());
}
/** 时长流水(分页) */
@GetMapping("Records")
public BasePageResult<UserTimeRecordVo> records(
@RequestParam(value = "rqType", defaultValue = "3") Integer rqType,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "startTime", required = false) String startTime,
@RequestParam(value = "endTime", required = false) String endTime,
@RequestParam(value = "pageIndex", defaultValue = "1") int pageIndex,
@RequestParam(value = "pageSize", defaultValue = "10") int pageSize) {
return userTimeService.records(schId(), rqType, keyword, startTime, endTime, pageIndex, pageSize);
}
/** 合计:区间新加总时长 + 当前剩余总时长 */
@GetMapping("Sum")
public UserTimeSumVo sum(@RequestParam(value = "rqType", defaultValue = "3") Integer rqType,
@RequestParam(value = "startTime", required = false) String startTime,
@RequestParam(value = "endTime", required = false) String endTime) {
return userTimeService.sum(schId(), rqType, startTime, endTime);
}
private Integer schId() {
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败");
}
return schId;
}
}
@@ -0,0 +1,32 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.entity.SysOperationLog;
import com.yunhai.workleave.model.BasePageQuery;
import com.yunhai.workleave.model.BasePageResult;
import com.yunhai.workleave.service.ISysOperationLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 审计日志查询接口(log/)。
* <p>本控制器被 SysOperationLogAspect 排除,避免日志查询自身再产生日志(防递归)。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/log/")
public class LogController {
@Autowired
private ISysOperationLogService logService;
/** 分页查询审计日志(responseData 不返回) */
@GetMapping("Page")
public BasePageResult<SysOperationLog> page(BasePageQuery query,
@RequestParam(value = "operationType", required = false) String operationType,
@RequestParam(value = "statusCode", required = false) Integer statusCode) {
return logService.page(query, operationType, statusCode);
}
}
@@ -0,0 +1,120 @@
package com.yunhai.workleave.controller.oapi;
import com.yunhai.workleave.entity.LeaveApply;
import com.yunhai.workleave.repository.LeaveApplyRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.Data;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 条件路由判断端点:flow-center CONDITION 节点(routerKey=work-leave.dept-judge)激活时同步调用——
* 按请假时长分流,小于3天走「小于3天」线,大于等于3天走「大于等于3天」线。
* <p>流程中心路由注册表:path=/oapi/flow/dept-judge(serviceKey=work-leave);
* 已发布流程 d2237a5b…/c3e9c6ee… 的出边名即本端点的出口契约。</p>
* <ul>
* <li>验签:收在 /oapi/* 下由 AppAuthFilter 强制验 flow-center 的应用签名,零新概念;</li>
* <li>纯读幂等:凭 bizId({leaveId}-{attempt})查 leave_apply 比对时长(variables 里有 formData 的
* totalHours 则免查库,与发起时透传口径一致);</li>
* <li>时长口径:3天 = 24小时(一天按作息 8 小时折算,同 LeaveFlowService#buildFormData 的
* days=totalHours/8,恰好等于3天归「大于等于3天」);</li>
* <li>契约纪律:返回 outlet 必须命中 outlets——流程定义与端点的出口名是同一份契约,不命中抛业务错
* (flow-center 侧按判断失败挂起,绝不默认放行);</li>
* <li>轻实现:同步短超时,最多一次主键查询。</li>
* </ul>
*/
@RestController
@RequestMapping("/oapi/flow")
public class FlowJudgeController {
private static final Logger log = LoggerFactory.getLogger(FlowJudgeController.class);
/** 出口名(须与流程定义出边 name 逐字一致) */
public static final String OUTLET_LT_3_DAYS = "小于3天";
public static final String OUTLET_GE_3_DAYS = "大于等于3天";
/** 3天折算小时数(8小时/天 × 3) */
static final double THREE_DAYS_HOURS = 24D;
@Autowired
private LeaveApplyRepository leaveApplyRepository;
/** 判断请求体(flow-center CONDITION 节点下发;字段忽略未知向前兼容) */
@Data
public static class JudgeBody {
private Long schId;
private Long instanceId;
private Integer instanceVersion;
private String bizType;
private String bizId;
private String nodeId;
private String nodeName;
private String routerKey;
private List<String> outlets;
private Map<String, Object> variables;
}
@PostMapping("/dept-judge")
public Map<String, Object> deptJudge(@RequestBody JudgeBody body) {
Double hours = variablesTotalHours(body);
if (hours == null) {
LeaveApply leave = leaveApplyRepository.findById(parseLeaveId(body))
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "请假单不存在:bizId=" + body.getBizId()));
hours = leave.getTotalHours();
}
if (hours == null) {
// 无时长无法判断:挂起而不是默认放行
throw new BizException(ErrorCode.PARAM_ERROR, "请假单缺少时长,无法判断:bizId=" + body.getBizId());
}
String outlet = hours < THREE_DAYS_HOURS ? OUTLET_LT_3_DAYS : OUTLET_GE_3_DAYS;
List<String> outlets = body.getOutlets() == null ? Collections.emptyList() : body.getOutlets();
if (!outlets.contains(outlet)) {
// 出口契约不一致(流程定义改了出口名而端点没跟上):抛错让 flow-center 挂起,不默认放行
throw new BizException(ErrorCode.PARAM_ERROR,
"判断结果未命中出口集合:outlet=" + outlet + " outlets=" + outlets
+ "(totalHours=" + hours + ", bizId=" + body.getBizId() + ")");
}
log.info("[dept-judge] bizId={}, instanceId={}, totalHours={}h, outlet={}",
body.getBizId(), body.getInstanceId(), hours, outlet);
return Collections.singletonMap("outlet", outlet);
}
/** formData 里的请假时长(发起时 bizSnapshot 同源透传;缺字段/非法返回 null 回落查表) */
private static Double variablesTotalHours(JudgeBody body) {
Object v = body.getVariables() == null ? null : body.getVariables().get("totalHours");
if (v instanceof Number) {
return ((Number) v).doubleValue();
}
if (v instanceof String) {
try {
return Double.valueOf(((String) v).trim());
} catch (NumberFormatException e) {
// 回落查表
}
}
return null;
}
/** bizId 形态 {leaveId}-{attempt},取首段(同 LeaveFlowCallbackHandler) */
private static Integer parseLeaveId(JudgeBody body) {
if (!StringUtils.hasText(body.getBizId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "bizId 缺失,无法定位请假单");
}
try {
return Integer.valueOf(body.getBizId().trim().split("-")[0]);
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "bizId 非法:" + body.getBizId());
}
}
}
@@ -0,0 +1,42 @@
package com.yunhai.workleave.entity;
import com.yunhai.workleave.common.listener.BaseAuditListener;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link BaseAuditListener} 自动填充)。
* <p>创建人/更新人只存平台用户ID(不保存账号,见需求「实体不保存教师和学生账号」)。</p>
*/
@MappedSuperclass
@Getter
@Setter
@EntityListeners(BaseAuditListener.class)
public abstract class BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
/** 创建人用户ID(平台用户中心ID) */
@Column(name = "create_by", updatable = false)
private Integer createBy;
/** 创建时间 */
@Column(name = "create_time", updatable = false)
private LocalDateTime createTime;
/** 更新人用户ID */
@Column(name = "update_by")
private Integer updateBy;
/** 更新时间 */
@Column(name = "update_time")
private LocalDateTime updateTime;
}
@@ -0,0 +1,28 @@
package com.yunhai.workleave.entity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 自定义联系组(应用设置-自定义组)。组名按学校隔离。
*/
@NoArgsConstructor
@Getter
@Setter
@Entity
@Table(name = "contact_group")
public class ContactGroup extends BaseEntity {
/** 学校ID(多租户隔离) */
@Column(name = "sch_id")
private Integer schId;
/** 组名 */
@Column(name = "name", length = 100, nullable = false)
private String name;
}
@@ -0,0 +1,29 @@
package com.yunhai.workleave.entity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 联系组-成员关联。只存成员(教师)平台用户ID,不存账号。
*/
@NoArgsConstructor
@Getter
@Setter
@Entity
@Table(name = "contact_group_user",
uniqueConstraints = @UniqueConstraint(name = "uk_group_user", columnNames = {"group_id", "user_id"}))
public class ContactGroupUser extends BaseEntity {
@Column(name = "group_id", nullable = false)
private Integer groupId;
/** 成员(教师)平台用户ID */
@Column(name = "user_id", nullable = false)
private Integer userId;
}
@@ -0,0 +1,117 @@
package com.yunhai.workleave.entity;
import com.yunhai.workleave.enums.LeaveStatusEnum;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 请假申请主表。
* <p>表名不用 leave:MySQL 保留字。人员只存平台用户ID(不存账号),
* 姓名/头像/类型名为冗余展示字段。审核流转由平台流程审核器承担,
* 本表只保留单据状态与流程实例映射(biz_id / instance_id / instance_version)。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_apply")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveApply extends BaseEntity {
/** 学校ID(多租户隔离) */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 申请人用户ID(平台用户中心数字ID,不存账号) */
@Column(name = "user_id", nullable = false)
private Integer userId;
/** 申请人姓名(冗余展示) */
@Column(name = "user_name", length = 64)
private String userName;
/** 申请人头像 URL(冗余展示,可空) */
@Column(name = "user_photo", length = 255)
private String userPhoto;
/** 请假类型(leaveType 字典 dictKey) */
@Column(name = "leave_type", length = 50, nullable = false)
private String leaveType;
/** 请假类型名称(冗余展示) */
@Column(name = "leave_type_name", length = 100)
private String leaveTypeName;
/** 开始时间 yyyy-MM-dd HH:mm:ss */
@Column(name = "start_time", nullable = false, length = 19)
private String startTime;
/** 结束时间 yyyy-MM-dd HH:mm:ss */
@Column(name = "end_time", nullable = false, length = 19)
private String endTime;
/** 请假时长(小时,服务端按作息复核后落库) */
@Column(name = "total_hours")
private Double totalHours;
/** 请假事由 */
@Column(name = "reason", length = 500)
private String reason;
/** 附件ID CSV(up_file.id) */
@Column(name = "file_ids", length = 500)
private String fileIds;
/** 抄送人用户ID CSV(发起时选择;流程 NOTIFY 节点另发消息) */
@Column(name = "cc_user_ids", length = 500)
private String ccUserIds;
/** 课务安排:0 无课务安排(默认)/ 1 自行安排 / 2 教务安排(对应历史课务安排三选一) */
@Column(name = "course_arrange")
private Integer courseArrange = 0;
/** 单据状态:见 {@link LeaveStatusEnum} */
@Column(name = "status", nullable = false)
private Integer status;
/** 流程业务ID({leaveId}-{attempt},流程中心按 bizType+bizId 幂等) */
@Column(name = "biz_id", length = 64)
private String bizId;
/** 流程实例ID(流程中心 instanceId) */
@Column(name = "instance_id")
private Long instanceId;
/** 已处理的最大流程版本号(乱序门) */
@Column(name = "instance_version")
private Integer instanceVersion;
/** 流程定义标识(flowGuid) */
@Column(name = "flow_guid", length = 64)
private String flowGuid;
/** 第几次提交(拒绝后重新提交自增,驱动 bizId 变化) */
@Column(name = "flow_attempt")
private Integer flowAttempt;
/** 终态快照 JSON(回调事件 data:审批意见/节点等) */
@Column(name = "snapshot", columnDefinition = "TEXT")
private String snapshot;
/** 申请时间(列表排序/统计口径) */
@Column(name = "apply_time", nullable = false)
private LocalDateTime applyTime;
/** 销假时间 */
@Column(name = "cancel_time")
private LocalDateTime cancelTime;
/** 销假事由/备注 */
@Column(name = "cancel_reason", length = 500)
private String cancelReason;
}
@@ -0,0 +1,52 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 审核痕迹表(本地留痕)。
* <p>流程中心的审核历史在中心侧;本表在审核动作提交成功后落一条,
* 支撑「我已审核」列表与详情时间线补全,避免每次翻页都查流程中心。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_audit_trace")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveAuditTrace extends BaseEntity {
@Column(name = "sch_id", nullable = false)
private Integer schId;
@Column(name = "leave_id", nullable = false)
private Integer leaveId;
/** 流程任务ID */
@Column(name = "task_id")
private Long taskId;
/** 动作:approve / reject */
@Column(name = "action", length = 20)
private String action;
/** 审批意见 */
@Column(name = "opinion", length = 500)
private String opinion;
/** 操作人 */
@Column(name = "operator_id")
private Integer operatorId;
@Column(name = "operator_name", length = 64)
private String operatorName;
/** 审核时间 */
@Column(name = "audit_time", nullable = false)
private LocalDateTime auditTime;
}
@@ -0,0 +1,42 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 抄送留痕表。
* <p>平台流程中心 NOTIFY 节点负责发抄送消息;本表在回调 flow.cc.sent 时落痕,
* 供「抄送列表」页渲染(历史 Common_SendUserinfo/SendUserID 的替代)。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_cc")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveCc extends BaseEntity {
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 请假单ID */
@Column(name = "leave_id", nullable = false)
private Integer leaveId;
/** 被抄送人用户ID */
@Column(name = "cc_user_id")
private Integer ccUserId;
/** 被抄送人姓名(冗余展示) */
@Column(name = "cc_user_name", length = 64)
private String ccUserName;
/** 流程实例ID(幂等键组成部分) */
@Column(name = "instance_id")
private Long instanceId;
}
@@ -0,0 +1,64 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 审批流程绑定(一校多行)。
* <p>对应《子应用流程与消息对接手册 v1.0》§1.3/§3.2:本应用 bizType 恒为 {@code leave},
* 多套审批流用 flowGuid 区分(如 leave-annual-v1 / leave-cancel-v1),
* 这里记录「哪个场景走哪条 flowGuid」。</p>
*
* <p>两级作用域:
* <ul>
* <li>{@link #SCOPE_DEFAULT} 学校默认流程(一校仅一条,兜底所有场景)</li>
* <li>{@link #SCOPE_TYPE} 按请假类型指定流程(scopeKey = 请假类型 dictKey),优先级高于默认</li>
* </ul>
* 未绑定任何记录时,回落到配置 {@code leave.flow.guid}。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_flow_bind")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveFlowBind extends BaseEntity {
/** 绑定范围:学校默认流程 */
public static final Integer SCOPE_DEFAULT = 0;
/** 绑定范围:按请假类型指定流程 */
public static final Integer SCOPE_TYPE = 1;
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 绑定范围:0=学校默认 / 1=按请假类型 */
@Column(name = "bind_scope", nullable = false)
private Integer bindScope = SCOPE_DEFAULT;
/** 范围键:scope=1 时为请假类型 dictKey;scope=0 时为 "-"(便于唯一性判断) */
@Column(name = "scope_key", length = 100)
private String scopeKey = "-";
/** 流程定义标识(流程中心「流程编排」注册所得的 flowGuid) */
@Column(name = "flow_guid", nullable = false, length = 100)
private String flowGuid;
/** 流程名称(冗余展示;流程中心改名后重新绑定即刷新) */
@Column(name = "flow_name", length = 200)
private String flowName;
/** 流程版本(冗余展示) */
@Column(name = "flow_version")
private Integer flowVersion;
/** 节点摘要(冗余展示,如「发起 → 部门审核 → 校长审批」) */
@Column(name = "node_summary", length = 500)
private String nodeSummary;
}
@@ -0,0 +1,48 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 流程启用绑定(一校多行):标记哪条流程「启用并绑定到本应用」。
*
* <p>背景:开放网关 oapi 的流程列表不按应用隔离(无 code 字段),
* 通知流程等非本应用的流程会混进候选列表。启用绑定开关用于:
* <ul>
* <li>开启(enable):登记该流程为本应用可用,并回调登记到流程中心;之后才允许设为默认 / 按类型绑定</li>
* <li>关闭(disable):该流程仅展示、不可被绑定;若已有默认/按类型绑定,一并解除</li>
* </ul>
* 唯一性由 (sch_id, flow_guid) 逻辑保证,不建物理唯一索引(与 leave_flow_bind 同理)。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_flow_enable")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveFlowEnable extends BaseEntity {
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 流程定义标识(流程中心「流程编排」注册所得的 flowGuid) */
@Column(name = "flow_guid", nullable = false, length = 100)
private String flowGuid;
/** 流程名称(冗余展示) */
@Column(name = "flow_name", length = 200)
private String flowName;
/** 流程版本(冗余展示) */
@Column(name = "flow_version")
private Integer flowVersion;
/** 节点摘要(冗余展示,如「发起 → 部门审核 → 校长审批」) */
@Column(name = "node_summary", length = 500)
private String nodeSummary;
}
@@ -0,0 +1,41 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 节次表(设置页可维护;P7 指派代课/调课的节次数据源)。
* <p>对应历史 Setting_CourseSection,rank 决定排序。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_section")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveSection extends BaseEntity {
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 节次名称(如 "第一节") */
@Column(name = "name", length = 100)
private String name;
/** 排序号 */
@Column(name = "rank_no")
private Integer rankNo = 0;
/** 开始时间 "HH:mm" */
@Column(name = "begin_time", length = 10)
private String beginTime;
/** 结束时间 "HH:mm" */
@Column(name = "end_time", length = 10)
private String endTime;
}
@@ -0,0 +1,54 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 学校请假设置(一校一行)。
* <p>对应历史 QJ_LeaveMustItem,按新方案裁剪:
* 「按小时/按天」拆成固化为小时制,「半天上下午」字段取消,「课务安排」延后到 P7。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_setting")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveSetting extends BaseEntity {
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 请假事由是否必填:0 选填 / 1 必填(默认 1) */
@Column(name = "reason_must")
private Integer reasonMust = 1;
/** 是否显示请假说明:0 否 / 1 是(默认 1) */
@Column(name = "show_explain")
private Integer showExplain = 1;
/** 是否启用课务安排(P7 预留,默认 0) */
@Column(name = "enable_course")
private Integer enableCourse = 0;
/** 是否允许换课(P7 预留,默认 0) */
@Column(name = "allow_exchange")
private Integer allowExchange = 0;
/** 时长管控的请假类型 CSV(dictKey;留空=全部类型不限时长) */
@Column(name = "time_managed_types", length = 500)
private String timeManagedTypes;
/** 是否启用时长管控:0 否 / 1 是(默认 0,与历史「类型绑定即管控」语义等价但集中开关) */
@Column(name = "time_limit_enabled")
private Integer timeLimitEnabled = 0;
/** 请假计算口径:1 按小时(默认,历史 IsCalculateWay=true)/ 0 按天(半天/半天以上输入模式) */
@Column(name = "calc_way")
private Integer calcWay = 1;
}
@@ -0,0 +1,50 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 学校请假作息时间(一校一行)。
* <p>对应历史 QJ_SetSchTime。默认 上午 08:00-12:00 / 下午 13:00-17:00,
* 最短请假间隔 10 分钟(intervalMinutes 目前仅存设置,不参与时长计算)。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_time_set")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveTimeSet extends BaseEntity {
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 上午开始 "HH:mm" */
@Column(name = "forenoon1", length = 10)
private String forenoon1 = "08:00";
/** 上午结束 "HH:mm" */
@Column(name = "forenoon2", length = 10)
private String forenoon2 = "12:00";
/** 下午开始 "HH:mm" */
@Column(name = "afternoon1", length = 10)
private String afternoon1 = "13:00";
/** 下午结束 "HH:mm" */
@Column(name = "afternoon2", length = 10)
private String afternoon2 = "17:00";
/** 最短请假间隔(分钟,默认 10) */
@Column(name = "interval_minutes")
private Integer intervalMinutes = 10;
/** 最少请假时间(分钟,0 = 不限制;对应历史学校时间设置「最少请假时间」) */
@Column(name = "min_minutes")
private Integer minMinutes = 0;
}
@@ -0,0 +1,41 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 教师可用时长池(一校一人一行)。
* <p>对应历史 QJ_SchUserTime。小时制;(schId,userId) 唯一。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_user_time")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveUserTime extends BaseEntity {
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 教师用户ID(平台用户中心ID,不存账号) */
@Column(name = "user_id", nullable = false)
private Integer userId;
/** 教师姓名(冗余展示) */
@Column(name = "user_name", length = 64)
private String userName;
/** 剩余可用小时数(默认 0,绝不出现 null 相加) */
@Column(name = "usable_hours")
private Double usableHours = 0D;
/** 备注 */
@Column(name = "remark", length = 500)
private String remark;
}
@@ -0,0 +1,68 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 教师时长变更流水。
* <p>对应历史 QJ_SchUserTimeRecord。editHours 正=新加、负=扣除;管理员手动调整
* 的流水 leaveType 记为空串,由 describe 区分("新加"/"销假返还"/"扣除"等原样留存)。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_user_time_record")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveUserTimeRecord extends BaseEntity {
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 教师用户ID */
@Column(name = "user_id", nullable = false)
private Integer userId;
/** 教师姓名(冗余展示) */
@Column(name = "user_name", length = 64)
private String userName;
/** 关联请假单ID(管理员手动调整时为空) */
@Column(name = "leave_id")
private Integer leaveId;
/** 请假类型 dictKey(手动调整为空串) */
@Column(name = "leave_type", length = 50)
private String leaveType;
/** 本次增减小时(正=加,负=减) */
@Column(name = "edit_hours")
private Double editHours;
/** 变更前小时 */
@Column(name = "old_hours")
private Double oldHours;
/** 变更后小时 */
@Column(name = "new_hours")
private Double newHours;
/** 备注("扣除"/"销假返还"/管理员备注/"新加") */
@Column(name = "describe_text", length = 500)
private String describeText;
/** 操作人用户ID(审核人/管理员) */
@Column(name = "operator_id")
private Integer operatorId;
/** 发生时间(统计过滤口径,独立于审计 createTime) */
@Column(name = "occur_time", nullable = false)
private LocalDateTime occurTime;
}
@@ -0,0 +1,45 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 系统字典(主办部门 dept / 活动分类 category)。
* <p>schId 做学校隔离;schId=0 视为全局种子数据(学校无自有字典时兜底可用)。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sys_dict")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysDict extends BaseEntity {
/** 学校ID;0=全局种子 */
@Column(name = "sch_id")
private Integer schId = 0;
/** 字典类型:dept=主办部门 / category=活动分类 */
@Column(name = "dict_type", length = 50)
private String dictType;
/** 字典 key(前端 CodeID,如 D001/C001) */
@Column(name = "dict_key", length = 100)
private String dictKey;
/** 字典 value(显示名称,前端 Name) */
@Column(name = "dict_value", length = 200)
private String dictValue;
/** 排序号 */
@Column(name = "sort_order")
private Integer sortOrder = 0;
/** 是否启用:0 禁用 / 1 启用 */
@Column(name = "enable_flag")
private Integer enableFlag = 1;
}
@@ -0,0 +1,84 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 系统审计日志实体(移植自 UserclassService SysOperationLog)。
* 全量记录 controller 请求:用户、URI、参数、响应(GZIP)、耗时、异常。
* 索引由 resources/sql/sys_operation_log.sql 创建,本实体不含 @Table(indexes),
* 避免 ddl-auto=update 启动时重复建索引告警。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sys_operation_log")
public class SysOperationLog implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */
@Column(name = "log_level", nullable = false, columnDefinition = "TINYINT")
private Integer logLevel;
@Column(name = "operation_type", nullable = false, length = 16)
private String operationType;
@Column(name = "user_id")
private Integer userId;
@Column(name = "user_name", length = 64)
private String userName;
@Column(name = "sch_id")
private Integer schId;
@Column(name = "request_ip", nullable = false, length = 64)
private String requestIp;
@Column(name = "request_uri", nullable = false, length = 512)
private String requestUri;
@Column(name = "request_method", nullable = false, length = 16)
private String requestMethod;
@Column(name = "class_method", nullable = false, length = 512)
private String classMethod;
@Column(name = "request_params", columnDefinition = "TEXT")
private String requestParams;
/** GZIP 压缩后的响应 JSON(不参与 JSON 序列化) */
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
private byte[] responseData;
@Column(name = "status_code", nullable = false)
private Integer statusCode;
/** 耗时 ms */
@Column(name = "cost_time", nullable = false)
private Long costTime;
@Column(name = "user_agent", length = 512)
private String userAgent;
@Column(name = "error_msg", columnDefinition = "TEXT")
private String errorMsg;
@CreationTimestamp
@Column(name = "create_time", nullable = false, updatable = false, columnDefinition = "DATETIME(3)")
private LocalDateTime createTime;
}
@@ -0,0 +1,44 @@
package com.yunhai.workleave.entity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 附件元信息实体(按前端 UpFileVo 契约重建)。
*/
@NoArgsConstructor
@Getter
@Setter
@Entity
@Table(name = "up_file")
public class UpFile extends BaseEntity {
/** 主题类型(如 "leave") */
@Column(name = "topic_type", length = 50)
private String topicType;
/** 主题ID(活动ID;未绑定时为空) */
@Column(name = "topic_id")
private Integer topicId;
/** 原始文件名 */
@Column(name = "file_true_name", length = 255)
private String fileTrueName;
/** 存储相对路径 */
@Column(name = "file_name", length = 255)
private String fileName;
/** 文件大小(字节) */
@Column(name = "file_size")
private Integer fileSize;
/** 扩展名(小写) */
@Column(name = "file_type", length = 50)
private String fileType;
}
@@ -0,0 +1,71 @@
package com.yunhai.workleave.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 请假单据状态。
* <p>历史状态机的 0~6 收敛为 5 个:撤销待审/通过(撤销被拒)/修改待审
* 三个依赖自建流程引擎的状态取消,审核流转交由平台流程审核器承担。</p>
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum LeaveStatusEnum {
/** 审批中(已发起流程,待审核) */
APPROVING(0, "审批中"),
/** 已通过(flow.completed;管控类型扣减时长) */
PASSED(1, "已通过"),
/** 已拒绝(flow.rejected) */
REJECTED(2, "已拒绝"),
/** 已销假(已通过后教师发起销假;返还时长) */
CANCELED(3, "已销假"),
/** 已撤回(flow.withdrawn,提交人撤回) */
WITHDRAWN(4, "已撤回");
private Integer id;
private String name;
public static String nameById(Integer id) {
if (id == null) {
return "";
}
for (LeaveStatusEnum e : values()) {
if (e.id.equals(id)) {
return e.name;
}
}
return "";
}
/** 终态判定:通过/拒绝/销假为终态;撤回与审批中非终态 */
public static boolean isFinal(Integer id) {
return PASSED.id.equals(id) || REJECTED.id.equals(id) || CANCELED.id.equals(id);
}
/** 是否可编辑:审批中/已拒绝/已撤回 */
public static boolean editable(Integer id) {
return APPROVING.id.equals(id) || REJECTED.id.equals(id) || WITHDRAWN.id.equals(id);
}
/** 是否可删除:非已通过(已通过只能销假,不能删) */
public static boolean deletable(Integer id) {
return !PASSED.id.equals(id);
}
/** 该状态是否计入时间冲突(与历史 leave_status in (0,1,5) 对齐) */
public static boolean conflictStatus(Integer id) {
return APPROVING.id.equals(id) || PASSED.id.equals(id);
}
/** 该状态是否占用时长(在途占用:审批中) */
public static boolean occupyingStatus(Integer id) {
return APPROVING.id.equals(id);
}
}
@@ -0,0 +1,20 @@
package com.yunhai.workleave.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 用户类型(与基础平台 BoaUser.type 对齐)。
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum UserType {
SUPER_ADMINISTRATOR(1, "超级管理员"),
SCHOOL_ADMIN(2, "学校管理员"),
REGULAR_USER(3, "普通用户");
private Integer id;
private String name;
}
@@ -0,0 +1,67 @@
package com.yunhai.workleave.flow;
import com.yunhai.workleave.entity.LeaveApply;
import com.yunhai.workleave.model.leave.FlowProgressVo;
import com.yunhai.platform.openapi.flow.model.FlowApplyResult;
import com.yunhai.platform.openapi.flow.model.FlowProgress;
import com.yunhai.platform.openapi.flow.model.FlowSummary;
import com.yunhai.platform.openapi.flow.model.FlowProgressStep;
import com.yunhai.platform.openapi.flow.model.FlowProgressTask;
import com.yunhai.platform.openapi.flow.model.FlowTodoTask;
import com.yunhai.platform.openapi.flow.model.FlowTodoTaskPage;
import java.util.List;
/**
* 流程审核器门面(本模块对 YschFlowOpenApi 的唯一出口)。
* <p>把静态 SDK 调用收敛到一处,便于单测替换与异常语义统一:
* 跨仓调用失败一律包成 BizException 抛出,由平台全局异常处理返回统一错误。</p>
*/
public interface ILeaveFlowService {
/** 回调登记(首次发起前确保已注册;结果落 starter 的登记镜像表) */
void ensureRegistered(Integer schId, String flowGuid);
/** 编排注册所得的本模块流程标识(配置 leave.flow.guid,未配时为默认值) */
String defaultFlowGuid();
/**
* 流程中心的流程列表(流程编排里已注册的流程)。
*
* @return 流程摘要列表;流程中心不可达时抛 BizException(由调用方决定是否降级)
*/
List<FlowSummary> listFlows(Integer schId);
/** 发起流程:返回实例ID;duplicated=true 表示同 bizId 幂等命中 */
FlowApplyResult apply(LeaveApply leave, String bizId, String bizTitle);
/** 撤回(仅提交人本人 + 尚无任务被办理) */
void withdraw(Integer schId, Long instanceId, Integer submitterId);
/** 我的待办(bizType 固定 leave) */
FlowTodoTaskPage todoTasks(Integer schId, Integer assigneeId, Integer page, Integer size, String bizType);
/** 同意任务 */
void approveTask(Integer schId, Long taskId, Integer operatorId, String operatorName, String opinion);
/** 拒绝任务(意见必填,服务层已校验) */
void rejectTask(Integer schId, Long taskId, Integer operatorId, String operatorName, String opinion);
/** 办件进度(时间线渲染);异常/不可达返回 null(详情页降级展示) */
FlowProgress progress(LeaveApply leave);
/** 流程图短时 URL(不可达返回 null) */
String diagramUrl(LeaveApply leave);
/** 流程中心进度 → 前端视图 */
FlowProgressVo toProgressVo(FlowProgress progress);
/** 待办条目 → 本地字段映射辅助(供 service 组装 TodoVo) */
List<FlowTodoTask> contentOf(FlowTodoTaskPage page);
/** 流程节点/任务遍历辅助(进度视图构建用) */
List<FlowProgressStep> stepsOf(FlowProgress progress);
/** 任务遍历辅助 */
List<FlowProgressTask> tasksOf(FlowProgressStep step);
}
@@ -0,0 +1,297 @@
package com.yunhai.workleave.flow;
import com.yunhai.workleave.entity.LeaveApply;
import com.yunhai.workleave.entity.LeaveCc;
import com.yunhai.workleave.enums.LeaveStatusEnum;
import com.yunhai.workleave.repository.LeaveApplyRepository;
import com.yunhai.workleave.repository.LeaveCcRepository;
import com.yunhai.workleave.service.ILeaveUserTimeService;
import com.alibaba.fastjson.JSON;
import com.yunhai.platform.flowcallback.FlowCallbackHandler;
import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.Map;
/**
* 请假流程回调处理器(手册 §2.4 范本落地)。
* <p>事件已过全部门禁(验签、bizType 过滤、eventId 幂等、乱序门),本类只写状态推进:</p>
* <ul>
* <li>flow.submitted / task.created / task.handled → 审批中(终态不回退);</li>
* <li>flow.completed → 已通过 + 扣减时长 + 落终态快照;</li>
* <li>flow.rejected → 已拒绝 + 落终态快照(不扣不返还);</li>
* <li>flow.withdrawn → 已撤回(仅审批中可回退);</li>
* <li>flow.cc.sent → 抄送留痕(幂等)。</li>
* </ul>
* <p>事务语义:本方法与事件行同一事务(starter 内置),抛出异常整体回滚 + 流程中心重推;
* 确定性脏消息(bizId 解析失败等)记录 warn 后正常 return,绝不靠抛异常过滤。</p>
*/
@Component
public class LeaveFlowCallbackHandler implements FlowCallbackHandler {
private static final Logger log = LoggerFactory.getLogger(LeaveFlowCallbackHandler.class);
public static final String EVENT_SUBMITTED = "flow.submitted";
public static final String EVENT_TASK_CREATED = "flow.task.created";
public static final String EVENT_TASK_HANDLED = "flow.task.handled";
public static final String EVENT_COMPLETED = "flow.completed";
public static final String EVENT_REJECTED = "flow.rejected";
public static final String EVENT_WITHDRAWN = "flow.withdrawn";
public static final String EVENT_CC_SENT = "flow.cc.sent";
@Autowired
private LeaveApplyRepository leaveApplyRepository;
@Autowired
private LeaveCcRepository leaveCcRepository;
@Autowired
private ILeaveUserTimeService userTimeService;
@Override
@Transactional
public void onEvent(FlowCallbackEvent event) {
if (event == null || !StringUtils.hasText(event.getEvent())) {
log.warn("[flow-callback] 空事件,忽略");
return;
}
String eventCode = event.getEvent();
log.info("[flow-callback] 收到事件: event={}, bizId={}, instanceId={}, version={}, operator={}",
eventCode, event.getBizId(), event.getInstanceId(), event.getInstanceVersion(),
event.getOperatorName());
// 抄送事件不推进单据状态,按 leaveId 落留痕即可
if (EVENT_CC_SENT.equals(eventCode)) {
handleCcSent(event);
return;
}
LeaveApply leave = resolveLeave(event);
if (leave == null) {
// 确定性脏消息:本应用单据不存在,记录后正常返回(不触发重推)
log.warn("[flow-callback] 未找到对应单据,忽略: bizId={}", event.getBizId());
return;
}
if (!belongsToCurrentAttempt(leave, event)) {
log.info("[flow-callback] 非当前流程实例事件,忽略: leaveId={}, currentBizId={}, eventBizId={}, "
+ "currentInstanceId={}, eventInstanceId={}",
leave.getId(), leave.getBizId(), event.getBizId(), leave.getInstanceId(), event.getInstanceId());
return;
}
// 乱序门(starter 已按 instanceVersion 过滤大部分;此处对单据已记录版本再兜一层)
if (event.getInstanceVersion() != null && leave.getInstanceVersion() != null
&& event.getInstanceVersion() < leave.getInstanceVersion()) {
log.info("[flow-callback] 事件版本落后,忽略: leaveId={}, eventV={}, recordV={}",
leave.getId(), event.getInstanceVersion(), leave.getInstanceVersion());
return;
}
if (event.getInstanceVersion() != null) {
leave.setInstanceVersion(event.getInstanceVersion());
}
switch (eventCode) {
case EVENT_SUBMITTED:
case EVENT_TASK_CREATED:
case EVENT_TASK_HANDLED:
markApproving(leave);
break;
case EVENT_COMPLETED:
markPassed(leave, event);
break;
case EVENT_REJECTED:
markRejected(leave, event);
break;
case EVENT_WITHDRAWN:
markWithdrawn(leave);
break;
default:
log.warn("[flow-callback] 未知事件,忽略: {}", eventCode);
return;
}
leaveApplyRepository.save(leave);
}
// ============================ 状态推进 ============================
/** 审批中:终态不回退(并发推送可能乱序到达) */
private void markApproving(LeaveApply leave) {
if (!LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())) {
log.info("[flow-callback] 当前流程已离开审批中状态({}),拒绝非终态事件回退: leaveId={}",
LeaveStatusEnum.nameById(leave.getStatus()), leave.getId());
return;
}
leave.setStatus(LeaveStatusEnum.APPROVING.getId());
}
/** 已通过:扣减时长 + 终态快照 */
private void markPassed(LeaveApply leave, FlowCallbackEvent event) {
if (LeaveStatusEnum.PASSED.getId().equals(leave.getStatus())) {
log.info("[flow-callback] 通过事件重复到达,忽略副作用: leaveId={}", leave.getId());
return;
}
if (!LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())) {
log.warn("[flow-callback] 单据已处其他终态({}),忽略冲突通过事件: leaveId={}",
LeaveStatusEnum.nameById(leave.getStatus()), leave.getId());
return;
}
Integer before = leave.getStatus();
leave.setStatus(LeaveStatusEnum.PASSED.getId());
leave.setSnapshot(snapshotOf(event));
// 幂等:已扣减过则 service 内部跳过
userTimeService.deduct(leave.getSchId(), leave.getUserId(), leave.getUserName(),
leave.getId(), leave.getLeaveType(),
leave.getTotalHours() == null ? 0D : leave.getTotalHours(),
event.getOperatorUserId());
log.info("[flow-callback] 单据已通过: leaveId={}, before={}, operator={}, opinion={}",
leave.getId(), LeaveStatusEnum.nameById(before), event.getOperatorName(), opinionOf(event));
}
/** 已拒绝:不扣不返还(从未扣过),落终态快照 */
private void markRejected(LeaveApply leave, FlowCallbackEvent event) {
if (LeaveStatusEnum.REJECTED.getId().equals(leave.getStatus())) {
log.info("[flow-callback] 拒绝事件重复到达,忽略: leaveId={}", leave.getId());
return;
}
if (!LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())) {
log.warn("[flow-callback] 单据已处其他终态({}),忽略冲突拒绝事件: leaveId={}",
LeaveStatusEnum.nameById(leave.getStatus()), leave.getId());
return;
}
leave.setStatus(LeaveStatusEnum.REJECTED.getId());
leave.setSnapshot(snapshotOf(event));
log.info("[flow-callback] 单据已拒绝: leaveId={}, operator={}, opinion={}",
leave.getId(), event.getOperatorName(), opinionOf(event));
}
/** 已撤回:仅审批中可回退 */
private void markWithdrawn(LeaveApply leave) {
if (!LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())) {
log.info("[flow-callback] 单据非审批中({}),忽略撤回事件: leaveId={}",
LeaveStatusEnum.nameById(leave.getStatus()), leave.getId());
return;
}
leave.setStatus(LeaveStatusEnum.WITHDRAWN.getId());
}
/** 抄送留痕(幂等:同实例同人不重复落) */
private void handleCcSent(FlowCallbackEvent event) {
Long leaveId = parseLeaveId(event.getBizId());
if (leaveId == null) {
log.warn("[flow-callback] 抄送事件 bizId 非法,忽略: {}", event.getBizId());
return;
}
LeaveApply leave = leaveApplyRepository.findById(leaveId.intValue()).orElse(null);
if (leave == null) {
log.warn("[flow-callback] 抄送事件未找到单据,忽略: bizId={}", event.getBizId());
return;
}
if (!belongsToCurrentAttempt(leave, event)) {
log.info("[flow-callback] 非当前流程实例抄送事件,忽略: leaveId={}, eventBizId={}",
leave.getId(), event.getBizId());
return;
}
Integer ccUserId = event.getOperatorUserId();
if (ccUserId == null) {
// 抄送事件可能以 data.ccUserIds 列表下发;解析首个
Object dataValue = event.getData() == null ? null : event.getData().get("ccUserIds");
if (dataValue != null) {
String raw = String.valueOf(dataValue);
for (String part : raw.split(",")) {
recordCc(leave, parseInteger(part), null, event.getInstanceId());
}
}
return;
}
recordCc(leave, ccUserId, event.getOperatorName(), event.getInstanceId());
}
private void recordCc(LeaveApply leave, Integer ccUserId, String ccUserName, Long instanceId) {
if (ccUserId == null) {
return;
}
if (leaveCcRepository.existsByLeaveIdAndCcUserIdAndInstanceId(leave.getId(), ccUserId, instanceId)) {
return;
}
LeaveCc cc = new LeaveCc();
cc.setSchId(leave.getSchId());
cc.setLeaveId(leave.getId());
cc.setCcUserId(ccUserId);
cc.setCcUserName(ccUserName);
cc.setInstanceId(instanceId);
leaveCcRepository.save(cc);
}
// ============================ 工具 ============================
private LeaveApply resolveLeave(FlowCallbackEvent event) {
// 优先按 instanceId 定位(bizId 因重提交变化不影响)
if (event.getInstanceId() != null) {
LeaveApply byInstance = leaveApplyRepository.findByInstanceId(event.getInstanceId()).orElse(null);
if (byInstance != null) {
return byInstance;
}
}
Long leaveId = parseLeaveId(event.getBizId());
return leaveId == null ? null : leaveApplyRepository.findById(leaveId.intValue()).orElse(null);
}
/**
* 同一请假单可在拒绝/撤回后重新提交。按主键兜底定位时必须再核对当前 attempt,
* 否则旧实例迟到事件会污染新实例状态。
*/
private boolean belongsToCurrentAttempt(LeaveApply leave, FlowCallbackEvent event) {
if (StringUtils.hasText(leave.getBizId()) && StringUtils.hasText(event.getBizId())
&& !leave.getBizId().equals(event.getBizId())) {
return false;
}
return leave.getInstanceId() == null || event.getInstanceId() == null
|| leave.getInstanceId().equals(event.getInstanceId());
}
/** bizId 形态 {leaveId}-{attempt},取首段 */
private Long parseLeaveId(String bizId) {
if (!StringUtils.hasText(bizId)) {
return null;
}
String first = bizId.trim().split("-")[0];
try {
return Long.parseLong(first);
} catch (NumberFormatException e) {
return null;
}
}
private Integer parseInteger(String value) {
try {
return Integer.parseInt(value.trim());
} catch (Exception e) {
return null;
}
}
/** 终态快照:event.data 序列化(含 opinion/节点等),详情页免二次查询 */
private String snapshotOf(FlowCallbackEvent event) {
Map<String, Object> data = event.getData();
if (data == null || data.isEmpty()) {
return null;
}
try {
return JSON.toJSONString(data);
} catch (Exception e) {
log.warn("[flow-callback] 快照序列化失败: {}", e.getMessage());
return null;
}
}
private String opinionOf(FlowCallbackEvent event) {
Object value = event.getData() == null ? null : event.getData().get("opinion");
return value == null ? null : String.valueOf(value);
}
}
@@ -0,0 +1,24 @@
package com.yunhai.workleave.flow;
import com.yunhai.platform.openapi.core.JumpTarget;
/**
* 流程跳转页面名常量(手册 §3.1:页面名 + query 参数,多端最大公约数)。
* <p>PC 端路由 {@code /detail?leaveId=x} 与移动端页面名 {@code detail} 保持一致。</p>
*/
public final class LeaveFlowPages {
private LeaveFlowPages() {
}
/** 请假详情页(消息/待办跳转目标) */
public static final String DETAIL = "detail";
/** 详情页参数名 */
public static final String PARAM_LEAVE_ID = "leaveId";
/** 构造详情页跳转目标 */
public static JumpTarget detail(Integer leaveId) {
return JumpTarget.page(DETAIL).param(PARAM_LEAVE_ID, leaveId);
}
}
@@ -0,0 +1,305 @@
package com.yunhai.workleave.flow;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.flowcallback.FlowCallbackRegistrar;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.openapi.flow.YschFlowOpenApi;
import com.yunhai.platform.openapi.flow.model.FlowApplyBody;
import com.yunhai.platform.openapi.flow.model.FlowApplyResult;
import com.yunhai.platform.openapi.flow.model.FlowDiagramUrl;
import com.yunhai.platform.openapi.flow.model.FlowProgress;
import com.yunhai.platform.openapi.flow.model.FlowProgressStep;
import com.yunhai.platform.openapi.flow.model.FlowProgressTask;
import com.yunhai.platform.openapi.flow.model.FlowSummary;
import com.yunhai.platform.openapi.flow.model.FlowTodoTask;
import com.yunhai.platform.openapi.flow.model.FlowTodoTaskPage;
import com.yunhai.workleave.entity.LeaveApply;
import com.yunhai.workleave.model.leave.FlowProgressVo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 流程审核器门面实现(对齐《子应用流程与消息对接手册 v1.0》)。
* <p>地址类配置零项:SDK 按 {@code yh.open.base-host} + 服务名自动拼装
* (三级解析:call.targets → open.services → 枚举兜底)。</p>
*/
@Service
public class LeaveFlowService implements ILeaveFlowService {
private static final Logger log = LoggerFactory.getLogger(LeaveFlowService.class);
/** 本应用全局唯一业务类型(手册 §3.2 红线:= app_code,不可带点分档) */
public static final String BIZ_TYPE = "leave";
/** 流程流转中单据标题截断长度(快照/标题只放摘要) */
private static final int TITLE_MAX = 60;
@Value("${leave.flow.guid:leave-apply-v1}")
private String defaultFlowGuid;
/** 回调登记注册器(starter 提供,可能未装配) */
@Autowired
private ObjectProvider<FlowCallbackRegistrar> callbackRegistrarProvider;
// ============================ 登记 ============================
@Override
public String defaultFlowGuid() {
return defaultFlowGuid;
}
// ============================ 流程列表 ============================
@Override
public List<FlowSummary> listFlows(Integer schId) {
try {
List<FlowSummary> flows = YschFlowOpenApi.listFlows(schId == null ? 0L : schId, FlowSummary.class);
return flows == null ? Collections.emptyList() : flows;
} catch (Exception e) {
log.error("[flow] 流程列表查询失败: schId={}", schId, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取流程列表失败:" + e.getMessage());
}
}
@Override
public void ensureRegistered(Integer schId, String flowGuid) {
FlowCallbackRegistrar registrar = callbackRegistrarProvider.getIfAvailable();
if (registrar == null) {
log.warn("[flow] 回调注册器未装配(yh.flow-callback.enabled=false?),跳过登记: schId={}, flowGuid={}",
schId, flowGuid);
return;
}
try {
registrar.ensureRegistered(schId, flowGuid);
} catch (Exception e) {
// 登记失败不阻断提交:流程中心可能已存在旧登记,或本地网络暂不可达
log.warn("[flow] 回调登记失败: schId={}, flowGuid={}, err={}", schId, flowGuid, e.getMessage());
}
}
// ============================ 发起 ============================
@Override
public FlowApplyResult apply(LeaveApply leave, String bizId, String bizTitle) {
FlowApplyBody body = buildApplyBody(leave, bizId, bizTitle);
try {
FlowApplyResult result = YschFlowOpenApi.apply(leave.getSchId(), body, FlowApplyResult.class);
if (result == null || result.getInstanceId() == null) {
throw new IllegalStateException("流程中心未返回流程实例ID");
}
log.info("[flow] 发起流程成功: leaveId={}, bizId={}, instanceId={}, duplicated={}",
leave.getId(), bizId, result.getInstanceId(), result.isDuplicated());
return result;
} catch (Exception e) {
log.error("[flow] 发起流程失败: leaveId={}, bizId={}", leave.getId(), bizId, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "发起审批流程失败:" + e.getMessage());
}
}
/**
* 构造流程发起契约。保持包级可见,供契约单测直接断言,避免测试触发真实 OpenAPI。
*/
FlowApplyBody buildApplyBody(LeaveApply leave, String bizId, String bizTitle) {
FlowApplyBody body = new FlowApplyBody();
body.setFlowGuid(StringUtils.hasText(leave.getFlowGuid()) ? leave.getFlowGuid() : defaultFlowGuid);
body.setBizType(BIZ_TYPE);
body.setBizId(bizId);
body.setBizTitle(truncate(bizTitle, TITLE_MAX));
body.setBizJump(LeaveFlowPages.detail(leave.getId()));
body.setBizSnapshot(buildSnapshot(leave));
body.setSubmitterId(leave.getUserId());
body.setSubmitterName(leave.getUserName());
body.setFormData(buildFormData(leave));
return body;
}
// ============================ 撤回 ============================
@Override
public void withdraw(Integer schId, Long instanceId, Integer submitterId) {
if (instanceId == null) {
throw new BizException(ErrorCode.FORBIDDEN, "该单据尚未发起审批流程,无法撤回");
}
try {
YschFlowOpenApi.withdraw(schId, instanceId, submitterId == null ? 0 : submitterId);
log.info("[flow] 撤回成功: instanceId={}, submitterId={}", instanceId, submitterId);
} catch (Exception e) {
log.error("[flow] 撤回失败: instanceId={}", instanceId, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "撤回失败:" + e.getMessage());
}
}
// ============================ 待办 / 审核 ============================
@Override
public FlowTodoTaskPage todoTasks(Integer schId, Integer assigneeId, Integer page, Integer size, String bizType) {
try {
return YschFlowOpenApi.todoTasks(schId == null ? 0L : schId, assigneeId == null ? 0 : assigneeId,
bizType, page == null ? 1 : page, size == null ? 10 : size);
} catch (Exception e) {
log.error("[flow] 待办查询失败: schId={}, assigneeId={}", schId, assigneeId, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取待办列表失败:" + e.getMessage());
}
}
@Override
public void approveTask(Integer schId, Long taskId, Integer operatorId, String operatorName, String opinion) {
try {
YschFlowOpenApi.approveTask(schId == null ? 0L : schId, taskId,
operatorId == null ? 0 : operatorId, operatorName, opinion);
log.info("[flow] 审核同意: taskId={}, operatorId={}", taskId, operatorId);
} catch (Exception e) {
log.error("[flow] 审核同意失败: taskId={}", taskId, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "审核提交失败:" + e.getMessage());
}
}
@Override
public void rejectTask(Integer schId, Long taskId, Integer operatorId, String operatorName, String opinion) {
try {
YschFlowOpenApi.rejectTask(schId == null ? 0L : schId, taskId,
operatorId == null ? 0 : operatorId, operatorName, opinion);
log.info("[flow] 审核拒绝: taskId={}, operatorId={}", taskId, operatorId);
} catch (Exception e) {
log.error("[flow] 审核拒绝失败: taskId={}", taskId, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "审核提交失败:" + e.getMessage());
}
}
// ============================ 进度 / 流程图 ============================
@Override
public FlowProgress progress(LeaveApply leave) {
if (leave == null) {
return null;
}
try {
return YschFlowOpenApi.progress(leave.getInstanceId(), BIZ_TYPE, leave.getBizId(),
leave.getSchId() == null ? 0L : leave.getSchId(), FlowProgress.class);
} catch (Exception e) {
log.warn("[flow] 进度查询失败(降级空进度): leaveId={}, err={}", leave.getId(), e.getMessage());
return null;
}
}
@Override
public String diagramUrl(LeaveApply leave) {
if (leave == null || leave.getInstanceId() == null) {
return null;
}
try {
FlowDiagramUrl url = YschFlowOpenApi.diagramUrl(leave.getInstanceId(), leave.getFlowGuid(),
"track", leave.getSchId() == null ? 0L : leave.getSchId(), FlowDiagramUrl.class);
return url == null ? null : url.getImgUrl();
} catch (Exception e) {
log.warn("[flow] 流程图查询失败: leaveId={}, err={}", leave.getId(), e.getMessage());
return null;
}
}
@Override
public FlowProgressVo toProgressVo(FlowProgress progress) {
if (progress == null) {
return null;
}
FlowProgressVo vo = new FlowProgressVo();
vo.setInstanceId(progress.getInstanceId());
vo.setFlowName(progress.getBizTitle());
vo.setStatus(progress.getStatus() == null ? null : String.valueOf(progress.getStatus()));
vo.setCurrentNodeName(progress.getCurrentNodes());
vo.setSubmitterName(progress.getSubmitterName());
vo.setStartTime(progress.getSubmitTime());
vo.setEndTime(progress.getFinishTime());
List<FlowProgressVo.StepVo> steps = new ArrayList<>();
for (FlowProgressStep step : stepsOf(progress)) {
List<FlowProgressTask> tasks = tasksOf(step);
if (tasks == null || tasks.isEmpty()) {
// 无处理人的节点(尚未到达)也展示为待处理
FlowProgressVo.StepVo s = new FlowProgressVo.StepVo();
s.setNodeName(step.getNodeName());
s.setFinished(!step.isActive() && false);
s.setAction(step.getLabel());
steps.add(s);
continue;
}
for (FlowProgressTask task : tasks) {
FlowProgressVo.StepVo s = new FlowProgressVo.StepVo();
s.setNodeName(step.getNodeName());
s.setAssigneeName(task.getUserName());
s.setAction(StringUtils.hasText(task.getStatusLabel()) ? task.getStatusLabel() : step.getLabel());
s.setOpinion(task.getOpinion());
s.setTime(StringUtils.hasText(task.getHandleTime()) ? task.getHandleTime() : task.getArriveTime());
s.setFinished(StringUtils.hasText(task.getHandleTime()));
steps.add(s);
}
}
vo.setSteps(steps);
return vo;
}
@Override
public List<FlowTodoTask> contentOf(FlowTodoTaskPage page) {
if (page == null || page.getContent() == null) {
return Collections.emptyList();
}
return page.getContent();
}
@Override
public List<FlowProgressStep> stepsOf(FlowProgress progress) {
if (progress == null || progress.getSteps() == null) {
return Collections.emptyList();
}
return progress.getSteps();
}
@Override
public List<FlowProgressTask> tasksOf(FlowProgressStep step) {
if (step == null || step.getAssignees() == null) {
return Collections.emptyList();
}
return step.getAssignees();
}
// ============================ 内部方法 ============================
/** 只读摘要(手册坑 5:勿放大字段/富文本) */
private Map<String, Object> buildSnapshot(LeaveApply leave) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("leaveTypeName", leave.getLeaveTypeName());
snapshot.put("startTime", leave.getStartTime());
snapshot.put("endTime", leave.getEndTime());
snapshot.put("totalHours", leave.getTotalHours());
snapshot.put("reason", truncate(leave.getReason(), 100));
return snapshot;
}
/** 驱动条件路由/会签的表单数据 */
private Map<String, Object> buildFormData(LeaveApply leave) {
Map<String, Object> form = new LinkedHashMap<>();
form.put("leaveType", leave.getLeaveType());
form.put("leaveTypeName", leave.getLeaveTypeName());
form.put("totalHours", leave.getTotalHours());
form.put("days", leave.getTotalHours() == null ? 0 : Math.round(leave.getTotalHours() / 8D * 100D) / 100D);
return form;
}
private String truncate(String value, int max) {
if (value == null) {
return null;
}
return value.length() <= max ? value : value.substring(0, max);
}
}
@@ -0,0 +1,14 @@
package com.yunhai.workleave.model;
import lombok.Data;
/**
* 统一分页查询入参。pageIndex 从 1 起。
*/
@Data
public class BasePageQuery {
private int pageIndex = 1;
private int pageSize = 10;
/** 通用模糊搜索关键词 */
private String keyword;
}
@@ -0,0 +1,33 @@
package com.yunhai.workleave.model;
import lombok.Data;
import java.util.List;
/**
* 统一分页响应外壳。
*/
@Data
public class BasePageResult<T> {
/** 总记录数 */
private int total;
/** 当前页数据 */
private List<T> content;
/** 当前页码(从 1 起) */
private int pageIndex;
/** 每页条数 */
private int pageSize;
/** 总页数 */
private int totalPages;
/** 是否还有下一页 */
private boolean hasNext;
public BasePageResult(List<T> content, int total, int pageIndex, int pageSize, int totalPages, boolean hasNext) {
this.content = content;
this.total = total;
this.pageIndex = pageIndex;
this.pageSize = pageSize;
this.totalPages = totalPages;
this.hasNext = hasNext;
}
}
@@ -0,0 +1,106 @@
package com.yunhai.workleave.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* 班级管理接口-教师(bschclass {@code /oapi/DbClass/GetSchteaList} 响应项)。
* 对应 C# BestSchool.Entity.Model.SchTeaList。
*/
public class SchTeaList {
/** 学校 GUID(SchSerID) */
@JsonProperty("SchSerID")
private String schSerID;
@JsonProperty("TeaTel")
private String teaTel;
/** 教师姓名 */
@JsonProperty("TeaName")
private String teaName;
/** 教师用户ID */
@JsonProperty("UserTeaSerID")
private Integer userTeaSerID;
@JsonProperty("TeaIDCard")
private String teaIDCard;
/** 教师工号 */
@JsonProperty("TeaNo")
private String teaNo;
/** 教师所属组ID列表(兼容旧字段 GroupIds) */
@JsonProperty("GroupIds")
private List<Integer> groupIds;
/** 教师所属组ID(接口现行字段 GroupID,单个) */
@JsonProperty("GroupID")
private Integer groupId;
public Integer getGroupId() {
return groupId;
}
public void setGroupId(Integer groupId) {
this.groupId = groupId;
}
public String getSchSerID() {
return schSerID;
}
public void setSchSerID(String schSerID) {
this.schSerID = schSerID;
}
public String getTeaTel() {
return teaTel;
}
public void setTeaTel(String teaTel) {
this.teaTel = teaTel;
}
public String getTeaName() {
return teaName;
}
public void setTeaName(String teaName) {
this.teaName = teaName;
}
public Integer getUserTeaSerID() {
return userTeaSerID;
}
public void setUserTeaSerID(Integer userTeaSerID) {
this.userTeaSerID = userTeaSerID;
}
public String getTeaIDCard() {
return teaIDCard;
}
public void setTeaIDCard(String teaIDCard) {
this.teaIDCard = teaIDCard;
}
public String getTeaNo() {
return teaNo;
}
public void setTeaNo(String teaNo) {
this.teaNo = teaNo;
}
public List<Integer> getGroupIds() {
return groupIds;
}
public void setGroupIds(List<Integer> groupIds) {
this.groupIds = groupIds;
}
}
@@ -0,0 +1,29 @@
package com.yunhai.workleave.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 用户系统角色上下文(来自请求头 currentrole,URL 编码 JSON:{childId, roleId, schId, userId})。
* 请假模块不使用 childId,仅透传平台统一的 currentrole 上下文。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserSysRole {
/** 子账号/孩子ID,0=主账号 */
private Integer childId;
/** 角色ID */
private Integer roleId;
/** 学校ID(数据隔离关键字段) */
private Integer schId;
/** 用户ID(部分平台版本携带) */
private Integer userId;
}

Some files were not shown because too many files have changed in this diff Show More