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: Make nested「已关联 M 条」clickable on screen, and make PDF print render related approvals as multi-line field blocks with up to 3 levels of recursion (deduped).
Architecture: Keep screen enrichment flat (value + optional relatedIds). Do not return a summary tree from the API. Print path asynchronously batches fetchOaRelatedSummaries, then a pure recursive builder (maxDepth=3, visited) produces RelatedPrintBlock[] for OaApprovalPrintSheet.
Tech Stack: FastAPI · pytest · Vue 3 · TypeScript · existing oa-approval-print.ts / html2canvas / jspdf · Element Plus
Spec: docs/superpowers/specs/2026-07-22-oa-related-approval-nested-print-design.md
| Path | Responsibility |
|---|---|
Modify: backend/app/services/oa_related_approval.py |
Flatten relatedApproval fields with relatedIds |
Modify: backend/tests/test_oa_related_approval.py |
Assert relatedIds on nested/related flatten |
Modify: web-admin/src/api/oaApproval.ts |
OaRelatedSummaryField.relatedIds?: string[] |
Modify: web-admin/src/components/oa-approval/OaRelatedApproval.vue |
Clickable「已关联 M 条」→ detail |
Create: web-admin/src/utils/oa-approval-print-related.ts |
Pure recursive block builder + async fetch wrapper |
Modify: web-admin/src/utils/oa-approval-print.ts |
Async form rows; special-case relatedApproval |
Modify: web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue |
Render blocks with indent |
| Verify: V1 spec cross-ref (already updated in brainstorm) | No further doc task unless drift |
Locked rules:
M=1 → that id; M>1 → first id). No nested cards.relatedIds while depth < 3; at depth 3 keep count text only; duplicate id → title (已在上文出现).relatedIds (TDD)Files:
backend/tests/test_oa_related_approval.pyModify: backend/app/services/oa_related_approval.py
[ ] Step 1: Write failing test for relatedIds
Add to TestFlatten in backend/tests/test_oa_related_approval.py:
def test_related_approval_field_includes_related_ids(self):
schema = {
"rule": [
{"type": "input", "field": "reason", "title": "事由"},
{"type": "relatedApproval", "field": "prev", "title": "关联审批单"},
]
}
data = {"reason": "拜访", "prev": ["id1", "", "id2", "id1"]}
fields = flatten_form_fields_for_summary(schema, data)
rel = next(f for f in fields if f["label"] == "关联审批单")
self.assertEqual(rel["value"], "已关联 2 条")
self.assertEqual(rel["relatedIds"], ["id1", "id2"])
def test_related_approval_empty_ids(self):
schema = {"rule": [{"type": "relatedApproval", "field": "prev", "title": "关联"}]}
fields = flatten_form_fields_for_summary(schema, {"prev": []})
self.assertEqual(fields[0]["value"], "已关联 0 条")
self.assertEqual(fields[0]["relatedIds"], [])
Also update existing test_skips_layout_and_summarizes_upload assertion for the related field — after change it should allow relatedIds:
self.assertEqual(fields[2]["value"], "已关联 2 条")
self.assertEqual(fields[2].get("relatedIds"), ["id1", "id2"])
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py::TestFlatten::test_related_approval_field_includes_related_ids -v
Expected: AssertionError / KeyError — no relatedIds.
In backend/app/services/oa_related_approval.py, add helper and change flatten_form_fields_for_summary:
def _normalize_related_ids(raw: Any) -> List[str]:
if isinstance(raw, list):
items = raw
elif raw is None or raw == "":
items = []
else:
items = [raw]
out: List[str] = []
seen = set()
for x in items:
t = str(x or "").strip()
if not t or t in seen:
continue
seen.add(t)
out.append(t)
return out
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)
raw = data.get(field)
if typ == "relatedApproval":
ids = _normalize_related_ids(raw)
out.append(
{
"label": label,
"value": f"已关联 {len(ids)} 条",
"relatedIds": ids,
}
)
continue
text, full = _format_value(typ, raw)
item: Dict[str, Any] = {"label": label, "value": text}
if full:
item["fullWidth"] = True
out.append(item)
return out
Keep _format_value relatedApproval branch for any other callers, or leave as-is (dead for flatten path).
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
Expected: all PASS.
git add backend/app/services/oa_related_approval.py backend/tests/test_oa_related_approval.py
git commit -m "$(cat <<'EOF'
feat(oa): emit relatedIds when flattening nested relatedApproval fields
Screen jump and print recursion need the instance id list alongside the
「已关联 M 条」summary text without changing form_data shape.
EOF
)"
Files:
web-admin/src/api/oaApproval.tsModify: web-admin/src/components/oa-approval/OaRelatedApproval.vue
[ ] Step 1: Extend field type
In web-admin/src/api/oaApproval.ts:
export type OaRelatedSummaryField = {
label: string
value: string
fullWidth?: boolean
/** 嵌套关联审批单的 instanceId 列表(可跳转 / 打印递归) */
relatedIds?: string[]
}
In OaRelatedApproval.vue field value area, replace the plain {{ f.value }} branch with:
<template v-else-if="(f.relatedIds?.length || 0) > 0">
<button
type="button"
class="oa-related-approval__link-btn"
@click.stop="goDetail(f.relatedIds![0])"
>
{{ f.value }}
</button>
</template>
<template v-else>{{ f.value }}</template>
Add style (native button, survives form.disabled):
.oa-related-approval__link-btn {
padding: 0;
border: 0;
background: transparent;
color: var(--primary, #10b981);
font-size: inherit;
text-decoration: underline;
cursor: pointer;
}
.oa-related-approval__link-btn:hover {
color: var(--primary-dark, #059669);
}
Reuse existing goDetail(id) (router push to /system-manage/oa-approval/instances/${id}).
relatedIds.git add web-admin/src/api/oaApproval.ts web-admin/src/components/oa-approval/OaRelatedApproval.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): make nested relatedApproval count jump to first instance
Uses relatedIds from enrichment so screen stays count-only without nested cards.
EOF
)"
Files:
Create: web-admin/src/utils/oa-approval-print-related.ts
[ ] Step 1: Add types + pure builder
Create web-admin/src/utils/oa-approval-print-related.ts:
import type { OaRelatedSummary, OaRelatedSummaryField } from '@/api/oaApproval'
import { fetchOaRelatedSummaries } from '@/api/oaApproval'
export const OA_RELATED_PRINT_MAX_DEPTH = 3
export type RelatedPrintBlock = {
indexLabel: string
title: string
lines?: { label: string; value: string }[]
children?: RelatedPrintBlock[]
}
function datePart(applyTime: string) {
const t = String(applyTime || '').trim()
return t ? t.slice(0, 10) : ''
}
function titleOf(s: OaRelatedSummary) {
if (s.unavailable) return '无法查看'
const name = String(s.appName || '审批单').trim() || '审批单'
const d = datePart(s.applyTime)
return d ? `${name} · ${d}` : name
}
/** Pure: build blocks from an already-loaded summary map (for tests / recursion). */
export function buildRelatedPrintBlocksFromMap(
ids: string[],
summaryById: Map<string, OaRelatedSummary>,
opts: {
depth: number
maxDepth?: number
visited: Set<string>
indexPrefix?: string
}
): RelatedPrintBlock[] {
const maxDepth = opts.maxDepth ?? OA_RELATED_PRINT_MAX_DEPTH
const depth = opts.depth
const visited = opts.visited
const prefix = opts.indexPrefix || ''
const blocks: RelatedPrintBlock[] = []
ids.forEach((rawId, i) => {
const id = String(rawId || '').trim()
if (!id) return
const indexLabel = prefix ? `${prefix}.${i + 1}` : String(i + 1)
if (visited.has(id)) {
blocks.push({ indexLabel, title: '(已在上文出现)' })
return
}
visited.add(id)
const s = summaryById.get(id)
if (!s) {
blocks.push({ indexLabel, title: '关联信息加载失败' })
return
}
if (s.unavailable) {
blocks.push({ indexLabel, title: '无法查看' })
return
}
const lines: { label: string; value: string }[] = []
const children: RelatedPrintBlock[] = []
const fields = Array.isArray(s.fields) ? s.fields : []
for (const f of fields) {
const label = String(f.label || '').trim() || '-'
const value = String(f.value || '').trim() || '-'
const nestedIds = (f.relatedIds || []).map((x) => String(x || '').trim()).filter(Boolean)
if (nestedIds.length && depth < maxDepth) {
// Parent line keeps count text; children expand underneath
lines.push({ label, value })
children.push(
...buildRelatedPrintBlocksFromMap(nestedIds, summaryById, {
depth: depth + 1,
maxDepth,
visited,
indexPrefix: indexLabel
})
)
} else {
lines.push({ label, value })
}
}
blocks.push({
indexLabel,
title: titleOf(s),
lines,
children: children.length ? children : undefined
})
})
return blocks
}
async function loadSummariesIntoMap(ids: string[], map: Map<string, OaRelatedSummary>) {
const missing = [...new Set(ids.map((x) => String(x || '').trim()).filter(Boolean))].filter(
(id) => !map.has(id)
)
if (!missing.length) return
try {
const res: any = await fetchOaRelatedSummaries(missing, 'view')
const list: OaRelatedSummary[] = Array.isArray(res?.list) ? res.list : Array.isArray(res) ? res : []
for (const s of list) {
if (s?.id) map.set(String(s.id), s)
}
for (const id of missing) {
if (!map.has(id)) {
map.set(id, {
id,
unavailable: true,
appName: '',
applyTime: '',
status: '',
fields: []
})
}
}
} catch {
for (const id of missing) {
if (!map.has(id)) {
map.set(id, {
id,
unavailable: true,
appName: '',
applyTime: '',
status: '',
fields: []
})
}
}
}
}
/** Collect nested relatedIds up to maxDepth for batch prefetch. */
function collectNestedIds(
ids: string[],
map: Map<string, OaRelatedSummary>,
depth: number,
maxDepth: number,
acc: Set<string>
) {
for (const id of ids) {
const t = String(id || '').trim()
if (!t || acc.has(t)) continue
acc.add(t)
if (depth >= maxDepth) continue
const s = map.get(t)
if (!s || s.unavailable) continue
for (const f of s.fields || []) {
const nested = (f.relatedIds || []).map((x) => String(x || '').trim()).filter(Boolean)
for (const n of nested) acc.add(n)
if (nested.length) collectNestedIds(nested, map, depth + 1, maxDepth, acc)
}
}
}
/**
* Prefetch summaries (breadth waves up to maxDepth) then build blocks.
* depth starts at 1 for ids on the printed form's relatedApproval field.
*/
export async function assembleRelatedPrintBlocks(
ids: string[],
opts?: { maxDepth?: number }
): Promise<RelatedPrintBlock[]> {
const maxDepth = opts?.maxDepth ?? OA_RELATED_PRINT_MAX_DEPTH
const rootIds = [...new Set(ids.map((x) => String(x || '').trim()).filter(Boolean))]
if (!rootIds.length) return []
const map = new Map<string, OaRelatedSummary>()
// Wave fetch: load roots, then any nested ids referenced, up to maxDepth
await loadSummariesIntoMap(rootIds, map)
for (let d = 1; d < maxDepth; d++) {
const need = new Set<string>()
collectNestedIds(rootIds, map, 1, maxDepth, need)
const missing = [...need].filter((id) => !map.has(id))
if (!missing.length) break
await loadSummariesIntoMap(missing, map)
}
return buildRelatedPrintBlocksFromMap(rootIds, map, {
depth: 1,
maxDepth,
visited: new Set()
})
}
export function normalizeRelatedIdsFromFormValue(raw: unknown): string[] {
if (Array.isArray(raw)) {
const out: string[] = []
const seen = new Set<string>()
for (const x of raw) {
const t = String(x || '').trim()
if (!t || seen.has(t)) continue
seen.add(t)
out.push(t)
}
return out
}
if (raw == null || raw === '') return []
const t = String(raw).trim()
return t ? [t] : []
}
/** @internal exported for quick node assert — optional smoke */
export function _smokeBuildDuplicate() {
const map = new Map<string, OaRelatedSummary>()
map.set('a', {
id: 'a',
appName: '单A',
applyTime: '2026-07-10 12:00:00',
status: 'completed',
fields: [{ label: '关联', value: '已关联 1 条', relatedIds: ['a'] }]
})
return buildRelatedPrintBlocksFromMap(['a'], map, { depth: 1, visited: new Set() })
}
From web-admin (or skip if no ts-node). Prefer manual reason:
a → a: first block full; nested child title (已在上文出现).Depth: when depth === 3, nested relatedIds must not create children.
[ ] Step 3: Commit
git add web-admin/src/utils/oa-approval-print-related.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add related-approval print block assembler
Pure map-based recursion with maxDepth=3 and visited dedupe; async
wrapper batch-fetches summaries for PDF export.
EOF
)"
Files:
web-admin/src/utils/oa-approval-print.tsModify: web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue
[ ] Step 1: Extend OaPrintFieldRow and make form-row build async
In oa-approval-print.ts:
import {
assembleRelatedPrintBlocks,
normalizeRelatedIdsFromFormValue,
type RelatedPrintBlock
} from '@/utils/oa-approval-print-related'
export type OaPrintFieldRow = {
label: string
text?: string
urls?: string[]
/** 关联审批单多行块(优先于 text) */
blocks?: RelatedPrintBlock[]
}
Change walkRules / buildFormRows to async:
async function walkRules(
rules: any[],
formData: Record<string, any>,
out: OaPrintFieldRow[]
) {
if (!Array.isArray(rules)) return
for (const rule of rules) {
if (!rule || typeof rule !== 'object') continue
const type = String(rule.type || '').trim()
const children = rule.children || rule.props?.rule
if (Array.isArray(children) && children.length) {
await walkRules(children, formData, out)
}
if (SKIP_RULE_TYPES.has(type)) continue
const field = String(rule.field || '').trim()
if (!field) continue
const label = String(rule.title || rule.label || rule.props?.label || field).trim() || field
const value = formData?.[field]
if (type === 'relatedApproval') {
const ids = normalizeRelatedIdsFromFormValue(value)
if (!ids.length) {
out.push({ label, text: '-' })
continue
}
const blocks = await assembleRelatedPrintBlocks(ids)
out.push({ label, blocks: blocks.length ? blocks : [{ indexLabel: '1', title: '关联信息加载失败' }] })
continue
}
// … keep existing upload / url / formatScalar branches …
}
}
async function buildFormRows(detail: any): Promise<OaPrintFieldRow[]> {
// same as today but await walkRules(...)
}
export async function buildOaPrintModel(
detail: any,
opts: { printerName: string; printTime?: string }
): Promise<OaPrintModel> {
// …
rows: [...headerRows, ...(await buildFormRows(detail))],
// …
}
export async function exportOaApprovalPdf(...) {
const model = await buildOaPrintModel(detail, { printerName: opts.printerName })
// unchanged mount/canvas
}
Search callers of buildOaPrintModel — only exportOaApprovalPdf today; ensure await.
OaApprovalPrintSheet.vueIn the table cell template:
<template v-if="row.blocks?.length">
<div class="oa-print-related">
<div
v-for="(b, bi) in row.blocks"
:key="`b-${idx}-${bi}`"
class="oa-print-related__block"
>
<div class="oa-print-related__title">{{ b.indexLabel }}. {{ b.title }}</div>
<div
v-for="(line, li) in b.lines || []"
:key="`l-${bi}-${li}`"
class="oa-print-related__line"
>
<span class="oa-print-related__k">{{ line.label }}</span>
<span class="oa-print-related__v">{{ line.value }}</span>
</div>
<div
v-if="b.children?.length"
class="oa-print-related__children"
>
<!-- recurse: reuse a small inner component or inline second level -->
<div
v-for="(c, ci) in b.children"
:key="`c-${bi}-${ci}`"
class="oa-print-related__block"
>
<div class="oa-print-related__title">{{ c.indexLabel }}. {{ c.title }}</div>
<div
v-for="(line, li) in c.lines || []"
:key="`cl-${ci}-${li}`"
class="oa-print-related__line"
>
<span class="oa-print-related__k">{{ line.label }}</span>
<span class="oa-print-related__v">{{ line.value }}</span>
</div>
<div v-if="c.children?.length" class="oa-print-related__children">
<div
v-for="(g, gi) in c.children"
:key="`g-${ci}-${gi}`"
class="oa-print-related__block"
>
<div class="oa-print-related__title">{{ g.indexLabel }}. {{ g.title }}</div>
<div
v-for="(line, li) in g.lines || []"
:key="`gl-${gi}-${li}`"
class="oa-print-related__line"
>
<span class="oa-print-related__k">{{ line.label }}</span>
<span class="oa-print-related__v">{{ line.value }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="row.urls?.length">
...
</template>
<template v-else>
<span class="oa-print-text">{{ row.text || '-' }}</span>
</template>
SCSS:
.oa-print-related__block { margin: 0 0 8px; }
.oa-print-related__title { font-weight: 600; margin-bottom: 2px; }
.oa-print-related__line {
display: flex;
gap: 8px;
font-size: 12px;
line-height: 1.5;
}
.oa-print-related__k { color: #64748b; flex: 0 0 auto; }
.oa-print-related__v { color: #334155; word-break: break-all; }
.oa-print-related__children {
margin: 4px 0 0 14px;
padding-left: 8px;
border-left: 2px solid #cbd5e1;
}
(Three nested template levels cover maxDepth=3 without a recursive SFC.)
git add web-admin/src/utils/oa-approval-print.ts web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): print relatedApproval as recursive multi-line blocks
PDF export fetches summaries and renders up to three nested levels with
dedupe, instead of dumping raw instance ids.
EOF
)"
Files:
docs/superpowers/specs/2026-07-22-oa-related-approval-nested-print-design.md (status line)Run: ./scripts/update-graph.sh frontend (and backend if Python signatures changed)
[ ] Step 1: Mark spec status
Change header status to: 已批准(实现计划见 docs/superpowers/plans/2026-07-22-oa-related-approval-nested-print.md)
Confirm V1 §5 already cross-references nested-print design (done in brainstorm).
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
cd ../web-admin && pnpm exec vue-tsc --noEmit 2>&1 | head -50
./scripts/update-graph.sh all
git add docs/superpowers/specs/2026-07-22-oa-related-approval-nested-print-design.md \
docs/superpowers/plans/2026-07-22-oa-related-approval-nested-print.md \
backend/graphify-out web-admin/graphify-out
git commit -m "$(cat <<'EOF'
docs(oa): add nested relatedApproval print plan and mark spec ready
EOF
)"
| Spec requirement | Task |
|---|---|
enrichment relatedIds |
Task 1 |
| Screen clickable「已关联 M 条」 | Task 2 |
| Print multi-line + business fields | Task 3–4 |
| Recurse max 3 levels | Task 3 (OA_RELATED_PRINT_MAX_DEPTH) |
| Visited dedupe | Task 3 |
| Unavailable / load fail placeholders | Task 3–4 |
| No screen nested cards | Task 2 (explicit non-goal) |
| Old apps unchanged | Task 4 (only relatedApproval branch) |
| V1 cross-ref | Task 5 (verify) |
buildOaPrintModel becomes async — only exportOaApprovalPdf awaits it.