Kaynağa Gözat

docs(oa): add implementation plan for or/and-sign progress and PDF display

Break work into shared person-line formatter, OaProgressChain, and print flow wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 ay önce
ebeveyn
işleme
f951c12071

+ 629 - 0
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 `<script setup>` add:
+
+```ts
+import {
+  formatProgressPersonLines,
+  type ProgressPersonLine
+} from '@/utils/oa-progress-person-lines'
+
+function personLines(item: OaProgressChainItem): ProgressPersonLine[] {
+  if (item.kind === 'terminal' || item.kind === 'submit') return []
+  if (item.taskType === 'cc' || item.signMode === 'cc') return []
+  const list = displayPeople(item)
+  if (!list.length) return []
+  return formatProgressPersonLines(list)
+}
+
+function showNodeComment(item: OaProgressChainItem): boolean {
+  // 有按人行时不再展示节点级拼接意见,避免双份
+  if (personLines(item).length) return false
+  return Boolean(item.commentText)
+}
+```
+
+In template, **replace** the comment block and add lines after the card row (inside `.oa-flow-card`):
+
+```vue
+          <div v-if="personLines(item).length" class="oa-person-lines">
+            <p
+              v-for="(line, li) in personLines(item)"
+              :key="`${item.key}-pl-${li}`"
+              class="oa-person-line"
+              :class="{
+                'is-ok': line.tone === 'ok',
+                'is-pending': line.tone === 'pending',
+                'is-danger': line.tone === 'danger',
+                'is-mute': line.tone === 'mute'
+              }"
+            >
+              {{ line.text }}
+            </p>
+          </div>
+          <div v-else-if="showNodeComment(item)" class="oa-flow-card__comment">
+            意见:{{ item.commentText }}
+          </div>
+```
+
+Keep existing chips in the header row (高亮/灰显已由 `personChipClass` 覆盖 `done`→`is-mute`、`approved`→`is-ok`)。
+
+- [ ] **Step 2: Add scoped styles**
+
+```scss
+  .oa-person-lines {
+    margin-top: 8px;
+    display: grid;
+    gap: 4px;
+  }
+
+  .oa-person-line {
+    margin: 0;
+    color: var(--mint-text, #334155);
+    font-size: 12px;
+    line-height: 1.55;
+    word-break: break-word;
+
+    &.is-ok {
+      color: #059669;
+      font-weight: 500;
+    }
+
+    &.is-pending {
+      color: #d97706;
+    }
+
+    &.is-danger {
+      color: #dc2626;
+    }
+
+    &.is-mute {
+      color: #94a3b8;
+    }
+  }
+```
+
+- [ ] **Step 3: Smoke-check in browser**
+
+1. `cd web-admin && pnpm dev`
+2. 打开含或签多人节点的实例详情,确认:未批见「姓名 待处理」;一人同意后甲高亮行 + 乙丙「无需处理」灰显;节点下不再出现「意见:甲:…」拼接块。
+3. 会签部分完成:已批行含意见时间,未批「待处理」。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web-admin/src/components/oa-approval/OaProgressChain.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): show per-person approval lines on OA progress chain
+
+Render name/opinion/time under multi-approver nodes; hide duplicate node comment.
+EOF
+)"
+```
+
+---
+
+### Task 3: PDF buildFlowItems + PrintSheet
+
+**Files:**
+- Modify: `web-admin/src/utils/oa-approval-print.ts`
+- Modify: `web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue`
+
+- [ ] **Step 1: Extend type and wire formatter in `buildFlowItems`**
+
+In `oa-approval-print.ts`:
+
+1. Import:
+
+```ts
+import { formatProgressPersonLines } from '@/utils/oa-progress-person-lines'
+```
+
+2. Change type:
+
+```ts
+export type OaPrintFlowItem = {
+  left: string
+  right: string
+  /** @deprecated 多人节点改用 personLines;单人/发起人可保留 */
+  comment?: string
+  /** 或签/会签按人纯文本行 */
+  personLines?: string[]
+}
+```
+
+3. Add helper near `peopleDisplayNames`:
+
+```ts
+function progressPersonLinesFromItem(p: any): string[] {
+  const people = Array.isArray(p?.people) ? p.people : []
+  if (!people.length) return []
+  return formatProgressPersonLines(people).map((x) => x.text)
+}
+```
+
+4. In the `kind === 'pending_node'` branch, after building names, prefer person lines:
+
+```ts
+    if (kind === 'pending_node') {
+      const personLines = progressPersonLinesFromItem(p)
+      const head = signLabel ? `【${title}·${signLabel}】` : `【${title}】`
+      items.push({
+        left: personLines.length
+          ? `${head} ${st}`
+          : name
+            ? `${head} ${name} ${st}`
+            : `${head} ${st}`,
+        right: personLines.length ? '' : ts,
+        personLines: personLines.length ? personLines : undefined
+      })
+      continue
+    }
+```
+
+5. Replace the task branch (comment `// task(或签/会签…)`) with:
+
+```ts
+    // task(或签/会签同节点多人已合并)
+    const head = signLabel ? `【${title}·${signLabel}】` : `【${title}】`
+    const personLines = progressPersonLinesFromItem(p)
+    if (personLines.length) {
+      items.push({
+        left: `${head} ${st}`,
+        right: '',
+        personLines
+      })
+      continue
+    }
+    items.push({
+      left: name ? `${head} ${name} ${st}` : `${head} ${st}`,
+      right: ts,
+      comment: comment || undefined
+    })
+```
+
+Keep submit / terminal / cc behavior unchanged(submit 仍可用单行 `comment`)。
+
+- [ ] **Step 2: Update PrintSheet template**
+
+In `OaApprovalPrintSheet.vue`, inside `.oa-print-flow__cell`, after the left/right row:
+
+```vue
+                  <div v-if="f.personLines?.length" class="oa-print-flow__person-lines">
+                    <div
+                      v-for="(line, li) in f.personLines"
+                      :key="li"
+                      class="oa-print-flow__person-line"
+                    >
+                      {{ line }}
+                    </div>
+                  </div>
+                  <div
+                    v-else-if="f.comment"
+                    class="oa-print-flow__comment"
+                  >
+                    意见:{{ f.comment }}
+                  </div>
+```
+
+Add styles:
+
+```scss
+.oa-print-flow__person-lines {
+  margin-top: 6px;
+  display: grid;
+  gap: 3px;
+}
+.oa-print-flow__person-line {
+  line-height: 1.55;
+  word-break: break-word;
+  white-space: pre-wrap;
+}
+```
+
+- [ ] **Step 3: Extend check script for buildFlowItems or签样例(可选但推荐)**
+
+Append to `web-admin/scripts/check-oa-progress-person-lines.ts` (or create second assert block in same file):
+
+```ts
+import { buildFlowItems } from '../src/utils/oa-approval-print'
+
+  const detail = {
+    progress: [
+      {
+        kind: 'submit',
+        title: '发起人',
+        status: 'submitted',
+        assigneeUserName: '张三',
+        timestamp: '2026-07-23 09:00:00',
+        people: []
+      },
+      {
+        kind: 'task',
+        title: '部门审批',
+        status: 'approved',
+        signMode: 'or',
+        timestamp: '2026-07-23 10:05:00',
+        commentText: '甲:同意,材料齐全',
+        people: [
+          {
+            nickName: '甲',
+            status: 'approved',
+            commentText: '同意,材料齐全',
+            timestamp: '2026-07-23 10:05:00'
+          },
+          { nickName: '乙', status: 'done' },
+          { nickName: '丙', status: 'done' }
+        ]
+      },
+      { kind: 'terminal', title: '流程结束', status: 'done', timestamp: '2026-07-23 10:05:00' }
+    ]
+  }
+  const flow = buildFlowItems(detail)
+  const taskFlow = flow.find((x) => x.left.includes('部门审批'))
+  assert.ok(taskFlow)
+  assert.ok(taskFlow!.left.includes('或签'))
+  assert.deepEqual(taskFlow!.personLines, [
+    '甲 同意,材料齐全 2026-07-23 10:05:00',
+    '乙 无需处理',
+    '丙 无需处理'
+  ])
+  assert.equal(taskFlow!.comment, undefined)
+```
+
+Note: `buildFlowItems` 当前文件有 Vue/`html2canvas` 副作用依赖;若 `tsx` 导入失败,可只保留 Task 1 纯函数断言,本步改为手动打印验收,**不要**为测 PDF 强行拆文件除非导入报错。
+
+若导入报错,跳过本脚本扩展,改为 Step 4 手工验收。
+
+- [ ] **Step 4: Manual PDF check**
+
+1. 在「我发起的」对或签已通过实例点「打印」,打开 PDF「审批流程」。
+2. 期望类似:
+
+```
+【部门审批·或签】 已同意
+甲 同意,材料齐全 2026-07-23 10:05:00
+乙 无需处理
+丙 无需处理
+```
+
+3. 会签完成实例:每人一行「姓名 意见 时间」,无表头三列。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web-admin/src/utils/oa-approval-print.ts \
+  web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue \
+  web-admin/scripts/check-oa-progress-person-lines.ts
+git commit -m "$(cat <<'EOF'
+feat(web-admin): print OA or/and-sign nodes as per-person text lines
+
+Align PDF flow section with progress UI person-line format.
+EOF
+)"
+```
+
+---
+
+### Task 4: 回归与收尾
+
+**Files:** none required(可选:在 print PDF design 文首加一句「多人节点见 2026-07-23 spec」——仅当发现文档仍写「姓名逗号拼接」且易误导时再改)
+
+- [ ] **Step 1: Re-run formatter check**
+
+```bash
+cd web-admin && pnpm exec tsx scripts/check-oa-progress-person-lines.ts
+```
+
+Expected: PASS
+
+- [ ] **Step 2: Backend regression (engine unchanged)**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_oa_engine.py::TestProgressItems::test_or_sign_same_node_merged_into_one_card -v
+```
+
+(若类名路径不同,用:`pytest tests/test_oa_engine.py -k or_sign_same_node_merged -v`)
+
+Expected: PASS — `people` 仍含三人含 `done`。
+
+- [ ] **Step 3: Acceptance checklist (spec §7)**
+
+- [ ] 或签未批:进度 + PDF 见全部候选人(待处理)
+- [ ] 或签一人同意:仅操作人高亮;乙丙「无需处理」仍在
+- [ ] 会签部分/全部:每人一行意见时间
+- [ ] 无表头三列表格
+- [ ] 单人节点、抄送、终态无回归
+- [ ] 节点级「意见:甲:…」在多人时不再与 person-lines 双显
+
+- [ ] **Step 4: Final commit only if docs tweaked; else done**
+
+若修改了 `docs/superpowers/specs/2026-07-15-oa-approval-print-pdf-design.md` 交叉引用:
+
+```bash
+git add docs/superpowers/specs/2026-07-15-oa-approval-print-pdf-design.md
+git commit -m "docs(oa): point print flow multi-approver display to 2026-07-23 spec"
+```
+
+---
+
+## Spec coverage (self-review)
+
+| Spec 要求 | Task |
+|-----------|------|
+| §3 统一行格式 | Task 1 |
+| §4.1 或签高亮 + 无需处理仍列出 | Task 1–2(tone/chip)+ Task 3 PDF |
+| §4.2 会签每人一行 | Task 1–3 |
+| §5.1 OaProgressChain | Task 2 |
+| §5.2 buildFlowItems + PrintSheet | Task 3 |
+| §5.3 隐藏节点级双份意见 | Task 2–3 |
+| §7 验收 | Task 4 |
+| 不改引擎 | 无后端改动任务 |
+
+**Placeholder scan:** 无 TBD;`tsx` 导入 `buildFlowItems` 失败时的降级路径已写明。
+
+**Type consistency:** `ProgressPersonLine.text/tone`;`OaPrintFlowItem.personLines: string[]`(PDF 不需要 tone,避免模板复杂化)。