docs: add implementation plan for payment card period collapse

Break down presenter periodBlocks, MarketListCard UI, CSS, and TDD steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-14 14:26:45 +08:00
1 parent 42dde4b0bc
commit bbc24b7c73
1 file changed
+471
@@ -0,0 +1,471 @@
# Payment List Card Period Collapse 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:** 市场【缴费信息】列表卡片按期折叠展示,默认只显示「第几期 / 应缴 / 已缴」,点击可独立展开明细。
**Architecture:** 对齐运费 `sampleBlocks`:`paymentCard` 产出 `periodBlocks` + `footerFields`(累计实缴等放在期次区之后);`MarketListCard` 渲染每期独立折叠行;样式对齐 Demo。不改后端与催缴模块。
**Tech Stack:** Vue 3, Vant 4, CSS, Node.js `node:test`
**Spec:** `docs/superpowers/specs/2026-10-14-payment-card-period-collapse-design.md`
**Demo:** `docs/demos/2026-10-14-payment-card-period-collapse-demo.html`
---
## File map
| File | Responsibility |
|------|----------------|
| `mobile-oa-h5/src/market/list-presenter.js` | `isPaymentPeriodPresent` + `paymentPeriodBlock` + rewrite `paymentCard` |
| `mobile-oa-h5/src/market/list-presenter.test.js` | presenter 行为断言 |
| `mobile-oa-h5/src/market/components/MarketListCard.vue` | 期次折叠 UI + `footerFields` |
| `mobile-oa-h5/src/styles/market.css` | `.market-list-card__periods*` 样式 |
| `mobile-oa-h5/src/market/mobile-parity-ui.test.js` | 结构 / 默认折叠 / 44px 触控 |
---
### Task 1: Presenter — failing tests for `periodBlocks`
**Files:**
- Modify: `mobile-oa-h5/src/market/list-presenter.test.js`
- Test: `mobile-oa-h5/src/market/list-presenter.test.js`
- [ ] **Step 1: Update existing payment card test and add period-block cases**
Replace the assertions in `test('payment card formats summary totals and progress', …)` that expect flat period fields, and add a focused test. Keep the existing fixture; change expectations as follows:
```js
test('payment card formats summary totals and progress', () => {
const card = buildMarketCard('payment', 'manager', {
protocolNo: 'P-1', motherName: '周雨桐', sampleCode: 'S-1', payMode: 'fq',
contractAmount: '10000.00', paidAmount: '3000.00', remainAmount: '7000.00',
signDate: '2026-10-01', storageYears: 20, sampleTypeName: '脐带血',
discountAmount: '1000.00', receivableAmount: '9000.00', remark: '首期已缴',
periods: [{ scheduleNo: 1, dueAmount: 3000, dueDate: '2026-10-10', displayPaidAmount: 3000, payDate: '2026-10-11', arrearsAmount: 0 }],
clearedScheduleCount: 1, activeScheduleCount: 4, paymentProgress: '部分已缴',
allowedActions: ['register-transfer']
})
assert.equal(card.status, '部分已缴')
assert.equal(card.subtitle, 'S-1')
assert.ok(card.fields.some((field) => field.label === '样本编码' && field.value === 'S-1'))
assert.ok(card.fields.some((field) => field.label === '协议号' && field.value === 'P-1'))
assert.ok(card.fields.some((field) => field.label === '签署日期' && field.value === '2026-10-01'))
assert.ok(card.fields.some((field) => field.label === '存储项目' && field.value === '脐带血'))
assert.ok(!card.fields.some((field) => String(field.label).includes('第1期')))
assert.equal(card.periodBlocks.length, 1)
assert.equal(card.periodBlocks[0].scheduleNo, 1)
assert.equal(card.periodBlocks[0].dueAmount, '¥3,000.00')
assert.equal(card.periodBlocks[0].paidAmount, '¥3,000.00')
assert.ok(card.periodBlocks[0].fields.some((f) => f.label === '应缴费时间' && f.value === '2026-10-10'))
assert.ok(card.periodBlocks[0].fields.some((f) => f.label === '已缴费时间' && f.value === '2026-10-11'))
assert.ok(card.periodBlocks[0].fields.some((f) => f.label === '欠缴费金额' && f.value === '¥0.00'))
assert.ok(card.footerFields.some((field) => field.label === '待缴金额' && field.value === '¥7,000.00'))
assert.ok(card.footerFields.some((field) => field.label === '累计实缴' && field.value === '¥3,000.00'))
assert.ok(card.footerFields.some((field) => field.label === '备注' && field.value === '首期已缴'))
assert.deepEqual(card.actions, ['view-protocol', 'view-payment-detail'])
})
test('payment card periodBlocks skip empty periods and do not pad to six', () => {
const card = buildMarketCard('payment', 'specialist', {
motherName: '李婷', sampleCode: 'S-2', payMode: 'fq',
periods: [
{ scheduleNo: 1, dueAmount: 1000, dueDate: '2026-01-01', paidAmount: 1000, payDate: '2026-01-02', arrearsAmount: 0 },
{},
{ scheduleNo: 3, dueAmount: 2000, dueDate: '2026-07-01', paidAmount: 0, arrearsAmount: 2000 },
null,
undefined
]
})
assert.equal(card.periodBlocks.length, 2)
assert.equal(card.periodBlocks[0].key, 'period-1')
assert.equal(card.periodBlocks[1].scheduleNo, 3)
assert.equal(card.periodBlocks[1].dueAmount, '¥2,000.00')
assert.equal(card.periodBlocks[1].paidAmount, '¥0.00')
assert.ok(!card.fields.some((field) => /第\d+期/.test(field.label)))
})
```
- [ ] **Step 2: Run tests and verify RED**
```bash
cd mobile-oa-h5 && node --test --test-name-pattern="payment card" src/market/list-presenter.test.js
```
Expected: FAIL — `periodBlocks` / `footerFields` undefined, or flat `第1期·` fields still present.
---
### Task 2: Presenter — implement `periodBlocks` + `footerFields`
**Files:**
- Modify: `mobile-oa-h5/src/market/list-presenter.js` (`paymentCard` and helpers near it)
- [ ] **Step 1: Add helpers and rewrite `paymentCard`**
Insert before `paymentCard` (after `chaseCard`):
```js
function hasPresentValue(value) {
if (value == null) return false
if (typeof value === 'number') return Number.isFinite(value)
return String(value).trim() !== ''
}
function isPaymentPeriodPresent(period) {
if (!period || typeof period !== 'object') return false
return (
hasPresentValue(period.scheduleNo) ||
hasPresentValue(period.dueAmount) ||
hasPresentValue(period.displayPaidAmount) ||
hasPresentValue(period.paidAmount) ||
hasPresentValue(period.arrearsAmount) ||
hasPresentValue(period.dueDate) ||
hasPresentValue(period.payDate)
)
}
function paymentPeriodBlock(period, index) {
const scheduleNo = Number(period.scheduleNo) || index + 1
const paidRaw = period.displayPaidAmount ?? period.paidAmount
return {
key: String(period.id || `period-${scheduleNo}`),
scheduleNo,
dueAmount: money(period.dueAmount),
paidAmount: money(paidRaw),
fields: [
field('应缴费时间', period.dueDate),
field('已缴费时间', period.payDate),
field('欠缴费金额', money(period.arrearsAmount), { money: true })
]
}
}
function paymentCard(item) {
const progress = label({}, item.paymentProgress || item.progress || (Number(item.paidAmount || 0) > 0 ? '部分已缴' : '待缴清'))
const periods = (Array.isArray(item.periods) ? item.periods : []).filter(isPaymentPeriodPresent)
return {
title: text(item.motherName),
subtitle: text(item.sampleCode),
status: progress,
fields: [
field('协议号', item.protocolNo, { wide: true }),
field('姓名', item.motherName), field('手机号', item.motherPhone),
field('样本编码', item.sampleCode, { wide: true }), field('签署日期', item.signDate),
field('存储年限', item.storageYears), field('存储项目', item.sampleTypeName, { wide: true }),
field('合同总额', money(item.contractAmount), { money: true }),
field('缴费方式', label(PAY_MODE_LABELS, item.payMode), { tag: true }),
field('优惠金额', money(item.discountAmount), { money: true }),
field('应收总额', money(item.receivableAmount), { money: true })
],
periodBlocks: periods.map(paymentPeriodBlock),
footerFields: [
field('累计实缴', money(item.paidAmount), { money: true }),
field('待缴金额', money(item.dueRemainAmount ?? item.remainAmount), { money: true }),
field('备注', item.remark, { wide: true })
],
actions: ['view-protocol', 'view-payment-detail']
}
}
```
Remove the old `Array.from({ length: 6 }, …)` + `periodFields` flatMap implementation entirely.
- [ ] **Step 2: Run tests and verify GREEN**
```bash
cd mobile-oa-h5 && node --test --test-name-pattern="payment card" src/market/list-presenter.test.js
```
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add mobile-oa-h5/src/market/list-presenter.js mobile-oa-h5/src/market/list-presenter.test.js
git commit -m "$(cat <<'EOF'
feat(mobile-oa-h5): present payment periods as collapsible periodBlocks
Stop flattening six installment field groups into list cards; emit periodBlocks and footerFields instead.
EOF
)"
```
---
### Task 3: UI parity tests for period collapse (RED)
**Files:**
- Modify: `mobile-oa-h5/src/market/mobile-parity-ui.test.js`
- [ ] **Step 1: Add parity test**
```js
test('payment list cards collapse period blocks by default and allow independent expand', () => {
const card = source('./components/MarketListCard.vue')
const presenter = source('./list-presenter.js')
const styles = source('../styles/market.css')
assert.match(presenter, /periodBlocks:\s*periods\.map\(paymentPeriodBlock\)/)
assert.match(presenter, /footerFields:/)
assert.match(card, /card\.periodBlocks/)
assert.match(card, /card\.footerFields/)
assert.match(card, /periodOpenKeys/)
assert.match(card, /aria-expanded/)
assert.match(card, /应缴费金额/)
assert.match(card, /已缴费金额/)
assert.match(styles, /\.market-list-card__periods\s*\{/)
assert.match(styles, /\.market-list-card__period-toggle\s*\{[\s\S]*?min-height:\s*44px;/)
})
```
- [ ] **Step 2: Run and verify RED**
```bash
cd mobile-oa-h5 && node --test --test-name-pattern="payment list cards collapse period" src/market/mobile-parity-ui.test.js
```
Expected: FAIL — template/CSS not yet present.
---
### Task 4: MarketListCard — render periods + footerFields
**Files:**
- Modify: `mobile-oa-h5/src/market/components/MarketListCard.vue`
- [ ] **Step 1: Insert period section and footer fields in template**
After the existing `card.fields` block and **before** the `sampleBlocks` section, insert:
```vue
<div v-if="card.periodBlocks?.length" class="market-list-card__periods">
<div
v-for="block in card.periodBlocks"
:key="block.key"
class="market-list-card__period"
:class="{ 'is-open': periodOpenKeys[block.key] }"
>
<button
type="button"
class="market-list-card__period-toggle"
:aria-expanded="periodOpenKeys[block.key] ? 'true' : 'false'"
:aria-label="periodOpenKeys[block.key] ? `收起第${block.scheduleNo}期` : `展开第${block.scheduleNo}期`"
@click="togglePeriod(block.key)"
>
<span class="market-list-card__period-no">第{{ block.scheduleNo }}期</span>
<span class="market-list-card__period-col">
<span>应缴费金额</span>
<b class="is-money">{{ block.dueAmount }}</b>
</span>
<span class="market-list-card__period-col">
<span>已缴费金额</span>
<b class="is-money">{{ block.paidAmount }}</b>
</span>
<van-icon class="market-list-card__period-chevron" name="arrow-down" />
</button>
<div v-show="periodOpenKeys[block.key]" class="market-list-card__period-body">
<div class="market-list-card__fields market-list-card__fields--compact">
<div
v-for="field in block.fields"
:key="`${block.key}:${field.label}`"
class="market-list-card__field"
:class="{ 'is-wide': field.wide }"
>
<span>{{ field.label }}</span>
<b :class="{ 'is-money': field.money }">{{ field.value }}</b>
</div>
</div>
</div>
</div>
</div>
<div v-if="card.footerFields?.length" class="market-list-card__fields">
<div
v-for="field in card.footerFields"
:key="`footer:${field.label}:${field.value}`"
class="market-list-card__field"
:class="{ 'is-wide': field.wide }"
>
<span>{{ field.label }}</span>
<van-tag v-if="field.tag" class="market-tag" :type="resolveStatusTone(field.value)" size="medium" round :title="field.value">{{ field.value }}</van-tag>
<b v-else :class="{ 'is-money': field.money }">{{ field.value }}</b>
</div>
</div>
```
- [ ] **Step 2: Add script state**
Alongside `samplesOpen`, add:
```js
const periodOpenKeys = ref({})
function togglePeriod(key) {
periodOpenKeys.value = {
...periodOpenKeys.value,
[key]: !periodOpenKeys.value[key]
}
}
watch(
() => props.item?.id || props.index,
() => {
samplesOpen.value = false
periodOpenKeys.value = {}
}
)
```
Merge with the existing `samplesOpen` watch — keep a **single** watch that resets both `samplesOpen` and `periodOpenKeys`. Delete the old watch that only cleared `samplesOpen`.
- [ ] **Step 3: Do not run full GREEN yet** — CSS still missing; proceed to Task 5.
---
### Task 5: CSS for period rows
**Files:**
- Modify: `mobile-oa-h5/src/styles/market.css`
- [ ] **Step 1: Add styles after `.market-list-card__fields--compact` block**
```css
.market-list-card__periods {
margin: 0 0 12px;
overflow: hidden;
border: 1px solid #eef4f1;
border-radius: 12px;
background: #fafcfb;
}
.market-list-card__period + .market-list-card__period {
border-top: 1px solid #eef4f1;
}
.market-list-card__period-toggle {
display: grid;
width: 100%;
min-height: 44px;
grid-template-columns: 64px 1fr 1fr 20px;
align-items: center;
gap: 8px;
padding: 10px 12px;
border: 0;
background: transparent;
color: var(--market-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.market-list-card__period-toggle:active {
background: #f0f7f3;
}
.market-list-card__period-no {
font-size: 13px;
font-weight: 700;
color: var(--market-primary-dark);
}
.market-list-card__period-col {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.market-list-card__period-col > span {
color: var(--market-muted);
font-size: 11px;
}
.market-list-card__period-col > b {
overflow: hidden;
color: #c98312;
font-size: 13px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.market-list-card__period-chevron {
color: var(--market-muted);
font-size: 14px;
transition: transform 0.2s ease;
}
.market-list-card__period.is-open .market-list-card__period-chevron {
transform: rotate(180deg);
}
.market-list-card__period-body {
padding: 0 12px 12px;
border-top: 1px dashed #eef4f1;
}
```
Align with Demo; use existing token vars where already defined (`--market-text`, `--market-muted`, `--market-primary-dark`). Money color `#c98312` matches `.market-list-card__field b.is-money`.
- [ ] **Step 2: Run parity + presenter tests GREEN**
```bash
cd mobile-oa-h5 && node --test --test-name-pattern="payment card|payment list cards collapse period" src/market/list-presenter.test.js src/market/mobile-parity-ui.test.js
```
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add mobile-oa-h5/src/market/components/MarketListCard.vue mobile-oa-h5/src/styles/market.css mobile-oa-h5/src/market/mobile-parity-ui.test.js
git commit -m "$(cat <<'EOF'
feat(mobile-oa-h5): collapse payment installment rows on list cards
Render periodBlocks as independent expandable summary rows with footer totals after the period section.
EOF
)"
```
---
### Task 6: Full market suite + build verification
**Files:**
- Verify only
- [ ] **Step 1: Run all market tests**
```bash
cd mobile-oa-h5 && node --test src/market/*.test.js
```
Expected: zero failures.
- [ ] **Step 2: Production build**
```bash
cd mobile-oa-h5 && pnpm build
```
Expected: Vite exit 0.
- [ ] **Step 3: Manual smoke (optional but recommended)**
Open市场专员 / 地区经理【缴费信息】列表:卡片默认矮;点第 N 期展开三明细;切换列表项后折叠状态重置;无数据期次不出现。
---
## Spec coverage checklist
| Spec requirement | Task |
|------------------|------|
| 折叠行:期数 / 应缴 / 已缴 | Task 4 |
| 展开:时间 + 欠缴 | Task 2 + 4 |
| 默认折叠、独立多开 | Task 4 (`periodOpenKeys`) |
| 不 pad 到 6、跳过空期 | Task 1–2 |
| `footerFields` 在期次后 | Task 2 + 4 |
| 仅 payment 列表 | Task 2(仅改 `paymentCard`) |
| 44px 触控 | Task 5 |
| 测试 | Task 1, 3, 6 |