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: 后端在 list_tasks 内批量 enrichment(实例 / 应用 / 用户 / pending 审批任务),写入统一摘要字段;前端 pending/done/mine/cc 各写完整页面,删除 RuntimeList 依赖。
Tech Stack: FastAPI / SQLAlchemy / Vue 3 / Element Plus;单测 backend/tests/test_oa_engine.py
Spec: docs/superpowers/specs/2026-07-15-oa-task-list-columns-design.md
| 文件 | 职责 |
|---|---|
backend/app/services/oa_engine.py |
_INSTANCE_STATUS_NODE_LABEL、_enrich_list_rows、改写 list_tasks |
backend/tests/test_oa_engine.py |
enrichment 单测(running / terminal / mine / done 整单进度) |
web-admin/src/views/oa-approval/pending/index.vue |
待处理独立列表 |
web-admin/src/views/oa-approval/done/index.vue |
已处理独立列表 |
web-admin/src/views/oa-approval/mine/index.vue |
我发起的独立列表 + 复制并重新发起 |
web-admin/src/views/oa-approval/cc/index.vue |
抄送我的独立列表 |
web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue |
删除 |
Files:
backend/app/services/oa_engine.pyModify: backend/tests/test_oa_engine.py
[ ] Step 1: 写失败单测
在 backend/tests/test_oa_engine.py 末尾新增:
class TestListTasksEnrichment(unittest.TestCase):
"""list_tasks 返回整单此刻进度摘要字段。"""
def setUp(self):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.models import OaApp, OaAppVersion, OaInstance, OaTask, SysUser
self.engine = create_engine("sqlite:///:memory:")
OaApp.__table__.create(self.engine)
OaAppVersion.__table__.create(self.engine)
OaInstance.__table__.create(self.engine)
OaTask.__table__.create(self.engine)
SysUser.__table__.create(self.engine)
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
self.db = Session()
self.OaTask = OaTask
now = datetime(2026, 7, 15, 10, 0, 0)
self.db.add(
OaApp(
id="app1",
app_name="请假审批",
initiator_scope="all",
admin_scope="all_oa_admin",
status="1",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.add(
OaAppVersion(
id="v1",
app_id="app1",
version_no=1,
status="published",
is_latest_published="1",
form_schema_json="{}",
flow_tree_json="{}",
advanced_settings_json="{}",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.add(
SysUser(
id="u0",
user_no="initiator",
nick_name="发起人甲",
password="x",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.add(
SysUser(
id="u1",
user_no="approver1",
nick_name="审批人乙",
password="x",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.add(
SysUser(
id="u2",
user_no="approver2",
nick_name="审批人丙",
password="x",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.add(
OaInstance(
id="i1",
app_id="app1",
app_version_id="v1",
title="请假-忽略",
initiator_user_id="u0",
initiator_dept_id="d1",
form_data_json="{}",
status="running",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
# u1 已处理;同单仍有 u2 pending
self.db.add(
OaTask(
id="t-done",
instance_id="i1",
node_key="a1",
node_name="一级审批",
task_type="approval",
assignee_user_id="u1",
status="approved",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.add(
OaTask(
id="t-pending",
instance_id="i1",
node_key="a2",
node_name="二级审批",
task_type="approval",
assignee_user_id="u2",
status="pending",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.commit()
def tearDown(self):
self.db.close()
self.engine.dispose()
def test_done_list_shows_instance_current_progress(self):
from app.services.oa_engine import list_tasks
done = list_tasks(self.db, tenant_no="zkxw", user_id="u1", kind="done")
self.assertEqual(len(done), 1)
row = done[0]
self.assertEqual(row["appName"], "请假审批")
self.assertEqual(row["initiatorUserName"], "发起人甲")
self.assertEqual(row["applyTime"], "2026-07-15 10:00:00")
self.assertEqual(row["instanceStatus"], "running")
self.assertEqual(row["currentNodeName"], "二级审批")
self.assertEqual(row["currentApproverNames"], "审批人丙")
# 任务级 status 仍保留
self.assertEqual(row["status"], "approved")
def test_mine_list_enrichment(self):
from app.services.oa_engine import list_tasks
mine = list_tasks(self.db, tenant_no="zkxw", user_id="u0", kind="mine")
self.assertEqual(len(mine), 1)
row = mine[0]
self.assertEqual(row["appName"], "请假审批")
self.assertEqual(row["instanceStatus"], "running")
self.assertEqual(row["currentNodeName"], "二级审批")
self.assertEqual(row["currentApproverNames"], "审批人丙")
def test_terminal_instance_labels(self):
from app.services.oa_engine import list_tasks
inst = self.db.query(
__import__("app.models.models", fromlist=["OaInstance"]).OaInstance
).filter_by(id="i1").first()
inst.status = "returned"
pending = self.db.query(self.OaTask).filter_by(id="t-pending").first()
pending.status = "cancelled"
self.db.commit()
mine = list_tasks(self.db, tenant_no="zkxw", user_id="u0", kind="mine")
row = mine[0]
self.assertEqual(row["instanceStatus"], "returned")
self.assertEqual(row["currentNodeName"], "已打回")
self.assertEqual(row["currentApproverNames"], "-")
若 SysUser 必填字段与上不同,按 backend/app/models/models.py 中 SysUser 实际列调整 setUp(保持能 insert 即可)。
Run:
cd backend && source venv/bin/activate && pytest tests/test_oa_engine.py::TestListTasksEnrichment -q
Expected: FAIL(缺 appName / currentNodeName 等)
在 oa_engine.py 中 _task_to_dict 之后新增(字段名与 spec 一致):
_INSTANCE_STATUS_NODE_LABEL = {
"completed": "已完成",
"rejected": "已拒绝",
"returned": "已打回",
"cancelled": "已撤销",
}
def _instance_progress_summary(
inst: OaInstance,
*,
app_name: str,
initiator_user_name: str,
pending_approval_tasks: List[OaTask],
name_map: Dict[str, str],
) -> Dict[str, Any]:
status = (inst.status or "").strip()
apply_time = (
inst.create_time.strftime("%Y-%m-%d %H:%M:%S") if inst.create_time else ""
)
if status == "running":
node_names: List[str] = []
seen_nodes = set()
approver_names: List[str] = []
for t in pending_approval_tasks:
nn = (t.node_name or t.node_key or "").strip()
if nn and nn not in seen_nodes:
seen_nodes.add(nn)
node_names.append(nn)
uid = (t.assignee_user_id or "").strip()
if uid:
approver_names.append(name_map.get(uid, uid))
current_node = "、".join(node_names) if node_names else "-"
current_approvers = "、".join(approver_names) if approver_names else "-"
else:
current_node = _INSTANCE_STATUS_NODE_LABEL.get(status, status or "-")
current_approvers = "-"
return {
"appName": app_name or (inst.title or ""),
"initiatorUserId": inst.initiator_user_id or "",
"initiatorUserName": initiator_user_name
or name_map.get(inst.initiator_user_id or "", inst.initiator_user_id or ""),
"applyTime": apply_time,
"instanceStatus": status,
"currentNodeName": current_node,
"currentApproverNames": current_approvers,
}
def _enrich_list_rows(
db: Session,
*,
tenant_no: str,
rows: List[Dict[str, Any]],
instances: List[OaInstance],
) -> List[Dict[str, Any]]:
if not rows:
return []
inst_by_id = {i.id: i for i in instances}
app_ids = list({i.app_id for i in instances if i.app_id})
apps = (
db.query(OaApp).filter(OaApp.id.in_(app_ids), OaApp.del_flag == 0).all()
if app_ids
else []
)
app_name_by_id = {a.id: (a.app_name or "") for a in apps}
inst_ids = list(inst_by_id.keys())
pending_tasks = (
db.query(OaTask)
.filter(
OaTask.tenant_no == tenant_no,
OaTask.del_flag == 0,
OaTask.instance_id.in_(inst_ids),
OaTask.task_type == "approval",
OaTask.status == "pending",
)
.order_by(OaTask.create_time.asc())
.all()
if inst_ids
else []
)
pending_by_inst: Dict[str, List[OaTask]] = {}
for t in pending_tasks:
pending_by_inst.setdefault(t.instance_id, []).append(t)
user_ids: List[str] = []
for i in instances:
if i.initiator_user_id:
user_ids.append(i.initiator_user_id)
for t in pending_tasks:
if t.assignee_user_id:
user_ids.append(t.assignee_user_id)
name_map = _user_name_map(db, user_ids)
out: List[Dict[str, Any]] = []
for row in rows:
iid = row.get("instanceId") or row.get("id")
inst = inst_by_id.get(iid)
if not inst:
out.append(row)
continue
summary = _instance_progress_summary(
inst,
app_name=app_name_by_id.get(inst.app_id, ""),
initiator_user_name=name_map.get(inst.initiator_user_id or "", ""),
pending_approval_tasks=pending_by_inst.get(inst.id, []),
name_map=name_map,
)
merged = dict(row)
merged.update(summary)
# mine 行:保留 instance 自身 status,同时带 instanceStatus
if "instanceId" not in row and "status" in row:
# 实例行的 status 本就是 instance status,保持
pass
out.append(merged)
return out
改写 list_tasks 尾部:
def list_tasks(...):
...
elif kind == "mine":
insts = (
db.query(OaInstance)
.filter(
OaInstance.tenant_no == tenant_no,
OaInstance.del_flag == 0,
OaInstance.initiator_user_id == user_id,
)
.order_by(OaInstance.create_time.desc())
.all()
)
rows = [_instance_to_dict(i) for i in insts]
return _enrich_list_rows(db, tenant_no=tenant_no, rows=rows, instances=insts)
rows_orm = q.order_by(OaTask.create_time.desc()).all()
rows = [_task_to_dict(t) for t in rows_orm]
inst_ids = list({t.instance_id for t in rows_orm if t.instance_id})
insts = (
db.query(OaInstance)
.filter(
OaInstance.tenant_no == tenant_no,
OaInstance.del_flag == 0,
OaInstance.id.in_(inst_ids),
)
.all()
if inst_ids
else []
)
return _enrich_list_rows(db, tenant_no=tenant_no, rows=rows, instances=insts)
确认文件顶部已 import OaApp(与现有 OaInstance/OaTask 一并)。
cd backend && source venv/bin/activate && pytest tests/test_oa_engine.py::TestListTasksEnrichment tests/test_oa_engine.py::TestCompleteTaskOrSign::test_approve_keeps_approved_not_done -q
Expected: PASS
git add backend/app/services/oa_engine.py backend/tests/test_oa_engine.py
git commit -m "$(cat <<'EOF'
feat(oa): enrich list_tasks with instance progress summary fields
EOF
)"
Files:
Modify: web-admin/src/views/oa-approval/pending/index.vue
[ ] Step 1: 用完整页面替换 RuntimeList 引用
将 pending/index.vue 写为自包含列表(勿 import RuntimeList):
<!-- OA 待处理 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">待处理</h2>
</div>
<div class="list-body list-wrap">
<el-table class="list-table" :data="rows" v-loading="loading" stripe>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="appName"
label="流程名称"
min-width="160"
align="left"
header-align="left"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.appName || row.title || '-' }}</template>
</el-table-column>
<el-table-column
prop="initiatorUserName"
label="申请人"
min-width="120"
align="center"
>
<template #default="{ row }">{{ row.initiatorUserName || '-' }}</template>
</el-table-column>
<el-table-column
prop="applyTime"
label="申请时间"
min-width="170"
align="center"
>
<template #default="{ row }">{{ row.applyTime || '-' }}</template>
</el-table-column>
<el-table-column
prop="currentNodeName"
label="当前节点"
min-width="140"
align="center"
>
<template #default="{ row }">{{ row.currentNodeName || '-' }}</template>
</el-table-column>
<el-table-column
prop="currentApproverNames"
label="当前审批人"
min-width="140"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.currentApproverNames || '-' }}</template>
</el-table-column>
<el-table-column prop="instanceStatus" label="流程状态" min-width="110" align="center">
<template #default="{ row }">
<el-tag size="small" :type="statusType(row.instanceStatus)">
{{ statusText(row.instanceStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="260" fixed="right" align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="openDetail(row)">
<el-icon><View /></el-icon>
查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { onActivated, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { View } from '@element-plus/icons-vue'
import { fetchOaTasks } from '@/api/oaApproval'
defineOptions({ name: 'OaApprovalPending' })
const router = useRouter()
const loading = ref(false)
const rows = ref<any[]>([])
function statusType(s: string) {
if (s === 'pending' || s === 'running') return 'warning'
if (s === 'approved' || s === 'completed' || s === 'done') return 'success'
if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
return 'info'
}
function statusText(s: string) {
if (!s) return '-'
const map: Record<string, string> = {
pending: '待处理',
running: '进行中',
approved: '已同意',
rejected: '已拒绝',
completed: '已完成',
cancelled: '已撤销',
done: '已结束',
returned: '已打回'
}
return map[s] || s
}
function openDetail(row: any) {
const id = row.instanceId
if (!id) return
router.push({ path: `/system-manage/oa-approval/instances/${id}` })
}
async function load() {
loading.value = true
try {
const res = await fetchOaTasks('pending')
rows.value = res?.list || []
} catch {
rows.value = []
} finally {
loading.value = false
}
}
onMounted(load)
onActivated(load)
</script>
[ ] Step 2: 目视确认无 RuntimeList import;列齐全
[ ] Step 3: Commit(可选,用户要求时)
Files:
Modify: web-admin/src/views/oa-approval/done/index.vue
[ ] Step 1: 复制 Task 2 结构,改为「已处理」
与 pending 相同列与逻辑,差异:
defineOptions({ name: 'OaApprovalDone' })已处理fetchOaTasks('done')row.instanceId注意: 流程状态列必须用 instanceStatus(整单),不要用任务 status。
Files:
Modify: web-admin/src/views/oa-approval/mine/index.vue
[ ] Step 1: 独立页 + 复制并重新发起
在 Task 2 列基础上:
我发起的;fetchOaTasks('mine')openDetail 用 row.id(实例主键)<el-button
v-if="row.appId && ['returned', 'cancelled', 'rejected'].includes(row.instanceStatus || row.status)"
class="btn-op-item"
@click="openCopyRestart(row)"
>
<el-icon><RefreshRight /></el-icon>
复制并重新发起
</el-button>
import { RefreshRight, View } from '@element-plus/icons-vue'
function openDetail(row: any) {
if (!row?.id) return
router.push({ path: `/system-manage/oa-approval/instances/${row.id}` })
}
function openCopyRestart(row: any) {
if (!row?.appId || !row?.id) return
router.push({
path: '/system-manage/oa-approval/start',
query: { appId: row.appId, copyFromInstanceId: row.id }
})
}
defineOptions({ name: 'OaApprovalMine' })
Files:
web-admin/src/views/oa-approval/cc/index.vueDelete: web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue
[ ] Step 1: 按 Task 2 写 cc/index.vue
标题 抄送我的;fetchOaTasks('cc');详情 row.instanceId
defineOptions({ name: 'OaApprovalCc' })
[ ] Step 2: 全局确认无 RuntimeList 引用后删除文件
rg "RuntimeList" web-admin/src
Expected: 无匹配(或仅残留注释)。然后删除 _placeholder/RuntimeList.vue。若 _placeholder 目录空,可一并删除空目录。
[x] Step 1: 后端全量相关测 — pytest tests/test_oa_engine.py -q → 21 passed
[x] Step 2: 更新图谱 — backend 增量更新;frontend force 覆盖(RuntimeList 已移除)
[x] Step 3: 勾选 spec §7 验收项
| Spec 要求 | Task |
|---|---|
| 摘要字段 appName/initiator/applyTime/current*/instanceStatus | Task 1 |
| 整单此刻进度;done 不展示本人任务快照 | Task 1 test_done_list_shows_instance_current_progress |
终态节点文案 + 审批人 - |
Task 1 test_terminal_instance_labels |
| 批量无 N+1 | Task 1 _enrich_list_rows |
| 四页独立列 | Task 2–5 |
| 删除 RuntimeList | Task 5 |
| 我发起终态复制再发 | Task 4 |
| 图谱 | Task 6 |