diff --git a/docs/superpowers/plans/2026-09-16-bx-reimburse-invoice-thumbnail.md b/docs/superpowers/plans/2026-09-16-bx-reimburse-invoice-thumbnail.md new file mode 100644 index 000000000..9b485c01c --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-bx-reimburse-invoice-thumbnail.md @@ -0,0 +1,919 @@ +# 小程序报销关联发票缩略图 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:** 报销申请选择/已选、报销详情展示关联发票均改为缩略图(金额+日期),选择弹层支持每次 10 条滚动加载。 + +**Architecture:** 方案 1 页面内改造。抽取纯函数工具 `bxInvoiceThumb.js`(扁平化、合并勾选、日期/图片判断)供 apply/detail 复用;样式补在 `bx-theme.scss`;不改后端。 + +**Tech Stack:** uni-app Vue 3、`invoiceApi` / `attachmentApi`、现有 `bx-theme.scss` Token。 + +**Spec:** `docs/superpowers/specs/2026-09-16-bx-reimburse-invoice-thumbnail-design.md` +**Demo:** `docs/superpowers/demos/2026-09-16-bx-reimburse-invoice-thumb-demo.html` + +--- + +## File map + +| File | Responsibility | +|------|----------------| +| Create: `miniprogram/utils/bxInvoiceThumb.js` | 纯函数:图片判断、扩展名、日期 YYYY-MM-DD、发票→附件项、draft 合并 | +| Create: `miniprogram/scripts/test-bx-invoice-thumb.mjs` | Node 断言脚本验证纯函数(仓库无 miniprogram 单测框架) | +| Modify: `miniprogram/styles/bx-theme.scss` | `.invoice-thumb-grid` / 选中卡 / 已选缩略图副文案 | +| Modify: `miniprogram/pages/bx/reimburse/apply.vue` | 弹层缩略图分页 + 已选缩略图预览 | +| Modify: `miniprogram/pages/bx/reimburse/detail.vue` | 「关联发票」区块(在申请附件上方) | + +--- + +### Task 1: 纯函数工具 + 断言脚本 + +**Files:** +- Create: `miniprogram/utils/bxInvoiceThumb.js` +- Create: `miniprogram/scripts/test-bx-invoice-thumb.mjs` + +- [ ] **Step 1: 先写失败的断言脚本** + +创建 `miniprogram/scripts/test-bx-invoice-thumb.mjs`: + +```js +import assert from 'node:assert/strict' +import { + isImageFileType, + fileExtLabel, + formatInvoiceDateYmd, + buildAttachItemsFromInvoiceRows, + mergeInvoicePicksByDraftKeys +} from '../utils/bxInvoiceThumb.js' + +assert.equal(isImageFileType('image/jpeg'), true) +assert.equal(isImageFileType('application/pdf'), false) +assert.equal(fileExtLabel('a.PDF'), 'PDF') +assert.equal(formatInvoiceDateYmd('2026-09-16T12:00:00'), '2026-09-16') +assert.equal(formatInvoiceDateYmd(''), '—') + +const rows = [ + { + id: 'inv1', + amount: 100, + createTime: '2026-09-01T10:00:00', + attachments: [ + { id: 'a1', fileName: 'x.jpg', fileType: 'image/jpeg' }, + { id: 'a2', fileName: 'y.pdf', fileType: 'application/pdf' } + ] + } +] +const items = buildAttachItemsFromInvoiceRows(rows) +assert.equal(items.length, 2) +assert.equal(items[0].key, 'inv1:a1') +assert.equal(items[1].fileName, 'y.pdf') + +const prev = [ + { + key: 'inv9:a9', + invoiceId: 'inv9', + attachmentId: 'a9', + amount: 9, + createTime: '2026-08-01', + fileName: 'old.jpg', + fileType: 'image/jpeg', + previewUrl: 'http://old' + } +] +const merged = mergeInvoicePicksByDraftKeys( + ['inv9:a9', 'inv1:a1'], + items, + prev +) +assert.equal(merged.length, 2) +assert.ok(merged.some((p) => p.key === 'inv9:a9' && p.previewUrl === 'http://old')) +assert.ok(merged.some((p) => p.key === 'inv1:a1')) + +console.log('bxInvoiceThumb ok') +``` + +- [ ] **Step 2: 运行确认失败(模块不存在)** + +Run: + +```bash +cd miniprogram && node scripts/test-bx-invoice-thumb.mjs +``` + +Expected: FAIL — `Cannot find module` / ERR_MODULE_NOT_FOUND for `bxInvoiceThumb.js` + +- [ ] **Step 3: 实现 `bxInvoiceThumb.js`** + +Create `miniprogram/utils/bxInvoiceThumb.js`: + +```js +export function isImageFileType(fileType) { + return !!(fileType && String(fileType).startsWith('image/')) +} + +export function fileExtLabel(name) { + const ext = String(name || '').split('.').pop() + return ext ? ext.toUpperCase() : 'FILE' +} + +/** 展示用 YYYY-MM-DD;无有效日期返回 — */ +export function formatInvoiceDateYmd(value) { + if (value == null || value === '') return '—' + const s = String(value).trim() + const m = s.match(/^(\d{4}-\d{2}-\d{2})/) + if (m) return m[1] + const d = new Date(s) + if (Number.isNaN(d.getTime())) return '—' + const y = d.getFullYear() + const mo = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${mo}-${day}` +} + +function invoiceFileList(inv) { + const raw = Array.isArray(inv?.attachments) ? inv.attachments : [] + if (raw.length) { + return raw.map((a) => ({ + id: String(a.id || a.attachmentId || ''), + fileName: a.fileName || a.file_name || '发票附件', + fileType: a.fileType || a.file_type || '' + })) + } + const aid = inv?.attachmentId || inv?.attachment_id + if (aid) { + return [ + { + id: String(aid), + fileName: inv.fileName || inv.file_name || '发票附件', + fileType: inv.fileType || inv.file_type || '' + } + ] + } + return [] +} + +/** + * 发票列表行 → 附件粒度选项 + * @returns {{ key, invoiceId, attachmentId, amount, createTime, fileName, fileType, previewUrl, previewLoading }[]} + */ +export function buildAttachItemsFromInvoiceRows(rows) { + const items = [] + for (const row of rows || []) { + const createTime = row.createTime || row.create_time || '' + for (const file of invoiceFileList(row)) { + if (!file.id) continue + items.push({ + key: `${row.id}:${file.id}`, + invoiceId: String(row.id), + attachmentId: file.id, + amount: row.amount, + createTime, + fileName: file.fileName, + fileType: file.fileType, + previewUrl: '', + previewLoading: false + }) + } + } + return items +} + +/** + * 确定勾选:已加载项优先;draft 中仍勾选但不在已加载列表的旧已选项保留(防分页冲掉) + */ +export function mergeInvoicePicksByDraftKeys(draftKeys, attachItems, previousPicks) { + const set = new Set(draftKeys || []) + const fromLoaded = (attachItems || []).filter((i) => set.has(i.key)) + const loadedKeys = new Set(fromLoaded.map((i) => i.key)) + const fromPrev = (previousPicks || []).filter( + (p) => set.has(p.key) && !loadedKeys.has(p.key) + ) + const byKey = new Map() + for (const p of fromPrev) byKey.set(p.key, { ...p }) + for (const i of fromLoaded) { + const prev = byKey.get(i.key) + byKey.set(i.key, { + ...i, + previewUrl: i.previewUrl || prev?.previewUrl || '' + }) + } + return Array.from(set) + .map((k) => byKey.get(k)) + .filter(Boolean) +} +``` + +- [ ] **Step 4: 再跑断言脚本** + +Run: + +```bash +cd miniprogram && node scripts/test-bx-invoice-thumb.mjs +``` + +Expected: 打印 `bxInvoiceThumb ok`,exit 0 + +- [ ] **Step 5: Commit** + +```bash +git add miniprogram/utils/bxInvoiceThumb.js miniprogram/scripts/test-bx-invoice-thumb.mjs +git commit -m "feat(miniprogram): add invoice thumb helper utilities." +``` + +--- + +### Task 2: bx-theme 缩略图样式 + +**Files:** +- Modify: `miniprogram/styles/bx-theme.scss`(在 `.invoice-picked-*` 段落后追加;可保留旧 class 以免其它页引用,但申请页将改用新 class) + +- [ ] **Step 1: 追加样式** + +在 `.bx-page .invoice-picked-item__remove { ... }` 块之后插入: + +```scss +/* 关联发票缩略图(申请已选 / 详情 / 选择弹层) */ +.bx-page .invoice-thumb-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.bx-page .invoice-thumb { + position: relative; + width: 72px; +} + +.bx-page .invoice-thumb__media { + width: 72px; + height: 72px; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border); + background: #f1f5f9; +} + +.bx-page .invoice-thumb__media image { + width: 100%; + height: 100%; +} + +.bx-page .invoice-thumb__doc { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 6px; + font-size: 10px; + color: var(--text-muted); + text-align: center; +} + +.bx-page .invoice-thumb__doc-ext { + font-weight: 700; + font-size: 12px; + color: var(--text-sec); +} + +.bx-page .invoice-thumb__meta { + width: 72px; + margin-top: 4px; + text-align: center; +} + +.bx-page .invoice-thumb__amt { + display: block; + font-size: 11px; + font-weight: 600; + color: var(--primary-dark); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bx-page .invoice-thumb__date { + display: block; + font-size: 10px; + color: var(--text-muted); + margin-top: 1px; +} + +.bx-page .invoice-thumb__del { + position: absolute; + top: 2px; + right: 2px; + z-index: 2; + width: 20px; + height: 20px; + line-height: 20px; + text-align: center; + border-radius: 50%; + background: rgba(15, 23, 42, 0.55); + color: #fff; + font-size: 13px; +} + +.bx-page .bx-sheet__body--invoice-thumbs { + padding: 12px; + max-height: 50vh; +} + +.bx-page .invoice-pick-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.bx-page .invoice-pick-card { + position: relative; + border: 1.5px solid var(--border); + border-radius: 8px; + overflow: hidden; + background: var(--surface); + padding: 0; + text-align: left; +} + +.bx-page .invoice-pick-card.is-selected { + border-color: var(--primary); + box-shadow: 0 0 0 1px var(--primary); +} + +.bx-page .invoice-pick-card__check { + position: absolute; + top: 6px; + right: 6px; + z-index: 2; + width: 22px; + height: 22px; + border-radius: 50%; + border: 1.5px solid #fff; + background: rgba(15, 23, 42, 0.25); + color: #fff; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; +} + +.bx-page .invoice-pick-card.is-selected .invoice-pick-card__check { + background: var(--primary); +} + +.bx-page .invoice-pick-card__preview { + width: 100%; + aspect-ratio: 1 / 1; + background: #f1f5f9; + overflow: hidden; +} + +.bx-page .invoice-pick-card__preview image { + width: 100%; + height: 100%; +} + +.bx-page .invoice-pick-card__doc { + width: 100%; + height: 100%; + min-height: 96px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 8px; + font-size: 11px; + color: var(--text-muted); + text-align: center; +} + +.bx-page .invoice-pick-card__meta { + padding: 6px 8px 8px; + border-top: 1px solid var(--border); +} + +.bx-page .invoice-pick-card__amt { + font-size: 12px; + font-weight: 700; + color: var(--primary-dark); +} + +.bx-page .invoice-pick-card__date { + margin-top: 2px; + font-size: 10px; + color: var(--text-muted); +} + +.bx-page .invoice-pick-footer-tip { + margin-top: 10px; + text-align: center; + font-size: 12px; + color: var(--text-muted); + padding: 4px 0 8px; +} +``` + +- [ ] **Step 2: 目视确认 Demo 类名语义一致(无需编译)** + +对照 `docs/superpowers/demos/2026-09-16-bx-reimburse-invoice-thumb-demo.html`:3 列网格、72 已选格、选中描边。 + +- [ ] **Step 3: Commit** + +```bash +git add miniprogram/styles/bx-theme.scss +git commit -m "style(miniprogram): add invoice thumbnail grid styles for bx." +``` + +--- + +### Task 3: 申请页 — 选择弹层缩略图 + 分页 + +**Files:** +- Modify: `miniprogram/pages/bx/reimburse/apply.vue` + +- [ ] **Step 1: 替换导入与状态(去掉一次拉 200)** + +在 `