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:
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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[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 已产出,作为视觉对照。
|
||||
Reference in new issue
Block a user