Procházet zdrojové kódy

docs(oa): add related-approval design and plan progress notes

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu před 3 měsíci
rodič
revize
67cdf76741

+ 884 - 0
docs/superpowers/plans/2026-07-22-oa-related-approval.md

@@ -0,0 +1,884 @@
+# OA Related Approval Control 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:** Deliver the FormCreate `relatedApproval` control so designers can drag it, initiators can multi-select their own completed approvals, see expand/collapse field summaries, persist `oa_relation` with `field_key`, and open related instance details.
+
+**Architecture:** Backend adds picker list + enrichment APIs and changes `start_instance` to extract `relatedApproval` IDs from `form_data` (with `relatedInstanceIds` fallback). Frontend registers a custom FormCreate component on both the main app and `oa-fc-host` (four-layer FcDesigner extension), rendering Mint-styled summaries (Web wrap flow / narrow stacked rows).
+
+**Tech Stack:** FastAPI · SQLAlchemy · pytest · Vue 3 · FormCreate / `@form-create/designer` · Element Plus · Fresh Mint SCSS
+
+**Spec:** `docs/superpowers/specs/2026-07-22-oa-related-approval-design.md`
+
+---
+
+## File map
+
+| Path | Responsibility |
+|------|----------------|
+| Create: `backend/app/services/oa_related_approval.py` | Flatten schema→summary fields; extract links from form_data; picker/enrich helpers |
+| Modify: `backend/app/services/oa_engine.py` | Call extract+validate on start; write `oa_relation` with `field_key` |
+| Modify: `backend/app/api/oa_approval.py` | `GET .../related-candidates`, `GET .../related-summaries` |
+| Create: `backend/tests/test_oa_related_approval.py` | Unit tests for flatten / extract / validate |
+| Create: `web-admin/src/components/oa-approval/OaRelatedApproval.vue` | Runtime control UI |
+| Create: `web-admin/src/utils/oa-related-approval-rule.ts` | DragRule |
+| Create: `web-admin/src/utils/oa-form-create-related-approval.ts` | `setupOaRelatedApproval` registration |
+| Modify: `web-admin/src/main.ts` | Call setup after FormCreate install |
+| Modify: `web-admin/src/oa-fc-host/main.ts` | Same setup + `FcDesigner.addDragRule` |
+| Modify: `web-admin/src/oa-fc-host/App.vue` | Optional `addComponent` if instance API preferred (prefer global `addDragRule`) |
+| Modify: `web-admin/src/api/oaApproval.ts` | Client APIs |
+| Modify: `docs/superpowers/specs/2026-07-22-oa-related-approval-design.md` | Keep in sync if behavior clarifies |
+
+**Clarification locked in this plan (view enrichment):** For `mode=view`, return summaries for any existing tenant instance IDs; mark `unavailable: true` only if missing/deleted. Jump-to-detail still uses existing detail auth. Picker + start validation stay **self + completed** only.
+
+**Progress (2026-07-22):** Tasks 1–7 implemented on `feat/oa-approval-dingtalk`. Task 8 (manual E2E) remains for human verification.
+
+---
+
+### Task 1: Flatten + extract helpers (TDD)
+
+**Files:**
+- Create: `backend/app/services/oa_related_approval.py`
+- Create: `backend/tests/test_oa_related_approval.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+# backend/tests/test_oa_related_approval.py
+import unittest
+
+from app.services.oa_related_approval import (
+    extract_related_approval_links,
+    flatten_form_fields_for_summary,
+)
+
+
+class TestFlatten(unittest.TestCase):
+    def test_skips_layout_and_summarizes_upload(self):
+        schema = {
+            "rule": [
+                {"type": "html", "title": "说明"},
+                {"type": "input", "field": "reason", "title": "事由"},
+                {"type": "upload", "field": "files", "title": "附件"},
+                {"type": "relatedApproval", "field": "prev", "title": "关联"},
+                {"type": "textarea", "field": "long", "title": "详细说明"},
+            ]
+        }
+        data = {
+            "reason": "出差",
+            "files": ["a.png", "b.png"],
+            "prev": ["id1", "id2"],
+            "long": "很长很长的说明文字" * 5,
+        }
+        fields = flatten_form_fields_for_summary(schema, data)
+        labels = [f["label"] for f in fields]
+        self.assertNotIn("说明", labels)
+        self.assertEqual(fields[0], {"label": "事由", "value": "出差"})
+        self.assertEqual(fields[1]["value"], "2 个")
+        self.assertEqual(fields[2]["value"], "已关联 2 条")
+        self.assertTrue(fields[3].get("fullWidth"))
+
+    def test_extract_links_with_field_key_and_dedupe(self):
+        schema = {
+            "rule": [
+                {"type": "relatedApproval", "field": "relA"},
+                {
+                    "type": "fcRow",
+                    "children": [{"type": "relatedApproval", "field": "relB"}],
+                },
+            ]
+        }
+        data = {"relA": ["x", "x", ""], "relB": "solo"}
+        links = extract_related_approval_links(schema, data)
+        self.assertEqual(links, [("relA", "x"), ("relB", "solo")])
+
+
+if __name__ == "__main__":
+    unittest.main()
+```
+
+- [ ] **Step 2: Run tests — expect FAIL**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
+```
+
+Expected: import error or missing symbols.
+
+- [ ] **Step 3: Implement helpers**
+
+```python
+# backend/app/services/oa_related_approval.py
+"""关联审批单:字段扁平化与 form_data 抽链"""
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional, Sequence, Tuple
+
+LAYOUT_SKIP = frozenset(
+    {"fcRow", "fcTable", "col", "row", "elCard", "space", "divider", "html", "title", "text", "tag"}
+)
+UPLOAD_TYPES = frozenset({"upload", "fcUpload", "image", "oaAttachment"})
+LONG_TYPES = frozenset({"textarea", "input"})
+
+
+def _walk_rules(rules: Any, out: List[dict]) -> None:
+    if not isinstance(rules, list):
+        return
+    for r in rules:
+        if not isinstance(r, dict):
+            continue
+        out.append(r)
+        children = r.get("children") or (r.get("props") or {}).get("rule") or []
+        if isinstance(children, list):
+            _walk_rules(children, out)
+
+
+def _schema_rules(schema: Any) -> List[dict]:
+    if isinstance(schema, list):
+        out: List[dict] = []
+        _walk_rules(schema, out)
+        return out
+    if not isinstance(schema, dict):
+        return []
+    out = []
+    _walk_rules(schema.get("rule") or schema.get("rules") or [], out)
+    return out
+
+
+def _format_value(typ: str, raw: Any) -> Tuple[str, bool]:
+    """returns (text, fullWidth)"""
+    if typ in UPLOAD_TYPES:
+        if raw is None or raw == "" or raw == []:
+            n = 0
+        elif isinstance(raw, list):
+            n = len(raw)
+        else:
+            n = len([x for x in str(raw).split(",") if x.strip()])
+        return f"{n} 个", False
+    if typ == "relatedApproval":
+        if isinstance(raw, list):
+            n = len([x for x in raw if str(x).strip()])
+        elif raw:
+            n = 1
+        else:
+            n = 0
+        return f"已关联 {n} 条", False
+    if isinstance(raw, list):
+        text = "、".join(str(x) for x in raw if x is not None and str(x) != "")
+    elif isinstance(raw, dict):
+        text = str(raw.get("label") or raw.get("value") or raw)
+    else:
+        text = "" if raw is None else str(raw)
+    full = typ in LONG_TYPES and len(text) > 40
+    return text, full
+
+
+def flatten_form_fields_for_summary(schema: Any, form_data: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    data = form_data or {}
+    out: List[Dict[str, Any]] = []
+    for rule in _schema_rules(schema):
+        typ = str(rule.get("type") or "")
+        if not typ or typ in LAYOUT_SKIP:
+            continue
+        field = str(rule.get("field") or "").strip()
+        if not field:
+            continue
+        label = str(rule.get("title") or rule.get("label") or field)
+        text, full = _format_value(typ, data.get(field))
+        item: Dict[str, Any] = {"label": label, "value": text}
+        if full:
+            item["fullWidth"] = True
+        out.append(item)
+    return out
+
+
+def extract_related_approval_links(
+    schema: Any, form_data: Optional[Dict[str, Any]]
+) -> List[Tuple[str, str]]:
+    """Ordered unique (field_key, to_instance_id)."""
+    data = form_data or {}
+    seen = set()
+    links: List[Tuple[str, str]] = []
+    for rule in _schema_rules(schema):
+        if str(rule.get("type") or "") != "relatedApproval":
+            continue
+        field = str(rule.get("field") or "").strip()
+        if not field:
+            continue
+        raw = data.get(field)
+        ids: Sequence[Any]
+        if isinstance(raw, list):
+            ids = raw
+        elif raw is None or raw == "":
+            ids = []
+        else:
+            ids = [raw]
+        for x in ids:
+            tid = str(x or "").strip()
+            if not tid:
+                continue
+            key = (field, tid)
+            if key in seen:
+                continue
+            seen.add(key)
+            links.append((field, tid))
+    return links
+```
+
+- [ ] **Step 4: Run tests — expect PASS**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/oa_related_approval.py backend/tests/test_oa_related_approval.py
+git commit -m "$(cat <<'EOF'
+feat(oa): add related-approval flatten and extract helpers
+
+EOF
+)"
+```
+
+---
+
+### Task 2: Picker list + enrichment service + API
+
+**Files:**
+- Modify: `backend/app/services/oa_related_approval.py`
+- Modify: `backend/app/api/oa_approval.py`
+- Modify: `backend/tests/test_oa_related_approval.py`
+
+- [ ] **Step 1: Add failing tests for validate + summary shape**
+
+```python
+class TestValidateLinkable(unittest.TestCase):
+    def test_rejects_non_completed(self):
+        from app.services.oa_related_approval import assert_linkable_instance
+        from app.services.oa_engine import OaEngineError
+        from unittest.mock import MagicMock
+        inst = MagicMock(status="running", initiator_user_id="u1", tenant_no="t", del_flag=0)
+        with self.assertRaises(OaEngineError):
+            assert_linkable_instance(inst, user_id="u1", tenant_no="t")
+```
+
+- [ ] **Step 2: Run — expect FAIL** (missing `assert_linkable_instance`)
+
+- [ ] **Step 3: Implement service functions**
+
+Append to `oa_related_approval.py`:
+
+```python
+from datetime import datetime
+
+from app.models.models import OaApp, OaAppVersion, OaInstance
+from app.services.oa_engine import OaEngineError, _loads
+
+
+def assert_linkable_instance(inst: OaInstance, *, user_id: str, tenant_no: str) -> None:
+    if not inst or inst.del_flag != 0 or inst.tenant_no != tenant_no:
+        raise OaEngineError("关联的审批单不存在")
+    if (inst.initiator_user_id or "") != user_id:
+        raise OaEngineError("只能关联本人发起的审批单")
+    if (inst.status or "") != "completed":
+        raise OaEngineError("只能关联已通过的审批单")
+
+
+def list_related_candidates(
+    db,
+    *,
+    tenant_no: str,
+    user_id: str,
+    keyword: str = "",
+    page: int = 1,
+    page_size: int = 20,
+) -> Dict[str, Any]:
+    q = db.query(OaInstance).filter(
+        OaInstance.tenant_no == tenant_no,
+        OaInstance.del_flag == 0,
+        OaInstance.initiator_user_id == user_id,
+        OaInstance.status == "completed",
+    )
+    kw = (keyword or "").strip()
+    if kw:
+        from sqlalchemy import or_
+
+        app_ids = [
+            a.id
+            for a in db.query(OaApp.id)
+            .filter(
+                OaApp.tenant_no == tenant_no,
+                OaApp.del_flag == 0,
+                OaApp.app_name.like(f"%{kw}%"),
+            )
+            .all()
+        ]
+        clauses = [OaInstance.title.like(f"%{kw}%")]
+        if app_ids:
+            clauses.append(OaInstance.app_id.in_(app_ids))
+        q = q.filter(or_(*clauses))
+    total = q.count()
+    page = max(1, int(page or 1))
+    page_size = min(100, max(1, int(page_size or 20)))
+    rows = (
+        q.order_by(OaInstance.create_time.desc())
+        .offset((page - 1) * page_size)
+        .limit(page_size)
+        .all()
+    )
+    app_map = {}
+    ids = list({r.app_id for r in rows if r.app_id})
+    if ids:
+        for a in db.query(OaApp).filter(OaApp.id.in_(ids)).all():
+            app_map[a.id] = a.app_name or ""
+    items = []
+    for r in rows:
+        items.append(
+            {
+                "id": r.id,
+                "appName": app_map.get(r.app_id, ""),
+                "title": r.title or "",
+                "applyTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
+                "status": r.status or "",
+            }
+        )
+    return {"list": items, "total": total, "page": page, "pageSize": page_size}
+
+
+def build_related_summaries(
+    db,
+    *,
+    tenant_no: str,
+    user_id: str,
+    ids: List[str],
+    mode: str = "picker",
+) -> List[Dict[str, Any]]:
+    mode = (mode or "picker").strip()
+    out: List[Dict[str, Any]] = []
+    clean = [str(i).strip() for i in ids if str(i).strip()]
+    if not clean:
+        return out
+    insts = (
+        db.query(OaInstance)
+        .filter(
+            OaInstance.tenant_no == tenant_no,
+            OaInstance.del_flag == 0,
+            OaInstance.id.in_(clean),
+        )
+        .all()
+    )
+    by_id = {i.id: i for i in insts}
+    for iid in clean:
+        inst = by_id.get(iid)
+        if not inst:
+            out.append(
+                {
+                    "id": iid,
+                    "unavailable": True,
+                    "appName": "",
+                    "applyTime": "",
+                    "status": "",
+                    "fields": [],
+                }
+            )
+            continue
+        if mode == "picker":
+            try:
+                assert_linkable_instance(inst, user_id=user_id, tenant_no=tenant_no)
+            except OaEngineError:
+                out.append(
+                    {
+                        "id": iid,
+                        "unavailable": True,
+                        "appName": "",
+                        "applyTime": "",
+                        "status": "",
+                        "fields": [],
+                    }
+                )
+                continue
+        app = db.query(OaApp).filter(OaApp.id == inst.app_id, OaApp.del_flag == 0).first()
+        ver = (
+            db.query(OaAppVersion)
+            .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
+            .first()
+        )
+        schema = _loads(ver.form_schema_json, {}) if ver else {}
+        form_data = _loads(inst.form_data_json, {}) if inst.form_data_json else {}
+        out.append(
+            {
+                "id": inst.id,
+                "unavailable": False,
+                "appName": (app.app_name if app else "") or (inst.title or ""),
+                "applyTime": inst.create_time.strftime("%Y-%m-%d %H:%M:%S") if inst.create_time else "",
+                "status": inst.status or "",
+                "fields": flatten_form_fields_for_summary(schema, form_data),
+            }
+        )
+    return out
+```
+
+- [ ] **Step 4: Wire API routes** in `oa_approval.py` (after templates section is fine):
+
+```python
+@router.get("/related-candidates")
+async def list_related_candidates(
+    keyword: 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.services import oa_related_approval as rel
+
+    tenant_no = _get_tenant_no(db, user)
+    data = rel.list_related_candidates(
+        db,
+        tenant_no=tenant_no,
+        user_id=user.get("user_id") or "",
+        keyword=keyword,
+        page=page,
+        page_size=pageSize,
+    )
+    return success_response(data)
+
+
+@router.get("/related-summaries")
+async def get_related_summaries(
+    ids: str = Query("", description="comma-separated instance ids"),
+    mode: str = Query("picker"),
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    from app.services import oa_related_approval as rel
+    from app.services.oa_engine import OaEngineError
+
+    tenant_no = _get_tenant_no(db, user)
+    id_list = [x.strip() for x in (ids or "").split(",") if x.strip()]
+    try:
+        items = rel.build_related_summaries(
+            db,
+            tenant_no=tenant_no,
+            user_id=user.get("user_id") or "",
+            ids=id_list,
+            mode=mode,
+        )
+    except OaEngineError as e:
+        return error_response(str(e))
+    return success_response({"list": items})
+```
+
+- [ ] **Step 5: Run unit tests**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
+```
+
+Expected: PASS (mock-based validate test).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/app/services/oa_related_approval.py backend/app/api/oa_approval.py backend/tests/test_oa_related_approval.py
+git commit -m "$(cat <<'EOF'
+feat(oa): add related-approval candidate and summary APIs
+
+EOF
+)"
+```
+
+---
+
+### Task 3: `start_instance` writes relations with `field_key`
+
+**Files:**
+- Modify: `backend/app/services/oa_engine.py` (`start_instance`)
+- Modify: `backend/tests/test_oa_related_approval.py`
+
+- [ ] **Step 1: Add unit test for merge logic (pure function)**
+
+In `oa_related_approval.py` add:
+
+```python
+def merge_relation_targets(
+    schema: Any,
+    form_data: Optional[Dict[str, Any]],
+    legacy_ids: Optional[List[str]] = None,
+) -> List[Tuple[Optional[str], str]]:
+    """Returns (field_key|None, to_id) — form fields first, then legacy without key."""
+    links = extract_related_approval_links(schema, form_data)
+    result: List[Tuple[Optional[str], str]] = [(f, i) for f, i in links]
+    seen = {(f, i) for f, i in links}
+    seen_ids = {i for _, i in links}
+    for x in legacy_ids or []:
+        tid = str(x or "").strip()
+        if not tid or tid in seen_ids:
+            continue
+        result.append((None, tid))
+        seen_ids.add(tid)
+    return result
+```
+
+Test:
+
+```python
+def test_merge_prefers_form_fields(self):
+    schema = {"rule": [{"type": "relatedApproval", "field": "r1"}]}
+    m = merge_relation_targets(schema, {"r1": ["a"]}, ["a", "b"])
+    self.assertEqual(m, [("r1", "a"), (None, "b")])
+```
+
+- [ ] **Step 2: Replace relation loop in `start_instance`**
+
+After `db.add(inst)`, replace the `for to_id in related_instance_ids` block with:
+
+```python
+    from app.services.oa_related_approval import (
+        assert_linkable_instance,
+        merge_relation_targets,
+    )
+
+    targets = merge_relation_targets(schema, data, related_instance_ids)
+    for field_key, to_id in targets:
+        target = (
+            db.query(OaInstance)
+            .filter(
+                OaInstance.id == to_id,
+                OaInstance.tenant_no == tenant_no,
+                OaInstance.del_flag == 0,
+            )
+            .first()
+        )
+        assert_linkable_instance(target, user_id=initiator_user_id, tenant_no=tenant_no)
+        db.add(
+            OaRelation(
+                id=generate_uuid(),
+                from_instance_id=inst.id,
+                to_instance_id=to_id,
+                field_key=field_key,
+                tenant_no=tenant_no,
+                order_num=0,
+                remark=None,
+                create_by=initiator_user_id,
+                create_dept_id=dept_id or "",
+                create_time=now,
+                update_by=initiator_user_id,
+                update_time=now,
+                del_flag=0,
+            )
+        )
+```
+
+- [ ] **Step 3: Run tests**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py tests/test_oa_engine.py -v --tb=short
+```
+
+Expected: related tests PASS; existing engine tests still PASS.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add backend/app/services/oa_engine.py backend/app/services/oa_related_approval.py backend/tests/test_oa_related_approval.py
+git commit -m "$(cat <<'EOF'
+feat(oa): persist relatedApproval links with field_key on start
+
+EOF
+)"
+```
+
+---
+
+### Task 4: Frontend API client
+
+**Files:**
+- Modify: `web-admin/src/api/oaApproval.ts`
+
+- [ ] **Step 1: Append APIs**
+
+```typescript
+export type OaRelatedSummaryField = {
+  label: string
+  value: string
+  fullWidth?: boolean
+}
+
+export type OaRelatedSummary = {
+  id: string
+  unavailable?: boolean
+  appName: string
+  applyTime: string
+  status: string
+  fields: OaRelatedSummaryField[]
+}
+
+export function fetchOaRelatedCandidates(params?: {
+  keyword?: string
+  page?: number
+  pageSize?: number
+}) {
+  return request.get<{ list: any[]; total: number; page: number; pageSize: number }>({
+    url: `${BASE}/related-candidates`,
+    params
+  })
+}
+
+export function fetchOaRelatedSummaries(ids: string[], mode: 'picker' | 'view' = 'view') {
+  return request.get<{ list: OaRelatedSummary[] }>({
+    url: `${BASE}/related-summaries`,
+    params: { ids: ids.filter(Boolean).join(','), mode }
+  })
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web-admin/src/api/oaApproval.ts
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add OA related-approval API clients
+
+EOF
+)"
+```
+
+---
+
+### Task 5: Runtime component + FormCreate registration (spike shell)
+
+**Files:**
+- Create: `web-admin/src/components/oa-approval/OaRelatedApproval.vue`
+- Create: `web-admin/src/utils/oa-form-create-related-approval.ts`
+- Modify: `web-admin/src/main.ts`
+- Modify: `web-admin/src/oa-fc-host/main.ts`
+
+- [ ] **Step 1: Create minimal runtime component** (modelValue `string[]`, designer-safe empty state)
+
+Use `modelValue` + `update:modelValue` + `disabled` + `formCreateInject` like SignaturePad. Collapsed N=3 constant:
+
+```ts
+const COLLAPSE_FIELD_COUNT = 3
+```
+
+Skeleton behavior:
+- If `disabled` or no `modelValue?.length`: show Mint dashed box +「添加关联」button (hidden when `disabled`)
+- Else: list cards; each loads summary via `fetchOaRelatedSummaries` (`mode=view` when disabled, `picker` when editing — use `view` always for display after select is fine)
+- Expand/collapse local Set
+- Web: class `oa-rel--flow`; narrow (`matchMedia('(max-width: 768px)')` or container): `oa-rel--stack`
+- Click title → `router.push(`/system-manage/oa-approval/instances/${id}`)`
+- Picker dialog: `el-dialog` + table from `fetchOaRelatedCandidates`; multi-select; confirm merges into modelValue
+
+Keep SCSS scoped; colors `--primary` / `#10b981` / `#ecfdf5`; **no** FormCreate blue.
+
+- [ ] **Step 2: Registration helper**
+
+```typescript
+// web-admin/src/utils/oa-form-create-related-approval.ts
+import FcDesigner from '@form-create/designer'
+import OaRelatedApproval from '@/components/oa-approval/OaRelatedApproval.vue'
+import { relatedApprovalDragRule } from '@/utils/oa-related-approval-rule'
+
+const FLAG = '__oaRelatedApprovalConfigured'
+
+export function setupOaRelatedApproval(formCreate?: { component?: Function }) {
+  if ((FcDesigner as any)[FLAG]) return
+  FcDesigner.component('relatedApproval', OaRelatedApproval)
+  formCreate?.component?.('relatedApproval', OaRelatedApproval)
+  if (typeof FcDesigner.addDragRule === 'function') {
+    FcDesigner.addDragRule(relatedApprovalDragRule)
+  }
+  ;(FcDesigner as any)[FLAG] = true
+}
+```
+
+- [ ] **Step 3: Call setup** in `main.ts` and `oa-fc-host/main.ts` immediately after `setupFormCreateUpload(...)`.
+
+- [ ] **Step 4: Manual smoke**
+
+```bash
+cd web-admin && pnpm dev
+```
+
+Open designer form tab: left menu should list control after Task 6 DragRule; for now if rule missing, at least runtime import must not crash app.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web-admin/src/components/oa-approval/OaRelatedApproval.vue \
+  web-admin/src/utils/oa-form-create-related-approval.ts \
+  web-admin/src/main.ts web-admin/src/oa-fc-host/main.ts
+git commit -m "$(cat <<'EOF'
+feat(web-admin): register relatedApproval FormCreate runtime component
+
+EOF
+)"
+```
+
+---
+
+### Task 6: DragRule for designer palette
+
+**Files:**
+- Create: `web-admin/src/utils/oa-related-approval-rule.ts`
+- Ensure Task 5 import resolves
+
+- [ ] **Step 1: Implement DragRule**
+
+```typescript
+import uniqueId from '@form-create/utils/lib/unique'
+
+export const relatedApprovalDragRule = {
+  menu: 'main' as const,
+  icon: 'icon-select',
+  label: '关联审批单',
+  name: 'relatedApproval',
+  input: true,
+  mask: true,
+  validate: ['array'],
+  event: ['change'],
+  rule() {
+    return {
+      type: 'relatedApproval',
+      field: uniqueId(),
+      title: '关联审批单',
+      info: '',
+      $required: false,
+      props: {}
+    }
+  },
+  props() {
+    return [
+      {
+        type: 'input',
+        field: 'formCreateInfo',
+        title: '提示信息'
+      }
+    ]
+  }
+}
+```
+
+- [ ] **Step 2: Verify designer**
+
+Open `/#/system-manage/oa-approval/designer/:id` →「2 表单设计」→ left **基础组件** shows「关联审批单」→ drag → export JSON `type` is `relatedApproval` → publish still OK.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/utils/oa-related-approval-rule.ts web-admin/src/utils/oa-form-create-related-approval.ts
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add relatedApproval designer drag rule
+
+EOF
+)"
+```
+
+---
+
+### Task 7: Complete UI (picker, expand/collapse, responsive)
+
+**Files:**
+- Modify: `web-admin/src/components/oa-approval/OaRelatedApproval.vue`
+
+- [ ] **Step 1: Implement full interactions per spec §5**
+  - Collapse: `appName · applyTime` + first 3 `fields` as tag flow
+  - Expand: all fields; `fullWidth` items `flex-basis: 100%`
+  - Stack mode on narrow: each field one row `label:value`
+  - Remove button when not disabled
+  - Empty picker empty-state copy:「暂无已通过的申请」
+  - Already-selected rows disabled in picker
+  - Designer iframe: do not auto-fetch on mount if no ids; button may no-op or open empty dialog without crashing
+
+- [ ] **Step 2: Visual check against Fresh Mint** (primary green chips, 8–10px radius, no blue)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/components/oa-approval/OaRelatedApproval.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): finish relatedApproval picker and summary UI
+
+EOF
+)"
+```
+
+---
+
+### Task 8: End-to-end acceptance + docs touch-up
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-07-22-oa-related-approval-design.md` (view-mode clarification if needed)
+- Optional: checklist note in `docs/superpowers/plans/2026-07-14-oa-approval-e2e-checklist.md`
+
+- [ ] **Step 1: Manual E2E**
+
+1. Publish app with `relatedApproval` field  
+2. Have ≥1 completed instance as same user  
+3. Start new instance → select 2 → expand/collapse → submit  
+4. DB/API: `oa_relation` rows have `field_key`  
+5. Detail: summaries load; title navigates  
+6. Tamper ID in network → start fails with clear message  
+7. Old app without control: unchanged  
+
+- [ ] **Step 2: Run backend tests once more**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py tests/test_oa_publish.py -v
+```
+
+- [ ] **Step 3: Update graph if code landed**
+
+```bash
+./scripts/update-graph.sh backend
+./scripts/update-graph.sh frontend
+```
+
+- [ ] **Step 4: Commit docs / graph**
+
+```bash
+git add docs/superpowers/specs/2026-07-22-oa-related-approval-design.md \
+  docs/superpowers/plans/2026-07-22-oa-related-approval.md \
+  backend/graphify-out web-admin/graphify-out 2>/dev/null || true
+git commit -m "$(cat <<'EOF'
+docs(oa): mark related-approval E2E done and refresh graphs
+
+EOF
+)"
+```
+
+---
+
+## Spec coverage self-check
+
+| Spec item | Task |
+|-----------|------|
+| FormCreate 四层扩展 | 5–6 |
+| form_data `string[]` | 5, 7 |
+| 本人+completed+任意应用+多选 | 2, 7 |
+| 收起前 3 / 展开全字段 / Web 流 / 移动逐行 | 7 |
+| enrichment + flatten 规则 | 1–2 |
+| start 写 `oa_relation`+`field_key` + legacy 兜底 | 3 |
+| 越权拒绝 / 旧应用兼容 | 2–3, 8 |
+| Fresh Mint UI | 5, 7 |
+| 详情跳转 | 7 |
+| 不串子流程 / 不回填字段 | N/A (by omission) |
+
+## Placeholder scan
+
+No TBD steps; view-mode enrichment clarified in File map.
+
+---
+
+## Execution handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-07-22-oa-related-approval.md`.
+
+**Two execution options:**
+
+1. **Subagent-Driven (recommended)** — fresh subagent per task, review between tasks  
+2. **Inline Execution** — execute in this session with executing-plans checkpoints  
+
+Which approach?

+ 240 - 0
docs/superpowers/specs/2026-07-22-oa-related-approval-design.md

@@ -0,0 +1,240 @@
+# OA「关联审批单」控件设计文档
+
+**日期**: 2026-07-22  
+**状态**: 已批准(实现计划见 `docs/superpowers/plans/2026-07-22-oa-related-approval.md`)  
+**方案**: 方案 1 — `form_data` 仅存 instanceId[],展示时 enrichment 实时拼装概要;提交写 `oa_relation`  
+**界面模式**: 对齐钉钉「关联审批单」;视觉遵循 Fresh Mint(`docs/web-admin-ui-spec-fresh-mint.md`)  
+**前置文档**: `docs/superpowers/specs/2026-07-14-oa-approval-dingtalk-design.md` §4.5 / §5.5  
+
+**决策记录**:
+- 产品意图:引用 + 展示历史单字段内容 + 可跳转详情;**不**自动串子流程、**不**自动回填当前单其它字段
+- 选单范围:当前用户**本人发起**、**任意审批应用**、仅实例状态 **`completed`(已通过)**;支持**多选**
+- 展示:标签流;**展开**=全部可展示业务字段;**收起**=应用名 + 时间 + 前 **3** 个字段
+- 端差异:Web 横排充分利用宽度;移动端 / 窄屏**逐行**展示(同一 enrichment 数据,不同布局壳)
+- 实现底座:按 FormCreate / FcDesigner **四层扩展**注册自定义控件(官方扩展路径),**禁止**沿用「改写内置 upload」式补丁
+- 兼容:双端契约(schema + form_data)、Fresh Mint UI、主应用与 `oa-fc-host` 对称注册、无该控件的旧应用行为不变
+
+---
+
+## 1. 目标与范围
+
+### 1.1 目标
+
+在 OA 审批「2 表单设计」中提供可拖拽的「关联审批单」控件;发起时可选择本人已通过的历史申请,在当前单中展示概要(可展开看全字段),并支持跳转历史单详情。
+
+### 1.2 包含(V1)
+
+| 能力 | 说明 |
+|------|------|
+| 设计器控件 | 左侧可拖;`type=relatedApproval`;右侧基础属性(标题、必填、提示) |
+| 发起选单 | 弹窗;本人 + 已通过 + 任意应用;关键词/分页;多选;已选项禁用 |
+| 概要展示 | 收起/展开;Web 标签流横排;移动逐行 |
+| 持久化 | `form_data[field]=string[]`;提交写 `oa_relation`(含 `field_key`) |
+| 详情回显 | 只读同一控件;可展开/跳转 |
+| 权限 | 选单与 enrichment 服务端校验;防越权点选 |
+
+### 1.3 不包含(V1)
+
+- 设计器配置「可选应用 / 可选状态」
+- 选中后自动映射/回填当前表单其它字段
+- 因关联自动发起子流程或改变对方实例状态
+- 被关联方详情展示「谁关联了我」(二期可选)
+- 小程序运行时完整交互(Schema/值契约必须先兼容;运行时壳可二期)
+
+---
+
+## 2. FormCreate 扩展方式(兼容性核心)
+
+参考 [FcDesigner 添加新组件](https://view.form-create.com/development/component),必须补齐四层:
+
+| 层 | 要求 |
+|----|------|
+| Runtime 组件 | Vue 组件(如 `OaRelatedApproval.vue`):选单、列表、展开收起、`v-model` 为 `string[]` |
+| form-create 注册 | `formCreate.component('relatedApproval', Comp)`(必要时双写大小写/别名,但**对外 type 固定** `relatedApproval`) |
+| DragRule | `menu: 'main'`(基础组件);`name/label/icon/input/rule()/props()`;`validate` 对齐数组/必填 |
+| 设计器注册 | `FcDesigner.component(...)` + `designer.addComponent(dragRule)` 或全局 `FcDesigner.addDragRule` |
+
+**对称注册(SHALL)**:
+- `web-admin/src/main.ts`(发起/详情运行时)
+- `web-admin/src/oa-fc-host/main.ts`(设计器 iframe)
+
+**禁止**:
+- 仅改写内置 `upload`/`input` 冒充关联控件(与本仓附件补丁路径不同)
+- `rule.type` 与后端 `oa_publish.ALLOWED_CONTROL_TYPES` 不一致(现已含 `relatedApproval`)
+
+**UI 样式(SHALL)**:
+- Web 使用 Fresh Mint / Element Plus,与发起页、详情页一致
+- 不引入 FormCreate 设计器默认高饱和蓝作为业务主色
+- 弹窗使用现有 `dialog-form-layout` / 列表习惯,遮罩无 backdrop blur
+
+---
+
+## 3. 数据契约
+
+### 3.1 Schema
+
+```json
+{
+  "type": "relatedApproval",
+  "field": "<uniqueId>",
+  "title": "关联审批单",
+  "info": "",
+  "$required": false,
+  "props": {}
+}
+```
+
+V1 `props` 可不扩展业务过滤项;后续若开放设计器过滤,再增显式字段,保持向后兼容。
+
+### 3.2 form_data 值
+
+| 控件 | JSON 形态 |
+|------|-----------|
+| 关联审批单 | `string[]`(instanceId 列表;去重;顺序=用户选择顺序) |
+
+符合 OA 双端契约(§4.6):禁止 DOM / 组件实例 / 本地 File 进入 form_data。
+
+### 3.3 oa_relation
+
+沿用现表:
+
+| 字段 | 含义 |
+|------|------|
+| `from_instance_id` | 当前新实例 |
+| `to_instance_id` | 被关联历史实例 |
+| `field_key` | 表单字段 field(多控件时区分) |
+| `tenant_no` 等 | 公共字段规范 |
+
+提交时:扫描当前版本 schema 中所有 `relatedApproval` 字段 → 取其 `form_data` 数组 → 写关系行。  
+顶层请求体 `relatedInstanceIds` **保留作兜底**(无 field_key);主路径以 form_data 为准。
+
+---
+
+## 4. API
+
+前缀遵循现有 `/api/admin/...` OA 路由;租户取当前用户,不信任客户端覆盖。
+
+### 4.1 选单列表
+
+- **用途**: 弹窗可选实例
+- **过滤(SHALL)**: `initiator_user_id = 当前用户`、`status = completed`、`tenant_no`、`del_flag=0`
+- **能力**: 关键词(应用名/标题)、分页、按发起时间倒序
+- **返回**: `id, appName, title?, applyTime, status`(列表可先不带全字段,点选后再 enrichment)
+
+### 4.2 概要 enrichment
+
+- **入参**: `ids: string[]`
+- **校验**:
+  - **选单场景**: 仅本人发起且 `completed`
+  - **详情回显场景**: 调用方须已能查看*当前*实例;对被关联 id,若无可见权则该项返回占位(`unavailable: true`),不导致整接口 403
+- **返回**(建议):
+
+```ts
+{
+  id: string
+  appName: string
+  applyTime: string
+  status: string
+  fields: Array<{ label: string; value: string; fullWidth?: boolean }>
+}
+```
+
+### 4.3 发起 start 变更
+
+在现有 `start_instance` 中:
+
+1. 解析 form_data 中 relatedApproval 字段 → `to_instance_id` 列表(去重)
+2. 服务端再次校验每条可关联
+3. 写入 `oa_relation`(带 `field_key`)
+4. 合并/兼容旧 `relatedInstanceIds`(无 field_key)
+
+### 4.4 详情回显(V1 定案)
+
+V1 **不**改详情主接口形状:前端根据 form_data / `relatedInstanceIds` 再调 enrichment。  
+二期若有性能问题,再给 `get_instance_detail` 附加 `relatedSummaries`。
+
+---
+
+## 5. 字段扁平化规则
+
+对历史实例的 `form_schema` + `form_data`:
+
+| 对方控件类型 | 概要展示 |
+|--------------|----------|
+| 常规输入/选择/日期/金额等 | `label` + 格式化后的文本值 |
+| 附件 / 图片 / 上传 | `N 个`(不内嵌文件列表) |
+| 嵌套 `relatedApproval` | `已关联 M 条`(不递归展开) |
+| 布局 / 说明 / 纯展示 | 跳过 |
+| 长文本 | 可标 `fullWidth`;展开时单独占行 |
+
+- **收起**: 应用名 + 时间 + `fields` 的前 **3** 条  
+- **展开**: 全部 `fields`  
+- **Web**: `display:flex; flex-wrap` 标签流,尽量一行多字段  
+- **移动 / 窄屏**: 每字段一行(`label:value`)
+
+点击应用名(或行标题)→ 路由至现有实例详情页;权限不足时提示,不白屏。
+
+---
+
+## 6. 前端模块建议
+
+| 模块 | 职责 |
+|------|------|
+| `OaRelatedApproval.vue` | 运行时控件(选单、列表、展开收起) |
+| `oa-related-approval-rule.ts` | DragRule |
+| `setupOaRelatedApproval(...)` | 双侧注册入口(对称 `setupFormCreateUpload`) |
+| `api/oaApproval.ts` | 选单 + enrichment 客户端 |
+| 发起页 / 详情页 | 无需特殊分支(form-create 按 type 渲染);确保注册已执行 |
+
+设计器预览:只读空态或示意条即可,**SHALL NOT** 因未登录选单 API 而报错崩溃。
+
+---
+
+## 7. 边界与错误处理
+
+| 场景 | 行为 |
+|------|------|
+| 选单无数据 | 空态文案 |
+| 篡改不可关联 ID | enrichment/提交失败,明确错误,不写关系 |
+| 历史单删除或对当前用户不可见 | 占位「无法查看」;不阻断当前单保存(已提交关系保留) |
+| 必填未选 | FormCreate 校验拦截 |
+| 重复 ID | 选单禁用;提交去重 |
+| 字段极多 | 展开区域可滚动 |
+| 无该控件旧应用 | 行为不变 |
+
+---
+
+## 8. 验收标准
+
+1. 设计器可拖入「关联审批单」,导出 JSON `type` 为 `relatedApproval`,发布校验通过  
+2. 主应用发起页与设计器预览均能渲染控件(双侧注册)  
+3. 选单仅本人 `completed` 实例;可多选、可删  
+4. 收起/展开符合 §5;Web 横排、移动预览逐行  
+5. 提交后 `oa_relation` 正确且含 `field_key`;详情可回显并跳转  
+6. 越权 ID 无法提交;无控件旧应用回归通过  
+7. UI 主色与间距符合 Fresh Mint,无设计器默认蓝污染业务页  
+
+---
+
+## 9. 测试建议
+
+- 后端:选单过滤、enrichment 越权、start 写 relation、去重  
+- 前端:FormCreate 注册 spike(拖 → 导出 → 发起渲染);展开收起;必填  
+- 手工:设计器 PC/手机预览切换下的布局  
+
+---
+
+## 10. 实现顺序建议
+
+1. Spike:空壳四层注册打通  
+2. 选单 API + enrichment + 扁平化  
+3. Runtime 控件 UI(含展开收起、响应式布局)  
+4. start 写 `oa_relation`(field_key)  
+5. 详情只读与跳转  
+6. 回归旧应用 + 发布校验  
+
+---
+
+## 11. 修订说明
+
+相对 2026-07-14 总设 §5.5「选择有权查看的实例」:本需求收窄为 **本人发起 + 已通过**;展示从「可跳转」增强为 **可展开的全字段概要**。若与总设冲突,**以本文为关联审批单专项权威**,总设可在归档时回写一句交叉引用。