docs(oa): add related-approval design and plan progress notes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
f68927ce70
commit
67cdf76741
2 files changed
+1124
No files matched your search
@@ -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?
|
||||
@@ -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「选择有权查看的实例」:本需求收窄为 **本人发起 + 已通过**;展示从「可跳转」增强为 **可展开的全字段概要**。若与总设冲突,**以本文为关联审批单专项权威**,总设可在归档时回写一句交叉引用。
|
||||
Reference in new issue
Block a user