Files
zkxw/docs/mockups/oa-approval-transfer-demo.html
T
zlwuandCursor 2deb0e0539 docs(oa): add transfer action design and interactive mockup
Capture product decisions for replacing the current assignee via close-old + create-new pending task, with optional comment and no app-level switch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:01:55 +08:00

690 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OA 审批 · 转交 Demo(Fresh Mint)</title>
<style>
:root {
--primary: #10b981;
--primary-dark: #059669;
--primary-bg: #ecfdf5;
--text: #0f172a;
--text-2: #334155;
--muted: #64748b;
--border: #e2e8f0;
--bg: #f1f5f9;
--card: #fff;
--warn: #d97706;
--warn-bg: #fff7ed;
--danger: #dc2626;
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font: 14px/1.5 Inter, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background: linear-gradient(180deg, #eefcf6 0%, var(--bg) 160px) fixed;
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: 24px 20px 80px;
}
.hero {
margin-bottom: 16px;
padding: 16px 18px;
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.hero h1 {
margin: 0 0 6px;
font-size: 18px;
font-weight: 700;
color: var(--primary-dark);
}
.hero p {
margin: 0;
color: var(--text-2);
font-size: 13px;
}
.hero .hint {
margin-top: 10px;
padding: 8px 12px;
background: #f8fafc;
border: 1px dashed var(--border);
border-radius: 8px;
font-size: 12px;
color: var(--muted);
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.tab {
border: 1px solid var(--border);
background: #fff;
border-radius: 8px;
padding: 8px 14px;
font: inherit;
font-size: 13px;
cursor: pointer;
color: var(--text-2);
}
.tab:hover { border-color: #a7f3d0; }
.tab.is-on {
color: var(--primary-dark);
background: var(--primary-bg);
border-color: #a7f3d0;
font-weight: 600;
}
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 20px;
margin-bottom: 16px;
}
.panel-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.title-wrap {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.btn {
border: 1px solid var(--border);
background: #fff;
border-radius: 8px;
padding: 7px 14px;
cursor: pointer;
font: inherit;
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text);
height: 36px;
}
.btn:hover { border-color: #a7f3d0; background: #f8fafc; }
.btn.primary {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.btn.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
.btn.transfer {
border-color: #93c5fd;
color: #1d4ed8;
background: #eff6ff;
}
.btn.transfer:hover { background: #dbeafe; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
h2.title {
margin: 0;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
h2.title::before {
content: "";
width: 3px;
height: 16px;
background: var(--primary);
border-radius: 2px;
}
.tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
background: var(--warn-bg);
color: var(--warn);
}
.tag.ok { background: var(--primary-bg); color: var(--primary-dark); }
.tag.info { background: #f1f5f9; color: var(--muted); }
.tag.xfer { background: #eff6ff; color: #1d4ed8; }
.section { margin-bottom: 20px; }
.section h3 {
margin: 0 0 10px;
font-size: 14px;
font-weight: 600;
color: var(--text-2);
}
.form-view {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
padding: 12px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid var(--border);
}
.field label {
display: block;
font-size: 11px;
color: var(--muted);
margin-bottom: 4px;
}
.field .val {
font-size: 14px;
color: var(--text);
min-height: 22px;
}
.field.span2 { grid-column: 1 / -1; }
@media (max-width: 720px) {
.form-view { grid-template-columns: 1fr; }
}
/* progress chain */
.chain { display: flex; flex-direction: column; gap: 0; }
.chain-item {
display: grid;
grid-template-columns: 28px 1fr;
gap: 12px;
position: relative;
padding-bottom: 18px;
}
.chain-item:last-child { padding-bottom: 0; }
.dot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
z-index: 1;
background: var(--primary-bg);
color: var(--primary-dark);
border: 2px solid #a7f3d0;
}
.dot.pending { background: var(--warn-bg); color: var(--warn); border-color: #fdba74; }
.dot.xfer { background: #eff6ff; color: #1d4ed8; border-color: #93c5fd; }
.dot.done { background: var(--primary-bg); color: var(--primary-dark); }
.dot.start { background: #f1f5f9; color: var(--muted); border-color: var(--border); }
.chain-item::before {
content: "";
position: absolute;
left: 13px;
top: 28px;
bottom: 0;
width: 2px;
background: var(--border);
}
.chain-item:last-child::before { display: none; }
.card-body { min-width: 0; }
.card-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.card-meta { font-size: 12px; color: var(--muted); }
.card-lines {
margin-top: 6px;
font-size: 13px;
color: var(--text-2);
line-height: 1.55;
}
.card-lines .comment {
margin-top: 4px;
padding: 6px 8px;
background: #f8fafc;
border-radius: 6px;
color: var(--muted);
font-size: 12px;
}
/* modal */
.mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, .45);
display: none;
align-items: center;
justify-content: center;
z-index: 50;
padding: 16px;
backdrop-filter: none;
}
.mask.open { display: flex; }
.dialog {
width: min(520px, 100%);
background: #fff;
border-radius: var(--radius);
border: 1px solid var(--border);
max-height: calc(100vh - 100px);
display: flex;
flex-direction: column;
box-shadow: 0 20px 40px rgba(15, 23, 42, .12);
}
.dialog-hd {
padding: 14px 18px;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 15px;
}
.dialog-bd {
padding: 16px 18px;
overflow: auto;
}
.dialog-ft {
padding: 12px 18px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 8px;
}
.form-item { margin-bottom: 14px; }
.form-item:last-child { margin-bottom: 0; }
.form-item > label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.form-item .req { color: var(--danger); margin-left: 2px; }
select, textarea, input[type="text"] {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
font: inherit;
height: 36px;
background: #fff;
}
textarea { height: auto; min-height: 72px; resize: vertical; }
select:focus, textarea:focus, input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, .15);
}
.note {
font-size: 12px;
color: var(--muted);
margin-top: 6px;
}
.toast {
position: fixed;
top: 24px;
left: 50%;
transform: translateX(-50%) translateY(-12px);
background: #0f172a;
color: #fff;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
opacity: 0;
pointer-events: none;
transition: .2s ease;
z-index: 60;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.compare {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 800px) {
.compare { grid-template-columns: 1fr; }
}
.mini-card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
background: #f8fafc;
}
.mini-card h4 {
margin: 0 0 8px;
font-size: 13px;
color: var(--primary-dark);
}
.mini-card ul {
margin: 0;
padding-left: 18px;
font-size: 12px;
color: var(--text-2);
line-height: 1.7;
}
.highlight-btn {
outline: 2px solid #3b82f6;
outline-offset: 2px;
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, .35); }
50% { box-shadow: 0 0 0 6px rgba(59, 130, 246, 0); }
}
.view { display: none; }
.view.is-on { display: block; }
</style>
</head>
<body>
<div class="page">
<div class="hero">
<h1>OA 审批 ·【转交】交互 Demo</h1>
<p>在现有「同意 / 拒绝 / 打回 / 加签」旁增加「转交」。本页演示审批详情顶栏、转交弹窗与转交后进度展示。</p>
<div class="hint">
已定语义:转交 = 将<strong>本人当前待办</strong>交给另一人处理;转交后本人不再持有该待办;进度记录「已转交」。
与加签区别:加签是<strong>新增</strong>审批人,本人待办仍在。说明可选;无应用开关;可选租户内用户(排除本人与本节点已有待办)。
</div>
</div>
<div class="tabs" role="tablist">
<button class="tab is-on" data-tab="detail" type="button">1. 详情顶栏(含转交)</button>
<button class="tab" data-tab="dialog" type="button">2. 转交弹窗</button>
<button class="tab" data-tab="after" type="button">3. 转交后进度</button>
<button class="tab" data-tab="diff" type="button">4. 与加签对比</button>
</div>
<!-- View 1: detail -->
<div class="view is-on" id="view-detail">
<section class="panel">
<div class="panel-top">
<div class="title-wrap">
<button class="btn" type="button" disabled>← 返回</button>
<h2 class="title">差旅报销 · 张三 · 2026-07-23</h2>
<span class="tag">审批中</span>
</div>
<div class="actions">
<button class="btn primary" type="button">✓ 同意</button>
<button class="btn" type="button">✕ 拒绝</button>
<button class="btn" type="button">↺ 打回</button>
<button class="btn" type="button">+ 加签</button>
<button class="btn transfer highlight-btn" type="button" id="btn-open-transfer">↗ 转交</button>
</div>
</div>
<div class="section">
<h3>表单内容</h3>
<div class="form-view">
<div class="field">
<label>申请人</label>
<div class="val">张三</div>
</div>
<div class="field">
<label>部门</label>
<div class="val">研发中心 / 平台组</div>
</div>
<div class="field">
<label>报销金额</label>
<div class="val">¥ 2,860.00</div>
</div>
<div class="field">
<label>出差城市</label>
<div class="val">合肥</div>
</div>
<div class="field span2">
<label>事由</label>
<div class="val">客户现场实施与验收</div>
</div>
</div>
</div>
<div class="section">
<h3>审批进度</h3>
<div class="chain" id="chain-before">
<div class="chain-item">
<div class="dot start">起</div>
<div class="card-body">
<div class="card-title">提交 <span class="tag ok">已提交</span></div>
<div class="card-meta">张三 · 07-23 09:12</div>
</div>
</div>
<div class="chain-item">
<div class="dot pending">审</div>
<div class="card-body">
<div class="card-title">部门负责人 <span class="tag">审批中</span></div>
<div class="card-meta">当前处理人:李四</div>
<div class="card-lines">李四 · 待处理</div>
</div>
</div>
<div class="chain-item">
<div class="dot start">审</div>
<div class="card-body">
<div class="card-title">财务复核 <span class="tag info">未到达</span></div>
<div class="card-meta">王五</div>
</div>
</div>
</div>
</div>
</section>
</div>
<!-- View 2: dialog focus -->
<div class="view" id="view-dialog">
<section class="panel">
<div class="panel-top">
<div class="title-wrap">
<h2 class="title">转交弹窗示意</h2>
<span class="tag xfer">交互稿</span>
</div>
<div class="actions">
<button class="btn transfer" type="button" id="btn-open-transfer-2">打开转交弹窗</button>
</div>
</div>
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;">
字段对齐现有「加签」弹窗:选择用户 + 说明。差异点:转交人为<strong>单选</strong>(一人接手),说明<strong>可选</strong>。
</p>
<div class="compare">
<div class="mini-card">
<h4>字段</h4>
<ul>
<li>转交给(必填,单选用户)</li>
<li>转交说明(可选)</li>
<li>不可选:自己、已在本节点待办中的人</li>
</ul>
</div>
<div class="mini-card">
<h4>提交后效果</h4>
<ul>
<li>本人待办关闭为「已转交」</li>
<li>目标人新增同节点 pending 待办</li>
<li>进度展示「李四 已转交 …」+「赵六 待处理」</li>
<li>无应用开关;全应用可转交;允许再转交</li>
</ul>
</div>
</div>
</section>
</div>
<!-- View 3: after transfer -->
<div class="view" id="view-after">
<section class="panel">
<div class="panel-top">
<div class="title-wrap">
<button class="btn" type="button" disabled>← 返回</button>
<h2 class="title">差旅报销 · 张三 · 2026-07-23</h2>
<span class="tag">审批中</span>
</div>
<div class="actions">
<span class="note" style="align-self:center;">李四已转交,本人操作区消失(非当前处理人)</span>
</div>
</div>
<div class="section">
<h3>审批进度(转交后)</h3>
<div class="chain">
<div class="chain-item">
<div class="dot start">起</div>
<div class="card-body">
<div class="card-title">提交 <span class="tag ok">已提交</span></div>
<div class="card-meta">张三 · 07-23 09:12</div>
</div>
</div>
<div class="chain-item">
<div class="dot pending">审</div>
<div class="card-body">
<div class="card-title">部门负责人 <span class="tag">审批中</span></div>
<div class="card-meta">当前处理人:赵六</div>
<div class="card-lines">
李四 · 已转交 · 07-23 10:05
<div class="comment">转交说明:出差中,请赵六代为审批</div>
赵六 · 待处理
</div>
</div>
</div>
<div class="chain-item">
<div class="dot start">审</div>
<div class="card-body">
<div class="card-title">财务复核 <span class="tag info">未到达</span></div>
<div class="card-meta">王五</div>
</div>
</div>
</div>
</div>
</section>
</div>
<!-- View 4: diff -->
<div class="view" id="view-diff">
<section class="panel">
<div class="panel-top">
<div class="title-wrap">
<h2 class="title">转交 vs 加签</h2>
</div>
</div>
<div class="compare">
<div class="mini-card">
<h4>转交</h4>
<ul>
<li>替换处理人:A → B</li>
<li>A 待办结束(已转交)</li>
<li>通常单选一人</li>
<li>不改变节点人数语义(仍是「谁来批」)</li>
<li>适合:请假/出差临时交接</li>
</ul>
</div>
<div class="mini-card">
<h4>加签</h4>
<ul>
<li>追加处理人:A + B(+C…)</li>
<li>A 待办仍在</li>
<li>可多选</li>
<li>影响或签/会签收口</li>
<li>适合:需要他人共同意见</li>
</ul>
</div>
</div>
</section>
</div>
</div>
<!-- Transfer dialog -->
<div class="mask" id="transfer-mask" aria-hidden="true">
<div class="dialog" role="dialog" aria-labelledby="xfer-title">
<div class="dialog-hd" id="xfer-title">转交</div>
<div class="dialog-bd">
<div class="form-item">
<label>转交给<span class="req">*</span></label>
<select id="xfer-user">
<option value="">请选择用户</option>
<option value="u6">赵六(zhaoliu)</option>
<option value="u7">钱七(qianqi)</option>
<option value="u8">孙八(sunba)</option>
</select>
<div class="note">不可选本人,以及本节点已有待办的用户。</div>
</div>
<div class="form-item">
<label>转交说明(可选)</label>
<textarea id="xfer-comment" placeholder="可填写转交原因,便于追溯"></textarea>
</div>
</div>
<div class="dialog-ft">
<button class="btn" type="button" id="btn-cancel-xfer">✕ 取消</button>
<button class="btn primary" type="button" id="btn-confirm-xfer">✓ 确定转交</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
(function () {
var tabs = document.querySelectorAll('.tab')
var views = {
detail: document.getElementById('view-detail'),
dialog: document.getElementById('view-dialog'),
after: document.getElementById('view-after'),
diff: document.getElementById('view-diff')
}
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
tabs.forEach(function (t) { t.classList.remove('is-on') })
tab.classList.add('is-on')
Object.keys(views).forEach(function (k) {
views[k].classList.toggle('is-on', k === tab.dataset.tab)
})
})
})
var mask = document.getElementById('transfer-mask')
var toast = document.getElementById('toast')
function openMask() {
mask.classList.add('open')
mask.setAttribute('aria-hidden', 'false')
}
function closeMask() {
mask.classList.remove('open')
mask.setAttribute('aria-hidden', 'true')
}
function showToast(msg) {
toast.textContent = msg
toast.classList.add('show')
setTimeout(function () { toast.classList.remove('show') }, 1800)
}
document.getElementById('btn-open-transfer').addEventListener('click', openMask)
document.getElementById('btn-open-transfer-2').addEventListener('click', openMask)
document.getElementById('btn-cancel-xfer').addEventListener('click', closeMask)
mask.addEventListener('click', function (e) {
if (e.target === mask) closeMask()
})
document.getElementById('btn-confirm-xfer').addEventListener('click', function () {
var uid = document.getElementById('xfer-user').value
var comment = (document.getElementById('xfer-comment').value || '').trim()
if (!uid) {
showToast('请选择转交人')
return
}
closeMask()
showToast(comment ? '已转交' : '已转交(无说明)')
// jump to after view
tabs.forEach(function (t) {
t.classList.toggle('is-on', t.dataset.tab === 'after')
})
Object.keys(views).forEach(function (k) {
views[k].classList.toggle('is-on', k === 'after')
})
})
})()
</script>
</body>
</html>