diff --git a/docs/superpowers/plans/2026-07-23-oa-or-and-sign-display.md b/docs/superpowers/plans/2026-07-23-oa-or-and-sign-display.md new file mode 100644 index 00000000..08d0ae3b --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-oa-or-and-sign-display.md @@ -0,0 +1,629 @@ +# OA 或签 / 会签进度与 PDF 展示 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:** 审批详情「审批进度」与打印 PDF「审批流程」对或签/会签多人节点统一按纯文本行展示:`姓名 意见 时间`(未批「待处理」、或签免处理「无需处理」且仍列出)。 + +**Architecture:** 抽纯函数 `formatProgressPersonLines`(及行态 `tone`)供 `OaProgressChain` 与 `buildFlowItems` 共用;PDF 模型增加 `personLines`,模板分行渲染。不改引擎收口语义,不改发起页预览。 + +**Tech Stack:** Vue 3 · TypeScript · 现有 `OaProgressChain` / `oa-approval-print.ts` · `tsx` + `node:assert`(仓库无 vitest,用脚本测纯函数) + +**Spec:** `docs/superpowers/specs/2026-07-23-oa-or-and-sign-display-design.md` +**Demo:** `docs/mockups/oa-or-and-sign-progress-display-demo.html` + +--- + +## File map + +| Path | Responsibility | +|------|----------------| +| Create: `web-admin/src/utils/oa-progress-person-lines.ts` | 纯函数:`people[]` → 行文案 + tone | +| Create: `web-admin/scripts/check-oa-progress-person-lines.ts` | 用 `tsx` 跑断言(无 vitest) | +| Modify: `web-admin/src/components/oa-approval/OaProgressChain.vue` | 节点下渲染 person-lines;有行时隐藏节点级「意见:」避免双份 | +| Modify: `web-admin/src/utils/oa-approval-print.ts` | `OaPrintFlowItem.personLines`;`buildFlowItems` 接入格式化 | +| Modify: `web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue` | 渲染 `personLines` 多行;弱化旧 `comment` 双份 | + +**Locked rules (from spec):** + +- 行格式:已处理 `{姓名} {意见或状态文案} {时间}`;未处理 `{姓名} 待处理`;或签免处理 `{姓名} 无需处理`(必须仍列出)。 +- `done` → 无需处理;`pending|running|not_reached` → 待处理。 +- 意见非空用原文;已处理意见空则用 `已同意`/`已拒绝`/`已打回` 等,禁止双空格。 +- 后端原则上不改;仅当前端缺字段时再补。 + +--- + +### Task 1: 纯函数 + 断言脚本(TDD) + +**Files:** +- Create: `web-admin/src/utils/oa-progress-person-lines.ts` +- Create: `web-admin/scripts/check-oa-progress-person-lines.ts` + +- [ ] **Step 1: Write the failing check script first** + +Create `web-admin/scripts/check-oa-progress-person-lines.ts`: + +```ts +import assert from 'node:assert/strict' +import { + formatProgressPersonLines, + type ProgressPersonInput +} from '../src/utils/oa-progress-person-lines' + +function run() { + // 会签部分完成 + const andPartial: ProgressPersonInput[] = [ + { + nickName: '甲', + status: 'approved', + commentText: '同意', + timestamp: '2026-07-23 10:01:00' + }, + { + nickName: '乙', + status: 'approved', + commentText: '无异议', + timestamp: '2026-07-23 10:20:00' + }, + { nickName: '丙', status: 'pending', commentText: '', timestamp: '' } + ] + const andLines = formatProgressPersonLines(andPartial) + assert.deepEqual( + andLines.map((x) => x.text), + [ + '甲 同意 2026-07-23 10:01:00', + '乙 无异议 2026-07-23 10:20:00', + '丙 待处理' + ] + ) + assert.equal(andLines[0].tone, 'ok') + assert.equal(andLines[2].tone, 'pending') + + // 或签一人已批:操作人 + 免处理仍列出 + const orDone: ProgressPersonInput[] = [ + { + nickName: '甲', + status: 'approved', + commentText: '同意,材料齐全', + timestamp: '2026-07-23 10:05:00' + }, + { nickName: '乙', status: 'done', commentText: '', timestamp: '2026-07-23 10:00:00' }, + { nickName: '丙', status: 'done', commentText: '', timestamp: '' } + ] + const orLines = formatProgressPersonLines(orDone) + assert.deepEqual( + orLines.map((x) => x.text), + ['甲 同意,材料齐全 2026-07-23 10:05:00', '乙 无需处理', '丙 无需处理'] + ) + assert.equal(orLines[0].tone, 'ok') + assert.equal(orLines[1].tone, 'mute') + assert.equal(orLines[2].tone, 'mute') + + // 已同意但无意见 → 状态文案兜底 + const emptyComment = formatProgressPersonLines([ + { + nickName: '甲', + status: 'approved', + commentText: '', + timestamp: '2026-07-23 10:05:00' + } + ]) + assert.equal(emptyComment[0].text, '甲 已同意 2026-07-23 10:05:00') + + // 拒绝 + const rejected = formatProgressPersonLines([ + { + nickName: '甲', + status: 'rejected', + commentText: '不同意', + timestamp: '2026-07-23 11:00:00' + }, + { nickName: '乙', status: 'done' } + ]) + assert.equal(rejected[0].text, '甲 不同意 2026-07-23 11:00:00') + assert.equal(rejected[0].tone, 'danger') + assert.equal(rejected[1].text, '乙 无需处理') + + // 空 people + assert.deepEqual(formatProgressPersonLines([]), []) + assert.deepEqual(formatProgressPersonLines(undefined), []) + + console.log('oa-progress-person-lines: all assertions passed') +} + +run() +``` + +- [ ] **Step 2: Run check — expect FAIL (module missing)** + +```bash +cd web-admin && pnpm exec tsx scripts/check-oa-progress-person-lines.ts +``` + +Expected: error like `Cannot find module '../src/utils/oa-progress-person-lines'` + +- [ ] **Step 3: Implement the util** + +Create `web-admin/src/utils/oa-progress-person-lines.ts`: + +```ts +/** + * 或签/会签进度人员行格式化 + * Spec: docs/superpowers/specs/2026-07-23-oa-or-and-sign-display-design.md + */ + +export type ProgressPersonInput = { + id?: string + nickName?: string + status?: string + timestamp?: string + commentText?: string +} + +export type PersonLineTone = 'ok' | 'pending' | 'danger' | 'mute' | '' + +export type ProgressPersonLine = { + text: string + tone: PersonLineTone +} + +const ACTED_STATUS = new Set(['approved', 'rejected', 'returned', 'completed']) + +function displayName(p: ProgressPersonInput): string { + return String(p.nickName || p.id || '').trim() +} + +function statusFallbackLabel(status: string): string { + const s = (status || '').trim() + if (s === 'approved') return '已同意' + if (s === 'rejected') return '已拒绝' + if (s === 'returned') return '已打回' + if (s === 'completed') return '已完成' + return s || '已处理' +} + +function toneForStatus(status: string): PersonLineTone { + const s = (status || '').trim() + if (s === 'approved' || s === 'completed') return 'ok' + if (s === 'pending' || s === 'running' || s === 'not_reached') return 'pending' + if (s === 'rejected' || s === 'returned' || s === 'cancelled') return 'danger' + if (s === 'done') return 'mute' + return '' +} + +/** 将 progress.people 格式化为「姓名 意见/状态 时间」纯文本行 */ +export function formatProgressPersonLines( + people: ProgressPersonInput[] | null | undefined +): ProgressPersonLine[] { + const list = Array.isArray(people) ? people : [] + const out: ProgressPersonLine[] = [] + for (const p of list) { + const name = displayName(p) + if (!name) continue + const status = String(p.status || '').trim() + const comment = String(p.commentText || '').trim() + const ts = String(p.timestamp || '').trim() + const tone = toneForStatus(status) + + if (status === 'done') { + out.push({ text: `${name} 无需处理`, tone: 'mute' }) + continue + } + if (status === 'pending' || status === 'running' || status === 'not_reached' || !status) { + // 无 status 时:若有意见/时间仍按已处理拼;否则待处理 + if (!status && (comment || ts)) { + const mid = comment || statusFallbackLabel('approved') + out.push({ + text: ts ? `${name} ${mid} ${ts}` : `${name} ${mid}`, + tone: 'ok' + }) + continue + } + out.push({ text: `${name} 待处理`, tone: 'pending' }) + continue + } + if (ACTED_STATUS.has(status) || comment || ts) { + const mid = comment || statusFallbackLabel(status) + out.push({ + text: ts ? `${name} ${mid} ${ts}` : `${name} ${mid}`, + tone: tone || 'ok' + }) + continue + } + out.push({ text: `${name} 待处理`, tone: 'pending' }) + } + return out +} +``` + +- [ ] **Step 4: Run check — expect PASS** + +```bash +cd web-admin && pnpm exec tsx scripts/check-oa-progress-person-lines.ts +``` + +Expected: `oa-progress-person-lines: all assertions passed` + +- [ ] **Step 5: Commit** + +```bash +git add web-admin/src/utils/oa-progress-person-lines.ts web-admin/scripts/check-oa-progress-person-lines.ts +git commit -m "$(cat <<'EOF' +feat(web-admin): add OA progress person-line formatter for or/and sign + +Provide shared plain-text lines (name opinion time) used by progress UI and PDF. +EOF +)" +``` + +--- + +### Task 2: 审批进度 UI(OaProgressChain) + +**Files:** +- Modify: `web-admin/src/components/oa-approval/OaProgressChain.vue` + +- [ ] **Step 1: Import formatter and render person-lines** + +In `