Explorar o código

docs: add miniprogram reimburse invoice thumbnail design.

Record the approved scheme-1 UI for picker thumbnails, scroll paging, and detail linked-invoice display, with companion HTML demo.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong hai 1 mes
pai
achega
1af6a944fb

+ 767 - 0
docs/superpowers/demos/2026-09-16-bx-reimburse-invoice-thumb-demo.html

@@ -0,0 +1,767 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+  <title>报销申请 · 发票缩略图选择 Demo</title>
+  <style>
+    :root {
+      --primary: #00c98d;
+      --primary-dark: #00a874;
+      --primary-bg: #eafff6;
+      --bg: #f4f7f6;
+      --surface: #fff;
+      --text: #1e293b;
+      --text-sec: #64748b;
+      --text-muted: #94a3b8;
+      --border: #e2e8f0;
+      --danger: #ef4444;
+      --radius: 12px;
+      --phone-w: 375px;
+    }
+
+    * { box-sizing: border-box; margin: 0; padding: 0; }
+    body {
+      min-height: 100vh;
+      background: #dfe8e4;
+      color: var(--text);
+      font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
+      font-size: 14px;
+      line-height: 1.5;
+      padding: 24px 16px 48px;
+    }
+
+    .page-intro {
+      max-width: 980px;
+      margin: 0 auto 20px;
+      color: #334155;
+    }
+    .page-intro h1 { font-size: 18px; margin-bottom: 6px; }
+    .page-intro p { font-size: 13px; color: #64748b; margin-bottom: 4px; }
+    .page-intro code {
+      font-size: 12px;
+      background: #fff;
+      border: 1px solid #cbd5e1;
+      border-radius: 4px;
+      padding: 1px 5px;
+    }
+    .legend {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      margin-top: 12px;
+    }
+    .legend span {
+      font-size: 12px;
+      background: #fff;
+      border: 1px solid #cbd5e1;
+      border-radius: 999px;
+      padding: 4px 10px;
+      color: #475569;
+    }
+
+    .stage {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 28px;
+      justify-content: center;
+      align-items: flex-start;
+      max-width: 980px;
+      margin: 0 auto;
+    }
+
+    .phone-wrap { width: var(--phone-w); }
+    .phone-label {
+      text-align: center;
+      font-size: 13px;
+      font-weight: 600;
+      color: #334155;
+      margin-bottom: 10px;
+    }
+    .phone-label small {
+      display: block;
+      font-weight: 400;
+      color: #64748b;
+      margin-top: 2px;
+    }
+
+    .phone {
+      width: var(--phone-w);
+      background: var(--bg);
+      border-radius: 28px;
+      box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
+      overflow: hidden;
+      border: 8px solid #1e293b;
+      position: relative;
+      height: 740px;
+      display: flex;
+      flex-direction: column;
+    }
+
+    .header {
+      background: linear-gradient(145deg, #008f62 0%, #00c98d 70%, #34d399 160%);
+      color: #fff;
+      padding: 14px 16px 18px;
+      border-radius: 0 0 20px 20px;
+      flex-shrink: 0;
+    }
+    .header__row {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+    }
+    .header__back { font-size: 18px; opacity: 0.95; }
+    .header__title { font-size: 17px; font-weight: 700; }
+
+    .body {
+      flex: 1;
+      overflow-y: auto;
+      padding: 12px 12px 88px;
+      -webkit-overflow-scrolling: touch;
+    }
+
+    .card {
+      background: var(--surface);
+      border-radius: var(--radius);
+      padding: 14px;
+      margin-bottom: 12px;
+      box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
+    }
+    .card-h {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      margin-bottom: 10px;
+    }
+    .card-h h3 { font-size: 15px; font-weight: 600; }
+    .badge {
+      font-size: 11px;
+      color: var(--primary-dark);
+      background: var(--primary-bg);
+      padding: 2px 8px;
+      border-radius: 999px;
+      font-weight: 600;
+    }
+
+    .field { margin-bottom: 12px; }
+    .field:last-child { margin-bottom: 0; }
+    .field-label {
+      display: block;
+      font-size: 12px;
+      color: var(--text-sec);
+      margin-bottom: 6px;
+    }
+    .field-box {
+      height: 40px;
+      border: 1.5px solid var(--border);
+      border-radius: 8px;
+      padding: 0 12px;
+      display: flex;
+      align-items: center;
+      color: var(--text);
+      background: #fff;
+    }
+    .field-box.ph { color: var(--text-muted); }
+
+    .tip {
+      font-size: 13px;
+      color: var(--text-sec);
+      margin-bottom: 10px;
+      line-height: 1.5;
+    }
+    .tip.warn { color: #b45309; }
+    .form-tip { margin-top: 8px; font-size: 12px; color: var(--text-muted); }
+
+    .btn {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      height: 36px;
+      padding: 0 14px;
+      border: none;
+      border-radius: 8px;
+      font-size: 14px;
+      cursor: pointer;
+      font-family: inherit;
+    }
+    .btn-primary {
+      background: var(--primary);
+      color: #fff;
+      font-weight: 600;
+    }
+    .btn-block { width: 100%; height: 44px; }
+    .btn:disabled { opacity: 0.55; cursor: default; }
+
+    /* —— 已选:缩略图网格(替代文件名列表) —— */
+    .picked-grid {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      margin-top: 12px;
+    }
+    .picked-thumb {
+      position: relative;
+      width: 72px;
+      cursor: pointer;
+    }
+    .picked-thumb__img {
+      width: 72px;
+      height: 72px;
+      border-radius: 8px;
+      overflow: hidden;
+      border: 1px solid var(--border);
+      background: #f1f5f9;
+    }
+    .picked-thumb__img img {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      display: block;
+    }
+    .picked-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;
+    }
+    .picked-thumb__amt {
+      display: block;
+      width: 72px;
+      margin-top: 4px;
+      font-size: 11px;
+      font-weight: 600;
+      color: var(--primary-dark);
+      text-align: center;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+    .picked-thumb__del {
+      position: absolute;
+      top: 2px;
+      right: 2px;
+      width: 20px;
+      height: 20px;
+      border-radius: 50%;
+      border: none;
+      background: rgba(15, 23, 42, 0.55);
+      color: #fff;
+      font-size: 13px;
+      line-height: 1;
+      cursor: pointer;
+      z-index: 2;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+
+    .bottom-bar {
+      position: absolute;
+      left: 0; right: 0; bottom: 0;
+      padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
+      background: #fff;
+      border-top: 1px solid var(--border);
+    }
+
+    /* —— Sheet:缩略图多选 + 滚动加载 —— */
+    .mask {
+      position: absolute;
+      inset: 0;
+      background: rgba(15, 23, 42, 0.45);
+      opacity: 0;
+      pointer-events: none;
+      transition: opacity 0.2s;
+      z-index: 20;
+    }
+    .mask.show { opacity: 1; pointer-events: auto; }
+
+    .sheet {
+      position: absolute;
+      left: 0; right: 0; bottom: 0;
+      height: 78%;
+      background: #fff;
+      border-radius: 16px 16px 0 0;
+      z-index: 30;
+      transform: translateY(105%);
+      transition: transform 0.25s ease;
+      display: flex;
+      flex-direction: column;
+    }
+    .sheet.show { transform: translateY(0); }
+
+    .sheet__title {
+      padding: 14px 16px;
+      text-align: center;
+      font-weight: 700;
+      border-bottom: 1px solid var(--border);
+      position: relative;
+      flex-shrink: 0;
+    }
+    .sheet__close {
+      position: absolute;
+      right: 12px;
+      top: 50%;
+      transform: translateY(-50%);
+      width: 32px;
+      height: 32px;
+      border: none;
+      background: transparent;
+      color: var(--text-muted);
+      font-size: 22px;
+      cursor: pointer;
+      line-height: 1;
+    }
+
+    .sheet__body {
+      flex: 1;
+      overflow-y: auto;
+      padding: 12px;
+      -webkit-overflow-scrolling: touch;
+    }
+
+    .thumb-grid {
+      display: grid;
+      grid-template-columns: repeat(3, 1fr);
+      gap: 10px;
+    }
+
+    .thumb-card {
+      position: relative;
+      border: 1.5px solid var(--border);
+      border-radius: 8px;
+      background: #fff;
+      overflow: hidden;
+      cursor: pointer;
+      padding: 0;
+      text-align: left;
+      font-family: inherit;
+      width: 100%;
+    }
+    .thumb-card.is-selected {
+      border-color: var(--primary);
+      box-shadow: 0 0 0 1px var(--primary);
+    }
+    .thumb-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;
+    }
+    .thumb-card.is-selected .thumb-card__check {
+      background: var(--primary);
+      border-color: #fff;
+    }
+    .thumb-card__preview {
+      aspect-ratio: 1 / 1;
+      background: #f1f5f9;
+      overflow: hidden;
+    }
+    .thumb-card__preview img {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      display: block;
+    }
+    .thumb-card__doc {
+      width: 100%;
+      height: 100%;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      gap: 4px;
+      padding: 8px;
+      color: var(--text-muted);
+      font-size: 11px;
+      text-align: center;
+    }
+    .thumb-card__doc .ext {
+      font-weight: 700;
+      font-size: 13px;
+      color: var(--text-sec);
+    }
+    .thumb-card__meta {
+      padding: 6px 8px 8px;
+      border-top: 1px solid var(--border);
+    }
+    .thumb-card__amt {
+      font-size: 12px;
+      font-weight: 700;
+      color: var(--primary-dark);
+    }
+    .thumb-card__sub {
+      margin-top: 2px;
+      font-size: 10px;
+      color: var(--text-muted);
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    .load-more {
+      margin-top: 12px;
+      text-align: center;
+      font-size: 12px;
+      color: var(--text-muted);
+      padding: 8px;
+    }
+    .load-more.loading::after {
+      content: '加载中…';
+    }
+    .load-more.done::after {
+      content: '没有更多了';
+    }
+    .empty {
+      padding: 40px 16px;
+      text-align: center;
+      color: var(--text-muted);
+      font-size: 13px;
+    }
+
+    .sheet__footer {
+      padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
+      border-top: 1px solid var(--border);
+      flex-shrink: 0;
+    }
+
+    /* 预览层 */
+    .preview-mask {
+      position: absolute;
+      inset: 0;
+      z-index: 50;
+      background: rgba(0, 0, 0, 0.88);
+      display: none;
+      align-items: center;
+      justify-content: center;
+      flex-direction: column;
+      gap: 12px;
+      padding: 24px;
+    }
+    .preview-mask.show { display: flex; }
+    .preview-mask img {
+      max-width: 100%;
+      max-height: 70%;
+      border-radius: 8px;
+      object-fit: contain;
+    }
+    .preview-mask .hint {
+      color: #cbd5e1;
+      font-size: 13px;
+    }
+
+    /* 方案对比小标签 */
+    .variant-note {
+      margin-top: 16px;
+      padding: 12px 14px;
+      background: #fff;
+      border-radius: 10px;
+      border: 1px dashed #94a3b8;
+      font-size: 12px;
+      color: #475569;
+      line-height: 1.6;
+    }
+  </style>
+</head>
+<body>
+  <div class="page-intro">
+    <h1>报销申请 · 发票缩略图选择 Demo</h1>
+    <p>左侧:申请页已选发票改为缩略图(点击预览,× 删除)。右侧:从发票夹选择 = 3 列缩略图网格,滚动加载,默认每次 10 条。</p>
+    <p>可直接在手机框内操作;也可打开本地文件:<code>docs/superpowers/demos/2026-09-16-bx-reimburse-invoice-thumb-demo.html</code></p>
+    <div class="legend">
+      <span>缩略图 1:1 · aspectFill</span>
+      <span>pageSize = 10</span>
+      <span>多选 + 勾选角标</span>
+      <span>非图片显示扩展名占位</span>
+    </div>
+  </div>
+
+  <div class="stage">
+    <!-- ① 申请页:已选缩略图 -->
+    <div class="phone-wrap">
+      <div class="phone-label">
+        ① 报销申请 · 已选展示
+        <small>用缩略图替代发票名称</small>
+      </div>
+      <div class="phone" id="phone-apply">
+        <div class="header">
+          <div class="header__row">
+            <span class="header__back">←</span>
+            <span class="header__title">报销申请</span>
+            <span style="width:24px"></span>
+          </div>
+        </div>
+        <div class="body">
+          <div class="card">
+            <div class="card-h">
+              <h3>报销信息</h3>
+              <span class="badge">新建</span>
+            </div>
+            <div class="field">
+              <span class="field-label">申请人</span>
+              <div class="field-box">张三</div>
+            </div>
+            <div class="field">
+              <span class="field-label">报销日期 *</span>
+              <div class="field-box">2026-09-16</div>
+            </div>
+          </div>
+
+          <div class="card">
+            <div class="card-h">
+              <h3>关联发票(可选)</h3>
+            </div>
+            <div class="tip">发票余额 ¥1,280.00 · 本次合计 ¥356.50</div>
+            <button class="btn btn-primary" type="button" id="btn-open-picker">从发票夹选择</button>
+
+            <div class="picked-grid" id="picked-grid"></div>
+            <div class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。点击缩略图可预览。</div>
+          </div>
+
+          <div class="card">
+            <div class="card-h"><h3>报销说明</h3></div>
+            <div class="field-box ph">请输入报销说明</div>
+          </div>
+        </div>
+        <div class="bottom-bar">
+          <button class="btn btn-primary btn-block" type="button">保存</button>
+        </div>
+
+        <div class="preview-mask" id="preview-mask">
+          <img id="preview-img" alt="预览" />
+          <div class="hint">点击空白处关闭</div>
+        </div>
+      </div>
+      <div class="variant-note">
+        <b>已选区约定:</b>仅展示缩略图 + 金额角标文案;不再展示文件名。点击图片 → <code>uni.previewImage</code>;点 × 仅移除关联,不删发票夹原件。
+      </div>
+    </div>
+
+    <!-- ② 选择弹层:缩略图 + 滚动加载 -->
+    <div class="phone-wrap">
+      <div class="phone-label">
+        ② 从发票夹选择 · 缩略图列表
+        <small>滚动到底自动加载下一批(10 条)</small>
+      </div>
+      <div class="phone" id="phone-picker">
+        <div class="header">
+          <div class="header__row">
+            <span class="header__back">←</span>
+            <span class="header__title">报销申请</span>
+            <span style="width:24px"></span>
+          </div>
+        </div>
+        <div class="body" style="opacity:0.35; pointer-events:none;">
+          <div class="card"><div class="card-h"><h3>关联发票</h3></div><div class="tip">…</div></div>
+        </div>
+
+        <div class="mask show"></div>
+        <div class="sheet show">
+          <div class="sheet__title">
+            选择发票附件
+            <button class="sheet__close" type="button" title="关闭">×</button>
+          </div>
+          <div class="sheet__body" id="picker-scroll">
+            <div class="thumb-grid" id="thumb-grid"></div>
+            <div class="load-more" id="load-more"></div>
+          </div>
+          <div class="sheet__footer">
+            <button class="btn btn-primary btn-block" type="button" id="btn-confirm">
+              确定(已选 <span id="sel-count">0</span>)
+            </button>
+          </div>
+        </div>
+      </div>
+      <div class="variant-note">
+        <b>选择态:</b>点整卡切换勾选(角标 ✓)。金额与日期在缩略图下方,便于快速辨认。PDF 等非图片用扩展名占位,选中逻辑相同。
+      </div>
+    </div>
+  </div>
+
+  <script>
+    const PAGE_SIZE = 10
+    const PLACEHOLDERS = [
+      'https://picsum.photos/seed/inv1/200/200',
+      'https://picsum.photos/seed/inv2/200/200',
+      'https://picsum.photos/seed/inv3/200/200',
+      'https://picsum.photos/seed/inv4/200/200',
+      'https://picsum.photos/seed/inv5/200/200',
+      'https://picsum.photos/seed/inv6/200/200',
+      'https://picsum.photos/seed/inv7/200/200',
+      'https://picsum.photos/seed/inv8/200/200',
+      null, // PDF 占位
+      'https://picsum.photos/seed/inv9/200/200'
+    ]
+
+    /** 模拟发票夹全量 28 条附件 */
+    const ALL = Array.from({ length: 28 }, (_, i) => {
+      const isPdf = i % 9 === 8
+      return {
+        key: `inv-${i + 1}:att-${i + 1}`,
+        amount: (80 + (i * 37) % 900) + (i % 3) * 0.5,
+        date: `2026-0${(i % 9) + 1}-${String((i % 27) + 1).padStart(2, '0')}`,
+        fileName: isPdf ? `电子发票_${i + 1}.pdf` : `发票照片_${i + 1}.jpg`,
+        fileType: isPdf ? 'application/pdf' : 'image/jpeg',
+        previewUrl: isPdf ? null : PLACEHOLDERS[i % PLACEHOLDERS.length] || PLACEHOLDERS[0]
+      }
+    })
+
+    let loaded = []
+    let page = 0
+    let loading = false
+    let finished = false
+    const draft = new Set(['inv-1:att-1', 'inv-3:att-3'])
+    let picked = [
+      { ...ALL[0] },
+      { ...ALL[2] }
+    ]
+
+    function money(v) {
+      return Number(v || 0).toLocaleString('zh-CN', {
+        minimumFractionDigits: 2,
+        maximumFractionDigits: 2
+      })
+    }
+
+    function renderPicked() {
+      const el = document.getElementById('picked-grid')
+      if (!picked.length) {
+        el.innerHTML = '<div class="form-tip" style="margin:0">暂未选择发票</div>'
+        return
+      }
+      el.innerHTML = picked.map((p) => {
+        const inner = p.previewUrl
+          ? `<img src="${p.previewUrl}" alt="" />`
+          : `<div class="picked-thumb__doc"><span>PDF</span><span>${escapeHtml(p.fileName)}</span></div>`
+        return `
+          <div class="picked-thumb" data-key="${p.key}">
+            <button class="picked-thumb__del" type="button" data-del="${p.key}" aria-label="移除">×</button>
+            <div class="picked-thumb__img" data-preview="${p.key}">${inner}</div>
+            <span class="picked-thumb__amt">¥${money(p.amount)}</span>
+          </div>`
+      }).join('')
+    }
+
+    function renderGrid() {
+      const grid = document.getElementById('thumb-grid')
+      grid.innerHTML = loaded.map((item) => {
+        const selected = draft.has(item.key)
+        const preview = item.previewUrl
+          ? `<img src="${item.previewUrl}" alt="" loading="lazy" />`
+          : `<div class="thumb-card__doc"><span class="ext">PDF</span><span>${escapeHtml(item.fileName)}</span></div>`
+        return `
+          <button type="button" class="thumb-card ${selected ? 'is-selected' : ''}" data-key="${item.key}">
+            <span class="thumb-card__check">${selected ? '✓' : ''}</span>
+            <div class="thumb-card__preview">${preview}</div>
+            <div class="thumb-card__meta">
+              <div class="thumb-card__amt">¥${money(item.amount)}</div>
+              <div class="thumb-card__sub">${item.date}</div>
+            </div>
+          </button>`
+      }).join('')
+      document.getElementById('sel-count').textContent = String(draft.size)
+      const tip = document.getElementById('load-more')
+      tip.className = 'load-more ' + (finished ? 'done' : loading ? 'loading' : '')
+      tip.textContent = finished
+        ? '没有更多了'
+        : loading
+          ? '加载中…'
+          : loaded.length
+            ? '上拉加载更多'
+            : ''
+    }
+
+    function escapeHtml(s) {
+      return String(s).replace(/[&<>"']/g, (c) => ({
+        '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
+      })[c])
+    }
+
+    function loadNext() {
+      if (loading || finished) return
+      loading = true
+      renderGrid()
+      setTimeout(() => {
+        const start = page * PAGE_SIZE
+        const chunk = ALL.slice(start, start + PAGE_SIZE)
+        loaded = loaded.concat(chunk)
+        page += 1
+        if (loaded.length >= ALL.length || chunk.length < PAGE_SIZE) finished = true
+        loading = false
+        renderGrid()
+      }, 420)
+    }
+
+    document.getElementById('picker-scroll').addEventListener('scroll', (e) => {
+      const el = e.currentTarget
+      if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) loadNext()
+    })
+
+    document.getElementById('thumb-grid').addEventListener('click', (e) => {
+      const card = e.target.closest('.thumb-card')
+      if (!card) return
+      const key = card.dataset.key
+      if (draft.has(key)) draft.delete(key)
+      else draft.add(key)
+      renderGrid()
+    })
+
+    document.getElementById('btn-confirm').addEventListener('click', () => {
+      picked = ALL.filter((i) => draft.has(i.key))
+      renderPicked()
+      alert('已同步到左侧「已选」区域(Demo 示意)')
+    })
+
+    document.getElementById('picked-grid').addEventListener('click', (e) => {
+      const del = e.target.closest('[data-del]')
+      if (del) {
+        const key = del.getAttribute('data-del')
+        picked = picked.filter((p) => p.key !== key)
+        draft.delete(key)
+        renderPicked()
+        renderGrid()
+        return
+      }
+      const preview = e.target.closest('[data-preview]')
+      if (!preview) return
+      const key = preview.getAttribute('data-preview')
+      const item = picked.find((p) => p.key === key)
+      if (!item) return
+      if (!item.previewUrl) {
+        alert('非图片附件:正式环境可跳转下载/打开;Demo 仅提示')
+        return
+      }
+      const mask = document.getElementById('preview-mask')
+      document.getElementById('preview-img').src = item.previewUrl
+      mask.classList.add('show')
+    })
+
+    document.getElementById('preview-mask').addEventListener('click', () => {
+      document.getElementById('preview-mask').classList.remove('show')
+    })
+
+    document.getElementById('btn-open-picker').addEventListener('click', () => {
+      alert('正式实现会打开右侧样式的底部弹层;本 Demo 请直接在右侧手机框操作选择。')
+    })
+
+    renderPicked()
+    loadNext()
+  </script>
+</body>
+</html>

+ 136 - 0
docs/superpowers/specs/2026-09-16-bx-reimburse-invoice-thumbnail-design.md

@@ -0,0 +1,136 @@
+# 小程序报销申请 · 关联发票缩略图展示设计
+
+**日期**:2026-09-16  
+**范围**:`miniprogram` 报销申请页、报销详情页(我的申请单 / 全部申请单共用详情)  
+**方案**:页面内改造(方案 1),不抽公共组件、不改后端接口  
+**Demo**:`docs/superpowers/demos/2026-09-16-bx-reimburse-invoice-thumb-demo.html`
+
+---
+
+## 1. 背景与目标
+
+### 现状
+
+| 位置 | 行为 |
+|------|------|
+| 申请页「从发票夹选择」 | 文字列表(文件名 + 金额),一次最多拉 200 条 |
+| 申请页已选关联发票 | 文字列表(文件名 + 金额),不可预览 |
+| 详情页 | **无**「关联发票」区块;仅有「申请附件」缩略图 |
+| 我的申请单 / 全部申请单 | 均跳转同一 `reimburse/detail`,故同样看不到关联发票 |
+
+### 目标
+
+1. 选择弹层改为发票**缩略图网格**,支持**滚动加载**(默认每次 10 条)。
+2. 申请页已选关联发票改为**缩略图**,支持点击预览。
+3. 详情页在「申请附件」**上方**增加「关联发票」缩略图展示,可点击预览。
+4. 缩略图下方统一展示:**金额 + 日期**。
+
+---
+
+## 2. 非目标
+
+- 不改后端 API / Schema(详情已有 `invoices`;列表已支持 `page`/`pageSize`)。
+- 不抽独立选择页或跨页公共组件(本需求用方案 1)。
+- 不改变提交载荷结构(仍为 `invoiceIds` + `invoiceLinks`)。
+- 不删除发票夹原件;申请页 × 仅解除本单关联。
+
+---
+
+## 3. 架构与数据流
+
+```
+申请页 apply.vue
+  ├─ invoiceWalletApi.mineSummary → 余额提示(与列表解耦)
+  ├─ 打开弹层 → invoiceApi.list({ page, pageSize: 10 }) → 扁平化附件项
+  │                 → attachmentApi.preview(id) → previewUrl
+  ├─ 勾选/确定 → selectedInvoicePicks(含 previewUrl / amount / createTime)
+  └─ 提交 → buildInvoicePayload()(不变)
+
+详情页 detail.vue
+  └─ reimburseApi.getById → data.invoices[]
+        → attachmentId + fileType → attachmentApi.preview → 缩略图网格
+```
+
+**列表项键**:继续使用 `invoiceId:attachmentId` 作为 `key`(与现网一致)。
+
+**日期字段**:优先 `createTime` / `create_time`;展示为 `YYYY-MM-DD`(取日期部分)。无日期时显示 `—`。
+
+---
+
+## 4. 界面规格
+
+### 4.1 申请页 · 选择弹层
+
+- 容器:现有 `bx-sheet` + `scroll-view`。
+- 布局:3 列网格,缩略图 **1:1**,`mode="aspectFill"`。
+- 选中:主色描边 + 右上角 ✓;点整卡切换多选。
+- 下方文案:金额(`¥x.xx`)+ 日期。
+- 非图片:扩展名占位(如 PDF),可选中。
+- 分页:`page` 从 1 递增,`pageSize = 10`;`@scrolltolower` 加载下一页;底部提示「加载中… / 没有更多了」。
+- 打开时:用当前已选 keys 回填 `pickerDraftKeys`;若列表尚未加载则先拉第一页。
+- 确定:按 draft keys 从**已加载**项 + 必要时已选缓存合并写回 `selectedInvoicePicks`(避免未再次滚到的已选项丢失——见 4.2)。
+
+### 4.2 申请页 · 已选区
+
+- 去掉 `invoice-picked-item` 文件名行,改为缩略图网格(约 72×72,对齐 `att-grid`/`att-img`)。
+- 下方:金额 + 日期;右上角 × 移除关联。
+- 点击缩略图:图片 → `uni.previewImage`;非图片 → `uni.showToast` 简短提示(不强制下载)。
+- **已选与分页**:确定时合并「当前 draft 中仍勾选、且可能尚未出现在本轮已加载列表中的旧选中项」,防止滚动分页导致已选项被冲掉。
+
+### 4.3 详情页 · 关联发票
+
+- 文件:`pages/bx/reimburse/detail.vue`。
+- 位置:在「申请附件」卡片**上方**新增「关联发票」`detail-info-card`。
+- 数据:`detail.invoices`(`ReimburseInvoiceBriefOut`:`id`/`amount`/`attachmentId`/`fileName`/`fileType`/`createTime`)。
+- 展示:只读缩略图网格(金额 + 日期);无数据时「暂无关联发票」。
+- 点击预览规则同申请页。
+- 「申请附件」逻辑与文案不变。
+
+### 4.4 样式
+
+- 优先复用 `bx-theme.scss` 中 `att-grid` / `att-img`。
+- 选择弹层网格、选中描边、金额/日期副文案:在 `bx-theme.scss` 增加少量 sheet 内类名(如 `.invoice-thumb-grid`),色值用现有 `--primary` / `--text-muted`,不混用 Fresh Mint。
+
+---
+
+## 5. 错误与边界
+
+| 场景 | 处理 |
+|------|------|
+| 列表接口失败 | toast;保留已加载数据,可重试打开弹层 |
+| preview 失败 | 该格占位(空图/扩展名),不阻断选择 |
+| 余额不足 | 现有提示文案不变 |
+| 无发票 | 弹层空态「暂无发票附件」;详情「暂无关联发票」 |
+| 编辑回填 | `loadEditData` 根据 `data.invoices` 建 picks,并异步补 `previewUrl` |
+
+---
+
+## 6. 测试要点
+
+1. 发票 > 10 张时,弹层滚动加载第 2 页且勾选状态正确。
+2. 先选后滚再改选,确定后已选区与提交 payload 一致。
+3. 已选缩略图可预览;× 仅移除关联。
+4. 详情(从「我的申请单」「全部申请单」进入)显示关联发票在申请附件上方。
+5. PDF 等非图片可选中,点击预览有提示。
+6. 无关联发票时详情空态正确。
+
+---
+
+## 7. 涉及文件
+
+| 文件 | 变更 |
+|------|------|
+| `miniprogram/pages/bx/reimburse/apply.vue` | 弹层缩略图 + 分页;已选缩略图 + 预览 |
+| `miniprogram/pages/bx/reimburse/detail.vue` | 新增关联发票区块 |
+| `miniprogram/styles/bx-theme.scss` | 缩略图网格 / 选中态样式(按需) |
+
+参考实现:`miniprogram/pages/bx/invoice/mine.vue`(preview 懒加载、`scrolltolower`)。
+
+---
+
+## 8. 决策记录
+
+- 实现路径:**方案 1**(页面内改造)。
+- 详情区块顺序:**关联发票 → 申请附件**。
+- 缩略图副文案:**金额 + 日期**(申请弹层、已选区、详情一致)。
+- Companion Demo 已产出,作为视觉对照。