Selaa lähdekoodia

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>
zlwu 3 viikkoa sitten
vanhempi
commit
bbc24b7c73
1 muutettua tiedostoa jossa 471 lisäystä ja 0 poistoa
  1. 471 0
      docs/superpowers/plans/2026-10-14-payment-card-period-collapse.md

+ 471 - 0
docs/superpowers/plans/2026-10-14-payment-card-period-collapse.md

@@ -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 |