2026-07-15-oa-task-list-columns.md 21 KB

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 末尾新增:

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:

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 一致):

_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 一并)。

  • Step 4: 跑测通过
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(仅用户明确要求时再执行;本步可跳过)
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):

<!-- 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(实例主键)
  • 操作列增加:
<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' })

  • 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 引用后删除文件

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