Files
shanxia2.0/bak/mobile-ui/shanxia-mp-bx-reimburse-invoice-picker-demo.html
T
2026-09-12 19:42:20 +08:00

702 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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(对齐 Web)</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: 920px;
margin: 0 auto 20px;
color: #334155;
}
.page-intro h1 { font-size: 18px; margin-bottom: 6px; }
.page-intro p { font-size: 13px; color: #64748b; }
.page-intro code {
font-size: 12px;
background: #fff;
border: 1px solid #cbd5e1;
border-radius: 4px;
padding: 1px 5px;
}
.stage {
display: flex;
flex-wrap: wrap;
gap: 24px;
justify-content: center;
align-items: flex-start;
max-width: 920px;
margin: 0 auto;
}
.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;
min-height: 720px;
}
.header {
background: linear-gradient(145deg, #008f62 0%, #00c98d 70%, #34d399 160%);
color: #fff;
padding: 16px 16px 20px;
border-radius: 0 0 20px 20px;
}
.header__back { font-size: 13px; opacity: 0.95; }
.header__title { margin-top: 10px; font-size: 20px; font-weight: 700; }
.body { padding: 12px 12px 88px; }
.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 h3 {
font-size: 15px;
font-weight: 600;
margin-bottom: 10px;
}
.tip {
font-size: 13px;
color: var(--text-sec);
margin-bottom: 10px;
line-height: 1.5;
}
.tip b { color: var(--primary-dark); }
.form-tip { margin-top: 8px; font-size: 12px; color: var(--text-muted); }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.btn-primary {
background: var(--primary);
color: #fff;
font-weight: 600;
}
.btn-block { width: 100%; height: 44px; }
.btn-ghost {
background: #f8fafc;
color: var(--text-sec);
border: 1px solid var(--border);
}
/* 已选:附件卡片横向滚动(对齐 Web 缩略图语义) */
.picked-row {
display: flex;
gap: 10px;
overflow-x: auto;
margin-top: 12px;
padding-bottom: 4px;
-webkit-overflow-scrolling: touch;
}
.picked-card {
flex: 0 0 120px;
width: 120px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
position: relative;
overflow: hidden;
}
.picked-card__remove {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: rgba(15, 23, 42, 0.55);
color: #fff;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.picked-card__preview {
height: 80px;
background: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.picked-card__preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.picked-card__icon {
font-size: 12px;
color: var(--text-muted);
text-align: center;
padding: 8px;
}
.picked-card__meta { padding: 8px; }
.picked-card__name {
display: block;
font-size: 11px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.picked-card__amount {
margin-top: 4px;
font-size: 12px;
font-weight: 600;
color: var(--primary-dark);
}
.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: 72%;
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: 600;
border-bottom: 1px solid var(--border);
position: relative;
}
.sheet__close {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
color: var(--text-muted);
font-size: 20px;
}
.sheet__body {
flex: 1;
overflow-y: auto;
padding: 12px 12px 8px;
-webkit-overflow-scrolling: touch;
}
.month-block { margin-bottom: 18px; }
.month-block__title {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin: 0 0 10px 4px;
}
.attach-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.attach-card {
position: relative;
border: 1.5px solid var(--border);
border-radius: 8px;
background: #fff;
padding: 0;
text-align: left;
cursor: pointer;
overflow: hidden;
}
.attach-card.is-selected {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
}
.attach-card__index {
position: absolute;
top: 4px;
left: 4px;
z-index: 1;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 9px;
background: rgba(15, 23, 42, 0.5);
color: #fff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.attach-card__check {
position: absolute;
top: 4px;
right: 4px;
z-index: 1;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--primary);
color: #fff;
font-size: 12px;
display: none;
align-items: center;
justify-content: center;
}
.attach-card.is-selected .attach-card__check { display: flex; }
.attach-card__preview {
height: 72px;
background: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.attach-card__preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.attach-card__doc {
font-size: 11px;
color: var(--text-muted);
padding: 6px;
text-align: center;
}
.attach-card__meta { padding: 6px 8px 8px; }
.attach-card__name {
display: block;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attach-card__amount {
margin-top: 2px;
font-size: 12px;
font-weight: 600;
color: var(--primary-dark);
}
.sheet__footer {
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
}
/* 右侧说明面板 */
.side {
flex: 1;
min-width: 280px;
max-width: 420px;
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
}
.side h2 { font-size: 15px; margin-bottom: 8px; }
.side ol { padding-left: 18px; font-size: 13px; color: #475569; }
.side li { margin: 6px 0; }
.side pre {
margin-top: 12px;
background: #0f172a;
color: #e2e8f0;
border-radius: 10px;
padding: 12px;
font-size: 11px;
line-height: 1.55;
overflow: auto;
min-height: 160px;
}
.legend {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 12px 0 4px;
}
.pill {
font-size: 11px;
padding: 4px 8px;
border-radius: 999px;
background: var(--primary-bg);
color: var(--primary-dark);
border: 1px solid #a7f3d0;
}
.empty {
font-size: 12px;
color: var(--text-muted);
padding: 8px 0;
}
</style>
</head>
<body>
<div class="page-intro">
<h1>报销申请 · 从发票夹选择附件(移动端 Demo)</h1>
<p>
对齐 Web:选择粒度为 <code>invoiceId + attachmentId</code>;一张发票多附件可分别勾选。
打开文件:<code>mobile-ui/shanxia-mp-bx-reimburse-invoice-picker-demo.html</code>
</p>
</div>
<div class="stage">
<div class="phone" id="phone">
<header class="header">
<div class="header__back">← 返回</div>
<h1 class="header__title">报销申请</h1>
</header>
<div class="body">
<div class="card">
<h3>报销明细(示意)</h3>
<p class="tip">差旅费 · 合计 <b>¥1,280.00</b></p>
</div>
<div class="card">
<h3>关联发票(可选)</h3>
<p class="tip">
发票余额 <b>¥3,500.00</b> · 本次合计 <b>¥1,280.00</b>
</p>
<button type="button" class="btn btn-primary" id="btnOpen">从发票夹选择</button>
<div id="picked" class="picked-row" hidden></div>
<p class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。选择对象为附件文件。</p>
</div>
</div>
<div class="bottom-bar">
<button type="button" class="btn btn-primary btn-block" id="btnSave">保存</button>
</div>
<div class="mask" id="mask"></div>
<div class="sheet" id="sheet" role="dialog" aria-label="选择发票附件">
<div class="sheet__title">
选择发票附件
<button type="button" class="sheet__close" id="btnClose" aria-label="关闭">×</button>
</div>
<div class="sheet__body" id="sheetBody"></div>
<div class="sheet__footer">
<button type="button" class="btn btn-primary btn-block" id="btnConfirm">
确定(已选 <span id="selCount">0</span>)
</button>
</div>
</div>
</div>
<aside class="side">
<h2>交互说明</h2>
<div class="legend">
<span class="pill">粒度 = 附件</span>
<span class="pill">key = invoiceId:attachmentId</span>
<span class="pill">金额 = 整票金额</span>
</div>
<ol>
<li>点「从发票夹选择」打开底部 sheet。</li>
<li>按月份分组,3 列附件缩略图(图片 / PDF)。</li>
<li>同一发票的多附件可独立勾选。</li>
<li>确定后表单区横向展示已选附件卡片。</li>
<li>提交 payload 见下方 JSON(实时更新)。</li>
</ol>
<h2 style="margin-top:16px">提交 payload 预览</h2>
<pre id="payload">{}</pre>
</aside>
</div>
<script>
/**
* Mock:一次上传 = 1 张发票(一个金额)+ N 个附件
* 与后端 InvoiceService.upload_with_files / out_rows_with_attachments 一致
*/
const invoices = [
{
id: 'inv-202608-01',
amount: 800,
remark: '出差住宿',
createTime: '2026-08-07 14:20:00',
attachments: [
{ id: 'att-a1', fileName: '酒店发票.jpg', fileType: 'image/jpeg', previewHue: 160 },
{ id: 'att-a2', fileName: '房费明细.pdf', fileType: 'application/pdf' }
]
},
{
id: 'inv-202608-02',
amount: 480,
remark: '高铁票',
createTime: '2026-08-05 09:10:00',
attachments: [
{ id: 'att-b1', fileName: '高铁电子客票.jpg', fileType: 'image/jpeg', previewHue: 200 }
]
},
{
id: 'inv-202607-01',
amount: 1200,
remark: '客户招待',
createTime: '2026-07-28 18:40:00',
attachments: [
{ id: 'att-c1', fileName: '餐费发票-1.jpg', fileType: 'image/jpeg', previewHue: 40 },
{ id: 'att-c2', fileName: '餐费发票-2.jpg', fileType: 'image/jpeg', previewHue: 25 },
{ id: 'att-c3', fileName: '菜单拍照.jpg', fileType: 'image/jpeg', previewHue: 10 }
]
}
]
function flattenAttachItems(rows) {
const items = []
rows.forEach((row) => {
;(row.attachments || []).forEach((file) => {
items.push({
key: `${row.id}:${file.id}`,
invoiceId: row.id,
attachmentId: file.id,
amount: row.amount,
createTime: row.createTime,
remark: row.remark || '',
file
})
})
})
return items
}
function monthKey(raw) {
const m = String(raw || '').match(/^(\d{4})-(\d{2})/)
return m ? `${m[1]}-${m[2]}` : 'unknown'
}
function monthLabel(key) {
if (key === 'unknown') return '未知月份'
const [y, m] = key.split('-')
return `${y}年${Number(m)}月`
}
function isImage(ft) {
return String(ft || '').startsWith('image/')
}
function previewBlock(file) {
if (isImage(file.fileType)) {
const hue = file.previewHue || 150
// 占位图:纯色渐变模拟发票缩略图
return `<div class="attach-card__preview" style="background:linear-gradient(135deg,hsl(${hue} 55% 70%),hsl(${hue} 40% 45%))"></div>`
}
return `<div class="attach-card__preview"><div class="attach-card__doc">PDF<br/>${escapeHtml(file.fileName)}</div></div>`
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
const allItems = flattenAttachItems(invoices)
/** @type {Set<string>} */
let draftKeys = new Set()
/** @type {Set<string>} */
let selectedKeys = new Set()
const sheet = document.getElementById('sheet')
const mask = document.getElementById('mask')
const sheetBody = document.getElementById('sheetBody')
const selCount = document.getElementById('selCount')
const picked = document.getElementById('picked')
const payloadEl = document.getElementById('payload')
function openSheet() {
draftKeys = new Set(selectedKeys)
renderSheet()
mask.classList.add('show')
sheet.classList.add('show')
}
function closeSheet() {
mask.classList.remove('show')
sheet.classList.remove('show')
}
function groupByMonth(items) {
const map = new Map()
items.forEach((item) => {
const k = monthKey(item.createTime)
if (!map.has(k)) map.set(k, [])
map.get(k).push(item)
})
return Array.from(map.entries())
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([k, list]) => ({
key: k,
label: monthLabel(k),
items: list
.slice()
.sort((a, b) => String(b.createTime).localeCompare(String(a.createTime)))
}))
}
function renderSheet() {
const groups = groupByMonth(allItems)
let html = ''
groups.forEach((g) => {
html += `<section class="month-block"><h4 class="month-block__title">${g.label}</h4><div class="attach-grid">`
g.items.forEach((item, idx) => {
const selected = draftKeys.has(item.key) ? ' is-selected' : ''
html += `
<button type="button" class="attach-card${selected}" data-key="${item.key}">
<span class="attach-card__index">${idx + 1}</span>
<span class="attach-card__check">✓</span>
${previewBlock(item.file)}
<div class="attach-card__meta">
<span class="attach-card__name" title="${escapeHtml(item.file.fileName)}">${escapeHtml(item.file.fileName)}</span>
<div class="attach-card__amount">¥${Number(item.amount).toFixed(2)}</div>
</div>
</button>`
})
html += `</div></section>`
})
sheetBody.innerHTML = html || '<p class="empty">暂无发票附件</p>'
selCount.textContent = String(draftKeys.size)
sheetBody.querySelectorAll('.attach-card').forEach((el) => {
el.addEventListener('click', () => {
const key = el.getAttribute('data-key')
if (draftKeys.has(key)) draftKeys.delete(key)
else draftKeys.add(key)
renderSheet()
})
})
}
function buildPayload() {
const picks = allItems.filter((i) => selectedKeys.has(i.key))
const invoiceLinks = picks.map((p) => ({
invoiceId: p.invoiceId,
attachmentId: p.attachmentId
}))
const invoiceIds = Array.from(new Set(invoiceLinks.map((p) => p.invoiceId)))
return { invoiceIds, invoiceLinks }
}
function renderPicked() {
const picks = allItems
.filter((i) => selectedKeys.has(i.key))
.sort((a, b) => String(b.createTime).localeCompare(String(a.createTime)))
if (!picks.length) {
picked.hidden = true
picked.innerHTML = ''
} else {
picked.hidden = false
picked.innerHTML = picks
.map((item) => {
const preview = isImage(item.file.fileType)
? `<div class="picked-card__preview" style="background:linear-gradient(135deg,hsl(${item.file.previewHue || 150} 55% 70%),hsl(${item.file.previewHue || 150} 40% 45%))"></div>`
: `<div class="picked-card__preview"><div class="picked-card__icon">PDF</div></div>`
return `
<div class="picked-card" data-key="${item.key}">
<button type="button" class="picked-card__remove" data-key="${item.key}" aria-label="移除">×</button>
${preview}
<div class="picked-card__meta">
<span class="picked-card__name">${escapeHtml(item.file.fileName)}</span>
<div class="picked-card__amount">¥${Number(item.amount).toFixed(2)}</div>
</div>
</div>`
})
.join('')
picked.querySelectorAll('.picked-card__remove').forEach((btn) => {
btn.addEventListener('click', () => {
selectedKeys.delete(btn.getAttribute('data-key'))
renderPicked()
renderPayload()
})
})
}
renderPayload()
}
function renderPayload() {
payloadEl.textContent = JSON.stringify(buildPayload(), null, 2)
}
document.getElementById('btnOpen').addEventListener('click', openSheet)
document.getElementById('btnClose').addEventListener('click', closeSheet)
mask.addEventListener('click', closeSheet)
document.getElementById('btnConfirm').addEventListener('click', () => {
selectedKeys = new Set(draftKeys)
closeSheet()
renderPicked()
})
document.getElementById('btnSave').addEventListener('click', () => {
alert('Demo:将提交右侧 payload\n\n' + JSON.stringify(buildPayload(), null, 2))
})
renderPayload()
</script>
</body>
</html>