feat(duty): add web-admin calendar page and public H5 grab UI
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
36fc7f286c
commit
35e09fa09b
9 files changed
+3152
No files matched your search
@@ -0,0 +1,519 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>值班抢班 · 公开 H5 Demo</title>
|
||||
<style>
|
||||
:root {
|
||||
--brand: #0a9f6d;
|
||||
--brand-dark: #087f57;
|
||||
--brand-soft: #e9f9f2;
|
||||
--barley: #d6a343;
|
||||
--barley-soft: #fff7e8;
|
||||
--bg: #f5f8f7;
|
||||
--surface: #ffffff;
|
||||
--text: #1f2a26;
|
||||
--muted: #6b7b74;
|
||||
--border: #dcebe4;
|
||||
--danger: #d14343;
|
||||
--warning: #cf8a19;
|
||||
--success: #189b65;
|
||||
--am: #cf8a19;
|
||||
--am-bg: #fff7e8;
|
||||
--pm: #3b82f6;
|
||||
--pm-bg: #eff6ff;
|
||||
--duty: #d14343;
|
||||
--duty-bg: #fef2f2;
|
||||
}
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
|
||||
background: #dfe8e4;
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.chrome {
|
||||
position: sticky; top: 0; z-index: 40;
|
||||
background: #12241c; color: #d7e8df;
|
||||
padding: 10px 16px;
|
||||
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
|
||||
}
|
||||
.chrome strong { color: #fff; margin-right: 4px; }
|
||||
.chrome button {
|
||||
border: 1px solid #2a4638; background: #1a3228; color: #d7e8df;
|
||||
border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.chrome button.active {
|
||||
background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 600;
|
||||
}
|
||||
.chrome .hint { width: 100%; font-size: 11px; color: #8eaa9a; margin-top: 2px; }
|
||||
|
||||
.stage {
|
||||
display: flex; justify-content: center; gap: 28px; padding: 28px 16px 48px;
|
||||
flex-wrap: wrap; align-items: flex-start;
|
||||
}
|
||||
.stage-label {
|
||||
text-align: center; font-size: 12px; color: #4a6358; margin-bottom: 10px; font-weight: 600;
|
||||
}
|
||||
|
||||
.phone {
|
||||
width: 375px;
|
||||
background: var(--bg);
|
||||
border-radius: 32px;
|
||||
border: 10px solid #1a2420;
|
||||
box-shadow: 0 24px 60px rgba(18, 36, 28, 0.28);
|
||||
overflow: hidden;
|
||||
min-height: 760px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.notch {
|
||||
position: absolute; top: 8px; left: 50%; transform: translateX(-50%);
|
||||
width: 110px; height: 24px; background: #1a2420; border-radius: 0 0 14px 14px; z-index: 5;
|
||||
}
|
||||
|
||||
.page { display: none; flex: 1; flex-direction: column; min-height: 740px; }
|
||||
.page.active { display: flex; }
|
||||
|
||||
.header {
|
||||
background: linear-gradient(160deg, #0a9f6d 0%, #087f57 100%);
|
||||
color: #fff;
|
||||
padding: 44px 18px 22px;
|
||||
border-radius: 0 0 22px 22px;
|
||||
position: relative;
|
||||
}
|
||||
.header .eyebrow {
|
||||
font-size: 12px; opacity: 0.88; margin-bottom: 6px;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0; font-size: 22px; font-weight: 700; letter-spacing: 0.02em;
|
||||
}
|
||||
.header .sub {
|
||||
margin: 8px 0 0; font-size: 13px; line-height: 1.45; opacity: 0.92;
|
||||
}
|
||||
.header .meta {
|
||||
margin-top: 14px;
|
||||
display: flex; gap: 8px; flex-wrap: wrap;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
background: rgba(255,255,255,0.18);
|
||||
border: 1px solid rgba(255,255,255,0.22);
|
||||
border-radius: 999px; padding: 4px 10px; font-size: 11px;
|
||||
}
|
||||
|
||||
.body { padding: 14px 14px 28px; flex: 1; }
|
||||
|
||||
.tip {
|
||||
background: var(--barley-soft);
|
||||
border: 1px solid #f0dfb5;
|
||||
color: #8a6418;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.slot {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 2px rgba(31, 42, 38, 0.04);
|
||||
}
|
||||
.slot.is-full { opacity: 0.92; }
|
||||
.slot.is-closed { opacity: 0.85; }
|
||||
|
||||
.slot-hd {
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: 10px;
|
||||
}
|
||||
.slot-title {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 16px; font-weight: 700;
|
||||
}
|
||||
.dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.dot.am { background: var(--am); }
|
||||
.dot.pm { background: var(--pm); }
|
||||
.dot.duty { background: var(--duty); }
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px; font-weight: 600;
|
||||
padding: 3px 8px; border-radius: 999px;
|
||||
}
|
||||
.badge.open { background: var(--brand-soft); color: var(--brand-dark); }
|
||||
.badge.full { background: #eef2f0; color: var(--muted); }
|
||||
.badge.closed { background: #fde8e8; color: var(--danger); }
|
||||
|
||||
.cap-row {
|
||||
margin-top: 10px;
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
font-size: 12px; color: var(--muted);
|
||||
}
|
||||
.cap-row strong { color: var(--text); font-size: 15px; }
|
||||
|
||||
.bar {
|
||||
margin-top: 8px; height: 7px; background: #e8f0ec; border-radius: 999px; overflow: hidden;
|
||||
}
|
||||
.bar > i {
|
||||
display: block; height: 100%; border-radius: 999px; background: var(--brand);
|
||||
}
|
||||
.bar.full > i { background: #9aabb0; }
|
||||
.bar.am > i { background: var(--am); }
|
||||
.bar.pm > i { background: var(--pm); }
|
||||
.bar.duty > i { background: var(--duty); }
|
||||
|
||||
.people {
|
||||
margin-top: 10px;
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
}
|
||||
.person {
|
||||
font-size: 12px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
background: #f3f7f5;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.person.empty {
|
||||
background: transparent; border-style: dashed; color: #8a9a93;
|
||||
}
|
||||
|
||||
.field { margin-top: 12px; }
|
||||
.field label {
|
||||
display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px;
|
||||
}
|
||||
.field input {
|
||||
width: 100%; height: 44px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0 12px;
|
||||
font-size: 15px;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.field input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(10,159,109,0.12); }
|
||||
|
||||
.btn-grab {
|
||||
margin-top: 10px;
|
||||
width: 100%; height: 44px;
|
||||
border: none; border-radius: 12px;
|
||||
background: var(--brand); color: #fff;
|
||||
font-size: 15px; font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-grab:active { background: var(--brand-dark); }
|
||||
.btn-grab:disabled {
|
||||
background: #b7c5bf; color: #fff; cursor: not-allowed;
|
||||
}
|
||||
.btn-grab.secondary {
|
||||
background: #fff; color: var(--brand-dark);
|
||||
border: 1px solid var(--brand);
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
text-align: center; font-size: 11px; color: #8a9a93;
|
||||
padding: 4px 16px 18px; line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Success / closed full-page states */
|
||||
.state-panel {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 40px 28px; text-align: center;
|
||||
background: var(--bg);
|
||||
}
|
||||
.state-icon {
|
||||
width: 72px; height: 72px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 34px; margin-bottom: 16px;
|
||||
}
|
||||
.state-icon.ok { background: var(--brand-soft); color: var(--brand); }
|
||||
.state-icon.off { background: #eef2f0; color: var(--muted); }
|
||||
.state-panel h2 { margin: 0; font-size: 20px; }
|
||||
.state-panel p { margin: 10px 0 0; color: var(--muted); font-size: 14px; line-height: 1.55; }
|
||||
.state-card {
|
||||
width: 100%; margin-top: 22px; text-align: left;
|
||||
background: #fff; border: 1px solid var(--border);
|
||||
border-radius: 14px; padding: 14px;
|
||||
}
|
||||
.kv { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; }
|
||||
.kv span:first-child { color: var(--muted); }
|
||||
.kv span:last-child { font-weight: 600; }
|
||||
|
||||
.toast {
|
||||
position: absolute; left: 50%; bottom: 28px; transform: translateX(-50%);
|
||||
background: rgba(31,42,38,0.92); color: #fff;
|
||||
padding: 10px 14px; border-radius: 10px; font-size: 13px;
|
||||
white-space: nowrap; display: none; z-index: 20;
|
||||
max-width: 90%;
|
||||
}
|
||||
.toast.show { display: block; animation: fadeUp .2s ease; }
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translate(-50%, 8px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.stage { padding: 12px 0 24px; }
|
||||
.phone {
|
||||
width: 100%; border-radius: 0; border: none; min-height: 100vh;
|
||||
box-shadow: none;
|
||||
}
|
||||
.notch { display: none; }
|
||||
.chrome { border-radius: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="chrome">
|
||||
<strong>公开 H5 · 抢班页</strong>
|
||||
<button type="button" class="active" data-page="grab" onclick="showPage('grab', this)">可抢班(主流程)</button>
|
||||
<button type="button" data-page="success" onclick="showPage('success', this)">提交成功</button>
|
||||
<button type="button" data-page="closed" onclick="showPage('closed', this)">链接已关闭</button>
|
||||
<button type="button" data-page="invalid" onclick="showPage('invalid', this)">链接无效</button>
|
||||
<div class="hint">一天一个链接 · 班次独立罗列 · 免登录只填姓名 · 满员不可抢 · 对齐三墙 Token</div>
|
||||
</div>
|
||||
|
||||
<div class="stage">
|
||||
<div>
|
||||
<div class="stage-label">375px 手机预览 · 点击上方切换状态</div>
|
||||
<div class="phone" id="phone">
|
||||
<div class="notch"></div>
|
||||
|
||||
<!-- 主流程:可抢 -->
|
||||
<div class="page active" id="page-grab">
|
||||
<div class="header">
|
||||
<div class="eyebrow">山峡化工 · 值班抢班</div>
|
||||
<h1>8月20日 值班</h1>
|
||||
<p class="sub">无需登录,填写姓名后选择班次提交。名额满了就不能再抢。</p>
|
||||
<div class="meta">
|
||||
<span class="pill">周三</span>
|
||||
<span class="pill">链接有效</span>
|
||||
<span class="pill">剩余 2 个名额</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="tip">提示:同名可能重复,请填写真实姓名;提交后如需改动请联系管理员。</div>
|
||||
|
||||
<!-- 早班 已满 -->
|
||||
<section class="slot is-full">
|
||||
<div class="slot-hd">
|
||||
<div class="slot-title"><span class="dot am"></span>早班</div>
|
||||
<span class="badge full">已满</span>
|
||||
</div>
|
||||
<div class="cap-row">
|
||||
<span>名额</span>
|
||||
<strong>2 / 2</strong>
|
||||
</div>
|
||||
<div class="bar full am"><i style="width:100%"></i></div>
|
||||
<div class="people">
|
||||
<span class="person">张伟</span>
|
||||
<span class="person">陈静</span>
|
||||
</div>
|
||||
<button class="btn-grab" type="button" disabled>名额已满</button>
|
||||
</section>
|
||||
|
||||
<!-- 晚班 可抢 -->
|
||||
<section class="slot" id="slot-pm">
|
||||
<div class="slot-hd">
|
||||
<div class="slot-title"><span class="dot pm"></span>晚班</div>
|
||||
<span class="badge open">可抢</span>
|
||||
</div>
|
||||
<div class="cap-row">
|
||||
<span>名额</span>
|
||||
<strong id="pm-count">1 / 2</strong>
|
||||
</div>
|
||||
<div class="bar pm"><i id="pm-bar" style="width:50%"></i></div>
|
||||
<div class="people" id="pm-people">
|
||||
<span class="person">李娜</span>
|
||||
<span class="person empty">空缺 1</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="name-pm">您的姓名</label>
|
||||
<input id="name-pm" type="text" maxlength="20" placeholder="请输入真实姓名" autocomplete="name" />
|
||||
</div>
|
||||
<button class="btn-grab" type="button" onclick="submitGrab('pm')">提交抢班</button>
|
||||
</section>
|
||||
|
||||
<!-- 值班 空 -->
|
||||
<section class="slot" id="slot-duty">
|
||||
<div class="slot-hd">
|
||||
<div class="slot-title"><span class="dot duty"></span>值班</div>
|
||||
<span class="badge open">可抢</span>
|
||||
</div>
|
||||
<div class="cap-row">
|
||||
<span>名额</span>
|
||||
<strong id="duty-count">0 / 1</strong>
|
||||
</div>
|
||||
<div class="bar duty"><i id="duty-bar" style="width:0%"></i></div>
|
||||
<div class="people" id="duty-people">
|
||||
<span class="person empty">暂无人报名</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="name-duty">您的姓名</label>
|
||||
<input id="name-duty" type="text" maxlength="20" placeholder="请输入真实姓名" autocomplete="name" />
|
||||
</div>
|
||||
<button class="btn-grab" type="button" onclick="submitGrab('duty')">提交抢班</button>
|
||||
</section>
|
||||
</div>
|
||||
<div class="footer-note">提交即表示确认当日可到岗值班<br />链接由管理员分享 · 请勿转发无关人员</div>
|
||||
</div>
|
||||
|
||||
<!-- 成功页 -->
|
||||
<div class="page" id="page-success">
|
||||
<div class="header" style="padding-bottom:28px">
|
||||
<div class="eyebrow">山峡化工 · 值班抢班</div>
|
||||
<h1>报名成功</h1>
|
||||
<p class="sub">请按时到岗。如需调整请联系管理员。</p>
|
||||
</div>
|
||||
<div class="state-panel" style="justify-content:flex-start;padding-top:28px">
|
||||
<div class="state-icon ok">✓</div>
|
||||
<h2>已抢到晚班</h2>
|
||||
<p>2026年8月20日(周三)</p>
|
||||
<div class="state-card">
|
||||
<div class="kv"><span>日期</span><span>2026-08-20</span></div>
|
||||
<div class="kv"><span>班次</span><span>晚班</span></div>
|
||||
<div class="kv"><span>姓名</span><span>王小明</span></div>
|
||||
<div class="kv"><span>状态</span><span style="color:var(--success)">已报名</span></div>
|
||||
</div>
|
||||
<button class="btn-grab secondary" style="margin-top:18px;max-width:280px" type="button" onclick="showPage('grab')">返回查看当日班次</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 链接关闭 -->
|
||||
<div class="page" id="page-closed">
|
||||
<div class="state-panel">
|
||||
<div class="state-icon off">⊘</div>
|
||||
<h2>报名已关闭</h2>
|
||||
<p>管理员已关闭本链接,暂时无法继续抢班。<br />如需值班请联系管理员另行安排。</p>
|
||||
<div class="state-card">
|
||||
<div class="kv"><span>日期</span><span>2026-08-20</span></div>
|
||||
<div class="kv"><span>状态</span><span style="color:var(--danger)">已关闭</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 链接无效 -->
|
||||
<div class="page" id="page-invalid">
|
||||
<div class="state-panel">
|
||||
<div class="state-icon off">?</div>
|
||||
<h2>链接无效或已失效</h2>
|
||||
<p>请确认是否复制完整,或向管理员重新索取当日值班链接。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const slots = {
|
||||
pm: { taken: 1, cap: 2, names: ['李娜'] },
|
||||
duty: { taken: 0, cap: 1, names: [] }
|
||||
};
|
||||
|
||||
function showPage(id, btn) {
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById('page-' + id).classList.add('active');
|
||||
document.querySelectorAll('.chrome button').forEach(b => b.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
else {
|
||||
const b = document.querySelector('.chrome button[data-page="' + id + '"]');
|
||||
if (b) b.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function toast(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
clearTimeout(toast._timer);
|
||||
toast._timer = setTimeout(() => t.classList.remove('show'), 1800);
|
||||
}
|
||||
|
||||
function renderSlot(key) {
|
||||
const s = slots[key];
|
||||
const countEl = document.getElementById(key + '-count');
|
||||
const barEl = document.getElementById(key + '-bar');
|
||||
const peopleEl = document.getElementById(key + '-people');
|
||||
const section = document.getElementById('slot-' + key);
|
||||
if (!countEl) return;
|
||||
|
||||
countEl.textContent = s.taken + ' / ' + s.cap;
|
||||
barEl.style.width = Math.round((s.taken / s.cap) * 100) + '%';
|
||||
|
||||
peopleEl.innerHTML = '';
|
||||
s.names.forEach(n => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'person';
|
||||
span.textContent = n;
|
||||
peopleEl.appendChild(span);
|
||||
});
|
||||
const left = s.cap - s.taken;
|
||||
if (left > 0) {
|
||||
const empty = document.createElement('span');
|
||||
empty.className = 'person empty';
|
||||
empty.textContent = s.taken === 0 ? '暂无人报名' : ('空缺 ' + left);
|
||||
peopleEl.appendChild(empty);
|
||||
}
|
||||
|
||||
const badge = section.querySelector('.badge');
|
||||
const btn = section.querySelector('.btn-grab');
|
||||
const field = section.querySelector('.field');
|
||||
const bar = section.querySelector('.bar');
|
||||
if (s.taken >= s.cap) {
|
||||
badge.className = 'badge full';
|
||||
badge.textContent = '已满';
|
||||
btn.disabled = true;
|
||||
btn.textContent = '名额已满';
|
||||
if (field) field.style.display = 'none';
|
||||
section.classList.add('is-full');
|
||||
bar.classList.add('full');
|
||||
} else {
|
||||
badge.className = 'badge open';
|
||||
badge.textContent = '可抢';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '提交抢班';
|
||||
if (field) field.style.display = '';
|
||||
section.classList.remove('is-full');
|
||||
bar.classList.remove('full');
|
||||
}
|
||||
}
|
||||
|
||||
function submitGrab(key) {
|
||||
const input = document.getElementById('name-' + key);
|
||||
const name = (input.value || '').trim();
|
||||
if (!name) {
|
||||
toast('请先填写姓名');
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
const s = slots[key];
|
||||
if (s.taken >= s.cap) {
|
||||
toast('该班次名额已满');
|
||||
renderSlot(key);
|
||||
return;
|
||||
}
|
||||
s.names.push(name);
|
||||
s.taken += 1;
|
||||
input.value = '';
|
||||
renderSlot(key);
|
||||
toast(name + ' 已提交成功');
|
||||
setTimeout(() => showPage('success'), 600);
|
||||
}
|
||||
|
||||
renderSlot('pm');
|
||||
renderSlot('duty');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,548 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>值班排班 · 方案1 Demo</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--primary: #10b981;
|
||||
--primary-dark: #059669;
|
||||
--primary-bg: #ecfdf5;
|
||||
--text: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--border: #d9e8e2;
|
||||
--bg-page: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.06);
|
||||
--radius: 10px;
|
||||
--shift-am: #f59e0b;
|
||||
--shift-am-bg: #fffbeb;
|
||||
--shift-pm: #3b82f6;
|
||||
--shift-pm-bg: #eff6ff;
|
||||
--shift-duty: #ef4444;
|
||||
--shift-duty-bg: #fef2f2;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, -apple-system, "PingFang SC", sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: #e8eef2;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.demo-chrome {
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
background: #0f172a; color: #e2e8f0;
|
||||
padding: 10px 20px;
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
}
|
||||
.demo-chrome strong { color: #fff; margin-right: 8px; }
|
||||
.demo-chrome button {
|
||||
border: 1px solid #334155; background: #1e293b; color: #e2e8f0;
|
||||
border-radius: 8px; padding: 7px 14px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.demo-chrome button.active {
|
||||
background: var(--primary); border-color: var(--primary); color: #fff; font-weight: 600;
|
||||
}
|
||||
.demo-chrome .hint { margin-left: auto; font-size: 12px; color: #94a3b8; }
|
||||
.view { display: none; padding: 20px; }
|
||||
.view.active { display: block; }
|
||||
|
||||
/* ===== Admin shell ===== */
|
||||
.admin-shell {
|
||||
max-width: 1180px; margin: 0 auto;
|
||||
background: var(--bg-page); border-radius: 12px;
|
||||
border: 1px solid var(--border); overflow: hidden;
|
||||
box-shadow: var(--shadow); min-height: 720px;
|
||||
}
|
||||
.admin-top {
|
||||
height: 56px; background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; padding: 0 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.admin-top span { color: var(--primary); }
|
||||
.admin-body { padding: 24px; }
|
||||
.panel {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 20px 24px;
|
||||
}
|
||||
.panel-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; }
|
||||
.title-bar-heading {
|
||||
margin: 0; font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.title-bar-heading::before {
|
||||
content: ""; width: 3px; height: 16px; border-radius: 2px; background: var(--primary);
|
||||
}
|
||||
.month-nav { display: flex; align-items: center; gap: 8px; }
|
||||
.month-nav .month { font-weight: 600; min-width: 120px; text-align: center; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 36px; padding: 0 14px; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: #fff; color: var(--text);
|
||||
cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.btn:hover { background: #f8fafc; }
|
||||
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-dark); }
|
||||
.legend { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; color: var(--text-muted); font-size: 12px; }
|
||||
.legend i {
|
||||
display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle;
|
||||
}
|
||||
.cal {
|
||||
width: 100%; border-collapse: separate; border-spacing: 0;
|
||||
table-layout: fixed; border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
|
||||
}
|
||||
.cal th {
|
||||
background: #f8fafc; padding: 10px; font-weight: 500; color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border); font-size: 12px;
|
||||
}
|
||||
.cal th.wknd { color: #dc2626; }
|
||||
.cal td {
|
||||
vertical-align: top; height: 108px; padding: 6px;
|
||||
border-right: 1px solid var(--border); border-bottom: 1px solid var(--border);
|
||||
cursor: pointer; background: #fff; transition: background .15s;
|
||||
}
|
||||
.cal tr td:last-child { border-right: none; }
|
||||
.cal tr:last-child td { border-bottom: none; }
|
||||
.cal td:hover { background: #f0fdf4; }
|
||||
.cal td.muted { background: #fafafa; color: #94a3b8; }
|
||||
.cal td.muted:hover { background: #f1f5f9; }
|
||||
.cal td.today .day-num {
|
||||
background: var(--primary); color: #fff; border-radius: 50%;
|
||||
width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.day-num { font-size: 12px; font-weight: 600; margin-bottom: 4px; }
|
||||
.chip {
|
||||
display: block; font-size: 11px; line-height: 1.3; padding: 2px 6px;
|
||||
border-radius: 4px; margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.chip.am { background: var(--shift-am-bg); color: #b45309; }
|
||||
.chip.pm { background: var(--shift-pm-bg); color: #1d4ed8; }
|
||||
.chip.duty { background: var(--shift-duty-bg); color: #b91c1c; }
|
||||
.chip.empty { background: #f1f5f9; color: var(--text-muted); border: 1px dashed #cbd5e1; }
|
||||
.chip.more { background: transparent; color: var(--text-muted); padding-left: 2px; }
|
||||
|
||||
/* Dialog */
|
||||
.mask {
|
||||
position: fixed; inset: 0; background: rgba(15,23,42,.45);
|
||||
display: none; align-items: center; justify-content: center; z-index: 100;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.mask.show { display: flex; }
|
||||
.dlg {
|
||||
width: 560px; max-width: calc(100vw - 32px); max-height: calc(100vh - 100px);
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 20px 40px rgba(0,0,0,.15);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.dlg-hd {
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border);
|
||||
display: flex; justify-content: space-between; align-items: center; font-weight: 600;
|
||||
}
|
||||
.dlg-bd { padding: 16px 20px; overflow: auto; }
|
||||
.dlg-ft {
|
||||
padding: 12px 20px; border-top: 1px solid var(--border);
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
}
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
|
||||
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
|
||||
.field input, .field select {
|
||||
width: 100%; height: 36px; border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 0 10px; font-size: 14px;
|
||||
}
|
||||
.slot-card {
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.slot-card h4 { margin: 0 0 8px; font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||
.person-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 2px 8px; border-radius: 999px; font-size: 12px;
|
||||
background: var(--primary-bg); color: var(--primary-dark);
|
||||
}
|
||||
.tag button {
|
||||
border: none; background: transparent; cursor: pointer; color: var(--text-muted); padding: 0; line-height: 1;
|
||||
}
|
||||
.link-box {
|
||||
margin-top: 12px; padding: 10px 12px; background: var(--primary-bg);
|
||||
border: 1px solid #a7f3d0; border-radius: 8px; font-size: 12px;
|
||||
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
.link-box code { flex: 1; word-break: break-all; color: var(--primary-dark); }
|
||||
|
||||
/* ===== H5 phone frame ===== */
|
||||
.phone-wrap { display: flex; justify-content: center; padding: 12px; }
|
||||
.phone {
|
||||
width: 375px; min-height: 720px; background: #f5f7f6;
|
||||
border-radius: 28px; border: 10px solid #1e293b;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,.2); overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.phone-status {
|
||||
height: 44px; background: linear-gradient(135deg, #0a9f6d, #087f57);
|
||||
color: #fff; display: flex; align-items: flex-end; justify-content: center;
|
||||
padding-bottom: 8px; font-size: 11px; opacity: .9;
|
||||
}
|
||||
.h5-header {
|
||||
background: linear-gradient(135deg, #0a9f6d, #087f57);
|
||||
color: #fff; padding: 8px 16px 20px; border-radius: 0 0 20px 20px;
|
||||
}
|
||||
.h5-header h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
.h5-header p { margin: 6px 0 0; font-size: 13px; opacity: .9; }
|
||||
.h5-body { padding: 16px; flex: 1; overflow: auto; }
|
||||
.h5-card {
|
||||
background: #fff; border-radius: 14px; padding: 14px;
|
||||
margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||
border: 1px solid #e5ebe8;
|
||||
}
|
||||
.h5-card .row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.h5-card .name { font-weight: 600; font-size: 15px; }
|
||||
.badge {
|
||||
font-size: 11px; padding: 2px 8px; border-radius: 999px; font-weight: 500;
|
||||
}
|
||||
.badge.open { background: #e9f9f2; color: #087f57; }
|
||||
.badge.full { background: #f1f5f9; color: #64748b; }
|
||||
.progress {
|
||||
height: 6px; background: #eef2f0; border-radius: 999px; overflow: hidden; margin: 8px 0 10px;
|
||||
}
|
||||
.progress > span { display: block; height: 100%; background: #0a9f6d; border-radius: 999px; }
|
||||
.h5-card input {
|
||||
width: 100%; height: 40px; border: 1px solid #d7e0dc; border-radius: 10px;
|
||||
padding: 0 12px; margin-bottom: 8px; font-size: 14px;
|
||||
}
|
||||
.h5-card .grab {
|
||||
width: 100%; height: 40px; border: none; border-radius: 10px;
|
||||
background: #0a9f6d; color: #fff; font-weight: 600; font-size: 14px; cursor: pointer;
|
||||
}
|
||||
.h5-card .grab:disabled { background: #94a3b8; cursor: not-allowed; }
|
||||
.h5-card .people { font-size: 12px; color: #64748b; margin-top: 6px; }
|
||||
.toast {
|
||||
position: fixed; left: 50%; bottom: 40px; transform: translateX(-50%);
|
||||
background: #0f172a; color: #fff; padding: 10px 16px; border-radius: 8px;
|
||||
font-size: 13px; display: none; z-index: 200;
|
||||
}
|
||||
.toast.show { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="demo-chrome">
|
||||
<strong>值班 · 方案1 Demo</strong>
|
||||
<button type="button" class="active" data-view="calendar" onclick="showView('calendar', this)">① 管理端 · 月历</button>
|
||||
<button type="button" data-view="day" onclick="showView('day', this)">② 管理端 · 配置某日</button>
|
||||
<button type="button" data-view="h5" onclick="showView('h5', this)">③ 员工 · 公开 H5 抢班</button>
|
||||
<span class="hint">Fresh Mint 管理端 · 三墙绿 H5 · 仅示意交互</span>
|
||||
</div>
|
||||
|
||||
<!-- View 1: Calendar -->
|
||||
<div id="view-calendar" class="view active">
|
||||
<div class="admin-shell">
|
||||
<div class="admin-top">山峡化工 · <span>值班排班</span></div>
|
||||
<div class="admin-body">
|
||||
<div class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">值班排班</h2>
|
||||
<div class="month-nav">
|
||||
<button class="btn" type="button">‹</button>
|
||||
<span class="month">2026年 8月</span>
|
||||
<button class="btn" type="button">›</button>
|
||||
<button class="btn" type="button">今天</button>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span><i style="background:var(--shift-am)"></i>早班</span>
|
||||
<span><i style="background:var(--shift-pm)"></i>晚班</span>
|
||||
<span><i style="background:var(--shift-duty)"></i>值班</span>
|
||||
<span>点击日期可配置 / 复制链接</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="cal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="wknd">日</th><th>一</th><th>二</th><th>三</th><th>四</th><th>五</th><th class="wknd">六</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="muted"><div class="day-num">26</div></td>
|
||||
<td class="muted"><div class="day-num">27</div></td>
|
||||
<td class="muted"><div class="day-num">28</div></td>
|
||||
<td class="muted"><div class="day-num">29</div></td>
|
||||
<td class="muted"><div class="day-num">30</div></td>
|
||||
<td class="muted"><div class="day-num">31</div></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num" style="color:#dc2626">1</div>
|
||||
<span class="chip am">早班 张伟</span>
|
||||
<span class="chip pm">晚班 李娜</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td onclick="openDayDlg()"><div class="day-num" style="color:#dc2626">2</div>
|
||||
<span class="chip am">早班 王磊</span>
|
||||
<span class="chip duty">值班 刘洋</span>
|
||||
<span class="chip more">+2</span>
|
||||
</td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">3</div>
|
||||
<span class="chip am">早班 陈静</span>
|
||||
<span class="chip pm">晚班 赵鑫</span>
|
||||
</td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">4</div>
|
||||
<span class="chip empty">未排班 · 点击配置</span>
|
||||
</td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">5</div>
|
||||
<span class="chip am">早班 张伟</span>
|
||||
<span class="chip pm">晚班 李娜</span>
|
||||
<span class="chip duty">值班 王磊</span>
|
||||
</td>
|
||||
<td class="today" onclick="openDayDlg()"><div class="day-num">6</div>
|
||||
<span class="chip am">早班 刘洋 · 陈静</span>
|
||||
<span class="chip pm">晚班 1/2</span>
|
||||
<span class="chip empty">可抢 · 有链接</span>
|
||||
</td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">7</div>
|
||||
<span class="chip empty">未排班 · 点击配置</span>
|
||||
</td>
|
||||
<td onclick="openDayDlg()"><div class="day-num" style="color:#dc2626">8</div>
|
||||
<span class="chip empty">未排班 · 点击配置</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td onclick="openDayDlg()"><div class="day-num" style="color:#dc2626">9</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">10</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">11</div>
|
||||
<span class="chip am">早班 张伟</span>
|
||||
<span class="chip pm">晚班 满</span>
|
||||
</td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">12</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">13</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">14</div>
|
||||
<span class="chip duty">值班 赵鑫</span>
|
||||
</td>
|
||||
<td onclick="openDayDlg()"><div class="day-num" style="color:#dc2626">15</div><span class="chip empty">未排班</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td onclick="openDayDlg()"><div class="day-num" style="color:#dc2626">16</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">17</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">18</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">19</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">20</div>
|
||||
<span class="chip am">早班 2/2</span>
|
||||
<span class="chip pm">晚班 1/2</span>
|
||||
<span class="chip duty">值班 0/1</span>
|
||||
</td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">21</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num" style="color:#dc2626">22</div><span class="chip empty">未排班</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td onclick="openDayDlg()"><div class="day-num" style="color:#dc2626">23</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">24</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">25</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">26</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">27</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">28</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num" style="color:#dc2626">29</div><span class="chip empty">未排班</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td onclick="openDayDlg()"><div class="day-num" style="color:#dc2626">30</div><span class="chip empty">未排班</span></td>
|
||||
<td onclick="openDayDlg()"><div class="day-num">31</div><span class="chip empty">未排班</span></td>
|
||||
<td class="muted"><div class="day-num">1</div></td>
|
||||
<td class="muted"><div class="day-num">2</div></td>
|
||||
<td class="muted"><div class="day-num">3</div></td>
|
||||
<td class="muted"><div class="day-num">4</div></td>
|
||||
<td class="muted"><div class="day-num">5</div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View 2: Day config (standalone page look) -->
|
||||
<div id="view-day" class="view">
|
||||
<div class="admin-shell" style="max-width:640px">
|
||||
<div class="admin-top">配置值班日 · <span>2026-08-20</span></div>
|
||||
<div class="admin-body">
|
||||
<div class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">2026年8月20日</h2>
|
||||
<button class="btn" type="button" onclick="toast('链接已关闭(示意)')">关闭链接</button>
|
||||
</div>
|
||||
|
||||
<div class="slot-card">
|
||||
<h4><i style="width:8px;height:8px;border-radius:50%;background:var(--shift-am);display:inline-block"></i> 早班 · 名额 2</h4>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>名额</label><input type="number" value="2" min="1" /></div>
|
||||
<div class="field"><label>直接安排(系统用户)</label>
|
||||
<select><option>选择员工…</option><option>张伟</option><option>李娜</option><option>王磊</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="person-tags">
|
||||
<span class="tag">张伟(指定) <button type="button">×</button></span>
|
||||
<span class="tag">陈静(抢班) <button type="button">×</button></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slot-card">
|
||||
<h4><i style="width:8px;height:8px;border-radius:50%;background:var(--shift-pm);display:inline-block"></i> 晚班 · 名额 2</h4>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>名额</label><input type="number" value="2" min="1" /></div>
|
||||
<div class="field"><label>直接安排(系统用户)</label>
|
||||
<select><option>选择员工…</option><option>李娜</option><option>赵鑫</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="person-tags">
|
||||
<span class="tag">李娜(抢班) <button type="button">×</button></span>
|
||||
<span class="tag" style="background:#f1f5f9;color:#64748b">空缺 1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slot-card">
|
||||
<h4><i style="width:8px;height:8px;border-radius:50%;background:var(--shift-duty);display:inline-block"></i> 值班 · 名额 1</h4>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>名额</label><input type="number" value="1" min="1" /></div>
|
||||
<div class="field"><label>直接安排(系统用户)</label>
|
||||
<select><option>选择员工…</option><option>王磊</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="person-tags">
|
||||
<span class="tag" style="background:#f1f5f9;color:#64748b">空缺 1 · 可抢</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="link-box">
|
||||
<span>公开链接(一天一条)</span>
|
||||
<code>https://example.com/h5/duty/d8f3a2c1</code>
|
||||
<button class="btn btn-primary" type="button" onclick="toast('已复制链接(示意)')">复制链接</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px;display:flex;justify-content:flex-end;gap:8px">
|
||||
<button class="btn" type="button" onclick="showView('calendar')">返回日历</button>
|
||||
<button class="btn btn-primary" type="button" onclick="toast('已保存(示意)')">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View 3: H5 -->
|
||||
<div id="view-h5" class="view">
|
||||
<div class="phone-wrap">
|
||||
<div class="phone">
|
||||
<div class="phone-status">值班抢班</div>
|
||||
<div class="h5-header">
|
||||
<h1>8月20日 值班</h1>
|
||||
<p>免登录 · 填写姓名即可抢班 · 满员不可再抢</p>
|
||||
</div>
|
||||
<div class="h5-body">
|
||||
<div class="h5-card" data-slot="am">
|
||||
<div class="row">
|
||||
<span class="name">早班</span>
|
||||
<span class="badge full">已满 2/2</span>
|
||||
</div>
|
||||
<div class="progress"><span style="width:100%;background:#94a3b8"></span></div>
|
||||
<div class="people">已报名:张伟、陈静</div>
|
||||
<button class="grab" type="button" disabled>名额已满</button>
|
||||
</div>
|
||||
|
||||
<div class="h5-card" data-slot="pm">
|
||||
<div class="row">
|
||||
<span class="name">晚班</span>
|
||||
<span class="badge open">可抢 1/2</span>
|
||||
</div>
|
||||
<div class="progress"><span style="width:50%"></span></div>
|
||||
<div class="people">已报名:李娜</div>
|
||||
<input id="name-pm" type="text" placeholder="请输入姓名" />
|
||||
<button class="grab" type="button" onclick="grab('pm')">提交抢班</button>
|
||||
</div>
|
||||
|
||||
<div class="h5-card" data-slot="duty">
|
||||
<div class="row">
|
||||
<span class="name">值班</span>
|
||||
<span class="badge open">可抢 0/1</span>
|
||||
</div>
|
||||
<div class="progress"><span style="width:0%"></span></div>
|
||||
<div class="people">暂无人报名</div>
|
||||
<input id="name-duty" type="text" placeholder="请输入姓名" />
|
||||
<button class="grab" type="button" onclick="grab('duty')">提交抢班</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay dialog when clicking calendar day -->
|
||||
<div class="mask" id="dayMask" onclick="if(event.target===this)closeDayDlg()">
|
||||
<div class="dlg">
|
||||
<div class="dlg-hd">
|
||||
<span>配置 · 2026-08-06</span>
|
||||
<button class="btn" type="button" onclick="closeDayDlg()">关闭</button>
|
||||
</div>
|
||||
<div class="dlg-bd">
|
||||
<p style="margin:0 0 12px;color:var(--text-muted);font-size:13px">为当天配置班次名额,保存后生成公开链接。可直接从系统用户安排,也可留给员工抢班。</p>
|
||||
<div class="slot-card">
|
||||
<h4>早班</h4>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>名额</label><input type="number" value="2" /></div>
|
||||
<div class="field"><label>直接安排</label><select><option>选择员工…</option><option>张伟</option></select></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-card">
|
||||
<h4>晚班</h4>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>名额</label><input type="number" value="2" /></div>
|
||||
<div class="field"><label>直接安排</label><select><option>选择员工…</option><option>李娜</option></select></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-card">
|
||||
<h4>值班</h4>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>名额</label><input type="number" value="1" /></div>
|
||||
<div class="field"><label>直接安排</label><select><option>选择员工…</option><option>王磊</option></select></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="link-box">
|
||||
<code>https://example.com/h5/duty/a1b2c3d4</code>
|
||||
<button class="btn btn-primary" type="button" onclick="toast('已复制(示意)')">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dlg-ft">
|
||||
<button class="btn" type="button" onclick="closeDayDlg()">取消</button>
|
||||
<button class="btn btn-primary" type="button" onclick="toast('已保存并生成链接(示意)');closeDayDlg()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
function showView(id, btn) {
|
||||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||
document.getElementById('view-' + id).classList.add('active');
|
||||
document.querySelectorAll('.demo-chrome button').forEach(b => b.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
else {
|
||||
const b = document.querySelector('.demo-chrome button[data-view="' + id + '"]');
|
||||
if (b) b.classList.add('active');
|
||||
}
|
||||
closeDayDlg();
|
||||
}
|
||||
function openDayDlg() { document.getElementById('dayMask').classList.add('show'); }
|
||||
function closeDayDlg() { document.getElementById('dayMask').classList.remove('show'); }
|
||||
function toast(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 1800);
|
||||
}
|
||||
function grab(slot) {
|
||||
const input = document.getElementById('name-' + slot);
|
||||
const name = (input && input.value || '').trim();
|
||||
if (!name) { toast('请先填写姓名'); return; }
|
||||
toast(name + ' 已提交「' + (slot === 'pm' ? '晚班' : '值班') + '」(示意)');
|
||||
if (input) input.value = '';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,644 @@
|
||||
# 值班排班(日历 + 公开链接抢班)Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 web-admin 提供按日月历排班(预置早/晚/值班、名额、指定系统用户、复制/关闭按日公开链接);员工打开 `/#/public/duty/{token}` 免登录填姓名抢班,满员拒绝;支持历史翻月查看。
|
||||
|
||||
**Architecture:** 三表 `duty_day` / `duty_slot` / `duty_assignment`;管理 API 挂 `/api/web/admin/duty`;公开 API 挂 `/api/public/duty`(加密排除);公开页复用 `publicPlGet`/`publicPlPost` 与 `isPublicMobile` 静态路由。业务规则集中在 `app/services/duty_schedule.py`。
|
||||
|
||||
**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2;Vue 3 + Element Plus + SCSS(Fresh Mint 管理端 / 三墙 Token 公开 H5);pytest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-14-duty-schedule-design.md`
|
||||
**UI 原型:** `docs/demos/duty-schedule-v1-demo.html`、`docs/demos/duty-h5-grab-demo.html`
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| Create | `backend/scripts/migrate_20260814_duty_schedule.sql` | 建表 + 菜单 |
|
||||
| Create | `backend/app/models/duty_models.py` | ORM |
|
||||
| Create | `backend/app/schemas/duty.py` | 请求/响应 schema |
|
||||
| Create | `backend/app/services/duty_schedule.py` | 保存日配置、抢班、关链、序列化 |
|
||||
| Create | `backend/app/api/duty.py` | 管理端路由 |
|
||||
| Create | `backend/app/api/public_duty.py` | 公开路由 |
|
||||
| Create | `backend/tests/test_duty_schedule.py` | 名额/关链/缩容单测 |
|
||||
| Modify | `backend/app/main.py` | `include_router` |
|
||||
| Modify | `backend/app/core/config.py` | `API_ENCRYPT_EXCLUDE_PATHS` |
|
||||
| Create | `web-admin/src/api/duty.ts` | 管理端 API |
|
||||
| Create | `web-admin/src/api/public-duty.ts` | 公开 API |
|
||||
| Create | `web-admin/src/views/duty/schedule/index.vue` | 月历 + 日配置弹窗 |
|
||||
| Create | `web-admin/src/views/public/duty/index.vue` | 公开 H5 |
|
||||
| Modify | `web-admin/src/router/routes/staticRoutes.ts` | 公开路由 |
|
||||
| Modify | `web-admin/.env.development` / `.env.production`(若有独立排除列表) | 排除公开 path |
|
||||
| Sync | `docs/sql/` | 将 migrate 表结构并入基线说明或追加脚本备注 |
|
||||
|
||||
**Shift 常量(全栈统一):** `morning` / `evening` / `duty` → 文案 早班 / 晚班 / 值班。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: SQL 迁移(三表 + 菜单)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/scripts/migrate_20260814_duty_schedule.sql`
|
||||
|
||||
- [ ] **Step 1: 写迁移 SQL**
|
||||
|
||||
```sql
|
||||
-- backend/scripts/migrate_20260814_duty_schedule.sql
|
||||
-- 值班排班 V1:按日链接 + 班次名额 + 报名
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `duty_day` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID(UUID)',
|
||||
`duty_date` date NOT NULL COMMENT '值班日期',
|
||||
`access_token` varchar(64) NOT NULL COMMENT '公开访问令牌',
|
||||
`link_status` varchar(16) NOT NULL DEFAULT 'open' COMMENT 'open/closed',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建者',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标志',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_token` (`access_token`),
|
||||
KEY `idx_tenant_date` (`tenant_no`, `duty_date`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='值班日(一天一条公开链接)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `duty_slot` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID(UUID)',
|
||||
`duty_day_id` varchar(64) NOT NULL COMMENT '值班日ID',
|
||||
`shift_type` varchar(32) NOT NULL COMMENT 'morning/evening/duty',
|
||||
`capacity` int(11) NOT NULL DEFAULT 1 COMMENT '名额',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建者',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标志',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_day` (`duty_day_id`, `del_flag`),
|
||||
KEY `idx_tenant` (`tenant_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='值班班次名额';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `duty_assignment` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID(UUID)',
|
||||
`duty_slot_id` varchar(64) NOT NULL COMMENT '班次名额ID',
|
||||
`source` varchar(16) NOT NULL COMMENT 'assign/grab',
|
||||
`user_id` varchar(64) DEFAULT NULL COMMENT '系统用户ID(指定时)',
|
||||
`person_name` varchar(64) NOT NULL COMMENT '展示姓名',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建者',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标志',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_slot` (`duty_slot_id`, `del_flag`),
|
||||
KEY `idx_tenant` (`tenant_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='值班占位(指定或抢班)';
|
||||
|
||||
-- 菜单:挂在系统管理或独立一级均可;此处挂 Web 虚拟根下「值班排班」
|
||||
-- parent_id 请按环境库 Web 根菜单 id 调整(查询 parent_id=0 且面向 Web 的根)
|
||||
INSERT INTO `sys_menu` (
|
||||
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
|
||||
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
) VALUES (
|
||||
'DUTY000000000000000000000000001',
|
||||
'值班排班',
|
||||
'0',
|
||||
'C',
|
||||
'duty-schedule',
|
||||
'/duty/schedule/index',
|
||||
'duty:schedule:list',
|
||||
1, '1', '1', 'shanxia2.0', 90,
|
||||
'日历排班与公开抢班链接',
|
||||
'system', '', NOW(), NULL, NOW(), 0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name`=VALUES(`menu_name`),
|
||||
`component`=VALUES(`component`),
|
||||
`perms`=VALUES(`perms`),
|
||||
`path`=VALUES(`path`);
|
||||
```
|
||||
|
||||
执行前:查出现网 Web 菜单父级,将 `parent_id='0'` 改为正确父菜单 id(勿做成孤儿叶子;若 `0` 为虚拟根且项目允许顶栏入口,按门户习惯调整)。
|
||||
|
||||
- [ ] **Step 2: 在本地库执行迁移**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate
|
||||
# 用项目惯用方式执行 SQL,例如 mysql client 指向 .env.local
|
||||
mysql ... < scripts/migrate_20260814_duty_schedule.sql
|
||||
```
|
||||
|
||||
Expected: 三表创建成功;菜单可见(刷新权限后)。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/scripts/migrate_20260814_duty_schedule.sql
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore(duty): add duty_day/slot/assignment tables and menu SQL
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: ORM 模型
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/models/duty_models.py`
|
||||
- Modify: 若项目有 `models/__init__.py` 导出习惯则一并导出
|
||||
|
||||
- [ ] **Step 1: 创建模型(对齐 bx 公共字段风格)**
|
||||
|
||||
```python
|
||||
"""值班排班模型 — docs/superpowers/specs/2026-08-14-duty-schedule-design.md"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Date, DateTime, Integer, SmallInteger, String
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
SHIFT_MORNING = "morning"
|
||||
SHIFT_EVENING = "evening"
|
||||
SHIFT_DUTY = "duty"
|
||||
SHIFT_LABELS = {
|
||||
SHIFT_MORNING: "早班",
|
||||
SHIFT_EVENING: "晚班",
|
||||
SHIFT_DUTY: "值班",
|
||||
}
|
||||
LINK_OPEN = "open"
|
||||
LINK_CLOSED = "closed"
|
||||
SOURCE_ASSIGN = "assign"
|
||||
SOURCE_GRAB = "grab"
|
||||
|
||||
|
||||
class DutyDay(Base):
|
||||
__tablename__ = "duty_day"
|
||||
|
||||
id = Column(String(64), primary_key=True)
|
||||
duty_date = Column(Date, nullable=False)
|
||||
access_token = Column(String(64), nullable=False)
|
||||
link_status = Column(String(16), nullable=False, default=LINK_OPEN)
|
||||
tenant_no = Column(String(64), nullable=False, default="shanxia2.0")
|
||||
order_num = Column(Integer, nullable=True)
|
||||
remark = Column(String(500), nullable=True)
|
||||
create_by = Column(String(64), nullable=True)
|
||||
create_dept_id = Column(String(64), nullable=False, default="")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now)
|
||||
update_by = Column(String(64), nullable=True)
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0)
|
||||
|
||||
|
||||
class DutySlot(Base):
|
||||
__tablename__ = "duty_slot"
|
||||
|
||||
id = Column(String(64), primary_key=True)
|
||||
duty_day_id = Column(String(64), nullable=False)
|
||||
shift_type = Column(String(32), nullable=False)
|
||||
capacity = Column(Integer, nullable=False, default=1)
|
||||
tenant_no = Column(String(64), nullable=False, default="shanxia2.0")
|
||||
order_num = Column(Integer, nullable=True)
|
||||
remark = Column(String(500), nullable=True)
|
||||
create_by = Column(String(64), nullable=True)
|
||||
create_dept_id = Column(String(64), nullable=False, default="")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now)
|
||||
update_by = Column(String(64), nullable=True)
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0)
|
||||
|
||||
|
||||
class DutyAssignment(Base):
|
||||
__tablename__ = "duty_assignment"
|
||||
|
||||
id = Column(String(64), primary_key=True)
|
||||
duty_slot_id = Column(String(64), nullable=False)
|
||||
source = Column(String(16), nullable=False)
|
||||
user_id = Column(String(64), nullable=True)
|
||||
person_name = Column(String(64), nullable=False)
|
||||
tenant_no = Column(String(64), nullable=False, default="shanxia2.0")
|
||||
order_num = Column(Integer, nullable=True)
|
||||
remark = Column(String(500), nullable=True)
|
||||
create_by = Column(String(64), nullable=True)
|
||||
create_dept_id = Column(String(64), nullable=False, default="")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now)
|
||||
update_by = Column(String(64), nullable=True)
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/duty_models.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(duty): add SQLAlchemy models for duty schedule
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Schemas + 领域服务(含 TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/duty.py`
|
||||
- Create: `backend/app/services/duty_schedule.py`
|
||||
- Create: `backend/tests/test_duty_schedule.py`
|
||||
|
||||
- [ ] **Step 1: 写失败单测(纯函数/可测逻辑优先)**
|
||||
|
||||
在 `test_duty_schedule.py` 先测容量校验与姓名规范化(不依赖 DB 的部分),以及用 SQLite/fixture 测 `grab` 满员(若项目测试多用 mock Session,则测 service 内 `_assert_capacity` / `normalize_person_name`)。
|
||||
|
||||
最小必测:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from app.services.duty_schedule import normalize_person_name, SHIFT_LABELS
|
||||
from app.models.duty_models import SHIFT_MORNING
|
||||
|
||||
|
||||
def test_normalize_person_name_ok():
|
||||
assert normalize_person_name(" 张三 ") == "张三"
|
||||
|
||||
|
||||
def test_normalize_person_name_empty():
|
||||
with pytest.raises(ValueError, match="姓名"):
|
||||
normalize_person_name(" ")
|
||||
|
||||
|
||||
def test_normalize_person_name_too_long():
|
||||
with pytest.raises(ValueError, match="姓名"):
|
||||
normalize_person_name("啊" * 21)
|
||||
|
||||
|
||||
def test_shift_labels():
|
||||
assert SHIFT_LABELS[SHIFT_MORNING] == "早班"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败(函数尚未存在)**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_duty_schedule.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL import / not defined
|
||||
|
||||
- [ ] **Step 3: 实现 schemas**
|
||||
|
||||
```python
|
||||
# backend/app/schemas/duty.py
|
||||
from __future__ import annotations
|
||||
from datetime import date
|
||||
from typing import List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
ShiftType = Literal["morning", "evening", "duty"]
|
||||
LinkStatus = Literal["open", "closed"]
|
||||
SourceType = Literal["assign", "grab"]
|
||||
|
||||
|
||||
class DutyAssignmentIn(BaseModel):
|
||||
source: SourceType = "assign"
|
||||
userId: Optional[str] = None
|
||||
personName: str = ""
|
||||
|
||||
|
||||
class DutySlotSaveIn(BaseModel):
|
||||
shiftType: ShiftType
|
||||
capacity: int = Field(ge=1, le=99)
|
||||
assignments: List[DutyAssignmentIn] = []
|
||||
|
||||
|
||||
class DutyDaySaveBody(BaseModel):
|
||||
dutyDate: date
|
||||
slots: List[DutySlotSaveIn]
|
||||
linkStatus: Optional[LinkStatus] = None
|
||||
|
||||
|
||||
class DutyGrabBody(BaseModel):
|
||||
shiftType: ShiftType
|
||||
personName: str
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 实现 service 核心**
|
||||
|
||||
`duty_schedule.py` 必须提供:
|
||||
|
||||
- `normalize_person_name(name: str) -> str`
|
||||
- `new_access_token() -> str`(`secrets.token_urlsafe(24)`,截断到 ≤64)
|
||||
- `build_public_url(token: str) -> str` → `f"{settings.PUBLIC_WEB_ORIGIN.rstrip('/')}/#/public/duty/{token}"`
|
||||
- `get_month_summary(db, tenant_no, year, month) -> list`
|
||||
- `get_day_detail(db, tenant_no, duty_date|day_id) -> dict`
|
||||
- `save_day(db, tenant_no, user, body: DutyDaySaveBody) -> dict`
|
||||
- 校验:每个 slot `len(assignments) <= capacity`;停用已有报名的 shift 须先清空
|
||||
- upsert day(按 tenant+date);token 仅首次生成
|
||||
- soft-delete 去掉的 slots(无报名时)与多余 assignments
|
||||
- `set_link_status(db, tenant_no, day_id, status)`
|
||||
- `soft_delete_assignment(db, tenant_no, assignment_id)`
|
||||
- `get_public_page(db, token) -> dict`(无效 404 语义)
|
||||
- `grab(db, token, shift_type, person_name) -> dict`
|
||||
- 关链拒绝;姓名规范化;`with_for_update` 锁 slot 行或 day 行;`count < capacity` 再 insert `source=grab`
|
||||
|
||||
序列化字段用 camelCase,与前端一致:`dutyDate`, `linkStatus`, `accessToken`, `publicUrl`, `shiftType`, `shiftLabel`, `capacity`, `taken`, `assignments: [{ id, source, userId, personName }]`。
|
||||
|
||||
- [ ] **Step 5: 补充 DB 级单测(可用现有测试 DB fixture;若无 fixture,则 mock Session 测分支)**
|
||||
|
||||
至少覆盖:
|
||||
|
||||
1. `grab` 在 capacity=1 时第二次失败
|
||||
2. `link_status=closed` 时 grab 失败
|
||||
3. `save_day` 缩容小于已报名人数抛错
|
||||
|
||||
- [ ] **Step 6: 跑通测试**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_duty_schedule.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas/duty.py backend/app/services/duty_schedule.py backend/tests/test_duty_schedule.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(duty): add schemas and duty_schedule service with capacity tests
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 管理端 + 公开 API 路由注册
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/api/duty.py`
|
||||
- Create: `backend/app/api/public_duty.py`
|
||||
- Modify: `backend/app/main.py`
|
||||
- Modify: `backend/app/core/config.py`
|
||||
|
||||
- [ ] **Step 1: 管理路由** `prefix="/web/admin/duty"`
|
||||
|
||||
```python
|
||||
# 关键端点(均 Depends 现有 admin 鉴权,如 require_admin_or_super_admin / 菜单守卫)
|
||||
GET ""?year=&month= -> month summary
|
||||
GET "/day?dutyDate=YYYY-MM-DD" -> day detail(无则返回空骨架 slots=[])
|
||||
POST "/day" -> save_day body
|
||||
POST "/day/{dayId}/link" -> { linkStatus }
|
||||
DELETE "/assignments/{id}" -> soft delete
|
||||
```
|
||||
|
||||
鉴权用户取 `tenant_no`、`user_id`、`create_dept_id`(与 leave 一致)。指定用户时用现有用户查询解析姓名写入 `person_name`。
|
||||
|
||||
- [ ] **Step 2: 公开路由** `prefix="/public/duty"`
|
||||
|
||||
```python
|
||||
GET "/{token}" -> get_public_page;404 文案「链接无效或已失效」
|
||||
POST "/{token}/grab" -> DutyGrabBody;业务错误用 error_response / HTTP 400
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `main.py` 注册**
|
||||
|
||||
```python
|
||||
from app.api.duty import router as duty_router
|
||||
from app.api.public_duty import router as public_duty_router
|
||||
# ...
|
||||
app.include_router(duty_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(public_duty_router, prefix=settings.API_V1_PREFIX)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 加密排除**
|
||||
|
||||
在 `API_ENCRYPT_EXCLUDE_PATHS` 追加:
|
||||
|
||||
```
|
||||
"/api/public/duty,"
|
||||
```
|
||||
|
||||
检查 `web-admin/.env.development` 与 `.env.production` 的 `VITE_API_ENCRYPT_EXCLUDE_PATHS`,若存在则同样追加 `/api/public/duty`。
|
||||
|
||||
- [ ] **Step 5: 手工用 curl/Swagger 冒烟**
|
||||
|
||||
```bash
|
||||
# 登录后 POST /api/web/admin/duty/day 创建某日
|
||||
# GET /api/public/duty/{token}
|
||||
# POST /api/public/duty/{token}/grab {"shiftType":"evening","personName":"测试"}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/duty.py backend/app/api/public_duty.py backend/app/main.py backend/app/core/config.py web-admin/.env.development web-admin/.env.production
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(duty): register admin and public duty APIs with encrypt exclude
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: web-admin 管理端月历页
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/api/duty.ts`
|
||||
- Create: `web-admin/src/views/duty/schedule/index.vue`
|
||||
- 菜单 component 已指向 `/duty/schedule/index`(动态路由按现有菜单加载)
|
||||
|
||||
- [ ] **Step 1: API 模块**
|
||||
|
||||
```typescript
|
||||
// web-admin/src/api/duty.ts
|
||||
import request from '@/utils/http'
|
||||
|
||||
export type ShiftType = 'morning' | 'evening' | 'duty'
|
||||
|
||||
export const fetchDutyMonth = (year: number, month: number) =>
|
||||
request.get({ url: '/web/admin/duty', params: { year, month } })
|
||||
|
||||
export const fetchDutyDay = (dutyDate: string) =>
|
||||
request.get({ url: '/web/admin/duty/day', params: { dutyDate } })
|
||||
|
||||
export const saveDutyDay = (data: unknown) =>
|
||||
request.post({ url: '/web/admin/duty/day', data })
|
||||
|
||||
export const setDutyLinkStatus = (dayId: string, linkStatus: 'open' | 'closed') =>
|
||||
request.post({ url: `/web/admin/duty/day/${dayId}/link`, data: { linkStatus } })
|
||||
|
||||
export const deleteDutyAssignment = (id: string) =>
|
||||
request.del({ url: `/web/admin/duty/assignments/${id}` })
|
||||
```
|
||||
|
||||
(若项目 `request` 方法名是 `delete` 而非 `del`,与邻近 API 文件保持一致。)
|
||||
|
||||
- [ ] **Step 2: 月历页**
|
||||
|
||||
实现要求(对齐 `docs/demos/duty-schedule-v1-demo.html` + Fresh Mint):
|
||||
|
||||
- `panel-top` + `h2.title-bar-heading`「值班排班」
|
||||
- 月切换、今天
|
||||
- 7 列日历网格(可用纯 CSS/table,不必上 FullCalendar)
|
||||
- 日格展示 chip:早班/晚班/值班颜色区分(管理端可用琥珀/蓝/红浅底,勿用小程序主色当 primary 按钮)
|
||||
- 点击日期打开 `el-dialog`:`label-position="top"`,`modal-class` 无 blur,`dialog-form-layout`
|
||||
- 弹窗内三个可启用班次:`capacity` + 系统用户选择(复用现有用户远程搜索/选择组件,参考 `system/user` 或其它选人控件)+ 已安排 tags(指定/抢班)可删
|
||||
- 复制 `publicUrl`;开关链接按钮
|
||||
- 保存调用 `saveDutyDay`;缩容错误展示后端 message
|
||||
|
||||
按钮均带 `<el-icon>`。
|
||||
|
||||
- [ ] **Step 3: 本地 `pnpm dev` 打开菜单验证月历加载与保存**
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/duty.ts web-admin/src/views/duty/schedule/index.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(duty): add web-admin calendar schedule page
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 公开 H5 页
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/api/public-duty.ts`
|
||||
- Create: `web-admin/src/views/public/duty/index.vue`
|
||||
- Modify: `web-admin/src/router/routes/staticRoutes.ts`
|
||||
|
||||
- [ ] **Step 1: 静态路由**(插在其它 public 路由旁)
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: '/public/duty/:token',
|
||||
name: 'PublicDutyGrab',
|
||||
component: () => import('@views/public/duty/index.vue'),
|
||||
meta: { title: '值班抢班', isHideTab: true, isPublicMobile: true }
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 公开 API**
|
||||
|
||||
```typescript
|
||||
import { publicPlGet, publicPlPost } from '@/utils/http/public-pl'
|
||||
|
||||
export const publicDutyApi = {
|
||||
getPage: (token: string) => publicPlGet<any>(`/public/duty/${token}`),
|
||||
grab: (token: string, data: { shiftType: string; personName: string }) =>
|
||||
publicPlPost<any>(`/public/duty/${token}/grab`, data)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: H5 页面**
|
||||
|
||||
对齐 `docs/demos/duty-h5-grab-demo.html`:
|
||||
|
||||
- 三墙色:`#0a9f6d` / `#087f57` / `#e9f9f2` / `#f5f8f7`
|
||||
- 状态:加载中 / 无效 / 已关闭 / 可抢列表 / 成功摘要
|
||||
- 班次独立卡片;满员禁用;可抢输入姓名(max 20)提交
|
||||
- `onMounted` 读 `route.params.token`
|
||||
|
||||
参考 `web-admin/src/views/public/pl/transport/index.vue` 的公开页壳与错误处理。
|
||||
|
||||
- [ ] **Step 4: 端到端手测**
|
||||
|
||||
1. 管理端建日 → 复制链接 → 无痕打开 H5
|
||||
2. 抢晚班成功 → 再抢同班次满员失败
|
||||
3. 关链 → H5 显示关闭
|
||||
4. 管理端删人 → 名额释放可再抢
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/public-duty.ts web-admin/src/views/public/duty/index.vue web-admin/src/router/routes/staticRoutes.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(duty): add public H5 grab page for daily duty links
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 收尾与图谱
|
||||
|
||||
**Files:**
|
||||
- 按需更新 `docs/sql/` 备注或拷贝 migrate 说明
|
||||
- 跑 `./scripts/update-graph.sh backend` 与 `frontend`(或 `graphify update .`)
|
||||
|
||||
- [ ] **Step 1: 对照 spec 验收清单勾选**(设计文档「测试要点」7 条)
|
||||
|
||||
- [ ] **Step 2: 更新知识图谱**
|
||||
|
||||
```bash
|
||||
./scripts/update-graph.sh all
|
||||
# 或
|
||||
graphify update .
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 最终 commit(若有 docs/sql 或 graph 变更)**
|
||||
|
||||
```bash
|
||||
git add docs/sql backend/graphify-out web-admin/graphify-out graphify-out
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore(duty): sync sql notes and knowledge graph after duty schedule
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec 覆盖自检
|
||||
|
||||
| Spec 项 | Task |
|
||||
|---------|------|
|
||||
| D1 web + 公开 H5 | 5, 6 |
|
||||
| D2 一天一链接 | 1–4 save_day token |
|
||||
| D3 班次独立罗列 | 6 |
|
||||
| D4 只填姓名 | 3 grab / 6 |
|
||||
| D5 系统用户指定 | 4–5 |
|
||||
| D6/D7 员工不可取消、经理可改 | 4 delete/save/link |
|
||||
| D8 无统计页 | 5 仅日历 |
|
||||
| D9 预置班次 | models 常量 |
|
||||
| D10 按天建 | 5 点日配置 |
|
||||
| D11 同日多班次 | grab 不按人跨 slot 限制 |
|
||||
| D12 token 不重置 | save_day |
|
||||
| D13 缩容校验 | service + test |
|
||||
| D14 历史可改 | 无冻结逻辑 |
|
||||
| D15 菜单权限 | migrate menu |
|
||||
| 加密排除 | Task 4 |
|
||||
| 并发满员 | grab + test |
|
||||
|
||||
**V1 限流:** 若无现成中间件,可在 `grab` 内不做强限流,验收文档注明「后续可加」;不阻塞主路径(YAGNI)。若已有简易 IP 限流工具则挂上。
|
||||
|
||||
---
|
||||
|
||||
## 执行方式
|
||||
|
||||
Plan 已保存。可选:
|
||||
|
||||
1. **Subagent-Driven(推荐)** — 每任务新开子代理,任务间审查
|
||||
2. **Inline Execution** — 本会话按 executing-plans 连续推进并设检查点
|
||||
|
||||
请选择一种,或指出计划中要先改的部分。
|
||||
@@ -0,0 +1,205 @@
|
||||
# 值班排班(日历 + 公开链接抢班)— 设计文档
|
||||
|
||||
> **日期**: 2026-08-14
|
||||
> **状态**: 待实现
|
||||
> **仓库**: shanxia2.0(`backend/` + `web-admin/`)
|
||||
> **方案**: 方案 1 —「日排班 + 班次名额 + 公开 Token 链接」
|
||||
> **UI 原型**: `docs/demos/duty-schedule-v1-demo.html`(管理端)、`docs/demos/duty-h5-grab-demo.html`(公开 H5)
|
||||
> **权威对照**: Fresh Mint(`docs/web-admin-ui-spec-fresh-mint.md`)、公开页可参考运输计划司机免登录(`/#/public/pl/transport/{token}`)
|
||||
|
||||
## 背景
|
||||
|
||||
总经理需要按日历安排值班(早班 / 晚班 / 值班等),可设定各班次人数,并可直接指定系统用户;同时生成**按日**的公开链接,员工免登录填写姓名抢班,满员不可再抢。总经理在日历中可查看历史排班。
|
||||
|
||||
参考截图(仅视觉参考):`值班系统截图/`。第一版**不做**统计页。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
| # | 决策 |
|
||||
|---|------|
|
||||
| D1 | 管理端在 **web-admin** 排班;员工通过 **公开 H5 链接**抢班 |
|
||||
| D2 | **一天一个链接**(如「8 月 20 日值班」),每次配置某日即对应一条 token 链接 |
|
||||
| D3 | H5 上将当日多个班次**独立罗列**,不做筛选,方便直接抢 |
|
||||
| D4 | 抢班只填**姓名**;同名不拦截,靠总经理事后核对 |
|
||||
| D5 | 总经理「直接安排」从**系统用户**选择 |
|
||||
| D6 | 员工不能自己取消;总经理可删人、改名额/班次、关链接 |
|
||||
| D7 | 有人报名后总经理仍可改(不整日锁定) |
|
||||
| D8 | V1 只要**日历 + 历史排班**;统计页以后再说 |
|
||||
| D9 | 班次类型**系统预置**(早班 / 晚班 / 值班),暂不可自定义 |
|
||||
| D10 | 只能**按天**建:点日历某日 → 配置 → 生成该日链接(不做批量日期范围) |
|
||||
| D11 | 同一天同一人**可以**占多个班次(早班 + 晚班互不影响) |
|
||||
| D12 | 关链后重开仍用**原 token**(V1 不做「重置链接」) |
|
||||
| D13 | 将 `capacity` 调到小于当前报名人数时**禁止保存**,须先删人 |
|
||||
| D14 | 过往日期仍允许总经理修改(不做历史冻结) |
|
||||
| D15 | 权限按**菜单权限**控制,不单独硬编码「总经理」角色名 |
|
||||
|
||||
## 方案选择
|
||||
|
||||
**采用方案 1(已确认)**:`duty_day` + `duty_slot` + `duty_assignment`,公开 token 对齐现有免登录链接模式。
|
||||
|
||||
未采用:扁平日历事件(名额与关链难控);纯列表无月历(不符合「按日历排班/看历史」)。
|
||||
|
||||
## 架构与边界
|
||||
|
||||
| 端 | 职责 |
|
||||
|----|------|
|
||||
| **web-admin** | 月历、按日配置、指定用户、复制/关闭链接、删人改班、历史翻月 |
|
||||
| **公开页(web-admin `#/public/...`)** | token 打开当日抢班页;免登录;只填姓名提交 |
|
||||
| **backend** | 租户隔离管理 API + 公开抢班 API;名额并发控制;路径加入加密排除列表 |
|
||||
|
||||
公开页落地建议:与司机免登录一致,挂在 web-admin hash 路由,例如 `/#/public/duty/{token}`;链接 Origin 使用现有 `PUBLIC_WEB_ORIGIN`(或等价配置)拼接。
|
||||
|
||||
```
|
||||
duty_day (一天 = 一个 access_token)
|
||||
└── duty_slot × N(morning / evening / duty,各有 capacity)
|
||||
└── duty_assignment × M(source = assign | grab)
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
|
||||
表名前缀建议 `duty_`;均含门户公共字段(`id`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)。字段顺序:`id` → 业务字段 → 公共字段。软删。
|
||||
|
||||
### `duty_day`(值班日)
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `duty_date` | 值班日期;**同一 `tenant_no` 下唯一**(未删除) |
|
||||
| `access_token` | 公开访问令牌;足够长、不可猜;租户内唯一 |
|
||||
| `link_status` | `open` / `closed` |
|
||||
|
||||
### `duty_slot`(班次名额)
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `duty_day_id` | 所属值班日 |
|
||||
| `shift_type` | 预置:`morning`(早班)/ `evening`(晚班)/ `duty`(值班) |
|
||||
| `capacity` | 名额,≥ 1 |
|
||||
|
||||
约束:同一 `duty_day_id` + `shift_type` 唯一(未删除)。
|
||||
|
||||
### `duty_assignment`(占位)
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `duty_slot_id` | 所属班次 |
|
||||
| `source` | `assign`(管理端指定)/ `grab`(H5 抢班) |
|
||||
| `user_id` | 指定时必填;抢班为空 |
|
||||
| `person_name` | 展示名(用户姓名或手填姓名) |
|
||||
|
||||
满员规则:该 slot 下未删除 assignment 数 `< capacity` 才可新增。
|
||||
|
||||
SQL 变更须同步维护 `docs/sql/`(与 `database-conventions` 一致)。
|
||||
|
||||
## Web 管理端
|
||||
|
||||
### 入口与权限
|
||||
|
||||
- 新菜单「值班排班」(具体挂载位置实现时定),按钮级权限可拆:查看 / 编辑 / 关链(最小也可合并为菜单权限,能进即可改)。
|
||||
- UI:Fresh Mint;主色 `#10b981`;`label-position="top"`;弹窗无背景模糊。
|
||||
- 模式:月历主视图;**不做统计页**(D8)。
|
||||
|
||||
### 月历
|
||||
|
||||
- 月切换 +「今天」。
|
||||
- 日格摘要:班次 chip(类型 + 人名或 `已占/名额`);未配置显示未排班。
|
||||
- 点击日期 → 配置弹窗(与 Demo 一致)。
|
||||
|
||||
### 配置某日
|
||||
|
||||
- 配置预置班次及 `capacity`(可只启用部分班次)。
|
||||
- 从系统用户选择直接安排 → `source=assign`,写入 `user_id` + `person_name`;指定人数不得超过该班次剩余名额。
|
||||
- 列表展示已报名,区分指定 / 抢班;支持删除 assignment。
|
||||
- **复制链接**:`{PUBLIC_WEB_ORIGIN}/#/public/duty/{access_token}`。
|
||||
- **关闭 / 打开链接**:改 `link_status`;关链后 H5 展示关闭态;日历仍可见数据。
|
||||
- 缩容校验(D13):`capacity` 不得小于当前有效报名数。
|
||||
- 去掉某班次(不再启用):若该 slot 仍有有效报名,**禁止删除/停用**,须先清空报名。
|
||||
- 首次保存某日:创建 `duty_day` + slots,生成 `access_token`;再次保存更新 slots/安排,token 不变(D12)。
|
||||
|
||||
### 历史
|
||||
|
||||
- 翻月查看过往排班;过往日仍可编辑(D14)。
|
||||
|
||||
## 公开 H5
|
||||
|
||||
对齐原型 `docs/demos/duty-h5-grab-demo.html`;视觉用三墙 Token(`#0a9f6d`),与管理端色值不混用。
|
||||
|
||||
### 状态
|
||||
|
||||
| 状态 | 表现 |
|
||||
|------|------|
|
||||
| 可抢 | 班次卡片独立罗列;可填姓名提交;满员卡片禁用 |
|
||||
| 提交成功 | 展示日期 / 班次 / 姓名摘要 |
|
||||
| 链接关闭 | 「报名已关闭」 |
|
||||
| token 无效 | 「链接无效或已失效」 |
|
||||
|
||||
### 规则
|
||||
|
||||
- 免登录;只填姓名(D4)。
|
||||
- 不可自助取消(D6)。
|
||||
- 同天可抢多个班次(D11)。
|
||||
- 展示已报名姓名列表(便于员工感知剩余名额;同名风险已知晓)。
|
||||
|
||||
## API
|
||||
|
||||
### 管理端(登录 + 租户 + 数据权限按现有约定)
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| 月历查询 | 按年月返回每日摘要(slots + assignments 精简) |
|
||||
| 日详情 | 含链接状态、token/完整 URL、slots、assignments |
|
||||
| 保存日配置 | 创建或更新 day/slots;校验缩容;可选写入 assign |
|
||||
| 删除 assignment | 释放名额 |
|
||||
| 开关链接 | `open` / `closed` |
|
||||
|
||||
用户选择器复用现有系统用户列表接口。
|
||||
|
||||
### 公开(免登录)
|
||||
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| `GET /api/public/duty/{token}` | 当日只读:日期、状态、各 slot 与已报名姓名 |
|
||||
| `POST /api/public/duty/{token}/grab` | body: `{ shiftType, personName }`;`personName` 去首尾空格后非空且长度受限(建议 ≤20);校验开链与名额 |
|
||||
|
||||
须:
|
||||
|
||||
- 注册路由于 `app/api/__init__.py` 与 `app/main.py`。
|
||||
- 公开路径加入 `API_ENCRYPT_EXCLUDE_PATHS`(及前端等价排除,若有)。
|
||||
- 抢班在事务内检查人数后再插入,防止超卖。
|
||||
- 错误:满员 / 已关闭 / token 无效 — 明确文案。
|
||||
- V1:按 IP 或 token 做基础限流(实现时可用现有中间件或轻量计数)。
|
||||
|
||||
公开响应**不**返回其他日期或其他租户数据。
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 抢班时刚好满员 | 返回失败,H5 提示名额已满并刷新人数 |
|
||||
| 关链后提交 | 拒绝,展示关闭态 |
|
||||
| 管理端缩容过大 | 保存失败,提示先删除多余人员 |
|
||||
| 删除不存在的 assignment | 幂等或明确 404 |
|
||||
|
||||
## 测试要点(验收)
|
||||
|
||||
1. 按日配置早/晚/值班名额并复制链接,H5 可打开且班次独立展示。
|
||||
2. 指定系统用户占名额;H5 抢班占名额;满员后不可再抢。
|
||||
3. 关链后 H5 不可抢;打开后可继续抢(同 token)。
|
||||
4. 管理端删人后名额释放;缩容小于当前人数被拒。
|
||||
5. 同姓名可重复报名;同人同日可抢多个班次。
|
||||
6. 翻月可看历史排班。
|
||||
7. 租户隔离:A 租户链接不可看到 B 租户数据。
|
||||
|
||||
## 明确不做(V1)
|
||||
|
||||
- 统计页 / 按人汇总卡片
|
||||
- 批量按日期范围建排班
|
||||
- 自定义班次类型
|
||||
- H5 登录、手机号、验证码
|
||||
- 员工自助取消
|
||||
- 重置 token
|
||||
- 历史日期自动冻结
|
||||
|
||||
## 原型索引
|
||||
|
||||
- 管理端月历 + 配置:`docs/demos/duty-schedule-v1-demo.html`
|
||||
- 公开 H5 多状态:`docs/demos/duty-h5-grab-demo.html`
|
||||
@@ -0,0 +1,71 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export type ShiftType = 'morning' | 'evening' | 'duty'
|
||||
export type LinkStatus = 'open' | 'closed'
|
||||
|
||||
export interface DutyAssignmentItem {
|
||||
id?: string
|
||||
source: 'assign' | 'grab'
|
||||
userId?: string | null
|
||||
personName: string
|
||||
}
|
||||
|
||||
export interface DutySlotItem {
|
||||
id?: string
|
||||
shiftType: ShiftType
|
||||
shiftLabel?: string
|
||||
capacity: number
|
||||
taken?: number
|
||||
assignments: DutyAssignmentItem[]
|
||||
}
|
||||
|
||||
export interface DutyDayItem {
|
||||
id?: string | null
|
||||
dutyDate: string
|
||||
linkStatus: LinkStatus
|
||||
accessToken?: string | null
|
||||
publicUrl?: string | null
|
||||
slots: DutySlotItem[]
|
||||
}
|
||||
|
||||
export function fetchDutyMonth(year: number, month: number) {
|
||||
return request.get<DutyDayItem[]>({
|
||||
url: '/web/admin/duty',
|
||||
params: { year, month }
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchDutyDay(dutyDate: string) {
|
||||
return request.get<DutyDayItem>({
|
||||
url: '/web/admin/duty/day',
|
||||
params: { dutyDate }
|
||||
})
|
||||
}
|
||||
|
||||
export function saveDutyDay(data: {
|
||||
dutyDate: string
|
||||
slots: Array<{
|
||||
shiftType: ShiftType
|
||||
capacity: number
|
||||
assignments: DutyAssignmentItem[]
|
||||
}>
|
||||
linkStatus?: LinkStatus
|
||||
}) {
|
||||
return request.post<DutyDayItem>({
|
||||
url: '/web/admin/duty/day',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function setDutyLinkStatus(dayId: string, linkStatus: LinkStatus) {
|
||||
return request.post<DutyDayItem>({
|
||||
url: `/web/admin/duty/day/${dayId}/link`,
|
||||
data: { linkStatus }
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDutyAssignment(id: string) {
|
||||
return request.delete({
|
||||
url: `/web/admin/duty/assignments/${id}`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { publicPlGet, publicPlPost } from '@/utils/http/public-pl'
|
||||
import type { ShiftType } from '@/api/duty'
|
||||
|
||||
export interface PublicDutySlot {
|
||||
id?: string
|
||||
shiftType: ShiftType
|
||||
shiftLabel?: string
|
||||
capacity: number
|
||||
taken: number
|
||||
assignments: Array<{ id?: string; source: string; personName: string }>
|
||||
}
|
||||
|
||||
export interface PublicDutyPage {
|
||||
dutyDate: string
|
||||
linkStatus: 'open' | 'closed'
|
||||
closed?: boolean
|
||||
slots: PublicDutySlot[]
|
||||
}
|
||||
|
||||
export interface PublicDutyGrabResult {
|
||||
dutyDate: string
|
||||
shiftType: ShiftType
|
||||
shiftLabel: string
|
||||
personName: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export const publicDutyApi = {
|
||||
getPage: (token: string) => publicPlGet<PublicDutyPage>(`/public/duty/${token}`),
|
||||
grab: (token: string, data: { shiftType: string; personName: string }) =>
|
||||
publicPlPost<PublicDutyGrabResult>(`/public/duty/${token}/grab`, data)
|
||||
}
|
||||
@@ -87,6 +87,12 @@ export const staticRoutes: AppRouteRecordRaw[] = [
|
||||
component: () => import('@views/public/db/inventory/index.vue'),
|
||||
meta: { title: '出入库单', isHideTab: true, isPublicMobile: true }
|
||||
},
|
||||
{
|
||||
path: '/public/duty/:token',
|
||||
name: 'PublicDutyGrab',
|
||||
component: () => import('@views/public/duty/index.vue'),
|
||||
meta: { title: '值班抢班', isHideTab: true, isPublicMobile: true }
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'Exception404CatchAll',
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
<template>
|
||||
<div class="page-container duty-schedule-page">
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">值班排班</h2>
|
||||
<div class="month-nav">
|
||||
<el-button class="btn-ghost" @click="shiftMonth(-1)">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</el-button>
|
||||
<span class="month-label">{{ year }}年 {{ month }}月</span>
|
||||
<el-button class="btn-ghost" @click="shiftMonth(1)">
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" @click="goToday">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
今天
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span><i class="dot am" />早班</span>
|
||||
<span><i class="dot pm" />晚班</span>
|
||||
<span><i class="dot duty" />值班</span>
|
||||
<span class="muted">点击日期配置 / 复制链接</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="cal-wrap">
|
||||
<table class="duty-cal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="wknd">日</th>
|
||||
<th>一</th>
|
||||
<th>二</th>
|
||||
<th>三</th>
|
||||
<th>四</th>
|
||||
<th>五</th>
|
||||
<th class="wknd">六</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(week, wi) in weeks" :key="wi">
|
||||
<td
|
||||
v-for="(cell, di) in week"
|
||||
:key="di"
|
||||
:class="{
|
||||
muted: !cell.inMonth,
|
||||
today: cell.isToday,
|
||||
'has-day': cell.inMonth
|
||||
}"
|
||||
@click="cell.inMonth && openDay(cell.dateStr)"
|
||||
>
|
||||
<div class="day-num">{{ cell.day }}</div>
|
||||
<template v-if="cell.inMonth && cell.summary">
|
||||
<span
|
||||
v-for="(chip, ci) in cell.chips"
|
||||
:key="ci"
|
||||
class="chip"
|
||||
:class="chip.cls"
|
||||
>
|
||||
{{ chip.text }}
|
||||
</span>
|
||||
<span v-if="!cell.summary.slots?.length" class="chip empty">未排班 · 点击配置</span>
|
||||
</template>
|
||||
<span v-else-if="cell.inMonth" class="chip empty">未排班 · 点击配置</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
class="dialog-form-layout duty-day-dialog"
|
||||
:title="`配置 · ${editingDate}`"
|
||||
width="640px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form label-position="top" class="duty-day-form">
|
||||
<p class="form-tip">
|
||||
为当天配置班次名额,保存后生成公开链接。可直接从系统用户安排,也可留给员工抢班。
|
||||
</p>
|
||||
|
||||
<div v-for="slot in formSlots" :key="slot.shiftType" class="slot-card">
|
||||
<div class="slot-hd">
|
||||
<el-checkbox v-model="slot.enabled">{{ shiftLabel(slot.shiftType) }}</el-checkbox>
|
||||
<div class="cap-field" v-if="slot.enabled">
|
||||
<span>名额</span>
|
||||
<el-input-number v-model="slot.capacity" :min="1" :max="99" controls-position="right" />
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="slot.enabled">
|
||||
<el-form-item label="直接安排(系统用户)">
|
||||
<el-select
|
||||
v-model="slot.pendingUserId"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择员工后添加"
|
||||
style="width: 100%"
|
||||
@change="(v) => addAssign(slot, String(v || ''))"
|
||||
>
|
||||
<el-option
|
||||
v-for="u in userOptions"
|
||||
:key="u.value"
|
||||
:label="u.label"
|
||||
:value="u.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="person-tags">
|
||||
<el-tag
|
||||
v-for="(a, ai) in slot.assignments"
|
||||
:key="a.id || `${a.source}-${a.personName}-${ai}`"
|
||||
:type="a.source === 'grab' ? 'warning' : 'success'"
|
||||
closable
|
||||
size="small"
|
||||
@close="removeAssign(slot, ai)"
|
||||
>
|
||||
{{ a.personName }}({{ a.source === 'grab' ? '抢班' : '指定' }})
|
||||
</el-tag>
|
||||
<span v-if="!slot.assignments.length" class="empty-tip">暂无报名</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="dayDetail?.publicUrl" class="link-box">
|
||||
<span>公开链接</span>
|
||||
<code>{{ dayDetail.publicUrl }}</code>
|
||||
<el-button class="btn-main" type="primary" size="small" @click="copyLink">
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
复制
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="dayDetail.id"
|
||||
class="btn-ghost"
|
||||
size="small"
|
||||
@click="toggleLink"
|
||||
>
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
{{ dayDetail.linkStatus === 'closed' ? '打开链接' : '关闭链接' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="saving" @click="handleSave">
|
||||
<el-icon><Check /></el-icon>
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
Check,
|
||||
Close,
|
||||
CopyDocument,
|
||||
SwitchButton
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { fetchUserOptions } from '@/api/admin'
|
||||
import {
|
||||
deleteDutyAssignment,
|
||||
fetchDutyDay,
|
||||
fetchDutyMonth,
|
||||
saveDutyDay,
|
||||
setDutyLinkStatus,
|
||||
type DutyDayItem,
|
||||
type DutySlotItem,
|
||||
type ShiftType
|
||||
} from '@/api/duty'
|
||||
|
||||
defineOptions({ name: 'DutySchedule' })
|
||||
|
||||
const SHIFT_TYPES: ShiftType[] = ['morning', 'evening', 'duty']
|
||||
const SHIFT_LABELS: Record<ShiftType, string> = {
|
||||
morning: '早班',
|
||||
evening: '晚班',
|
||||
duty: '值班'
|
||||
}
|
||||
const SHIFT_CLS: Record<ShiftType, string> = {
|
||||
morning: 'am',
|
||||
evening: 'pm',
|
||||
duty: 'duty'
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const year = ref(now.getFullYear())
|
||||
const month = ref(now.getMonth() + 1)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const monthDays = ref<DutyDayItem[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingDate = ref('')
|
||||
const dayDetail = ref<DutyDayItem | null>(null)
|
||||
const userOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
type FormSlot = {
|
||||
shiftType: ShiftType
|
||||
enabled: boolean
|
||||
capacity: number
|
||||
pendingUserId: string
|
||||
assignments: DutySlotItem['assignments']
|
||||
}
|
||||
|
||||
const formSlots = reactive<FormSlot[]>(
|
||||
SHIFT_TYPES.map((t) => ({
|
||||
shiftType: t,
|
||||
enabled: false,
|
||||
capacity: 1,
|
||||
pendingUserId: '',
|
||||
assignments: []
|
||||
}))
|
||||
)
|
||||
|
||||
function shiftLabel(t: ShiftType) {
|
||||
return SHIFT_LABELS[t]
|
||||
}
|
||||
|
||||
function pad(n: number) {
|
||||
return n < 10 ? `0${n}` : String(n)
|
||||
}
|
||||
|
||||
function toDateStr(y: number, m: number, d: number) {
|
||||
return `${y}-${pad(m)}-${pad(d)}`
|
||||
}
|
||||
|
||||
const dayMap = computed(() => {
|
||||
const map = new Map<string, DutyDayItem>()
|
||||
for (const d of monthDays.value) {
|
||||
if (d.dutyDate) map.set(d.dutyDate, d)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const weeks = computed(() => {
|
||||
const y = year.value
|
||||
const m = month.value
|
||||
const first = new Date(y, m - 1, 1)
|
||||
const startWeekday = first.getDay()
|
||||
const daysInMonth = new Date(y, m, 0).getDate()
|
||||
const todayStr = toDateStr(now.getFullYear(), now.getMonth() + 1, now.getDate())
|
||||
const cells: Array<{
|
||||
day: number
|
||||
inMonth: boolean
|
||||
dateStr: string
|
||||
isToday: boolean
|
||||
summary?: DutyDayItem
|
||||
chips: Array<{ text: string; cls: string }>
|
||||
}> = []
|
||||
|
||||
const prevDays = new Date(y, m - 1, 0).getDate()
|
||||
for (let i = 0; i < startWeekday; i++) {
|
||||
const d = prevDays - startWeekday + i + 1
|
||||
cells.push({ day: d, inMonth: false, dateStr: '', isToday: false, chips: [] })
|
||||
}
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = toDateStr(y, m, d)
|
||||
const summary = dayMap.value.get(dateStr)
|
||||
const chips: Array<{ text: string; cls: string }> = []
|
||||
if (summary?.slots?.length) {
|
||||
for (const s of summary.slots) {
|
||||
const names = (s.assignments || []).map((a) => a.personName).filter(Boolean)
|
||||
const taken = s.taken ?? names.length
|
||||
const text = names.length
|
||||
? `${s.shiftLabel || SHIFT_LABELS[s.shiftType]} ${names.slice(0, 2).join('、')}${names.length > 2 ? '…' : ''}`
|
||||
: `${s.shiftLabel || SHIFT_LABELS[s.shiftType]} ${taken}/${s.capacity}`
|
||||
chips.push({ text, cls: SHIFT_CLS[s.shiftType] || 'am' })
|
||||
}
|
||||
}
|
||||
cells.push({
|
||||
day: d,
|
||||
inMonth: true,
|
||||
dateStr,
|
||||
isToday: dateStr === todayStr,
|
||||
summary,
|
||||
chips
|
||||
})
|
||||
}
|
||||
while (cells.length % 7 !== 0) {
|
||||
cells.push({
|
||||
day: cells.length % 7,
|
||||
inMonth: false,
|
||||
dateStr: '',
|
||||
isToday: false,
|
||||
chips: []
|
||||
})
|
||||
}
|
||||
const rows: (typeof cells)[] = []
|
||||
for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7))
|
||||
return rows
|
||||
})
|
||||
|
||||
async function loadMonth() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchDutyMonth(year.value, month.value)
|
||||
monthDays.value = Array.isArray(res) ? res : []
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载月历失败')
|
||||
monthDays.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function shiftMonth(delta: number) {
|
||||
let y = year.value
|
||||
let m = month.value + delta
|
||||
if (m < 1) {
|
||||
m = 12
|
||||
y -= 1
|
||||
} else if (m > 12) {
|
||||
m = 1
|
||||
y += 1
|
||||
}
|
||||
year.value = y
|
||||
month.value = m
|
||||
void loadMonth()
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
year.value = now.getFullYear()
|
||||
month.value = now.getMonth() + 1
|
||||
void loadMonth()
|
||||
}
|
||||
|
||||
function resetFormSlots(detail: DutyDayItem | null) {
|
||||
for (const fs of formSlots) {
|
||||
const found = detail?.slots?.find((s) => s.shiftType === fs.shiftType)
|
||||
fs.enabled = !!found
|
||||
fs.capacity = found?.capacity || 1
|
||||
fs.pendingUserId = ''
|
||||
fs.assignments = found ? [...(found.assignments || [])] : []
|
||||
}
|
||||
if (!detail?.slots?.length) {
|
||||
// 默认启用早班晚班各 1
|
||||
formSlots[0].enabled = true
|
||||
formSlots[0].capacity = 1
|
||||
formSlots[1].enabled = true
|
||||
formSlots[1].capacity = 1
|
||||
}
|
||||
}
|
||||
|
||||
async function openDay(dateStr: string) {
|
||||
editingDate.value = dateStr
|
||||
dialogVisible.value = true
|
||||
try {
|
||||
const detail = (await fetchDutyDay(dateStr)) as DutyDayItem
|
||||
dayDetail.value = detail
|
||||
resetFormSlots(detail)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载失败')
|
||||
dayDetail.value = { dutyDate: dateStr, linkStatus: 'open', slots: [] }
|
||||
resetFormSlots(null)
|
||||
}
|
||||
}
|
||||
|
||||
function addAssign(slot: FormSlot, userId: string) {
|
||||
if (!userId) return
|
||||
const u = userOptions.value.find((x) => x.value === userId)
|
||||
if (!u) return
|
||||
if (slot.assignments.some((a) => a.userId === userId || a.personName === u.label)) {
|
||||
ElMessage.warning('该员工已在名单中')
|
||||
slot.pendingUserId = ''
|
||||
return
|
||||
}
|
||||
if (slot.assignments.length >= slot.capacity) {
|
||||
ElMessage.warning('已达名额上限')
|
||||
slot.pendingUserId = ''
|
||||
return
|
||||
}
|
||||
slot.assignments.push({
|
||||
source: 'assign',
|
||||
userId,
|
||||
personName: u.label
|
||||
})
|
||||
slot.pendingUserId = ''
|
||||
}
|
||||
|
||||
async function removeAssign(slot: FormSlot, index: number) {
|
||||
const a = slot.assignments[index]
|
||||
if (a?.id) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认删除该报名?', '提示', { type: 'warning' })
|
||||
await deleteDutyAssignment(a.id)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
slot.assignments.splice(index, 1)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const slots = formSlots
|
||||
.filter((s) => s.enabled)
|
||||
.map((s) => ({
|
||||
shiftType: s.shiftType,
|
||||
capacity: s.capacity,
|
||||
assignments: s.assignments
|
||||
.filter((a) => a.source === 'assign')
|
||||
.map((a) => ({
|
||||
source: 'assign' as const,
|
||||
userId: a.userId || undefined,
|
||||
personName: a.personName
|
||||
}))
|
||||
}))
|
||||
if (!slots.length) {
|
||||
ElMessage.warning('请至少启用一个班次')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const detail = (await saveDutyDay({
|
||||
dutyDate: editingDate.value,
|
||||
slots,
|
||||
linkStatus: dayDetail.value?.linkStatus || 'open'
|
||||
})) as DutyDayItem
|
||||
dayDetail.value = detail
|
||||
ElMessage.success('保存成功')
|
||||
await loadMonth()
|
||||
resetFormSlots(detail)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
const url = dayDetail.value?.publicUrl
|
||||
if (!url) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
ElMessage.success('已复制链接')
|
||||
} catch {
|
||||
ElMessage.info(url)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLink() {
|
||||
if (!dayDetail.value?.id) return
|
||||
const next = dayDetail.value.linkStatus === 'closed' ? 'open' : 'closed'
|
||||
try {
|
||||
dayDetail.value = (await setDutyLinkStatus(dayDetail.value.id, next)) as DutyDayItem
|
||||
ElMessage.success(next === 'closed' ? '已关闭链接' : '已打开链接')
|
||||
await loadMonth()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const opts = await fetchUserOptions()
|
||||
userOptions.value = (Array.isArray(opts) ? opts : []) as Array<{
|
||||
label: string
|
||||
value: string
|
||||
}>
|
||||
} catch {
|
||||
userOptions.value = []
|
||||
}
|
||||
await loadMonth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.duty-schedule-page {
|
||||
.panel-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.month-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.month-label {
|
||||
min-width: 110px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
.legend .muted {
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dot.am {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.dot.pm {
|
||||
background: #3b82f6;
|
||||
}
|
||||
.dot.duty {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.duty-cal {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border: 1px solid var(--mint-border, #d9e8e2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.duty-cal th {
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--mint-border, #d9e8e2);
|
||||
}
|
||||
.duty-cal th.wknd {
|
||||
color: #dc2626;
|
||||
}
|
||||
.duty-cal td {
|
||||
height: 108px;
|
||||
padding: 6px;
|
||||
vertical-align: top;
|
||||
border-right: 1px solid var(--mint-border, #d9e8e2);
|
||||
border-bottom: 1px solid var(--mint-border, #d9e8e2);
|
||||
background: #fff;
|
||||
cursor: default;
|
||||
}
|
||||
.duty-cal tr td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.duty-cal tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.duty-cal td.has-day {
|
||||
cursor: pointer;
|
||||
}
|
||||
.duty-cal td.has-day:hover {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
.duty-cal td.muted {
|
||||
background: #fafafa;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.day-num {
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.duty-cal td.today .day-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #fff;
|
||||
background: var(--el-color-primary, #10b981);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.chip {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
padding: 2px 6px;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.chip.am {
|
||||
color: #b45309;
|
||||
background: #fffbeb;
|
||||
}
|
||||
.chip.pm {
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
}
|
||||
.chip.duty {
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
}
|
||||
.chip.empty {
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
background: #f1f5f9;
|
||||
border: 1px dashed #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
.duty-day-form {
|
||||
.form-tip {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
.slot-card {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border: 1px solid var(--mint-border, #d9e8e2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.slot-hd {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cap-field {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
.person-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.empty-tip {
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
}
|
||||
.link-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
background: var(--el-color-primary-light-9, #ecfdf5);
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.link-box code {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
word-break: break-all;
|
||||
color: var(--el-color-primary-dark-2, #059669);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<div class="duty-h5">
|
||||
<div v-if="loading" class="state">加载中…</div>
|
||||
<div v-else-if="errorType === 'invalid'" class="state">
|
||||
<div class="icon">?</div>
|
||||
<h2>链接无效或已失效</h2>
|
||||
<p>请确认是否复制完整,或向管理员重新索取当日值班链接。</p>
|
||||
</div>
|
||||
<div v-else-if="errorType === 'closed' || page?.linkStatus === 'closed' || page?.closed" class="state">
|
||||
<div class="icon off">⊘</div>
|
||||
<h2>报名已关闭</h2>
|
||||
<p>管理员已关闭本链接,暂时无法继续抢班。</p>
|
||||
<div v-if="page?.dutyDate" class="card">
|
||||
<div class="kv"><span>日期</span><span>{{ page.dutyDate }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="success" class="page">
|
||||
<header class="header">
|
||||
<div class="eyebrow">山峡化工 · 值班抢班</div>
|
||||
<h1>报名成功</h1>
|
||||
<p class="sub">请按时到岗。如需调整请联系管理员。</p>
|
||||
</header>
|
||||
<div class="body state-ok">
|
||||
<div class="icon ok">✓</div>
|
||||
<h2>已抢到{{ success.shiftLabel }}</h2>
|
||||
<p>{{ success.dutyDate }}</p>
|
||||
<div class="card">
|
||||
<div class="kv"><span>日期</span><span>{{ success.dutyDate }}</span></div>
|
||||
<div class="kv"><span>班次</span><span>{{ success.shiftLabel }}</span></div>
|
||||
<div class="kv"><span>姓名</span><span>{{ success.personName }}</span></div>
|
||||
</div>
|
||||
<button type="button" class="btn secondary" @click="backToList">返回查看当日班次</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="page" class="page">
|
||||
<header class="header">
|
||||
<div class="eyebrow">山峡化工 · 值班抢班</div>
|
||||
<h1>{{ formatTitle(page.dutyDate) }}</h1>
|
||||
<p class="sub">无需登录,填写姓名后选择班次提交。名额满了就不能再抢。</p>
|
||||
<div class="meta">
|
||||
<span class="pill">{{ weekdayLabel(page.dutyDate) }}</span>
|
||||
<span class="pill">链接有效</span>
|
||||
<span class="pill">剩余 {{ remainTotal }} 个名额</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="body">
|
||||
<div class="tip">提示:同名可能重复,请填写真实姓名;提交后如需改动请联系管理员。</div>
|
||||
<section v-for="slot in page.slots" :key="slot.shiftType" class="slot">
|
||||
<div class="slot-hd">
|
||||
<div class="title">
|
||||
<i class="dot" :class="slot.shiftType" />
|
||||
{{ slot.shiftLabel || slot.shiftType }}
|
||||
</div>
|
||||
<span class="badge" :class="isFull(slot) ? 'full' : 'open'">
|
||||
{{ isFull(slot) ? '已满' : '可抢' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="cap">
|
||||
<span>名额</span>
|
||||
<strong>{{ slot.taken }} / {{ slot.capacity }}</span>
|
||||
</div>
|
||||
<div class="bar"><i :style="{ width: barWidth(slot) }" :class="slot.shiftType" /></div>
|
||||
<div class="people">
|
||||
<span v-for="(a, i) in slot.assignments" :key="i" class="person">{{ a.personName }}</span>
|
||||
<span v-if="!slot.assignments?.length" class="person empty">暂无人报名</span>
|
||||
<span v-else-if="slot.capacity - slot.taken > 0" class="person empty"
|
||||
>空缺 {{ slot.capacity - slot.taken }}</span
|
||||
>
|
||||
</div>
|
||||
<template v-if="!isFull(slot)">
|
||||
<label class="field">
|
||||
<span>您的姓名</span>
|
||||
<input v-model="names[slot.shiftType]" maxlength="20" placeholder="请输入真实姓名" />
|
||||
</label>
|
||||
<button type="button" class="btn" :disabled="submitting" @click="grab(slot)">
|
||||
提交抢班
|
||||
</button>
|
||||
</template>
|
||||
<button v-else type="button" class="btn" disabled>名额已满</button>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="note">提交即表示确认当日可到岗值班<br />链接由管理员分享 · 请勿转发无关人员</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
publicDutyApi,
|
||||
type PublicDutyGrabResult,
|
||||
type PublicDutyPage,
|
||||
type PublicDutySlot
|
||||
} from '@/api/public-duty'
|
||||
|
||||
defineOptions({ name: 'PublicDutyGrab' })
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const errorType = ref<'invalid' | 'closed' | ''>('')
|
||||
const page = ref<PublicDutyPage | null>(null)
|
||||
const success = ref<PublicDutyGrabResult | null>(null)
|
||||
const names = reactive<Record<string, string>>({})
|
||||
|
||||
const token = computed(() => String(route.params.token || ''))
|
||||
|
||||
const remainTotal = computed(() => {
|
||||
if (!page.value?.slots) return 0
|
||||
return page.value.slots.reduce((sum, s) => sum + Math.max(0, s.capacity - s.taken), 0)
|
||||
})
|
||||
|
||||
function isFull(slot: PublicDutySlot) {
|
||||
return (slot.taken || 0) >= (slot.capacity || 0)
|
||||
}
|
||||
|
||||
function barWidth(slot: PublicDutySlot) {
|
||||
if (!slot.capacity) return '0%'
|
||||
return `${Math.min(100, Math.round((slot.taken / slot.capacity) * 100))}%`
|
||||
}
|
||||
|
||||
function formatTitle(dutyDate: string) {
|
||||
const d = dutyDate?.split('-')
|
||||
if (!d || d.length < 3) return '值班'
|
||||
return `${Number(d[1])}月${Number(d[2])}日 值班`
|
||||
}
|
||||
|
||||
function weekdayLabel(dutyDate: string) {
|
||||
const map = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
const dt = new Date(dutyDate.replace(/-/g, '/'))
|
||||
if (Number.isNaN(dt.getTime())) return ''
|
||||
return map[dt.getDay()]
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorType.value = ''
|
||||
success.value = null
|
||||
try {
|
||||
const data = await publicDutyApi.getPage(token.value)
|
||||
page.value = data
|
||||
if (data.linkStatus === 'closed' || data.closed) {
|
||||
errorType.value = 'closed'
|
||||
}
|
||||
for (const s of data.slots || []) {
|
||||
if (names[s.shiftType] === undefined) names[s.shiftType] = ''
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message || e || '')
|
||||
if (msg.includes('关闭')) errorType.value = 'closed'
|
||||
else errorType.value = 'invalid'
|
||||
page.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function grab(slot: PublicDutySlot) {
|
||||
const name = (names[slot.shiftType] || '').trim()
|
||||
if (!name) {
|
||||
window.alert('请先填写姓名')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await publicDutyApi.grab(token.value, {
|
||||
shiftType: slot.shiftType,
|
||||
personName: name
|
||||
})
|
||||
success.value = res
|
||||
names[slot.shiftType] = ''
|
||||
} catch (e: any) {
|
||||
window.alert(e?.message || '提交失败')
|
||||
await load()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
success.value = null
|
||||
void load()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.duty-h5 {
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
|
||||
color: #1f2a26;
|
||||
background: #f5f8f7;
|
||||
}
|
||||
.page {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
padding: 28px 18px 22px;
|
||||
color: #fff;
|
||||
background: linear-gradient(160deg, #0a9f6d 0%, #087f57 100%);
|
||||
border-radius: 0 0 22px 22px;
|
||||
}
|
||||
.eyebrow {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0.88;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sub {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.pill {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.body {
|
||||
padding: 14px 14px 28px;
|
||||
}
|
||||
.tip {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #8a6418;
|
||||
background: #fff7e8;
|
||||
border: 1px solid #f0dfb5;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.slot {
|
||||
margin-bottom: 12px;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #dcebe4;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 1px 2px rgba(31, 42, 38, 0.04);
|
||||
}
|
||||
.slot-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot.morning {
|
||||
background: #cf8a19;
|
||||
}
|
||||
.dot.evening {
|
||||
background: #3b82f6;
|
||||
}
|
||||
.dot.duty {
|
||||
background: #d14343;
|
||||
}
|
||||
.badge {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.badge.open {
|
||||
color: #087f57;
|
||||
background: #e9f9f2;
|
||||
}
|
||||
.badge.full {
|
||||
color: #6b7b74;
|
||||
background: #eef2f0;
|
||||
}
|
||||
.cap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #6b7b74;
|
||||
}
|
||||
.cap strong {
|
||||
font-size: 15px;
|
||||
color: #1f2a26;
|
||||
}
|
||||
.bar {
|
||||
height: 7px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
background: #e8f0ec;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #0a9f6d;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.bar > i.morning {
|
||||
background: #cf8a19;
|
||||
}
|
||||
.bar > i.evening {
|
||||
background: #3b82f6;
|
||||
}
|
||||
.bar > i.duty {
|
||||
background: #d14343;
|
||||
}
|
||||
.people {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.person {
|
||||
padding: 4px 9px;
|
||||
font-size: 12px;
|
||||
color: #6b7b74;
|
||||
background: #f3f7f5;
|
||||
border: 1px solid #dcebe4;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.person.empty {
|
||||
color: #8a9a93;
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
}
|
||||
.field {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.field span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: #6b7b74;
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
font-size: 15px;
|
||||
background: #fff;
|
||||
border: 1px solid #dcebe4;
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
margin-top: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
background: #0a9f6d;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
background: #b7c5bf;
|
||||
}
|
||||
.btn.secondary {
|
||||
max-width: 280px;
|
||||
color: #087f57;
|
||||
background: #fff;
|
||||
border: 1px solid #0a9f6d;
|
||||
}
|
||||
.note {
|
||||
padding: 4px 16px 24px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #8a9a93;
|
||||
text-align: center;
|
||||
}
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 70vh;
|
||||
padding: 40px 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.state-ok {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 34px;
|
||||
color: #0a9f6d;
|
||||
background: #e9f9f2;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.icon.off {
|
||||
color: #6b7b74;
|
||||
background: #eef2f0;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin-top: 22px;
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1px solid #dcebe4;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.kv span:first-child {
|
||||
color: #6b7b74;
|
||||
}
|
||||
.kv span:last-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user