docs: add OA start history design, plan, and UI demo
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
3beb7ffb8a
commit
cbfe592e47
3 files changed
+1659
No files matched your search
@@ -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`
|
||||
Reference in new issue
Block a user