@@ -0,0 +1,5 @@
|
||||
target/
|
||||
.idea/
|
||||
*.iml
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1 @@
|
||||
{"w":3056,"h":1298,"ch":3}
|
||||
@@ -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>
|
||||
@@ -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">起止两个日期框:宽度差 < 0.5px、高度差 < 0.5px、顶边与垂直中心线偏差 < 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">■</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">⇩ 批量导入</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">✓</span>事假</span>
|
||||
<span class="cbx on" data-page-node-id="GhyiRhbPZAWU4tfRAUthyl"><span class="box" data-page-node-id="xNicqTyQ6wWWEzCd9DPx9p">✓</span>病假</span>
|
||||
<span class="cbx on" data-page-node-id="08EAErdsyuJ2dEPxklNdCS"><span class="box" data-page-node-id="ZtPmmPxRRqaIAzemlquHB2">✓</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">☆</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">⏱</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">◫</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">⚠</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">◫</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">▼</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">▶</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">▶</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">▶</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">▶</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">⚲</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">↻ 刷新</button>
|
||||
<button class="btn plain sm" data-page-node-id="dtBsngp8IgMktay3x86YKS">↶ 清空改动</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">—</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">—</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">—</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">—</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">—</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">—</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">✓ 提交本次调整</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 小时绿色、<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>
|
||||
@@ -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__/*
|
||||
```
|
||||
@@ -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)。
|
||||
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -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));
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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)})`);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||