diff --git a/docs/demos/oa-start-history-demo.html b/docs/demos/oa-start-history-demo.html new file mode 100644 index 00000000..af961095 --- /dev/null +++ b/docs/demos/oa-start-history-demo.html @@ -0,0 +1,555 @@ + + + + + + OA 发起审批 · 历史记录 Demo + + + +
+
+ Demo 说明:在「发起审批」流程卡片上,【发起】左侧增加【历史记录】(按钮始终显示;全员数据靠权限字符)。 + 点击有权限示意卡片上的「历史记录」可打开弹窗。下方虚线按钮仅示意「无全员权限时仍显示历史,但只看本人」。 +
+ +
+ +
+ +
+ + + + +
+
选择一个审批单即可发起 · 有权限时可查看该流程全员历史
+ +
+

人事

+ + 3 个表单 +
+
+ +
+
+
假
+
+ 请假申请 + 事假 / 年假 / 病假等 +
+
+
+ + +
+
+ +
+
+
出
+
+ 出差申请 + 差旅事前审批 +
+
+
+ + +
+
+ + +
+
+
补
+
+ 补卡申请 + 无历史权限时不显示按钮 +
+
+
+ + +
+
+
+ +
+
交互点:点击「请假申请 / 出差申请」的 历史记录 打开弹窗
+
弹窗内:筛选(流程名称只读、审批状态、审批 ID、发起/完成时间段;全员权限另有申请人)+ 列表(标准列 + 表单前 6 个业务字段)+ 分页;操作查看/打印
+
+
+ +
+ +
+ + + + diff --git a/docs/superpowers/plans/2026-08-28-oa-start-history.md b/docs/superpowers/plans/2026-08-28-oa-start-history.md new file mode 100644 index 00000000..6b14b32d --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-oa-start-history.md @@ -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 + + + + +``` + +动态列表头用 `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 +
+ + + 历史记录 + + + + 发起 + +
+``` + +模板底部增加: + +```vue + +``` + +script: + +```typescript +import { Clock, Promotion, Search } from '@element-plus/icons-vue' +import OaAppHistoryDialog from '@/components/oa-approval/OaAppHistoryDialog.vue' + +const historyDialogRef = ref | 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 diff --git a/docs/superpowers/specs/2026-08-28-oa-start-history-design.md b/docs/superpowers/specs/2026-08-28-oa-start-history-design.md new file mode 100644 index 00000000..69c3f180 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-oa-start-history-design.md @@ -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`