Переглянути джерело

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>
wuzilong 1 місяць тому
батько
коміт
ea4d990abb

+ 919 - 0
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)**
+
+在 `<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`