docs: add reimburse invoice thumbnail implementation plan.

Break scheme-1 into helper, styles, apply picker, and detail tasks with verification steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-16 21:10:25 +08:00
1 parent 1af6a944fb
commit ea4d990abb
1 file changed
+919
@@ -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)**
在 `<script setup>` 中:
1. 增加 import:
```js
import {
isImageFileType,
fileExtLabel,
formatInvoiceDateYmd,
buildAttachItemsFromInvoiceRows,
mergeInvoicePicksByDraftKeys
} from '@/utils/bxInvoiceThumb'
```
2. 常量与状态:
```js
const INVOICE_PAGE_SIZE = 10
const attachItems = ref([])
const invPage = ref(1)
const invLoading = ref(false)
const invFinished = ref(false)
```
3. 删除本地 `invoiceFileList` / `buildAttachItems`(改用工具函数)。
4. `refreshWallet` 只拉余额:
```js
async function refreshWallet() {
try {
const owner = form.applicantId || undefined
const sumRes = await invoiceWalletApi.mineSummary(
owner ? { userId: owner } : undefined
)
walletBalance.value = Number(sumRes?.data?.balance ?? 0)
} catch (e) {
// ignore
}
}
```
5. 新增分页加载与 preview:
```js
function pagedRows(res) {
const d = res?.data
if (!d) return { rows: [], total: 0 }
if (Array.isArray(d)) return { rows: d, total: d.length }
const rows = d.rows ?? d.items ?? d.list ?? []
const total = typeof d.total === 'number' ? d.total : rows.length
return { rows, total }
}
function kickAttachPreviewLoads(items) {
for (const item of items) {
if (!isImageFileType(item.fileType) || item.previewUrl || item.previewLoading) continue
item.previewLoading = true
attachmentApi
.preview(item.attachmentId)
.then((pRes) => {
item.previewUrl = pRes?.data?.url || ''
})
.catch(() => {})
.finally(() => {
item.previewLoading = false
})
}
}
async function fetchInvoiceAttachPage(isRefresh) {
if (invLoading.value) return
invLoading.value = true
try {
const owner = form.applicantId || undefined
const res = await invoiceApi.list({
page: invPage.value,
pageSize: INVOICE_PAGE_SIZE,
userId: owner
})
const { rows, total } = pagedRows(res)
const chunk = buildAttachItemsFromInvoiceRows(rows)
if (isRefresh) attachItems.value = chunk
else attachItems.value = attachItems.value.concat(chunk)
kickAttachPreviewLoads(chunk)
const invoiceCountSoFar =
(isRefresh ? 0 : (invPage.value - 1) * INVOICE_PAGE_SIZE) + rows.length
// 以发票行分页结束为准
if (rows.length < INVOICE_PAGE_SIZE || invoiceCountSoFar >= total) {
invFinished.value = true
}
} catch (e) {
uni.showToast({ title: '发票列表加载失败', icon: 'none' })
if (isRefresh) invFinished.value = true
} finally {
invLoading.value = false
}
}
async function resetAndLoadInvoices() {
invPage.value = 1
invFinished.value = false
attachItems.value = []
await fetchInvoiceAttachPage(true)
invPage.value = 2
}
async function onInvoicePickerLoadMore() {
if (invFinished.value || invLoading.value) return
await fetchInvoiceAttachPage(false)
invPage.value += 1
}
```
6. 打开 / 确定 / 预览:
```js
async function openInvoicePicker() {
pickerDraftKeys.value = selectedInvoicePicks.value.map((p) => p.key)
showInvoicePicker.value = true
if (!attachItems.value.length && !invFinished.value) {
await resetAndLoadInvoices()
}
}
function confirmInvoicePicker() {
selectedInvoicePicks.value = mergeInvoicePicksByDraftKeys(
pickerDraftKeys.value,
attachItems.value,
selectedInvoicePicks.value
)
showInvoicePicker.value = false
}
async function previewInvoicePick(pick) {
if (!isImageFileType(pick.fileType)) {
uni.showToast({ title: '非图片附件请在管理端查看', icon: 'none' })
return
}
let url = pick.previewUrl
if (!url && pick.attachmentId) {
try {
const pRes = await attachmentApi.preview(pick.attachmentId)
url = pRes?.data?.url || ''
if (url) pick.previewUrl = url
} catch (e) {
uni.showToast({ title: '暂无法预览', icon: 'none' })
return
}
}
if (url) uni.previewImage({ urls: [url], current: url })
else uni.showToast({ title: '暂无法预览', icon: 'none' })
}
```
7. `loadEditData` 中构建 picks 时补 `previewUrl: ''`,构建完成后:
```js
kickAttachPreviewLoads(selectedInvoicePicks.value)
```
(`kickAttachPreviewLoads` 需能改 ref 数组内对象字段;与 mine 页一样原地赋值即可。)
编辑回填若仍依赖 `attachItems` 补 attachmentId,改为仅用详情 `invoices` 字段(已有 attachmentId);去掉对全量 attachItems 的依赖。示例:
```js
picks.push({
key: `${invoiceId}:${attachmentId}`,
invoiceId,
attachmentId,
amount: inv.amount,
createTime: createTime,
fileName,
fileType,
previewUrl: '',
previewLoading: false
})
```
- [ ] **Step 2: 替换模板 — 已选区 + 弹层**
已选区(替换原 `invoice-picked-row` 块):
```vue
<view v-if="selectedInvoicePicks.length" class="invoice-thumb-grid">
<view
v-for="pick in selectedInvoicePicks"
:key="pick.key"
class="invoice-thumb"
>
<view class="invoice-thumb__del" @click.stop="removeInvoicePick(pick.key)">×</view>
<view class="invoice-thumb__media" @click="previewInvoicePick(pick)">
<image
v-if="isImageFileType(pick.fileType) && pick.previewUrl"
:src="pick.previewUrl"
mode="aspectFill"
/>
<view v-else class="invoice-thumb__doc">
<text class="invoice-thumb__doc-ext">{{ fileExtLabel(pick.fileName) }}</text>
</view>
</view>
<view class="invoice-thumb__meta">
<text class="invoice-thumb__amt">¥{{ money(pick.amount) }}</text>
<text class="invoice-thumb__date">{{ formatInvoiceDateYmd(pick.createTime) }}</text>
</view>
</view>
</view>
```
弹层(替换原文字 `bx-sheet__item` 列表):
```vue
<view v-if="showInvoicePicker" class="bx-sheet-mask" @click="showInvoicePicker = false">
<view class="bx-sheet" @click.stop>
<view class="bx-sheet__title">选择发票附件</view>
<scroll-view
scroll-y
class="bx-sheet__body bx-sheet__body--invoice-thumbs"
@scrolltolower="onInvoicePickerLoadMore"
lower-threshold="80"
>
<view v-if="!attachItems.length && !invLoading" class="bx-sheet__empty">
暂无发票附件
</view>
<view v-else class="invoice-pick-grid">
<view
v-for="item in attachItems"
:key="item.key"
class="invoice-pick-card"
:class="{ 'is-selected': pickerDraftKeys.includes(item.key) }"
@click="togglePickerKey(item.key)"
>
<view class="invoice-pick-card__check">
<text v-if="pickerDraftKeys.includes(item.key)">✓</text>
</view>
<view class="invoice-pick-card__preview">
<image
v-if="isImageFileType(item.fileType) && item.previewUrl"
:src="item.previewUrl"
mode="aspectFill"
/>
<view v-else class="invoice-pick-card__doc">
<text class="invoice-thumb__doc-ext">{{ fileExtLabel(item.fileName) }}</text>
</view>
</view>
<view class="invoice-pick-card__meta">
<view class="invoice-pick-card__amt">¥{{ money(item.amount) }}</view>
<view class="invoice-pick-card__date">
{{ formatInvoiceDateYmd(item.createTime) }}
</view>
</view>
</view>
</view>
<view v-if="attachItems.length" class="invoice-pick-footer-tip">
<text v-if="invLoading">加载中…</text>
<text v-else-if="invFinished">没有更多了</text>
<text v-else>上拉加载更多</text>
</view>
</scroll-view>
<view class="bx-sheet__footer">
<button class="btn btn-primary btn-block" @click="confirmInvoicePicker">
确定(已选 {{ pickerDraftKeys.length }})
</button>
</view>
</view>
</view>
```
模板中可直接调用已导入的 `isImageFileType` / `fileExtLabel` / `formatInvoiceDateYmd`(script setup 自动暴露)。
- [ ] **Step 3: 手动冒烟(开发者工具)**
1. 打开报销申请 → 从发票夹选择 → 见 3 列缩略图。
2. 发票足够多时滚到底加载第 2 页。
3. 勾选确定 → 已选区为缩略图 + 金额 + 日期;点击可预览。
- [ ] **Step 4: Commit**
```bash
git add miniprogram/pages/bx/reimburse/apply.vue
git commit -m "feat(miniprogram): show invoice thumbnails in reimburse apply picker."
```
---
### Task 4: 详情页 — 关联发票缩略图
**Files:**
- Modify: `miniprogram/pages/bx/reimburse/detail.vue`
- [ ] **Step 1: 增加状态与加载逻辑**
Import:
```js
import {
isImageFileType,
fileExtLabel,
formatInvoiceDateYmd
} from '@/utils/bxInvoiceThumb'
```
状态:
```js
const linkedInvoices = ref([])
```
在 `loadDetail` 成功拿到 `detail.value` 后、加载申请附件之前,构建 linkedInvoices:
```js
const invs = detail.value?.invoices || []
linkedInvoices.value = invs
.map((inv) => {
const attachmentId = String(inv.attachmentId || inv.attachment_id || '')
if (!attachmentId) return null
return {
key: `${inv.id || inv.invoiceId}:${attachmentId}`,
invoiceId: String(inv.id || inv.invoiceId || ''),
attachmentId,
amount: inv.amount,
createTime: inv.createTime || inv.create_time || '',
fileName: inv.fileName || inv.file_name || '发票附件',
fileType: inv.fileType || inv.file_type || '',
previewUrl: '',
previewLoading: false
}
})
.filter(Boolean)
for (const item of linkedInvoices.value) {
if (!isImageFileType(item.fileType)) continue
item.previewLoading = true
try {
const pRes = await attachmentApi.preview(item.attachmentId)
item.previewUrl = pRes?.data?.url || ''
} catch (e) {
// keep empty
} finally {
item.previewLoading = false
}
}
```
预览函数(可与附件共用增强版):
```js
async function previewInvoicePick(pick) {
if (!isImageFileType(pick.fileType)) {
uni.showToast({ title: '非图片附件请在管理端查看', icon: 'none' })
return
}
let url = pick.previewUrl
if (!url && pick.attachmentId) {
try {
const pRes = await attachmentApi.preview(pick.attachmentId)
url = pRes?.data?.url || ''
if (url) pick.previewUrl = url
} catch (e) {
uni.showToast({ title: '暂无法预览', icon: 'none' })
return
}
}
if (url) uni.previewImage({ urls: [url], current: url })
else uni.showToast({ title: '暂无法预览', icon: 'none' })
}
```
- [ ] **Step 2: 模板插入「关联发票」卡片**
在「申请附件」`detail-info-card` **正上方**插入:
```vue
<view class="detail-info-card">
<view class="detail-info-card__title">关联发票</view>
<view v-if="linkedInvoices.length" class="invoice-thumb-grid" style="margin-top: 0">
<view
v-for="pick in linkedInvoices"
:key="pick.key"
class="invoice-thumb"
@click="previewInvoicePick(pick)"
>
<view class="invoice-thumb__media">
<image
v-if="isImageFileType(pick.fileType) && pick.previewUrl"
:src="pick.previewUrl"
mode="aspectFill"
/>
<view v-else class="invoice-thumb__doc">
<text class="invoice-thumb__doc-ext">{{ fileExtLabel(pick.fileName) }}</text>
</view>
</view>
<view class="invoice-thumb__meta">
<text class="invoice-thumb__amt">¥{{ formatAmount(pick.amount) }}</text>
<text class="invoice-thumb__date">{{ formatInvoiceDateYmd(pick.createTime) }}</text>
</view>
</view>
</view>
<view v-else class="detail-section-empty">暂无关联发票</view>
</view>
```
确认 `formatAmount` 已从 `@/utils/bxFormat` 导入(详情页已有则复用)。
- [ ] **Step 3: 手动验证**
1. 从「我的申请单」打开已关联发票的报销详情 → 「关联发票」在「申请附件」上方。
2. 从「全部申请单」同样进入 → 一致。
3. 无关联发票 → 「暂无关联发票」。
4. 点击缩略图可预览。
- [ ] **Step 4: Commit**
```bash
git add miniprogram/pages/bx/reimburse/detail.vue
git commit -m "feat(miniprogram): show linked invoice thumbnails on reimburse detail."
```
---
### Task 5: 回归与图谱更新
**Files:**
- 无新代码文件;按仓库约定更新 graphify
- [ ] **Step 1: 再跑纯函数脚本**
```bash
cd miniprogram && node scripts/test-bx-invoice-thumb.mjs
```
Expected: `bxInvoiceThumb ok`
- [ ] **Step 2: 按 Spec §6 清单手测**
对照 `docs/superpowers/specs/2026-09-16-bx-reimburse-invoice-thumbnail-design.md` 第 6 节 6 条。
- [ ] **Step 3: 更新 miniprogram 知识图谱**
```bash
./scripts/update-graph.sh miniprogram
```
或在 `miniprogram` 目录:`graphify update .`(以仓库脚本为准)。
- [ ] **Step 4: Commit 图谱(若有 diff)**
```bash
git add miniprogram/graphify-out/
git commit -m "chore(miniprogram): update graphify after invoice thumbnails."
```
若无变更则跳过 commit。
---
## Spec coverage self-check
| Spec 要求 | Task |
|-----------|------|
| 弹层缩略图网格 + 多选 | Task 3 |
| pageSize=10 滚动加载 | Task 3 |
| 已选缩略图 + 预览 + × | Task 3 |
| 金额+日期 | Task 2/3/4 |
| 详情关联发票在申请附件上方 | Task 4 |
| 我的/全部共用详情 | Task 4(同一页) |
| 分页不冲掉已选 | Task 1 `mergeInvoicePicksByDraftKeys` + Task 3 |
| 不改后端 / payload | 全任务未改 backend |
| 非图片占位与 toast | Task 3/4 |
| 样式 Token | Task 2 |
## Placeholder scan
无 TBD /「类似 Task N」占位;命令与关键代码块已写全。
## Type / API consistency
- `key` = `` `${invoiceId}:${attachmentId}` ``
- Preview:`attachmentApi.preview(id)` → `data.url`
- 列表:`invoiceApi.list({ page, pageSize: 10, userId })`
- 详情:`detail.invoices[].attachmentId|fileType|createTime|amount`