|
|
@@ -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
|
|
|
+<!-- 要点(完整实现时展开为完整 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` 或表格空文案「暂无历史记录」。
|
|
|
+
|
|
|
+- [ ] **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
|
|
|
+<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>
|
|
|
+```
|
|
|
+
|
|
|
+模板底部增加:
|
|
|
+
|
|
|
+```vue
|
|
|
+<OaAppHistoryDialog ref="historyDialogRef" />
|
|
|
+```
|
|
|
+
|
|
|
+script:
|
|
|
+
|
|
|
+```typescript
|
|
|
+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` 权限隐藏。
|
|
|
+
|
|
|
+- [ ] **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
|