Files
zkxw/docs/superpowers/plans/2026-07-15-oa-task-list-columns.md
T
2026-07-15 16:20:48 +08:00

688 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:** 后端在 `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`
---
## File map
| 文件 | 职责 |
|------|------|
| `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` | 删除 |
---
### Task 1: 后端 — list_tasks enrichment + 单测
**Files:**
- Modify: `backend/app/services/oa_engine.py`
- Modify: `backend/tests/test_oa_engine.py`
- [ ] **Step 1: 写失败单测**
在 `backend/tests/test_oa_engine.py` 末尾新增:
```python
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 即可)。
- [ ] **Step 2: 跑测确认失败**
Run:
```bash
cd backend && source venv/bin/activate && pytest tests/test_oa_engine.py::TestListTasksEnrichment -q
```
Expected: FAIL(缺 `appName` / `currentNodeName` 等)
- [ ] **Step 3: 实现 enrichment**
在 `oa_engine.py` 中 `_task_to_dict` 之后新增(字段名与 spec 一致):
```python
_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` 尾部:
```python
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` 一并)。
- [ ] **Step 4: 跑测通过**
```bash
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
- [ ] **Step 5: Commit(仅用户明确要求时再执行;本步可跳过)**
```bash
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
)"
```
---
### Task 2: 前端 — 待处理独立页
**Files:**
- Modify: `web-admin/src/views/oa-approval/pending/index.vue`
- [ ] **Step 1: 用完整页面替换 RuntimeList 引用**
将 `pending/index.vue` 写为自包含列表(勿 import RuntimeList):
```vue
<!-- 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(可选,用户要求时)**
---
### Task 3: 前端 — 已处理独立页
**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`。
- [ ] **Step 2: Commit(可选)**
---
### Task 4: 前端 — 我发起的独立页
**Files:**
- Modify: `web-admin/src/views/oa-approval/mine/index.vue`
- [ ] **Step 1: 独立页 + 复制并重新发起**
在 Task 2 列基础上:
- 标题 `我发起的`;`fetchOaTasks('mine')`
- `openDetail` 用 `row.id`(实例主键)
- 操作列增加:
```vue
<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>
```
```ts
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' })`
- [ ] **Step 2: Commit(可选)**
---
### Task 5: 前端 — 抄送我的 + 删除 RuntimeList
**Files:**
- Modify: `web-admin/src/views/oa-approval/cc/index.vue`
- Delete: `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 引用后删除文件**
```bash
rg "RuntimeList" web-admin/src
```
Expected: 无匹配(或仅残留注释)。然后删除 `_placeholder/RuntimeList.vue`。若 `_placeholder` 目录空,可一并删除空目录。
- [ ] **Step 3: Commit(可选)**
---
### Task 6: 验收 + 图谱
- [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 coverage (self-review)
| 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 |