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>
This commit is contained in:
zlwuandCursor committed 2026-07-23 15:01:55 +08:00
1 parent ea4ee3e573
commit 2deb0e0539
2 files changed
+897

No files matched your search

+689
View File
@@ -0,0 +1,689 @@
<!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>
@@ -0,0 +1,208 @@
# OA 审批 · 转交(Transfer)设计
| 项 | 内容 |
|----|------|
| 状态 | 已定稿(待实现) |
| 日期 | 2026-07-23 |
| 范围 | 审批待办「转交」:替换处理人;引擎 / API / 详情 UI / 进度文案 / 已办列表 |
| 交互 Demo | `docs/mockups/oa-approval-transfer-demo.html` |
| 关联 | 加签 `add_sign`;进度人员行 `2026-07-23-oa-or-and-sign-display-design.md`;打回终态 `2026-07-15-oa-return-copy-new-instance-design.md` |
---
## 1. 背景
当前 OA 审批详情在待办处理人视角提供:**同意 / 拒绝 / 打回 / 加签**。缺少「把本人待办交给他人处理」的能力;请假、出差等场景只能依赖加签(本人待办仍在)或线下协调。
产品确认增加 **转交**,语义对齐常见 OA「替换处理人」,而非代理委托。
---
## 2. 目标与非目标
**目标**
- 当前待办处理人可将**本人这一份** pending 审批任务转交给租户内另一有效用户。
- 转交后:原处理人待办结束(`transferred`),目标人新增同节点 pending;实例仍 `running`,节点不自动推进。
- 进度可追溯(含连续转交 A→B→C);「已处理」列表含转交记录。
- 全应用可用,**不**增加设计器开关。
**非目标**
- 设计器 `allowTransfer` 开关。
- 代理/委托(责任仍算原审批人)。
- 一次转交多人、转交并同意。
- 按部门或节点候选池收窄选人。
- 小程序端、钉钉式独立「转交墙」UI。
- 改变或签/会签收口规则(仅替换本人任务,`sign_mode` 不变)。
---
## 3. 产品决策(已确认)
| 项 | 决定 |
|----|------|
| 语义 | **替换处理人**:A 待办 → `transferred`;B 新建 pending |
| 转交说明 | **可选**(对齐加签说明) |
| 应用开关 | **无**;所有应用一律可转交 |
| 选人范围 | 租户内有效用户;排除本人;排除本节点已有 `pending` 的用户 |
| 再转交 | **允许**连续转交 |
| 实现路径 | **关旧待办 + 建新待办**(不用原地改 `assignee_user_id`) |
**与加签区别**
| | 转交 | 加签 |
|--|------|------|
| 本人待办 | 结束 | 仍在 |
| 选人 | 单选 | 可多选 |
| 节点人数 | 替换一人 | 追加一人或多 |
---
## 4. 引擎行为(SHALL)
新增 `transfer_task(db, task_id, *, tenant_no, user_id, target_user_id, comment=None)`,对齐 `add_sign` / `return_task` 的权限与校验风格。
### 4.1 前置条件
1. 任务存在、租户匹配、`del_flag=0`。
2. `assignee_user_id == user_id`(本人待办)。
3. `task_type == "approval"` 且 `status == "pending"`。
4. 实例 `status == "running"`。
5. `target_user_id` 非空、≠ `user_id`。
6. 目标用户:同租户、`del_flag=0`、`status=="1"`。
7. 目标用户**不在**同实例同 `node_key` 的 `pending` 任务 assignee 集合中。
不满足时抛 `OaEngineError`,文案明确(无权 / 仅待处理可转交 / 用户无效 / 已在待办中等)。
### 4.2 写入步骤
1. 当前任务:
- `status = "transferred"`
- `comment_text` = 说明 trim 后文本,可空
- `update_by` / `update_time` 更新
2. 同节点新建 `OaTask`:
- `node_key` / `node_name` / `sign_mode` / `round_no` 继承原任务(`round_no` 与实例 `current_round` 对齐,同加签)
- `assignee_user_id = target_user_id`
- `status = "pending"`
- `resolve_source = "transfer"`
- `remark` 可存说明快照(与加签一致,可选)
- 公共字段按现有任务创建约定填写
3. `_add_instance_event`:`event_type="transfer"`,`actor_user_id=user_id`,`task_id`/`node_key` 指向原任务;`comment_text` 可用用户填写的说明,或在说明为空时写入简短审计句(如「转交给 {targetUserId}」)。现有事件表无独立 payload 字段;**进度展示以 `oa_task` 行为权威**,事件仅审计。
4. `db.commit()`;返回 `get_instance_detail`。
### 4.3 节点聚合与列表
- `_aggregate_node_status`:在现有优先级(`rejected` / `returned` > `pending` > `approved` …)上,将 `transferred` 与 `approved` 同等对待——**不**因存在 `transferred` 把节点标为终态;同节点仍有 `pending` 时节点为审批中。
- 「已处理」任务查询:在现有 `approved` / `rejected` / `returned` 基础上 **SHALL 包含** `transferred`。
- 或签一人通过取消同节点其余 pending 时:行为不变;已 `transferred` 的历史行不被改写为 `done`(与「本人已同意不被覆盖」同一原则:只动仍为 `pending` 的任务)。
### 4.4 连续转交
B 持有 pending 后可再次转交 C:重复 4.1–4.2。进度同节点下按任务时间顺序展示多行(已转交 / 待处理)。
---
## 5. API(SHALL)
`POST /api/admin/oa/tasks/{task_id}/transfer`
- 鉴权:与现有 approve / add-sign 相同(`require_admin_or_super_admin` + 租户)。
- Body(Pydantic):
- `targetUserId: str`(必填)
- `comment: Optional[str] = None`
- 成功:`success_response(instance_detail, message="已转交")`
- 失败:`error_response(400, str(e))`
前端:`web-admin/src/api/oaApproval.ts` 增加 `fetchTransferOaTask(taskId, { targetUserId, comment? })`。
---
## 6. 前端 UI(SHALL)
权威交互:`docs/mockups/oa-approval-transfer-demo.html`(Fresh Mint)。
### 6.1 详情顶栏
- 条件:`canApprove`(存在本人 pending 审批任务)。
- 按钮顺序建议:同意 → 拒绝 → 打回(若允许)→ 加签(若允许)→ **转交**。
- **不**依赖 `allowTransfer` / `allowAddSign`;转交始终在 `canApprove` 时展示。
- 图标:Leading `el-icon`(如 `Position` / `Right` 一类,与现有按钮规范一致)。
### 6.2 转交弹窗
- `el-dialog`:`dialog-form-layout`、`label-position="top"`、无 backdrop blur、`modal-class` 与现有协议/加签弹窗一致。
- 字段:
- 转交给:单选用户(`el-select` filterable);选项来源同加签用户列表;前端可再过滤本人与本节点 pending 处理人(后端仍须二次校验)。
- 转交说明:可选 textarea。
- 底栏:取消 / 确定转交(Close / Check 图标)。
### 6.3 成功后
刷新实例详情;原用户无 pending → 操作区消失;进度见 §7。
---
## 7. 进度与文案(SHALL)
- 任务状态文案:`transferred` → **已转交**(详情 status 映射、进度人员行、打印同源)。
- 人员行格式对齐 `formatProgressPersonLines`:
- 无说明:`{姓名} 已转交 {时间}`
- 有说明:`{姓名} {说明} {时间}`(与同意/拒绝「有 comment 优先用 comment」一致)
- Tone:建议 `mute` 或独立中性(勿用 danger);`cancelled` 仍为 danger,避免与转交混淆。
- `ACTED_STATUS`(或等价集合)SHALL 包含 `transferred`,以便走「已处理」拼行分支而非「待处理」。
- 当前处理人展示:取同节点仍为 `pending` 的 assignee;转交后更新为新人。
---
## 8. 测试(SHALL)
`backend/tests/test_oa_engine.py`(风格对齐加签用例):
1. 成功转交:原任务 `transferred`,新任务 pending + `resolve_source=transfer`。
2. 说明可空。
3. 不可转给自己;不可转给本节点已有 pending 用户;无效用户报错。
4. 非 pending / 非本人 / 实例非 running 报错。
5. 会签:同节点另一人 pending 不受影响。
6. 连续转交两次成功。
7. 已办查询含 `transferred`(若有直接测 list kind=done 的辅助逻辑则一并覆盖)。
前端:至少手工走通 Demo 与详情页;若已有进度 formatter 单测,补 `transferred` 一行用例。
---
## 9. 改动面清单
| 层 | 路径 |
|----|------|
| 引擎 | `backend/app/services/oa_engine.py` |
| API / schema | `backend/app/api/oa_approval.py`(及现有 Oa body schema 文件) |
| 测试 | `backend/tests/test_oa_engine.py` |
| API 客户端 | `web-admin/src/api/oaApproval.ts` |
| 详情 | `web-admin/src/views/oa-approval/instances/detail.vue` |
| 进度文案 | `web-admin/src/utils/oa-progress-person-lines.ts` |
| 打印(若共用状态映射) | `web-admin/src/utils/oa-approval-print.ts` 等按需 |
| Demo | `docs/mockups/oa-approval-transfer-demo.html` |
无需新表;`oa_task.status` 增加取值 `transferred`(字符串枚举,无 DDL 强制时以约定为准)。
---
## 10. 风险与缓解
| 风险 | 缓解 |
|------|------|
| 已办列表漏 `transferred` | 与打回修订同一模式,查询显式纳入 |
| 与 `cancelled`/`done` 混淆 | 独立状态 + 进度文案「已转交」 |
| 聚合把仅含 transferred 的节点判错 | 有 pending 优先;测会签/或签样例 |
| 加签按钮旁误点 | Demo 与文案区分;实现后 UI 对照 Demo |
---
## 11. 验收标准
1. 待办人可打开转交弹窗,选人后成功;说明可空。
2. 进度显示原人已转交、新人待处理;当前处理人更新。
3. 原人「已处理」可见该单;新人「待处理」可见。
4. 会签只换自己那份;连续转交可用。
5. 转给自己 / 重复 pending 用户 / 非待办态均失败且有提示。