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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-28 10:56:24 +08:00
1 parent 3beb7ffb8a
commit cbfe592e47
3 files changed
+1659

No files matched your search

+555
View File
@@ -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>
@@ -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
@@ -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`