Sfoglia il codice sorgente

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>
zlwu 3 mesi fa
parent
commit
2deb0e0539

+ 689 - 0
docs/mockups/oa-approval-transfer-demo.html

@@ -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>

+ 208 - 0
docs/superpowers/specs/2026-07-23-oa-approval-transfer-design.md

@@ -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 用户 / 非待办态均失败且有提示。