Просмотр исходного кода

docs: add OA start history design, plan, and UI demo

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 месяцев назад
Родитель
Сommit
cbfe592e47

+ 555 - 0
docs/demos/oa-start-history-demo.html

@@ -0,0 +1,555 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>OA 发起审批 · 历史记录 Demo</title>
+  <style>
+    :root {
+      --primary: #10b981;
+      --primary-dark: #059669;
+      --primary-bg: #ecfdf5;
+      --text: #334155;
+      --text-strong: #0f172a;
+      --text-muted: #94a3b8;
+      --border: #cbd5e1;
+      --bg: #f8fafc;
+      --danger: #ef4444;
+      --warning: #d97706;
+      --info: #64748b;
+    }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
+      font-size: 14px;
+      line-height: 1.5;
+      color: var(--text);
+      background: var(--bg);
+    }
+    .page { max-width: 1100px; margin: 0 auto; padding: 24px; }
+    .note {
+      margin-bottom: 16px;
+      padding: 12px 16px;
+      background: #fff;
+      border: 1px solid var(--border);
+      border-radius: 10px;
+      color: var(--text-muted);
+      font-size: 13px;
+    }
+    .note strong { color: var(--text-strong); }
+    .search-wrap { display: flex; justify-content: center; margin: 8px 0 20px; }
+    .search {
+      width: min(520px, 100%);
+      height: 42px;
+      padding: 0 16px 0 40px;
+      border: 1px solid var(--border);
+      border-radius: 999px;
+      background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%2394a3b8' viewBox='0 0 16 16'%3E%3Cpath d='M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85zm-5.242.656a5 5 0 1 1 0-10 5 5 0 0 1 0 10z'/%3E%3C/svg%3E") 14px center no-repeat;
+      box-shadow: 0 1px 2px rgb(15 23 42 / 6%);
+      outline: none;
+    }
+    .tabs { display: flex; gap: 28px; margin-bottom: 8px; }
+    .tab {
+      padding: 0 0 12px;
+      border: 0;
+      background: none;
+      font-size: 16px;
+      font-weight: 600;
+      color: var(--text);
+      cursor: pointer;
+    }
+    .tab.active {
+      color: var(--text-strong);
+      box-shadow: inset 0 -3px 0 var(--text-strong);
+    }
+    .hint { color: var(--text-muted); font-size: 13px; margin: 8px 0 20px; }
+    .section-head {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      margin-bottom: 14px;
+    }
+    .section-head h3 { margin: 0; font-size: 14px; font-weight: 600; }
+    .dot { width: 6px; height: 6px; border-radius: 50%; background: #f87171; }
+    .meta { margin-left: auto; color: var(--text-muted); font-size: 12px; }
+    .card-row { display: flex; flex-wrap: wrap; gap: 16px; }
+    .card {
+      display: flex;
+      flex-direction: column;
+      gap: 12px;
+      width: 280px;
+      min-height: 100px;
+      padding: 14px 16px 10px;
+      background: #fff;
+      border: 1px solid var(--border);
+      border-radius: 14px;
+      box-shadow: 0 2px 8px rgb(15 23 42 / 8%);
+      transition: border-color .15s, box-shadow .15s, transform .15s;
+    }
+    .card:hover {
+      border-color: #86efac;
+      box-shadow: 0 4px 14px rgb(16 185 129 / 16%);
+      transform: translateY(-1px);
+    }
+    .card-main { display: flex; gap: 14px; align-items: center; }
+    .icon {
+      width: 40px; height: 40px; border-radius: 10px;
+      background: linear-gradient(135deg, #34d399, #059669);
+      color: #fff; display: grid; place-items: center; font-weight: 700; font-size: 14px;
+      flex-shrink: 0;
+    }
+    .icon.blue { background: linear-gradient(135deg, #60a5fa, #2563eb); }
+    .icon.amber { background: linear-gradient(135deg, #fbbf24, #d97706); }
+    .name { display: block; font-weight: 600; color: var(--text-strong); }
+    .desc { display: block; margin-top: 4px; font-size: 12px; color: var(--text-muted); }
+    .ops {
+      display: flex;
+      gap: 6px;
+      justify-content: flex-end;
+      padding-top: 8px;
+      border-top: 1px solid #f1f5f9;
+    }
+    .btn {
+      height: 26px;
+      padding: 0 10px;
+      border-radius: 5px;
+      border: 1px solid var(--border);
+      background: #fff;
+      color: var(--text);
+      font-size: 11px;
+      cursor: pointer;
+      display: inline-flex;
+      align-items: center;
+      gap: 3px;
+    }
+    .btn:hover { border-color: #86efac; color: var(--primary-dark); }
+    .btn.primary {
+      background: var(--primary);
+      border-color: var(--primary);
+      color: #fff;
+    }
+    .btn.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); color: #fff; }
+    .btn.ghost-perm {
+      border-style: dashed;
+      color: var(--text-muted);
+    }
+    .legend {
+      margin-top: 28px;
+      padding: 14px 16px;
+      background: #fff;
+      border: 1px dashed #86efac;
+      border-radius: 10px;
+      font-size: 13px;
+    }
+    .legend code {
+      padding: 1px 6px;
+      background: var(--primary-bg);
+      border-radius: 4px;
+      color: var(--primary-dark);
+      font-size: 12px;
+    }
+
+    /* Dialog */
+    .mask {
+      position: fixed; inset: 0;
+      background: rgb(15 23 42 / 45%);
+      display: none;
+      align-items: flex-start;
+      justify-content: center;
+      padding: 40px 16px 24px;
+      z-index: 50;
+      overflow: auto;
+    }
+    .mask.open { display: flex; }
+    .dialog {
+      width: min(1080px, 100%);
+      max-height: calc(100vh - 80px);
+      background: #fff;
+      border-radius: 12px;
+      box-shadow: 0 20px 50px rgb(15 23 42 / 25%);
+      display: flex;
+      flex-direction: column;
+      overflow: hidden;
+    }
+    .dialog-h {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 16px 20px;
+      border-bottom: 1px solid #e2e8f0;
+    }
+    .dialog-h h2 {
+      margin: 0;
+      font-size: 16px;
+      color: var(--text-strong);
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+    .dialog-h h2::before {
+      content: "";
+      width: 3px; height: 16px;
+      background: var(--primary);
+      border-radius: 2px;
+    }
+    .close {
+      width: 28px; height: 28px; border: 0; background: transparent;
+      font-size: 18px; color: var(--text-muted); cursor: pointer; border-radius: 6px;
+    }
+    .close:hover { background: #f1f5f9; }
+    .dialog-b { padding: 16px 20px; overflow: auto; flex: 1; }
+    .filter {
+      padding: 14px 16px;
+      background: #f8fafc;
+      border: 1px solid #e2e8f0;
+      border-radius: 10px;
+      margin-bottom: 14px;
+    }
+    .filter-grid {
+      display: grid;
+      grid-template-columns: repeat(3, 1fr);
+      gap: 12px 16px;
+    }
+    .field label {
+      display: block;
+      font-size: 11px;
+      color: var(--text-muted);
+      margin-bottom: 4px;
+    }
+    .field input, .field select {
+      width: 100%;
+      height: 36px;
+      padding: 0 10px;
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      background: #fff;
+      font-size: 13px;
+      color: var(--text);
+    }
+    .filter-actions {
+      display: flex;
+      justify-content: flex-end;
+      gap: 8px;
+      margin-top: 12px;
+    }
+    .btn-md {
+      height: 36px;
+      padding: 0 14px;
+      border-radius: 8px;
+      font-size: 13px;
+      border: 1px solid var(--border);
+      background: #fff;
+      cursor: pointer;
+      display: inline-flex;
+      align-items: center;
+      gap: 6px;
+    }
+    .btn-md.main {
+      background: var(--primary);
+      border-color: var(--primary);
+      color: #fff;
+    }
+    table {
+      width: 100%;
+      border-collapse: collapse;
+      font-size: 13px;
+    }
+    th, td {
+      padding: 10px 8px;
+      text-align: center;
+      border-bottom: 1px solid #f1f5f9;
+      white-space: nowrap;
+    }
+    th {
+      color: var(--text-muted);
+      font-weight: 600;
+      background: #fafbfc;
+      font-size: 12px;
+    }
+    tr:nth-child(even) td { background: #fafbfc; }
+    tr:hover td { background: var(--primary-bg); }
+    .tag {
+      display: inline-block;
+      padding: 2px 8px;
+      border-radius: 999px;
+      font-size: 12px;
+      line-height: 1.4;
+    }
+    .tag.ok { background: #ecfdf5; color: #059669; }
+    .tag.run { background: #ecfdf5; color: #047857; }
+    .tag.reject { background: #fef2f2; color: #dc2626; }
+    .tag.cancel { background: #f1f5f9; color: #64748b; }
+    .link {
+      color: var(--primary-dark);
+      background: none;
+      border: 0;
+      cursor: pointer;
+      font-size: 13px;
+      padding: 0 4px;
+    }
+    .pager {
+      display: flex;
+      align-items: center;
+      justify-content: flex-end;
+      gap: 12px;
+      margin-top: 12px;
+      padding-top: 12px;
+      border-top: 1px solid #e2e8f0;
+      color: var(--text-muted);
+      font-size: 13px;
+    }
+    .pager .btn { height: 28px; }
+    .dyn-col { background: #f0fdf4 !important; color: #047857; }
+    .dyn-cell { color: var(--text-strong); }
+    @media (max-width: 900px) {
+      .filter-grid { grid-template-columns: 1fr 1fr; }
+      .card { width: 100%; }
+    }
+  </style>
+</head>
+<body>
+  <div class="page">
+    <div class="note">
+      <strong>Demo 说明:</strong>在「发起审批」流程卡片上,【发起】左侧增加【历史记录】(按钮始终显示;全员数据靠权限字符)。
+      点击有权限示意卡片上的「历史记录」可打开弹窗。下方虚线按钮仅示意「无全员权限时仍显示历史,但只看本人」。
+    </div>
+
+    <div class="search-wrap">
+      <input class="search" placeholder="请输入审批单名称" readonly />
+    </div>
+
+    <div class="tabs">
+      <button class="tab active" type="button">全部</button>
+      <button class="tab" type="button">人事</button>
+      <button class="tab" type="button">财务</button>
+      <button class="tab" type="button">行政</button>
+    </div>
+    <div class="hint">选择一个审批单即可发起 · 有权限时可查看该流程全员历史</div>
+
+    <div class="section-head">
+      <h3>人事</h3>
+      <span class="dot"></span>
+      <span class="meta">3 个表单</span>
+    </div>
+    <div class="card-row">
+      <!-- 有权限:历史记录 + 发起 -->
+      <div class="card">
+        <div class="card-main">
+          <div class="icon">假</div>
+          <div>
+            <span class="name">请假申请</span>
+            <span class="desc">事假 / 年假 / 病假等</span>
+          </div>
+        </div>
+        <div class="ops">
+          <button class="btn" type="button" onclick="openHistory('请假申请')">历史记录</button>
+          <button class="btn primary" type="button">发起</button>
+        </div>
+      </div>
+
+      <div class="card">
+        <div class="card-main">
+          <div class="icon blue">出</div>
+          <div>
+            <span class="name">出差申请</span>
+            <span class="desc">差旅事前审批</span>
+          </div>
+        </div>
+        <div class="ops">
+          <button class="btn" type="button" onclick="openHistory('出差申请')">历史记录</button>
+          <button class="btn primary" type="button">发起</button>
+        </div>
+      </div>
+
+      <!-- 无权限:仅发起 -->
+      <div class="card">
+        <div class="card-main">
+          <div class="icon amber">补</div>
+          <div>
+            <span class="name">补卡申请</span>
+            <span class="desc">无历史权限时不显示按钮</span>
+          </div>
+        </div>
+        <div class="ops">
+          <button class="btn ghost-perm" type="button" title="示意:无权限则不渲染此按钮" disabled>历史记录(隐藏)</button>
+          <button class="btn primary" type="button">发起</button>
+        </div>
+      </div>
+    </div>
+
+    <div class="legend">
+      <div><strong>交互点:</strong>点击「请假申请 / 出差申请」的 <code>历史记录</code> 打开弹窗</div>
+      <div style="margin-top:6px">弹窗内:筛选(流程名称只读、审批状态、审批 ID、发起/完成时间段;全员权限另有申请人)+ 列表(标准列 + 表单前 6 个业务字段)+ 分页;操作查看/打印</div>
+    </div>
+  </div>
+
+  <div class="mask" id="mask" onclick="onMask(event)">
+    <div class="dialog" role="dialog" aria-modal="true">
+      <div class="dialog-h">
+        <h2 id="dlgTitle">历史记录 · 请假申请</h2>
+        <button class="close" type="button" onclick="closeHistory()" aria-label="关闭">×</button>
+      </div>
+      <div class="dialog-b">
+        <div class="filter">
+          <div class="filter-grid">
+            <div class="field">
+              <label>流程名称</label>
+              <input id="fAppName" value="请假申请" />
+            </div>
+            <div class="field">
+              <label>审批状态</label>
+              <select>
+                <option value="">全部</option>
+                <option>进行中</option>
+                <option selected>已完成</option>
+                <option>已拒绝</option>
+                <option>已打回</option>
+                <option>已撤销</option>
+              </select>
+            </div>
+            <div class="field">
+              <label>审批 ID</label>
+              <input placeholder="请输入审批 ID" />
+            </div>
+            <div class="field">
+              <label>发起时间</label>
+              <input value="2026-08-01 至 2026-08-28" />
+            </div>
+            <div class="field">
+              <label>完成时间</label>
+              <input placeholder="YYYY-MM-DD 至 YYYY-MM-DD" />
+            </div>
+          </div>
+          <div class="filter-actions">
+            <button class="btn-md" type="button">重置</button>
+            <button class="btn-md main" type="button">搜索</button>
+          </div>
+        </div>
+
+        <div style="overflow:auto">
+          <table>
+            <thead>
+              <tr>
+                <th>序号</th>
+                <th>审批 ID</th>
+                <th>流程名称</th>
+                <th>申请人</th>
+                <th class="dyn-col">请假类型 ★</th>
+                <th class="dyn-col">开始日期 ★</th>
+                <th class="dyn-col">请假天数 ★</th>
+                <th class="dyn-col">结束日期 ★</th>
+                <th class="dyn-col">请假事由 ★</th>
+                <th class="dyn-col">附件说明 ★</th>
+                <th>发起时间</th>
+                <th>完成时间</th>
+                <th>审批状态</th>
+                <th>当前节点</th>
+                <th>操作</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td>1</td>
+                <td>a1b2c3d4-e5f6</td>
+                <td>请假申请</td>
+                <td>张三</td>
+                <td class="dyn-cell">年假</td>
+                <td class="dyn-cell">2026-09-01</td>
+                <td class="dyn-cell">2</td>
+                <td class="dyn-cell">2026-09-02</td>
+                <td class="dyn-cell">家庭事务</td>
+                <td class="dyn-cell">-</td>
+                <td>2026-08-28 09:12</td>
+                <td>2026-08-28 11:30</td>
+                <td><span class="tag ok">已完成</span></td>
+                <td>—</td>
+                <td><button class="link" type="button">查看</button></td>
+              </tr>
+              <tr>
+                <td>2</td>
+                <td>b2c3d4e5-f6a7</td>
+                <td>请假申请</td>
+                <td>李四</td>
+                <td class="dyn-cell">事假</td>
+                <td class="dyn-cell">2026-08-29</td>
+                <td class="dyn-cell">1</td>
+                <td class="dyn-cell">2026-08-29</td>
+                <td class="dyn-cell">外出办事</td>
+                <td class="dyn-cell">-</td>
+                <td>2026-08-27 14:05</td>
+                <td>—</td>
+                <td><span class="tag run">进行中</span></td>
+                <td>部门负责人</td>
+                <td><button class="link" type="button">查看</button></td>
+              </tr>
+              <tr>
+                <td>3</td>
+                <td>c3d4e5f6-a7b8</td>
+                <td>请假申请</td>
+                <td>王五</td>
+                <td class="dyn-cell">病假</td>
+                <td class="dyn-cell">2026-08-26</td>
+                <td class="dyn-cell">3</td>
+                <td class="dyn-cell">2026-08-28</td>
+                <td class="dyn-cell">就医</td>
+                <td class="dyn-cell">医院证明</td>
+                <td>2026-08-25 10:22</td>
+                <td>2026-08-25 16:40</td>
+                <td><span class="tag reject">已拒绝</span></td>
+                <td>—</td>
+                <td><button class="link" type="button">查看</button></td>
+              </tr>
+              <tr>
+                <td>4</td>
+                <td>d4e5f6a7-b8c9</td>
+                <td>请假申请</td>
+                <td>赵六</td>
+                <td class="dyn-cell">年假</td>
+                <td class="dyn-cell">2026-08-22</td>
+                <td class="dyn-cell">0.5</td>
+                <td class="dyn-cell">2026-08-22</td>
+                <td class="dyn-cell">休息</td>
+                <td class="dyn-cell">-</td>
+                <td>2026-08-20 08:50</td>
+                <td>2026-08-20 09:10</td>
+                <td><span class="tag cancel">已撤销</span></td>
+                <td>—</td>
+                <td><button class="link" type="button">查看</button></td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+
+        <div class="pager">
+          <span>共 28 条</span>
+          <button class="btn" type="button">上一页</button>
+          <span>第 1 / 3 页</span>
+          <button class="btn" type="button">下一页</button>
+        </div>
+
+        <p style="margin:14px 0 0;font-size:12px;color:var(--text-muted)">
+          ★ 绿色表头为业务字段(表单前 6 列),插在「申请人」与「发起时间」之间;不同流程列名不同。审批 ID 为实例 id。
+        </p>
+      </div>
+    </div>
+  </div>
+
+  <script>
+    function openHistory(name) {
+      document.getElementById('dlgTitle').textContent = '历史记录 · ' + name;
+      document.getElementById('fAppName').value = name;
+      document.getElementById('mask').classList.add('open');
+    }
+    function closeHistory() {
+      document.getElementById('mask').classList.remove('open');
+    }
+    function onMask(e) {
+      if (e.target.id === 'mask') closeHistory();
+    }
+    document.addEventListener('keydown', function (e) {
+      if (e.key === 'Escape') closeHistory();
+    });
+  </script>
+</body>
+</html>

+ 854 - 0
docs/superpowers/plans/2026-08-28-oa-start-history.md

@@ -0,0 +1,854 @@
+# OA 发起审批 · 流程历史记录 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:** 在「发起审批」流程卡片增加「历史记录」弹窗,按权限查看本人或租户内该流程全部实例,支持筛选、分页、查看与打印。
+
+**Architecture:** 新接口 `GET /instances/history`(须注册在 `/instances/{id}` 之前)+ `oa_engine.list_instance_history`;全员范围由菜单权限码 `oa:start:history:all` 后端强制判定;前端 `OaAppHistoryDialog` 挂在发起页卡片上。
+
+**Tech Stack:** FastAPI · SQLAlchemy · unittest;Vue 3 · Element Plus · `useMenuPerm`
+
+**Spec:** `docs/superpowers/specs/2026-08-28-oa-start-history-design.md`  
+**UI Demo:** `docs/demos/oa-start-history-demo.html`
+
+---
+
+## File map
+
+| Path | Responsibility |
+|------|----------------|
+| Create: `backend/tests/test_oa_instance_history.py` | history 列表 TDD |
+| Modify: `backend/app/services/oa_engine.py` | `list_instance_history`;`_enrich_list_rows` 增加 `preview_limit` 与 `finishTime` |
+| Modify: `backend/app/api/oa_approval.py` | 注册 history 路由(在 `{instance_id}` 之前) |
+| Create: `docs/sql/2026-08-28-oa-start-history-all-perm.sql` | 按钮菜单种子 `oa:start:history:all` |
+| Modify: `web-admin/src/api/oaApproval.ts` | `fetchOaInstanceHistory` |
+| Create: `web-admin/src/components/oa-approval/OaAppHistoryDialog.vue` | 历史弹窗 |
+| Modify: `web-admin/src/views/oa-approval/start/index.vue` | 卡片按钮 + 打开弹窗 |
+| Run: `./scripts/update-graph.sh all` | 图谱增量(backend + frontend) |
+
+**常量:**
+- 权限码:`oa:start:history:all`
+- 终态(有 finishTime):`completed` / `rejected` / `cancelled`
+- `previewFields` 上限:history=6;既有 tasks 列表保持 3
+
+**详情可见性说明:** 当前 `get_instance_detail` 仅按 `tenant_no` 校验,管理端任意用户已可打开同租户实例。本期**无需**再改详情鉴权;实现后用手工/单测确认即可。
+
+---
+
+### Task 1: 后端 history 失败测试(TDD)
+
+**Files:**
+- Create: `backend/tests/test_oa_instance_history.py`
+
+- [ ] **Step 1: 写入测试文件**
+
+```python
+"""OA list_instance_history:按 appId + 全员权限收窄。"""
+from __future__ import annotations
+
+import unittest
+from datetime import datetime
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+from app.models.base import Base
+from app.models.models import OaApp, OaAppVersion, OaInstance, SysUser
+from app.services import oa_engine
+
+
+class TestOaInstanceHistory(unittest.TestCase):
+    def setUp(self):
+        self.engine = create_engine("sqlite:///:memory:")
+        Base.metadata.create_all(
+            self.engine,
+            tables=[
+                OaApp.__table__,
+                OaAppVersion.__table__,
+                SysUser.__table__,
+                OaInstance.__table__,
+            ],
+        )
+        Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
+        self.db = Session()
+        t1 = datetime(2026, 8, 10, 9, 0, 0)
+        t2 = datetime(2026, 8, 20, 15, 30, 0)
+        t3 = datetime(2026, 8, 25, 11, 0, 0)
+        self.db.add_all(
+            [
+                OaApp(
+                    id="app-leave",
+                    app_name="请假申请",
+                    initiator_scope="all",
+                    admin_scope="all_oa_admin",
+                    status="1",
+                    tenant_no="zkxw",
+                    create_dept_id="",
+                    create_time=t1,
+                    update_time=t1,
+                    del_flag=0,
+                ),
+                OaAppVersion(
+                    id="v-leave",
+                    app_id="app-leave",
+                    version_no=1,
+                    status="published",
+                    is_latest_published="1",
+                    form_schema_json='{"fields":[{"type":"input","field":"leaveType","title":"请假类型"},{"type":"input","field":"days","title":"天数"}]}',
+                    flow_tree_json="{}",
+                    advanced_settings_json="{}",
+                    tenant_no="zkxw",
+                    create_dept_id="",
+                    create_time=t1,
+                    update_time=t1,
+                    del_flag=0,
+                ),
+                SysUser(
+                    id="u-a",
+                    user_name="alice",
+                    nick_name="张三",
+                    tenant_no="zkxw",
+                    create_dept_id="",
+                    create_time=t1,
+                    update_time=t1,
+                    del_flag=0,
+                ),
+                SysUser(
+                    id="u-b",
+                    user_name="bob",
+                    nick_name="李四",
+                    tenant_no="zkxw",
+                    create_dept_id="",
+                    create_time=t1,
+                    update_time=t1,
+                    del_flag=0,
+                ),
+                OaInstance(
+                    id="inst-alice-done",
+                    app_id="app-leave",
+                    app_version_id="v-leave",
+                    title="请假-张三",
+                    initiator_user_id="u-a",
+                    form_data_json='{"leaveType":"年假","days":"2"}',
+                    status="completed",
+                    tenant_no="zkxw",
+                    create_dept_id="",
+                    create_time=t1,
+                    update_time=t2,
+                    del_flag=0,
+                ),
+                OaInstance(
+                    id="inst-bob-run",
+                    app_id="app-leave",
+                    app_version_id="v-leave",
+                    title="请假-李四",
+                    initiator_user_id="u-b",
+                    form_data_json='{"leaveType":"事假","days":"1"}',
+                    status="running",
+                    tenant_no="zkxw",
+                    create_dept_id="",
+                    create_time=t3,
+                    update_time=t3,
+                    del_flag=0,
+                ),
+            ]
+        )
+        self.db.commit()
+
+    def tearDown(self):
+        self.db.close()
+        self.engine.dispose()
+
+    def test_requires_app_id(self):
+        with self.assertRaises(oa_engine.OaEngineError):
+            oa_engine.list_instance_history(
+                self.db,
+                tenant_no="zkxw",
+                user_id="u-a",
+                can_view_all=False,
+                app_id="",
+            )
+
+    def test_self_scope_hides_others(self):
+        data = oa_engine.list_instance_history(
+            self.db,
+            tenant_no="zkxw",
+            user_id="u-a",
+            can_view_all=False,
+            app_id="app-leave",
+        )
+        self.assertEqual(data["total"], 1)
+        self.assertFalse(data["canViewAll"])
+        self.assertEqual(data["list"][0]["id"], "inst-alice-done")
+
+    def test_all_scope_returns_both(self):
+        data = oa_engine.list_instance_history(
+            self.db,
+            tenant_no="zkxw",
+            user_id="u-a",
+            can_view_all=True,
+            app_id="app-leave",
+        )
+        self.assertEqual(data["total"], 2)
+        self.assertTrue(data["canViewAll"])
+        ids = {r["id"] for r in data["list"]}
+        self.assertEqual(ids, {"inst-alice-done", "inst-bob-run"})
+
+    def test_initiator_name_ignored_without_all(self):
+        data = oa_engine.list_instance_history(
+            self.db,
+            tenant_no="zkxw",
+            user_id="u-a",
+            can_view_all=False,
+            app_id="app-leave",
+            initiator_name="李四",
+        )
+        self.assertEqual(data["total"], 1)
+        self.assertEqual(data["list"][0]["id"], "inst-alice-done")
+
+    def test_finish_time_only_on_terminal(self):
+        data = oa_engine.list_instance_history(
+            self.db,
+            tenant_no="zkxw",
+            user_id="u-a",
+            can_view_all=True,
+            app_id="app-leave",
+        )
+        by_id = {r["id"]: r for r in data["list"]}
+        self.assertTrue(by_id["inst-alice-done"].get("finishTime"))
+        self.assertFalse(by_id["inst-bob-run"].get("finishTime"))
+
+    def test_finish_time_filter(self):
+        data = oa_engine.list_instance_history(
+            self.db,
+            tenant_no="zkxw",
+            user_id="u-a",
+            can_view_all=True,
+            app_id="app-leave",
+            finish_time_from="2026-08-20",
+            finish_time_to="2026-08-20",
+        )
+        self.assertEqual(data["total"], 1)
+        self.assertEqual(data["list"][0]["id"], "inst-alice-done")
+
+    def test_preview_fields_limit_six(self):
+        data = oa_engine.list_instance_history(
+            self.db,
+            tenant_no="zkxw",
+            user_id="u-a",
+            can_view_all=False,
+            app_id="app-leave",
+        )
+        pf = data["list"][0].get("previewFields") or []
+        self.assertLessEqual(len(pf), 6)
+        self.assertGreaterEqual(len(pf), 1)
+
+
+if __name__ == "__main__":
+    unittest.main()
+```
+
+> 注:`SysUser` 必填字段若与仓库模型不一致,按 `tests/test_oa_tasks_filters.py` 现有构造补齐。`form_schema_json` 形状若与 `flatten_form_fields_for_summary` 不符,可改为该函数单测中已用过的 schema 样例,或对本用例仅断言 `previewFields` 为 list 且 `len <= 6`。
+
+- [ ] **Step 2: 跑测确认失败**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_oa_instance_history.py -v
+```
+
+Expected: FAIL(`list_instance_history` 未定义)
+
+- [ ] **Step 3: Commit 测试**
+
+```bash
+git add backend/tests/test_oa_instance_history.py
+git commit -m "$(cat <<'EOF'
+test: add OA instance history list failing tests
+
+EOF
+)"
+```
+
+---
+
+### Task 2: 实现 `list_instance_history` 与 enrich 扩展
+
+**Files:**
+- Modify: `backend/app/services/oa_engine.py`
+
+- [ ] **Step 1: 扩展 `_enrich_list_rows`**
+
+在函数签名增加 `preview_limit: int = 3`,将 `[:3]` 改为 `[: max(0, int(preview_limit or 3))]`。
+
+在 merged 行上增加 `finishTime`:
+
+```python
+_FINISH_STATUSES = frozenset({"completed", "rejected", "cancelled"})
+
+# 在 merged.update(summary) 之后:
+st = (inst.status or "").strip()
+if st in _FINISH_STATUSES and inst.update_time:
+    merged["finishTime"] = inst.update_time.strftime("%Y-%m-%d %H:%M:%S")
+else:
+    merged["finishTime"] = None
+```
+
+既有 `list_tasks` 调用不传 `preview_limit`,保持默认 3。
+
+- [ ] **Step 2: 新增 `list_instance_history`**
+
+放在 `list_tasks` 附近:
+
+```python
+def list_instance_history(
+    db: Session,
+    *,
+    tenant_no: str,
+    user_id: str,
+    can_view_all: bool,
+    app_id: str,
+    instance_id: Optional[str] = None,
+    initiator_name: Optional[str] = None,
+    apply_time_from: Optional[str] = None,
+    apply_time_to: Optional[str] = None,
+    finish_time_from: Optional[str] = None,
+    finish_time_to: Optional[str] = None,
+    instance_status: Optional[str] = None,
+    page: int = 1,
+    page_size: int = 20,
+) -> Dict[str, Any]:
+    app_id = (app_id or "").strip()
+    if not app_id:
+        raise OaEngineError("appId 不能为空")
+    page = max(1, int(page or 1))
+    page_size = min(100, max(1, int(page_size or 20)))
+    offset = (page - 1) * page_size
+
+    q = db.query(OaInstance).filter(
+        OaInstance.tenant_no == tenant_no,
+        OaInstance.del_flag == 0,
+        OaInstance.app_id == app_id,
+    )
+    if not can_view_all:
+        q = q.filter(OaInstance.initiator_user_id == user_id)
+        initiator_name = None
+
+    iid = (instance_id or "").strip()
+    if iid:
+        q = q.filter(OaInstance.id.like(f"%{iid}%"))
+
+    q, _, _ = _apply_instance_list_filters(
+        q,
+        initiator_name=initiator_name,
+        apply_time_from=apply_time_from,
+        apply_time_to=apply_time_to,
+        instance_status=instance_status,
+    )
+
+    ft0 = _parse_day_start(finish_time_from)
+    ft1 = _parse_day_end(finish_time_to)
+    if ft0 is not None or ft1 is not None:
+        q = q.filter(OaInstance.status.in_(tuple(_FINISH_STATUSES)))
+        if ft0 is not None:
+            q = q.filter(OaInstance.update_time >= ft0)
+        if ft1 is not None:
+            q = q.filter(OaInstance.update_time <= ft1)
+
+    total = q.count()
+    insts = (
+        q.order_by(OaInstance.create_time.desc())
+        .offset(offset)
+        .limit(page_size)
+        .all()
+    )
+    rows = [_instance_to_dict(i) for i in insts]
+    return {
+        "list": _enrich_list_rows(
+            db,
+            tenant_no=tenant_no,
+            rows=rows,
+            instances=insts,
+            preview_limit=6,
+        ),
+        "total": int(total),
+        "canViewAll": bool(can_view_all),
+    }
+```
+
+将 `_FINISH_STATUSES` 提为模块级常量,供 enrich 与 history 共用。
+
+- [ ] **Step 3: 跑测通过**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_oa_instance_history.py -v
+```
+
+Expected: PASS(若 schema flatten 导致 preview 断言不稳,放宽该断言后仍须其余用例 PASS)
+
+- [ ] **Step 4: 回归任务筛选**
+
+```bash
+cd backend && pytest tests/test_oa_tasks_filters.py tests/test_oa_tasks_pagination.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/oa_engine.py backend/tests/test_oa_instance_history.py
+git commit -m "$(cat <<'EOF'
+feat(oa): add list_instance_history with scoped preview fields
+
+EOF
+)"
+```
+
+---
+
+### Task 3: 注册 API 路由
+
+**Files:**
+- Modify: `backend/app/api/oa_approval.py`
+
+- [ ] **Step 1: 在 `GET /instances/{instance_id}` 之前插入路由**
+
+```python
+OA_START_HISTORY_ALL_PERM = "oa:start:history:all"
+
+@router.get("/instances/history")
+async def list_oa_instance_history(
+    appId: str = Query(..., description="流程应用 ID"),
+    instanceId: str = Query(""),
+    instanceStatus: str = Query(""),
+    initiatorName: str = Query(""),
+    applyTimeFrom: str = Query(""),
+    applyTimeTo: str = Query(""),
+    finishTimeFrom: str = Query(""),
+    finishTimeTo: str = Query(""),
+    page: int = Query(1),
+    pageSize: int = Query(20, alias="pageSize"),
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    from app.core.menu_guard import user_has_menu_perm
+    from app.services.oa_engine import OaEngineError, list_instance_history
+
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        can_view_all = user_has_menu_perm(db, user, OA_START_HISTORY_ALL_PERM)
+        data = list_instance_history(
+            db,
+            tenant_no=tenant_no,
+            user_id=user.get("user_id") or "",
+            can_view_all=can_view_all,
+            app_id=appId,
+            instance_id=instanceId or None,
+            initiator_name=initiatorName or None,
+            apply_time_from=applyTimeFrom or None,
+            apply_time_to=applyTimeTo or None,
+            finish_time_from=finishTimeFrom or None,
+            finish_time_to=finishTimeTo or None,
+            instance_status=instanceStatus or None,
+            page=page,
+            page_size=pageSize,
+        )
+        return success_response(data)
+    except OaEngineError as e:
+        return error_response(400, str(e))
+```
+
+务必放在 `@router.get("/instances/{instance_id}")` **上方**,避免 `history` 被当成 `instance_id`。
+
+- [ ] **Step 2: 手工冒烟(可选)**
+
+启动本地后端后:
+
+```bash
+# 需带登录 token;无 token 时期望 401/403,至少确认路由不是 404 落到 instance 详情
+curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:8000/web/admin/oa-approval/instances/history?appId=x"
+```
+
+Expected: 非 404(多为 401/403/400)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add backend/app/api/oa_approval.py
+git commit -m "$(cat <<'EOF'
+feat(oa): expose GET /instances/history for start-page history
+
+EOF
+)"
+```
+
+---
+
+### Task 4: 菜单权限种子 SQL
+
+**Files:**
+- Create: `docs/sql/2026-08-28-oa-start-history-all-perm.sql`
+
+- [ ] **Step 1: 编写 SQL**
+
+先查库中「发起审批」菜单 id(执行环境以实际为准):
+
+```sql
+-- 查找发起审批菜单
+SELECT id, parent_id, menu_name, perms, menu_type
+FROM sys_menu
+WHERE del_flag = 0 AND (perms = 'oa:start' OR menu_name LIKE '%发起审批%');
+```
+
+脚本模板(**将 `@parent_id` 换成上一步查到的 id**;id 用新 UUID):
+
+```sql
+-- OA 发起审批 · 全员历史权限(按钮 F)
+-- perms: oa:start:history:all
+-- 执行前请确认 parent_id 为「发起审批」菜单 id
+
+INSERT INTO sys_menu (
+  id, parent_id, menu_name, menu_type, path, component, perms,
+  icon, order_num, visible, status, is_frame, is_cache,
+  tenant_no, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
+) VALUES (
+  REPLACE(UUID(), '-', ''),
+  /* parent_id */ 'REPLACE_WITH_OA_START_MENU_ID',
+  '全员历史记录',
+  'F',
+  '',
+  '',
+  'oa:start:history:all',
+  '',
+  90,
+  '0',
+  '0',
+  0,
+  0,
+  'zkxw',
+  '发起审批历史弹窗:有此权限可查看本租户该流程全部实例',
+  'system',
+  '',
+  NOW(),
+  'system',
+  NOW(),
+  0
+);
+```
+
+若项目 `sys_menu` 列名/必填与模板不一致,对照 `docs/sql/zkxw.sql` 中已有 `menu_type='F'` 行改写;保持 `perms='oa:start:history:all'` 不变。
+
+在脚本头部用注释写明:执行后需在「角色管理」勾选该按钮权限。
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add docs/sql/2026-08-28-oa-start-history-all-perm.sql
+git commit -m "$(cat <<'EOF'
+docs(sql): seed oa:start:history:all button permission
+
+EOF
+)"
+```
+
+---
+
+### Task 5: 前端 API
+
+**Files:**
+- Modify: `web-admin/src/api/oaApproval.ts`
+
+- [ ] **Step 1: 增加方法**(放在 `fetchOaTasks` 附近)
+
+```typescript
+export function fetchOaInstanceHistory(params: {
+  appId: string
+  instanceId?: string
+  instanceStatus?: string
+  initiatorName?: string
+  applyTimeFrom?: string
+  applyTimeTo?: string
+  finishTimeFrom?: string
+  finishTimeTo?: string
+  page?: number
+  pageSize?: number
+}) {
+  return request.get<{
+    list: any[]
+    total: number
+    canViewAll?: boolean
+  }>({
+    url: `${BASE}/instances/history`,
+    params
+  })
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web-admin/src/api/oaApproval.ts
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add fetchOaInstanceHistory API
+
+EOF
+)"
+```
+
+---
+
+### Task 6: `OaAppHistoryDialog` 组件
+
+**Files:**
+- Create: `web-admin/src/components/oa-approval/OaAppHistoryDialog.vue`
+
+- [ ] **Step 1: 创建弹窗组件**
+
+对齐 `mine/index.vue` 的筛选/表格/打印;关键结构:
+
+```vue
+<!-- 要点(完整实现时展开为完整 SFC) -->
+<template>
+  <el-dialog
+    v-model="visible"
+    class="dialog-form-layout"
+    :title="`历史记录 · ${appName}`"
+    width="1080px"
+    append-to-body
+    destroy-on-close
+    align-center
+    modal-class="protocol-dialog-mask"
+    @closed="onClosed"
+  >
+    <div class="filter-block">...</div>
+    <!-- 流程名称只读;申请人 v-if="canViewAll" -->
+    <el-table class="list-table" :data="rows" v-loading="loading" stripe>
+      <!-- 列序见 spec §5.4;动态列:previewFieldLabels -->
+      <!-- 操作:查看 / 打印 -->
+    </el-table>
+    <el-pagination ... />
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+  import { computed, reactive, ref } from 'vue'
+  import { useRouter } from 'vue-router'
+  import { Printer, RefreshLeft, Search, View } from '@element-plus/icons-vue'
+  import { fetchOaInstanceDetail, fetchOaInstanceHistory } from '@/api/oaApproval'
+  import { useMenuPerm } from '@/hooks/core/useMenuPerm'
+  import { useUserStore } from '@/store/modules/user'
+  import { exportOaApprovalPdf } from '@/utils/oa-approval-print'
+
+  const OA_START_HISTORY_ALL = 'oa:start:history:all'
+  const { hasMenuPerm } = useMenuPerm()
+  const canViewAll = computed(() => hasMenuPerm(OA_START_HISTORY_ALL))
+
+  const visible = ref(false)
+  const appId = ref('')
+  const appName = ref('')
+  // searchForm / applyTimeRange / finishTimeRange / page / pageSize / rows / total / loading / printingId
+
+  function open(app: { id: string; appName?: string }) {
+    appId.value = app.id
+    appName.value = app.appName || ''
+    resetFilters()
+    visible.value = true
+    load()
+  }
+
+  async function load() {
+    if (!appId.value) return
+    loading.value = true
+    try {
+      const params: any = {
+        appId: appId.value,
+        instanceId: searchForm.instanceId || undefined,
+        instanceStatus: searchForm.instanceStatus || undefined,
+        applyTimeFrom: applyTimeRange.value?.[0],
+        applyTimeTo: applyTimeRange.value?.[1],
+        finishTimeFrom: finishTimeRange.value?.[0],
+        finishTimeTo: finishTimeRange.value?.[1],
+        page: page.value,
+        pageSize: pageSize.value
+      }
+      if (canViewAll.value && searchForm.initiatorName) {
+        params.initiatorName = searchForm.initiatorName
+      }
+      const data = await fetchOaInstanceHistory(params)
+      rows.value = data?.list || []
+      total.value = Number(data?.total || 0)
+    } finally {
+      loading.value = false
+    }
+  }
+
+  const previewFieldLabels = computed(() => {
+    const labels: string[] = []
+    for (const row of rows.value) {
+      for (const f of row.previewFields || []) {
+        const lb = (f.label || '').trim()
+        if (lb && !labels.includes(lb)) labels.push(lb)
+      }
+    }
+    return labels.slice(0, 6)
+  })
+
+  function previewValue(row: any, label: string) {
+    const hit = (row.previewFields || []).find((f: any) => f.label === label)
+    return hit?.value || '-'
+  }
+
+  function openDetail(row: any) {
+    const id = row.id
+    if (!id) return
+    visible.value = false
+    router.push({ path: `/system-manage/oa-approval/instances/${id}` })
+  }
+
+  async function printRow(row: any) {
+    // 同 mine/index.vue:fetchOaInstanceDetail + exportOaApprovalPdf
+  }
+
+  defineExpose({ open })
+</script>
+```
+
+动态列表头用 `v-for="label in previewFieldLabels"`;状态 Tag 复用 mine 的 `statusType` / `statusText`。
+
+空数据:`el-empty` 或表格空文案「暂无历史记录」。
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web-admin/src/components/oa-approval/OaAppHistoryDialog.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add OaAppHistoryDialog for per-app instance history
+
+EOF
+)"
+```
+
+---
+
+### Task 7: 发起页挂载按钮
+
+**Files:**
+- Modify: `web-admin/src/views/oa-approval/start/index.vue`
+
+- [ ] **Step 1: 改卡片操作区**
+
+```vue
+<div class="oa-app-card__ops">
+  <el-button class="oa-app-card__btn" @click.stop="openHistory(row)">
+    <el-icon><Clock /></el-icon>
+    历史记录
+  </el-button>
+  <el-button class="oa-app-card__btn primary" @click.stop="onPick(row)">
+    <el-icon><Promotion /></el-icon>
+    发起
+  </el-button>
+</div>
+```
+
+模板底部增加:
+
+```vue
+<OaAppHistoryDialog ref="historyDialogRef" />
+```
+
+script:
+
+```typescript
+import { Clock, Promotion, Search } from '@element-plus/icons-vue'
+import OaAppHistoryDialog from '@/components/oa-approval/OaAppHistoryDialog.vue'
+
+const historyDialogRef = ref<InstanceType<typeof OaAppHistoryDialog> | null>(null)
+
+function openHistory(row: any) {
+  historyDialogRef.value?.open({ id: row.id, appName: row.appName })
+}
+```
+
+按钮始终显示,不做 `v-if` 权限隐藏。
+
+- [ ] **Step 2: 本地 UI 冒烟**
+
+```bash
+cd web-admin && pnpm dev
+```
+
+打开 `/#/system-manage/oa-approval/start`:确认卡片有「历史记录」;点开弹窗;有/无权限角色验证申请人筛选项与数据范围(需先执行 Task 4 SQL 并给角色勾选)。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/views/oa-approval/start/index.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): show history button on OA start approval cards
+
+EOF
+)"
+```
+
+---
+
+### Task 8: 图谱与收尾核对
+
+**Files:**
+- Run: `./scripts/update-graph.sh all`
+
+- [ ] **Step 1: 更新知识图谱**
+
+```bash
+./scripts/update-graph.sh all
+```
+
+- [ ] **Step 2: Spec 验收对照**
+
+对照 `docs/superpowers/specs/2026-08-28-oa-start-history-design.md` §8:
+
+1. 历史按钮在发起左侧且始终可见  
+2. 无权限仅本人、无申请人筛选  
+3. 有权限全员 + 申请人筛选  
+4. 流程名称只读;含审批 ID / 状态 / 时间段  
+5. 列序:业务字段在发起时间前,≤6  
+6. 倒序 + 分页  
+7. 查看、打印可用  
+8. 后端强制收窄  
+9. 草稿不出现  
+
+- [ ] **Step 3: Commit 图谱(若有 diff)**
+
+```bash
+git add backend/graphify-out web-admin/graphify-out
+git commit -m "$(cat <<'EOF'
+chore: update graphify after OA start history feature
+
+EOF
+)"
+```
+
+---
+
+## Spec coverage checklist
+
+| Spec 要求 | Task |
+|-----------|------|
+| 卡片「历史记录」按钮 | Task 7 |
+| 弹窗 + 筛选 + 分页 | Task 6 |
+| 锁定 appId / 流程名称只读 | Task 6 |
+| 审批 ID / 完成时间不改表 | Task 2 |
+| 全员权限字符 | Task 3 + 4 + 6 |
+| 无权限仅本人 | Task 1–2 |
+| previewFields×6 | Task 2 + 6 |
+| 查看 + 打印 | Task 6 |
+| 草稿排除 | Task 2(只查 `oa_instance`) |
+| SQL 种子 | Task 4 |
+| 详情放宽 | 已有租户级开放;Task 8 核对 |
+
+## Placeholder / consistency self-check
+
+- 权限码全文统一:`oa:start:history:all`
+- 路由 path:`/instances/history`(在 `{instance_id}` 前)
+- `finishTime` 字段名前后端一致
+- `preview_limit` 仅 history=6,tasks 默认 3

+ 250 - 0
docs/superpowers/specs/2026-08-28-oa-start-history-design.md

@@ -0,0 +1,250 @@
+# OA 发起审批 · 流程历史记录设计
+
+**模块**: OA 审批 · 发起审批  
+**日期**: 2026-08-28  
+**状态**: 待实现  
+**UI Demo**: `docs/demos/oa-start-history-demo.html`  
+**决策记录**:
+- 实现方案: **方案 A**(弹窗 + 新列表 API + 菜单权限字符)
+- 按钮始终显示;全员数据靠 `oa:start:history:all` 控制
+- 审批 ID = 实例 `id`;完成时间 = 终态 `update_time`;**不改表**
+- 业务字段固定取表单 schema 前 **6** 个,列位置在「发起时间」之前
+- 操作:查看 + 打印(对齐「我发起的」)
+
+---
+
+## 1. 背景与目标
+
+OA 审批已具备完整工作流。客户希望在「发起审批」页面每张流程卡片的「发起」按钮左侧增加「历史记录」,弹窗展示该流程的发起记录(默认按发起时间倒序),支持筛选与分页,并按权限决定看本人还是看租户内全员。
+
+**成功标准**:
+- 发起页卡片常显「历史记录」,打开弹窗后可检索、分页浏览该流程实例
+- 无全员权限仅见本人;有 `oa:start:history:all` 可见本租户该流程全部,并显示「申请人」筛选
+- 列表含标准字段 + 最多 6 个业务字段;支持查看、打印
+- 后端强制鉴权,前端无法伪造成全员数据
+
+---
+
+## 2. 范围
+
+### 2.1 在范围内
+
+| 项 | 说明 |
+|----|------|
+| 入口 | `web-admin/src/views/oa-approval/start/index.vue` 流程卡片 |
+| 弹窗 | 新组件 `OaAppHistoryDialog.vue` |
+| API | `GET /web/admin/oa-approval/instances/history` |
+| 权限 | 按钮级菜单 `perms = oa:start:history:all` + SQL 种子 |
+| 详情可见性 | 有全员历史权限时可打开本租户任意该流程实例详情(打印同源) |
+
+### 2.2 非目标
+
+- 移动端入口
+- 独立菜单列表页
+- 新增审批编号发号字段或独立 `finish_time` 列
+- 可配置「列表摘要字段」
+- 「复制并重新发起」
+- 草稿箱记录进入历史
+
+---
+
+## 3. 权限模型
+
+| 角色/权限 | 「历史记录」按钮 | 列表数据范围 | 「申请人」筛选 |
+|-----------|------------------|--------------|----------------|
+| 能进发起审批页、无 `oa:start:history:all` | 显示 | 仅本人发起 | 不显示 |
+| 拥有 `oa:start:history:all` | 显示 | 本租户该 `appId` 全部实例 | 显示 |
+| 超级管理员 / `menuPermAll` | 显示 | 同全员 | 显示 |
+
+- 权限字符:`oa:start:history:all`
+- 挂载方式:`sys_menu` 按钮级节点,建议挂在「发起审批」菜单下;交付 `docs/sql/` 种子 SQL,可由角色勾选
+- 前端:`useMenuPerm().hasMenuPerm('oa:start:history:all')` 仅控制 UI(申请人筛选项等)
+- 后端:`user_has_menu_perm` **强制**收窄数据;忽略客户端伪造的「全员」意图
+
+---
+
+## 4. API 设计
+
+### 4.1 端点
+
+```
+GET /web/admin/oa-approval/instances/history
+```
+
+鉴权:管理端登录用户(与发起页一致);数据范围由权限字符决定。
+
+### 4.2 查询参数
+
+| 参数 | 必填 | 说明 |
+|------|:----:|------|
+| `appId` | 是 | 锁定流程;缺失返回 400 |
+| `instanceId` | 否 | 审批 ID,对 `oa_instance.id` 做包含匹配(`LIKE %value%`) |
+| `instanceStatus` | 否 | `running` / `completed` / `rejected` / `returned` / `cancelled` |
+| `initiatorName` | 否 | 申请人昵称模糊;**仅全员权限生效**,无权限时忽略 |
+| `applyTimeFrom` / `applyTimeTo` | 否 | 发起时间,`YYYY-MM-DD`,过滤 `create_time` |
+| `finishTimeFrom` / `finishTimeTo` | 否 | 完成时间,`YYYY-MM-DD`;见 §4.4 |
+| `page` / `pageSize` | 否 | 分页;默认排序 `create_time DESC` |
+
+### 4.3 响应
+
+```json
+{
+  "list": [ /* 行对象 */ ],
+  "total": 0,
+  "canViewAll": true
+}
+```
+
+行对象要点(在现有 `_enrich_list_rows` / `_instance_to_dict` 摘要之上扩展):
+
+| 字段 | 说明 |
+|------|------|
+| `id` | 审批 ID(列表列「审批 ID」) |
+| `appName` / `title` | 流程名称 |
+| `initiatorUserName` | 申请人 |
+| `applyTime` | 发起时间(`create_time`) |
+| `finishTime` | 完成时间;非终态为 `null` |
+| `status` | 审批状态 |
+| `currentNodeName` 等 | 与任务列表 enrich 一致 |
+| `previewFields` | 最多 6 项 `{ label, value }`,按**该实例锁定版本** `form_schema_json` 取前 6 个可摘要字段 |
+
+另:可将 `previewFields` 上限从现有任务列表的 3 参数化为 `limit`;历史接口传 `6`,既有 `tasks/{kind}` 保持 3 以免影响其它页。
+
+### 4.4 完成时间规则
+
+**终态集合**(有 `finishTime`):`completed`、`rejected`、`cancelled`  
+**非终态**(`finishTime = null`):`running`、`returned`
+
+- 展示:终态 → `update_time`;否则空
+- 筛选:仅当 `status IN (completed, rejected, cancelled)` 且 `update_time` 落在 `[finishTimeFrom, finishTimeTo]` 日界内
+
+### 4.5 数据安全
+
+1. 始终 `tenant_no` 隔离 + `del_flag = 0` + `app_id = appId`
+2. 无全员权限:强制 `initiator_user_id = 当前用户`
+3. 有全员权限:不按发起人收窄(仍可用 `initiatorName` 筛选)
+4. **不含草稿**(`oa_instance_draft` 不参与)
+
+### 4.6 实例详情可见性(连带)
+
+现有 `GET /instances/{id}` 若仅允许发起人/审批人/抄送人查看,则全员历史用户无法「查看/打印」他人单据。
+
+本期约定:调用方拥有 `oa:start:history:all` 且实例属于同租户时,允许读取该实例详情(打印走同一详情接口)。实现落在详情鉴权分支,避免仅放开列表却打不开详情。
+
+---
+
+## 5. 前端设计
+
+### 5.1 发起页卡片
+
+文件:`web-admin/src/views/oa-approval/start/index.vue`
+
+```
+[历史记录]  [发起]
+```
+
+- 「历史记录」在「发起」左侧;`@click.stop` 打开历史弹窗
+- 始终渲染,不按权限隐藏
+
+### 5.2 弹窗组件
+
+文件:`web-admin/src/components/oa-approval/OaAppHistoryDialog.vue`
+
+- `dialog-form-layout` + `append-to-body`,宽度约 1080px
+- `open({ id, appName })`:锁定 `appId`,标题 `历史记录 · {appName}`
+- 打开时重置筛选并加载第一页
+- 无 backdrop blur
+
+### 5.3 筛选区
+
+| 字段 | 行为 |
+|------|------|
+| 流程名称 | 只读预填,不可改 |
+| 审批状态 | 下拉,枚举同任务中心 |
+| 审批 ID | 文本 |
+| 发起时间 | `daterange`,`YYYY-MM-DD` |
+| 完成时间 | `daterange`,`YYYY-MM-DD` |
+| 申请人 | 仅 `canViewAll` / `hasMenuPerm('oa:start:history:all')` 时显示 |
+
+操作:重置、搜索(可与任务中心一致做防抖即时检索,非必须;至少支持显式搜索/重置)。
+
+### 5.4 列表列顺序
+
+1. 序号  
+2. 审批 ID  
+3. 流程名称  
+4. 申请人  
+5. **业务字段 × N(N≤6,动态表头)**  
+6. 发起时间  
+7. 完成时间  
+8. 审批状态(`el-tag`)  
+9. 当前节点  
+10. 操作:查看、打印  
+
+动态列:按当前页数据的 `previewFields` 标签对齐;不足 6 个则少列;缺值显示 `-`。
+
+### 5.5 操作
+
+与 `views/oa-approval/mine/index.vue` 对齐:
+
+- **查看**:跳转 `/system-manage/oa-approval/instances/:id`(或项目现有详情打开方式)
+- **打印**:`fetchOaInstanceDetail` + `exportOaApprovalPdf`,带 loading 防重复
+
+### 5.6 API 封装
+
+`web-admin/src/api/oaApproval.ts` 新增 `fetchOaInstanceHistory(params)`。
+
+---
+
+## 6. 后端实现要点
+
+| 位置 | 变更 |
+|------|------|
+| `app/api/oa_approval.py` | 注册 history 路由 |
+| `app/services/oa_engine.py` | `list_instance_history`;复用/扩展 `_apply_instance_list_filters`、`_enrich_list_rows`(`preview` limit=6,计算 `finishTime`) |
+| `app/core/menu_guard.py` | 使用已有 `user_has_menu_perm` |
+| `docs/sql/` | 插入按钮菜单种子(`perms=oa:start:history:all`) |
+
+表结构:**不改** `oa_instance`。
+
+---
+
+## 7. 边界情况
+
+| 场景 | 处理 |
+|------|------|
+| 无权限带 `initiatorName` | 忽略 |
+| 无权限搜他人审批 ID | 无结果 |
+| 表单字段少于 6 个 | 有几列展示几列 |
+| 多版本 schema | 每行按实例锁定版本取前 6 字段 |
+| `appId` 非法 | 400 |
+| 空列表 | 「暂无历史记录」 |
+
+---
+
+## 8. 验收清单
+
+1. 发起页卡片「历史记录」在「发起」左侧且始终可见  
+2. 无 `oa:start:history:all`:仅本人;无「申请人」筛选  
+3. 有该权限:租户内该流程全部;有「申请人」筛选  
+4. 流程名称只读锁定;筛选含审批 ID、状态、发起/完成时间段  
+5. 列序正确;业务字段 ≤6 且在发起时间前  
+6. 默认发起时间倒序;分页正确  
+7. 查看、打印与「我发起的」一致;全员权限可打开他人实例  
+8. 后端无法被前端伪造成全员数据  
+9. 草稿不出现在历史  
+
+---
+
+## 9. 测试建议
+
+- 后端:权限开/关、时间筛选、`appId` 必填、`previewFields` 长度、终态 `finishTime`、详情放宽分支  
+- 前端:弹窗打开/重置、动态列、权限切换后申请人筛选项显隐、打印 loading  
+
+---
+
+## 10. UI 参考
+
+- Demo:`docs/demos/oa-start-history-demo.html`(列序已按「业务字段在发起时间前」;文案「审批 ID」以实现为准同步 Demo)
+- 筛选/列表模式:任务中心 `pending` / `mine`
+- 打印:`mine/index.vue` + `exportOaApprovalPdf`