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
| 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:allcompleted / rejected / cancelledpreviewFields 上限:history=6;既有 tasks 列表保持 3详情可见性说明: 当前 get_instance_detail 仅按 tenant_no 校验,管理端任意用户已可打开同租户实例。本期无需再改详情鉴权;实现后用手工/单测确认即可。
Files:
Create: backend/tests/test_oa_instance_history.py
[ ] Step 1: 写入测试文件
"""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。
cd backend && source venv/bin/activate && pytest tests/test_oa_instance_history.py -v
Expected: FAIL(list_instance_history 未定义)
git add backend/tests/test_oa_instance_history.py
git commit -m "$(cat <<'EOF'
test: add OA instance history list failing tests
EOF
)"
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:
_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。
list_instance_history放在 list_tasks 附近:
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 共用。
cd backend && source venv/bin/activate && pytest tests/test_oa_instance_history.py -v
Expected: PASS(若 schema flatten 导致 preview 断言不稳,放宽该断言后仍须其余用例 PASS)
cd backend && pytest tests/test_oa_tasks_filters.py tests/test_oa_tasks_pagination.py -v
Expected: PASS
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
)"
Files:
Modify: backend/app/api/oa_approval.py
[ ] Step 1: 在 GET /instances/{instance_id} 之前插入路由
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。
启动本地后端后:
# 需带登录 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)
git add backend/app/api/oa_approval.py
git commit -m "$(cat <<'EOF'
feat(oa): expose GET /instances/history for start-page history
EOF
)"
Files:
Create: docs/sql/2026-08-28-oa-start-history-all-perm.sql
[ ] Step 1: 编写 SQL
先查库中「发起审批」菜单 id(执行环境以实际为准):
-- 查找发起审批菜单
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):
-- 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' 不变。
在脚本头部用注释写明:执行后需在「角色管理」勾选该按钮权限。
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
)"
Files:
Modify: web-admin/src/api/oaApproval.ts
[ ] Step 1: 增加方法(放在 fetchOaTasks 附近)
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
})
}
git add web-admin/src/api/oaApproval.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add fetchOaInstanceHistory API
EOF
)"
OaAppHistoryDialog 组件Files:
Create: web-admin/src/components/oa-approval/OaAppHistoryDialog.vue
[ ] Step 1: 创建弹窗组件
对齐 mine/index.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 或表格空文案「暂无历史记录」。
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
)"
Files:
Modify: web-admin/src/views/oa-approval/start/index.vue
[ ] Step 1: 改卡片操作区
<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>
模板底部增加:
<OaAppHistoryDialog ref="historyDialogRef" />
script:
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 权限隐藏。
cd web-admin && pnpm dev
打开 /#/system-manage/oa-approval/start:确认卡片有「历史记录」;点开弹窗;有/无权限角色验证申请人筛选项与数据范围(需先执行 Task 4 SQL 并给角色勾选)。
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
)"
Files:
Run: ./scripts/update-graph.sh all
[ ] Step 1: 更新知识图谱
./scripts/update-graph.sh all
对照 docs/superpowers/specs/2026-08-28-oa-start-history-design.md §8:
git add backend/graphify-out web-admin/graphify-out
git commit -m "$(cat <<'EOF'
chore: update graphify after OA start history feature
EOF
)"
| 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 核对 |
oa:start:history:all/instances/history(在 {instance_id} 前)finishTime 字段名前后端一致preview_limit 仅 history=6,tasks 默认 3