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>
This commit is contained in:
zlwuandCursor committed 2026-07-23 14:09:31 +08:00
1 parent 569a13a3ea
commit f951c12071
1 file changed
+629
@@ -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,避免模板复杂化)。