From 51ce9d6059adefe62bf8e33ef6a27fc6e470b2ff Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Wed, 22 Jul 2026 23:04:41 +0800 Subject: [PATCH] docs(oa): add nested relatedApproval print design, plan, and demos Records screen count+jump vs print multi-line recursion (max 3 levels) and links the V1 related-approval spec to the revision. Co-authored-by: Cursor --- .../oa-related-approval-nested-demo.html | 417 ++++++++++ .../oa-related-approval-print-demo.html | 280 +++++++ ...-07-22-oa-related-approval-nested-print.md | 741 ++++++++++++++++++ .../2026-07-22-oa-related-approval-design.md | 4 +- ...oa-related-approval-nested-print-design.md | 244 ++++++ 5 files changed, 1685 insertions(+), 1 deletion(-) create mode 100644 docs/mockups/oa-related-approval-nested-demo.html create mode 100644 docs/mockups/oa-related-approval-print-demo.html create mode 100644 docs/superpowers/plans/2026-07-22-oa-related-approval-nested-print.md create mode 100644 docs/superpowers/specs/2026-07-22-oa-related-approval-nested-print-design.md diff --git a/docs/mockups/oa-related-approval-nested-demo.html b/docs/mockups/oa-related-approval-nested-demo.html new file mode 100644 index 00000000..76b1c2d8 --- /dev/null +++ b/docs/mockups/oa-related-approval-nested-demo.html @@ -0,0 +1,417 @@ + + + + + + 关联审批单 · 嵌套展示方案 Demo + + + +
+

关联审批单 · 嵌套展示方案

+

+ 场景:发起/详情里选中一张历史审批单 A;A 自己的表单里也有「关联审批单」字段(指向 B)。 + 当前 V1 规格把嵌套字段压成「已关联 M 条」,导致看不到缩略信息与展开。请点选你更倾向的方案(可点卡片内「展开」试交互)。 +

+ +
+ 数据链:当前单 → 关联 差旅报销 · 07-18(单 A)→ 单 A 内又关联了 外出申请 · 07-10(单 B) +
+ +
+ +
+
+
A
+
+

现状(V1 已定)当前实现

+

嵌套 relatedApproval 只显示「已关联 M 条」,不递归、无子卡片缩略

+
+
+
+
关联审批单
+
+
+ + +
+ +
↑ 嵌套字段只有计数文案;即使点「展开」也看不到单 B 的业务字段
+
+
+
优点:实现简单、无递归性能风险。缺点:信息不足,正是你遇到的问题。
+
+ + +
+
+
B
+
+

计数 + 可跳转详情

+

仍显示「已关联 M 条」,但做成可点链接,跳进被关联单详情查看

+
+
+
+
关联审批单
+
+
+ + +
+ +
+
+
优点:改动小、无嵌套 UI。缺点:仍无缩略预览,要跳页才能看清。
+
+ + +
+
+
C
+
+

一层嵌套卡片(推荐倾向)

+

展开单 A 后,嵌套关联展示为子卡片(应用名·时间 + 前 3 字段);再往下仍用计数,不无限递归

+
+
+
+
关联审批单
+
+
+ + +
+ +
+
内含关联审批单 · 1 条
+
+
+ + +
+ +
+
+
+
+
优点:有缩略与展开,信息完整可控。缺点:enrichment 需多取一层;UI 略复杂。
+
+ + +
+
+
D
+
+

无限递归嵌套

+

每一层关联都可以再展开子关联,形成树状

+
+
+
+
关联审批单
+
+
+ + 已展开 +
+
+ 事由客户拜访 +
+
+
+
+ +
+
+
+
+ +
+
…可能继续嵌套
+
+
+
+
+
+
+
优点:最完整。缺点:性能、权限、循环引用、UI 噪声都更大,一般不建议 V1.x。
+
+
+ + + +
+ 当前选择:尚未选择 + 也可直接在对话里回复 A / B / C / D +
+
+ + + + diff --git a/docs/mockups/oa-related-approval-print-demo.html b/docs/mockups/oa-related-approval-print-demo.html new file mode 100644 index 00000000..945abe43 --- /dev/null +++ b/docs/mockups/oa-related-approval-print-demo.html @@ -0,0 +1,280 @@ + + + + + + 关联审批单 · 打印展示方案 Demo + + + +
+

关联审批单 · 打印(PDF)如何展示?

+

+ 屏幕端你已选 方案 B:嵌套关联显示「已关联 M 条」,可点进详情。 + 但「打印」导出的是静态 PDF,无法点击跳转。请选择打印行里该字段怎么写。 +

+
+ 现状问题:打印逻辑把 relatedApproval 的值当普通数组格式化, + 会直接打出一串 instanceId(UUID),既不可读,也和屏幕端「已关联 M 条」不一致。 +
+ +
+ +
+
+
0
+
+

现状(未特判)需改

+

直接打印 UUID 列表 —— 不可接受

+
+
+
+
+
差旅报销
+
安徽中科兴皖医学研究有限公司创建时间:2026-07-20
+ + + + +
审批编号57F72ADA…
事由客户拜访
关联审批单B6244B87…、C1D2E3F4…
+
+
+
问题:纸面无法读,也不符合方案 B 语义。
+
+ + +
+
+
1
+
+

仅计数(严格对齐屏幕 B)

+

打印「已关联 M 条」;纸面无法跳转,信息最少

+
+
+
+
+
差旅报销
+
安徽中科兴皖医学研究有限公司创建时间:2026-07-20
+ + + + +
审批编号57F72ADA…
事由客户拜访
关联审批单已关联 2 条
+
+
+
优点:实现最简单、与屏幕文案一致。缺点:打印归档时看不出关联了哪几单。
+
+ + +
+
+
2
+
+

可读清单(推荐配合 B)

+

一行汇总:应用名 · 日期(多条顿号分隔);不展开对方全字段

+
+
+
+
+
差旅报销
+
安徽中科兴皖医学研究有限公司创建时间:2026-07-20
+ + + + +
审批编号57F72ADA…
事由客户拜访
关联审批单外出申请 · 07-10、立项申请 · 06-01
+
+
+
优点:纸面可辨认关联对象;仍不递归嵌套内容。缺点:打印前需 enrichment 取应用名/时间。
+
+ + +
+
+
3
+
+

多行清单

+

每条关联单独占一行(或单元格内换行),含应用名、时间、可选编号尾号

+
+
+
+
+
差旅报销
+
安徽中科兴皖医学研究有限公司创建时间:2026-07-20
+ + + + + + + +
审批编号57F72ADA…
事由客户拜访
关联审批单 + 1. 外出申请 · 2026-07-10
+ 2. 立项申请 · 2026-06-01 +
+
+
+
优点:多条时更清晰。缺点:表格略占高;仍不打印对方表单正文。
+
+
+ +

+ 关于「嵌套」:若关联单 A 里还有关联单 B,打印侧建议与屏幕 B 一致 —— + 不递归打印 B 的字段,只打印当前单直接关联的那一层可读清单(方案 2/3)。 +

+ +
+ 当前选择:尚未选择 + 也可回复:1 / 2 / 3(0 为需废弃的现状) +
+
+ + + diff --git a/docs/superpowers/plans/2026-07-22-oa-related-approval-nested-print.md b/docs/superpowers/plans/2026-07-22-oa-related-approval-nested-print.md new file mode 100644 index 00000000..10097045 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-oa-related-approval-nested-print.md @@ -0,0 +1,741 @@ +# OA Related Approval Nested + Print 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:** 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` + +--- + +## File map + +| 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:** +- Screen: nested = count text + jump (`M=1` → that id; `M>1` → first id). No nested cards. +- Print: multi-line blocks; recurse on `relatedIds` while `depth < 3`; at depth 3 keep count text only; duplicate id → title `(已在上文出现)`. + +--- + +### Task 1: Backend flatten emits `relatedIds` (TDD) + +**Files:** +- Modify: `backend/tests/test_oa_related_approval.py` +- Modify: `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`: + +```python + 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`: + +```python + self.assertEqual(fields[2]["value"], "已关联 2 条") + self.assertEqual(fields[2].get("relatedIds"), ["id1", "id2"]) +``` + +- [ ] **Step 2: Run test — expect FAIL** + +```bash +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`. + +- [ ] **Step 3: Implement flatten change** + +In `backend/app/services/oa_related_approval.py`, add helper and change `flatten_form_fields_for_summary`: + +```python +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). + +- [ ] **Step 4: Run full related-approval tests — expect PASS** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v +``` + +Expected: all PASS. + +- [ ] **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): 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 +)" +``` + +--- + +### Task 2: Frontend types + clickable nested count + +**Files:** +- Modify: `web-admin/src/api/oaApproval.ts` +- Modify: `web-admin/src/components/oa-approval/OaRelatedApproval.vue` + +- [ ] **Step 1: Extend field type** + +In `web-admin/src/api/oaApproval.ts`: + +```ts +export type OaRelatedSummaryField = { + label: string + value: string + fullWidth?: boolean + /** 嵌套关联审批单的 instanceId 列表(可跳转 / 打印递归) */ + relatedIds?: string[] +} +``` + +- [ ] **Step 2: Render clickable count in card fields** + +In `OaRelatedApproval.vue` field value area, replace the plain `{{ f.value }}` branch with: + +```vue + + +``` + +Add style (native button, survives `form.disabled`): + +```scss +.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}`). + +- [ ] **Step 3: Manual smoke** + +1. Open an instance whose related card shows a nested field「已关联 1 条」with backend returning `relatedIds`. +2. Click the count → navigates to that instance. +3. Confirm no nested sub-cards appear. + +- [ ] **Step 4: Commit** + +```bash +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 +)" +``` + +--- + +### Task 3: Pure print block builder + +**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`: + +```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, + opts: { + depth: number + maxDepth?: number + visited: Set + 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) { + 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, + depth: number, + maxDepth: number, + acc: Set +) { + 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 { + 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() + // 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() + 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() + 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() + 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() }) +} +``` + +- [ ] **Step 2: Quick sanity in Node (optional)** + +From `web-admin` (or skip if no ts-node). Prefer manual reason: + +- Self-ref `a → a`: first block full; nested child title `(已在上文出现)`. +- Depth: when `depth === 3`, nested `relatedIds` must not create children. + +- [ ] **Step 3: Commit** + +```bash +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 +)" +``` + +--- + +### Task 4: Wire print model + sheet UI + +**Files:** +- Modify: `web-admin/src/utils/oa-approval-print.ts` +- Modify: `web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue` + +- [ ] **Step 1: Extend `OaPrintFieldRow` and make form-row build async** + +In `oa-approval-print.ts`: + +```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: + +```ts +async function walkRules( + rules: any[], + formData: Record, + 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 { + // same as today but await walkRules(...) +} + +export async function buildOaPrintModel( + detail: any, + opts: { printerName: string; printTime?: string } +): Promise { + // … + 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`. + +- [ ] **Step 2: Render blocks in `OaApprovalPrintSheet.vue`** + +In the table cell template: + +```vue + + + +``` + +SCSS: + +```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.) + +- [ ] **Step 3: Manual PDF check** + +1. Print instance with 1 related approval that has normal fields → PDF shows multi-line block, not UUID. +2. Chain A→B→C→D → D appears only as「已关联 M 条」under C (3rd related layer). +3. Cycle A→B→A → second A is「(已在上文出现)」. +4. Instance without relatedApproval → PDF unchanged. + +- [ ] **Step 4: Commit** + +```bash +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 +)" +``` + +--- + +### Task 5: Spec status + graph + final verify + +**Files:** +- Modify: `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). + +- [ ] **Step 2: Regression commands** + +```bash +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 +``` + +- [ ] **Step 3: Commit docs/graph if dirty** + +```bash +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 coverage checklist + +| 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) | + +--- + +## Self-review notes + +- No TBD placeholders in steps. +- `buildOaPrintModel` becomes async — only `exportOaApprovalPdf` awaits it. +- Sheet uses 3 hardcoded nest levels matching maxDepth=3 (no recursive Vue component required). +- Prefetch waves may over-fetch slightly; acceptable vs complex BFS queue. diff --git a/docs/superpowers/specs/2026-07-22-oa-related-approval-design.md b/docs/superpowers/specs/2026-07-22-oa-related-approval-design.md index c85e7c28..865d0b93 100644 --- a/docs/superpowers/specs/2026-07-22-oa-related-approval-design.md +++ b/docs/superpowers/specs/2026-07-22-oa-related-approval-design.md @@ -163,7 +163,7 @@ V1 **不**改详情主接口形状:前端根据 form_data / `relatedInstanceId |--------------|----------| | 常规输入/选择/日期/金额等 | `label` + 格式化后的文本值 | | 附件 / 图片 / 上传 | `N 个`(不内嵌文件列表) | -| 嵌套 `relatedApproval` | `已关联 M 条`(不递归展开) | +| 嵌套 `relatedApproval` | 屏幕:`已关联 M 条`(可跳转,不嵌套缩略卡);打印:多行递归展开业务字段,最多 3 层。细则见 `2026-07-22-oa-related-approval-nested-print-design.md` | | 布局 / 说明 / 纯展示 | 跳过 | | 长文本 | 可标 `fullWidth`;展开时单独占行 | @@ -238,3 +238,5 @@ V1 **不**改详情主接口形状:前端根据 form_data / `relatedInstanceId ## 11. 修订说明 相对 2026-07-14 总设 §5.5「选择有权查看的实例」:本需求收窄为 **本人发起 + 已通过**;展示从「可跳转」增强为 **可展开的全字段概要**。若与总设冲突,**以本文为关联审批单专项权威**,总设可在归档时回写一句交叉引用。 + +**2026-07-22 嵌套/打印修订**:屏幕嵌套与打印递归行为以 `2026-07-22-oa-related-approval-nested-print-design.md` 为准(本文 §5 嵌套行已改为交叉引用)。 diff --git a/docs/superpowers/specs/2026-07-22-oa-related-approval-nested-print-design.md b/docs/superpowers/specs/2026-07-22-oa-related-approval-nested-print-design.md new file mode 100644 index 00000000..d62ba226 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-oa-related-approval-nested-print-design.md @@ -0,0 +1,244 @@ +# OA「关联审批单」嵌套展示与打印修订设计 + +**日期**: 2026-07-22 +**状态**: 已批准(实现计划见 `docs/superpowers/plans/2026-07-22-oa-related-approval-nested-print.md`) +**方案**: 方案 1 — 屏幕保持计数可跳转;打印专用递归组装(最多 3 层) +**界面模式**: 屏幕对齐既有关联卡片;打印对齐现有钉钉风 PDF 主表 +**前置文档**: +- `docs/superpowers/specs/2026-07-22-oa-related-approval-design.md`(V1 基线) +- `docs/superpowers/specs/2026-07-15-oa-approval-print-pdf-design.md`(打印 PDF) +- Demo:`docs/mockups/oa-related-approval-nested-demo.html`、`docs/mockups/oa-related-approval-print-demo.html` + +**决策记录**: +- 屏幕 · 嵌套关联:方案 **B** — 显示「已关联 M 条」,可跳转详情,**不**嵌套缩略卡 +- 打印 · 布局:方案 **3** — 多行清单,每条关联单独成块 +- 打印 · 内容:打出对方单 **业务字段摘要**;嵌套关联继续递归 +- 打印 · 深度:最多 **3 层**;同一 `instanceId` 去重防环 +- 实现路径:方案 **1** — 打印专用递归;不改为统一树形 enrichment API + +--- + +## 1. 背景与问题 + +V1 规格 §5 规定:嵌套 `relatedApproval` 扁平化为「已关联 M 条(不递归展开)」。 + +实测:发起/详情选中一张本身也含「关联审批单」的历史单时,卡片上只见「已关联 M 条」,无缩略、无展开子内容;同时打印路径未特判该控件,会把 `string[]` instanceId 打成 UUID,纸面不可读。 + +产品确认: + +| 端 | 期望 | +|----|------| +| 屏幕 | 嵌套保持计数文案,但须可跳转;不在卡片内画子树 | +| 打印 | 多行展示关联单信息,含业务字段;嵌套最多递归 3 层 | + +本文件 **修订** V1 在嵌套/打印上的行为;`form_data` 契约与选单范围不变。 + +--- + +## 2. 目标与范围 + +### 2.1 目标 + +1. 屏幕:嵌套「已关联 M 条」可跳转至被关联实例详情 +2. 打印:`relatedApproval` 字段输出可读多行块(含对方业务字段),支持最多 3 层递归 +3. 防环:同一 instanceId 在一份 PDF 中不重复展开字段体 + +### 2.2 包含 + +| 项 | 说明 | +|----|------| +| enrichment 扁平化 | 嵌套 `relatedApproval` 字段增加可选 `relatedIds: string[]`,`value` 仍为「已关联 M 条」 | +| `OaRelatedApproval.vue` | 识别 `relatedIds`,渲染可点跳转 | +| `oa-approval-print.ts` + `OaApprovalPrintSheet.vue` | 特判关联控件;递归组装打印块;版式多行缩进 | +| 规格交叉引用 | 回写 V1 §5 嵌套行,指向本文 | + +### 2.3 不包含 + +- 屏幕端一层/无限嵌套缩略卡片(否决嵌套 Demo C/D) +- 改选单范围、自动串子流程、回填其它字段 +- 后端生成 PDF +- 统一 `summaries?depth=` 树形 API(否决实现方案 2) +- 打印时对每个关联 id 全量 `get_instance_detail`(否决实现方案 3) + +--- + +## 3. 屏幕行为 + +### 3.1 直接关联(不变) + +- 应用名 · 时间;收起前 3 字段 / 展开全字段;点标题进详情 +- 详情页 `form.disabled` 下展开钮须可点(已用原生 button 规避 `el-button` 继承禁用) + +### 3.2 嵌套 `relatedApproval` 字段 + +| 规则 | 行为 | +|------|------| +| 展示文案 | `已关联 M 条`(M 为 id 个数;0 则「已关联 0 条」) | +| 不展示 | 子卡片缩略、子级展开树 | +| 可点跳转 | 字段带 `relatedIds` 时:`M=1` 进该实例;`M>1` 进列表顺序**第一条** | +| 不可见 | 保持现有「无法查看」;无 `relatedIds` 时纯文本、不可点 | + +--- + +## 4. 打印行为 + +### 4.1 主表内版式 + +普通字段:两列「标签 | 值」。 + +`relatedApproval` 字段:右侧为**多行块**,示意: + +``` +关联审批单 +├─ 1. 外出申请 · 2026-07-10 +│ 目的地:上海 +│ 起止:07-10 ~ 07-12 +│ 关联审批单: +│ └─ 1.1 立项申请 · 2026-06-01 +│ 项目名称:XX +│ 关联审批单:已关联 1 条 ← 已到第 3 层,不再展开 +└─ 2. 另一张单 · 2026-06-15 + … +``` + +### 4.2 递归与安全 + +| 规则 | 定义 | +|------|------| +| 层数 | 当前被打印单上的直接关联 = 第 1 层;其子关联 = 第 2 层;再下 = 第 3 层 | +| 上限 | 最多展开到第 3 层;更深嵌套字段只保留文案「已关联 M 条」 | +| 去重 | `visited` 集合;已出现过的 id 输出「(已在上文出现)」,不重复字段体 | +| 不可见 | 该条标题「无法查看」,无字段体;不中断整份 PDF | +| 加载失败 | 该层提示「关联信息加载失败」;其它行照常 | + +### 4.3 单条块内容 + +1. 标题行:`{序号} {appName} · {applyTime 日期}` +2. 正文:enrichment `fields[]` 的 `label` + `value`(附件等沿用现打印 URL/既有格式) +3. 若某 field 含 `relatedIds` 且 `depth < 3`:在该字段下缩进递归子块 + +--- + +## 5. 数据契约 + +### 5.1 不变 + +- Schema `type: relatedApproval`,`form_data` 为 `string[]` +- 选单:本人发起 + `completed` + 租户隔离 + +### 5.2 enrichment field 扩展(向后兼容) + +```ts +{ + label: string + value: string + fullWidth?: boolean + relatedIds?: string[] // 仅嵌套/本控件扁平化为「已关联 M 条」时可选带出 +} +``` + +- 旧客户端忽略 `relatedIds` 仍可展示文案 +- 屏幕跳转依赖 `relatedIds`;缺省则不可点 + +### 5.3 打印行模型扩展(前端) + +在现有 `OaPrintFieldRow` 上扩展其一即可(实现时择一,保持单一来源): + +- `text` / `urls`(现有),或 +- `blocks?: RelatedPrintBlock[]`(关联专用多行树) + +`RelatedPrintBlock` 建议: + +```ts +{ + indexLabel: string // "1" | "1.1" + title: string // "外出申请 · 2026-07-10" | "无法查看" | "(已在上文出现)" + lines?: { label: string; value: string }[] + children?: RelatedPrintBlock[] +} +``` + +--- + +## 6. 数据流(方案 1) + +``` +列表「打印」 + → fetchOaInstanceDetail(id) + → buildOaPrintModel(detail) + → walk schema + → relatedApproval 字段: + assembleRelatedPrintBlocks(ids, depth=1, visited, maxDepth=3) + → fetchOaRelatedSummaries(batch ids) + → 每条 summary → RelatedPrintBlock + → fields 中带 relatedIds 且 depth < maxDepth → 递归 + → OaApprovalPrintSheet 渲染块 + → html2canvas + jspdf +``` + +屏幕路径不走上述递归;仅消费扁平 `fields` + `relatedIds` 跳转。 + +--- + +## 7. 模块落点 + +| 模块 | 改动 | +|------|------| +| `backend/app/services/oa_related_approval.py` | 扁平化 `relatedApproval` 时写入 `relatedIds`;`value` 保持「已关联 M 条」 | +| `backend/tests/test_oa_related_approval.py` | 断言 `relatedIds` | +| `web-admin/src/components/oa-approval/OaRelatedApproval.vue` | 「已关联 M 条」可点跳转 | +| `web-admin/src/api/oaApproval.ts` | 类型补 `relatedIds?`(若有) | +| `web-admin/src/utils/oa-approval-print.ts` | 特判 + 递归组装 + 批量去重请求 | +| `web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue` | 关联块多行缩进 UI | +| `docs/superpowers/specs/2026-07-22-oa-related-approval-design.md` | §5 嵌套行改为交叉引用本文 | + +--- + +## 8. 错误处理 + +| 场景 | 行为 | +|------|------| +| 嵌套 id 不可见 | 打印块「无法查看」;屏幕跳转后走现有详情权限 | +| summaries 部分失败 | 失败 id 占位;成功 id 正常 | +| 环引用 | visited 去重 | +| 无关联控件旧单 | 打印行为与现网一致 | + +--- + +## 9. 验收标准 + +1. 屏幕:嵌套字段为「已关联 M 条」;`M≥1` 且有 `relatedIds` 时可点进详情;无嵌套缩略卡 +2. 打印:关联字段为多行清单,含对方业务字段;非 UUID、非仅计数 +3. PDF 中嵌套最多 3 层;更深为计数文案 +4. 重复 id 不重复字段体,有「已在上文出现」 +5. 部分不可见时整份 PDF 仍可生成 +6. 无该控件旧应用打印回归通过 +7. 页眉/页脚/印章/水印等既有打印行为不变 + +--- + +## 10. 测试建议 + +- 后端:`flatten_form_fields_for_summary` 对嵌套关联产出 `relatedIds` +- 前端打印:depth=1..3、visited 环、并列多条、unavailable +- 手工:A←B←C←D 链;A↔B 环;列表打印目检 PDF + +--- + +## 11. 实现顺序建议 + +1. 后端扁平化补 `relatedIds` + 单测 +2. 屏幕「已关联 M 条」可跳转 +3. 打印组装递归 + PrintSheet 版式 +4. 回归旧应用与权限占位 +5. 回写 V1 规格交叉引用 + +--- + +## 12. 修订说明(相对 V1) + +| V1 §5 原文 | 本文 | +|------------|------| +| 嵌套 `relatedApproval` →「已关联 M 条(不递归展开)」 | **屏幕**仍不递归缩略,但可跳转;**打印**最多递归 3 层打出业务字段 | + +若与 V1 冲突,**嵌套展示与打印以本文为准**;V1 其余条款(选单、持久化、四层注册等)不变。