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>
This commit is contained in:
wuzilongandCursor committed 2026-09-16 21:07:35 +08:00
1 parent ca0098f6ca
commit 1af6a944fb
2 files changed
+903

No files matched your search

@@ -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>
@@ -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 已产出,作为视觉对照。