Files
zkxw/docs/demos/2026-10-10-payment-confirm-sample-detail-demo.html
T
zlwuandCursor 34c8a1a62d docs: add payment-info 缴费明细 list-mode transfer design
Spec and interactive demo for replacing 缴费确认 with a sample-level
detail dialog that manages transfers in SamplePaymentPanel list mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 14:16:26 +08:00

846 lines
27 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" />
<title>缴费信息 · 缴费明细(样本全量 + 列表模式转账)Demo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--primary: #10b981;
--primary-dark: #059669;
--primary-bg: #ecfdf5;
--bg-page: #f8fafc;
--panel: #ffffff;
--text: #0f172a;
--text-secondary: #334155;
--text-muted: #64748b;
--border: #d9e8e2;
--table-head: #f8fafc;
--row-alt: #fafafa;
--row-hover: #f1f5f9;
--radius: 10px;
--radius-sm: 8px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
--warning: #d97706;
--warning-bg: #fffbeb;
--danger: #dc2626;
--info: #64748b;
--info-bg: #f1f5f9;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--bg-page);
}
.page { max-width: 1180px; margin: 0 auto; padding: 24px 20px 100px; }
.hero { margin-bottom: 20px; }
.hero h1 {
margin: 0 0 8px;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
}
.hero h1::before {
content: "";
display: inline-block;
width: 4px;
height: 1.1em;
margin-right: 10px;
vertical-align: -0.15em;
border-radius: 2px;
background: var(--primary);
}
.hero p { margin: 0 0 8px; color: var(--text-muted); }
.ref-chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 6px 12px;
border-radius: 8px;
background: var(--primary-bg);
color: var(--primary-dark);
font-size: 12px;
font-weight: 600;
}
.flow {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 14px 0 0;
}
.flow-step {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: #fff;
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
}
.flow-step span {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--primary);
color: #fff;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.panel-top {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.panel-top h2 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.panel-top h2::before {
content: "";
display: inline-block;
width: 3px;
height: 1em;
margin-right: 8px;
vertical-align: -0.12em;
border-radius: 2px;
background: var(--primary);
}
.hint {
margin: 0;
padding: 10px 20px;
font-size: 13px;
color: var(--text-muted);
background: #f8fafc;
border-bottom: 1px solid var(--border);
}
table.list {
width: 100%;
border-collapse: collapse;
}
table.list th, table.list td {
padding: 12px 14px;
text-align: center;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
table.list th {
background: var(--table-head);
color: var(--text-secondary);
font-weight: 600;
white-space: nowrap;
}
table.list tbody tr:nth-child(even) { background: var(--row-alt); }
table.list tbody tr:hover { background: var(--row-hover); }
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
.tag.success { background: var(--primary-bg); color: var(--primary-dark); }
.tag.warning { background: var(--warning-bg); color: var(--warning); }
.tag.info { background: var(--info-bg); color: var(--info); }
.tag.primary { background: #eff6ff; color: #2563eb; }
.btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 32px;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: #fff;
color: var(--text-secondary);
font-size: 13px;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
}
.btn:hover { border-color: var(--primary); color: var(--primary-dark); }
.btn.primary {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.btn.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); color: #fff; }
.btn.ghost { background: #fff; }
.btn.sm { height: 28px; padding: 0 10px; font-size: 12px; }
.btn.link {
height: auto;
padding: 0 6px;
border: none;
background: transparent;
color: var(--primary-dark);
}
.btn.link.danger { color: var(--danger); }
.btn.link.warn { color: var(--warning); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.ops { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
.amount { font-weight: 600; color: var(--primary-dark); }
.overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: none;
align-items: flex-start;
justify-content: center;
padding: 24px 16px;
z-index: 100;
overflow-y: auto;
}
.overlay.open { display: flex; }
.dialog {
width: min(1080px, 100%);
margin: 16px auto 40px;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18);
display: flex;
flex-direction: column;
max-height: calc(100vh - 48px);
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.dialog-header h3 { margin: 0; font-size: 16px; font-weight: 700; }
.dialog-close {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 20px;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
}
.dialog-close:hover { background: var(--info-bg); color: var(--text); }
.dialog-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 20px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 18px;
}
@media (max-width: 720px) {
.summary-grid { grid-template-columns: 1fr 1fr; }
}
.summary-item {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #f8fafc;
}
.summary-item label {
display: block;
font-size: 11px;
color: var(--text-muted);
margin-bottom: 4px;
}
.summary-item strong { font-size: 14px; font-weight: 600; }
.summary-item .muted { color: var(--text-muted); font-weight: 400; font-size: 13px; }
.section-title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 700;
color: var(--text-secondary);
}
.schedule-block {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin-bottom: 14px;
overflow: hidden;
}
.schedule-block__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
background: #f8fafc;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.schedule-block__title {
font-size: 13px;
font-weight: 600;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
.schedule-arrears { color: var(--text-muted); font-weight: 500; }
.transfer-wrap { overflow-x: auto; }
table.transfer {
width: 100%;
border-collapse: collapse;
min-width: 980px;
}
table.transfer th, table.transfer td {
padding: 10px 8px;
border-bottom: 1px solid var(--border);
font-size: 13px;
text-align: center;
vertical-align: middle;
}
table.transfer th {
background: #fafafa;
color: var(--text-secondary);
font-weight: 600;
white-space: nowrap;
}
table.transfer tbody tr:nth-child(even) { background: #fcfcfc; }
table.transfer .empty {
padding: 18px;
color: var(--text-muted);
text-align: center;
}
.cell-input, .cell-select {
width: 100%;
height: 32px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
background: #fff;
}
.cell-input:disabled, .cell-select:disabled {
background: #f8fafc;
color: var(--text-muted);
}
.upload-mini {
width: 72px;
height: 52px;
border: 1px dashed #a7f3d0;
border-radius: 6px;
background: var(--primary-bg);
color: var(--primary-dark);
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.voucher-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 40px;
border-radius: 6px;
border: 1px solid #a7f3d0;
background: #fff;
font-size: 11px;
color: var(--primary-dark);
}
.row-ops {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
}
.toast {
position: fixed;
top: 24px;
left: 50%;
transform: translateX(-50%) translateY(-20px);
background: #0f172a;
color: #fff;
padding: 10px 18px;
border-radius: 8px;
font-size: 13px;
opacity: 0;
pointer-events: none;
transition: all 0.25s ease;
z-index: 200;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.compare {
margin-top: 28px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
@media (max-width: 800px) {
.compare { grid-template-columns: 1fr; }
}
.compare-card {
padding: 16px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: #fff;
}
.compare-card h4 { margin: 0 0 8px; font-size: 14px; }
.compare-card.new {
border-color: #a7f3d0;
background: linear-gradient(180deg, #ecfdf5 0%, #fff 48%);
}
.compare-card.new h4 { color: var(--primary-dark); }
.compare-card ul {
margin: 0;
padding-left: 18px;
color: var(--text-secondary);
font-size: 13px;
}
.compare-card li { margin-bottom: 4px; }
</style>
</head>
<body>
<div class="page">
<header class="hero">
<h1>缴费明细 · 样本全量 + 列表模式转账</h1>
<p>
列表「缴费明细」打开该样本全量应缴批次(应缴金额/时间只读);转账登记对齐<strong>采集协议单 · SamplePaymentPanel</strong>:
期次下表格行内编辑,支持添加 / 保存 / 提交财务确认 / 撤回 / 删除。改缴费方案仍走协议单。
</p>
<div class="ref-chip">参考:采集协议单新增/编辑弹窗 · 缴费方案 · 应缴批次列表模式(本页应缴字段只读)</div>
<div class="flow">
<div class="flow-step"><span>1</span> 列表点「缴费明细」</div>
<div class="flow-step"><span>2</span> 查看各期明细(应缴只读)</div>
<div class="flow-step"><span>3</span> 期次「添加转账」行内填写</div>
<div class="flow-step"><span>4</span> 行操作:保存 → 提交财务确认</div>
</div>
</header>
<section class="panel">
<div class="panel-top">
<h2>缴费信息(示意列表)</h2>
</div>
<p class="hint">点击「缴费明细」打开样本详情(有欠缴/可操作时主色;已缴清仍可查看,本 Demo 第二行示意「仅查看」)。</p>
<div style="overflow-x:auto">
<table class="list">
<thead>
<tr>
<th>姓名</th>
<th>合同编号</th>
<th>缴费方式</th>
<th>合同总额</th>
<th>累计实缴</th>
<th>待缴金额</th>
<th>缴费进度</th>
<th style="min-width:220px">操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>张敏</td>
<td>XY-2026-0318</td>
<td><span class="tag primary">分期</span></td>
<td>¥68,000</td>
<td class="amount">¥30,000</td>
<td>¥38,000</td>
<td><span class="tag primary">部分已缴</span></td>
<td>
<div class="ops">
<button class="btn" onclick="toast('打开协议单(现有)')">协议单</button>
<button class="btn primary" onclick="openDetail()">缴费明细</button>
</div>
</td>
</tr>
<tr>
<td>李婷</td>
<td>XY-2026-0281</td>
<td><span class="tag success">趸交</span></td>
<td>¥48,000</td>
<td class="amount">¥48,000</td>
<td>¥0</td>
<td><span class="tag success">已缴清</span></td>
<td>
<div class="ops">
<button class="btn" onclick="toast('打开协议单(现有)')">协议单</button>
<button class="btn" onclick="toast('已缴清:仍可打开明细查看(只读转账)')">缴费明细</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<div class="compare">
<div class="compare-card">
<h4>现状(待改)</h4>
<ul>
<li>按钮名「缴费确认」→ 直接弹单笔表单</li>
<li>看不到全期转账明细</li>
<li>与协议单编辑体验不一致</li>
</ul>
</div>
<div class="compare-card new">
<h4>本 Demo(目标)</h4>
<ul>
<li>按钮改名「缴费明细」→ 样本全量批次详情</li>
<li>应缴金额/时间只读;每期表格列表模式管转账</li>
<li>行操作:保存 / 提交财务确认 / 撤回 / 删除(对齐协议单)</li>
</ul>
</div>
</div>
</div>
<div class="overlay" id="detailOverlay" role="dialog" aria-modal="true">
<div class="dialog">
<div class="dialog-header">
<h3>样本缴费详情 · 张敏 / XY-2026-0318</h3>
<button class="dialog-close" onclick="closeDetail()" aria-label="关闭">×</button>
</div>
<div class="dialog-body">
<div class="summary-grid">
<div class="summary-item">
<label>客户</label>
<strong>张敏 <span class="muted">· 138****2618</span></strong>
</div>
<div class="summary-item">
<label>样本编码</label>
<strong>YB-2026-0318-A</strong>
</div>
<div class="summary-item">
<label>缴费方式</label>
<strong><span class="tag primary">分期</span></strong>
</div>
<div class="summary-item">
<label>合同额</label>
<strong class="amount">¥68,000.00</strong>
</div>
<div class="summary-item">
<label>已缴 / 待缴</label>
<strong class="amount">¥30,000.00 / ¥38,000.00</strong>
</div>
<div class="summary-item">
<label>缴费进度</label>
<strong><span class="tag primary">部分已缴</span> <span class="muted">· 1/2 期已缴清</span></strong>
</div>
</div>
<p class="section-title">应缴批次</p>
<!-- 第1期:已缴清,转账只读 -->
<div class="schedule-block">
<div class="schedule-block__head">
<div class="schedule-block__title">
<span>第 1 期</span>
<span class="schedule-arrears">应缴 ¥30,000.00 · 已缴 ¥30,000.00 · 欠缴 ¥0.00 · 2026-03-20</span>
<span class="tag success">已缴清</span>
</div>
</div>
<div class="transfer-wrap">
<table class="transfer">
<thead>
<tr>
<th style="width:48px">#</th>
<th>转账方式</th>
<th>转账金额(元)</th>
<th>转账时间</th>
<th>转账凭证</th>
<th>备注</th>
<th>财务备注</th>
<th>状态</th>
<th style="min-width:160px">操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>银行转账</td>
<td class="amount">30,000.00</td>
<td>2026-03-18 14:22</td>
<td><span class="voucher-chip">回单</span></td>
<td>—</td>
<td>到账匹配成功</td>
<td><span class="tag success">缴费成功</span></td>
<td><span style="color:var(--text-muted)">—</span></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 第2期:可编辑列表 -->
<div class="schedule-block" id="schedule2">
<div class="schedule-block__head">
<div class="schedule-block__title">
<span>第 2 期</span>
<span class="schedule-arrears" id="s2Arrears">应缴 ¥38,000.00 · 已缴 ¥0.00 · 欠缴 ¥38,000.00 · 2026-09-20</span>
<span class="tag warning" id="s2Status">待缴清</span>
</div>
<button class="btn primary sm" id="btnAdd" onclick="addTransfer()">
+ 添加转账
</button>
</div>
<div class="transfer-wrap">
<table class="transfer">
<thead>
<tr>
<th style="width:48px">#</th>
<th>转账方式</th>
<th>转账金额(元)</th>
<th>转账时间</th>
<th>转账凭证</th>
<th>备注</th>
<th>财务备注</th>
<th>状态</th>
<th style="min-width:260px">操作</th>
</tr>
</thead>
<tbody id="s2Body">
<tr>
<td colspan="9" class="empty">暂无转账记录,请点击上方按钮添加</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="dialog-footer">
<button class="btn ghost" onclick="closeDetail()">关闭</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
/** @type {{id?:string, type:string, amount:string, time:string, remark:string, finance:string, status:'dfj'|'jfqrz'|'yjf', saved:boolean}[]} */
let transfers = []
const detailOverlay = document.getElementById('detailOverlay')
function toast(msg) {
const el = document.getElementById('toast')
el.textContent = msg
el.classList.add('show')
clearTimeout(toast._t)
toast._t = setTimeout(() => el.classList.remove('show'), 2200)
}
function openDetail() {
detailOverlay.classList.add('open')
document.body.style.overflow = 'hidden'
setTimeout(() => {
document.getElementById('schedule2').scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 80)
}
function closeDetail() {
detailOverlay.classList.remove('open')
document.body.style.overflow = ''
}
function fmt(n) {
return Number(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function paidSum() {
return transfers
.filter((t) => ['dfj', 'jfqrz', 'yjf'].includes(t.status))
.reduce((s, t) => s + (Number(t.amount) || 0), 0)
}
function updateHead() {
const paid = paidSum()
const due = 38000
const arrears = Math.max(due - paid, 0)
document.getElementById('s2Arrears').textContent =
`应缴 ¥${fmt(due)} · 已缴 ¥${fmt(paid)} · 欠缴 ¥${fmt(arrears)} · 2026-09-20`
const st = document.getElementById('s2Status')
const hasJfqrz = transfers.some((t) => t.status === 'jfqrz')
if (arrears <= 0 && transfers.some((t) => t.status === 'yjf')) {
st.className = 'tag success'
st.textContent = '已缴清'
} else if (hasJfqrz) {
st.className = 'tag warning'
st.textContent = '缴费确认中'
} else {
st.className = 'tag warning'
st.textContent = '待缴清'
}
// 存在 jfqrz 时仍可添加其它草稿(协议单可多笔);Demo 简化:始终可添加,除非已缴清
document.getElementById('btnAdd').disabled = arrears <= 0 && hasJfqrz === false && paid >= due
}
function statusTag(status) {
if (status === 'yjf') return '<span class="tag success">缴费成功</span>'
if (status === 'jfqrz') return '<span class="tag warning">缴费确认中</span>'
return '<span class="tag info">已缴费</span>'
}
function typeLabel(v) {
return { yhzz: '银行转账', wx: '微信', zfb: '支付宝', xj: '现金' }[v] || v || '—'
}
function render() {
const body = document.getElementById('s2Body')
if (!transfers.length) {
body.innerHTML = '<tr><td colspan="9" class="empty">暂无转账记录,请点击上方按钮添加</td></tr>'
updateHead()
return
}
body.innerHTML = transfers.map((t, idx) => {
const locked = t.status === 'yjf' || t.status === 'jfqrz'
const typeCell = locked
? typeLabel(t.type)
: `<select class="cell-select" onchange="patch(${idx},'type',this.value)">
<option value="yhzz" ${t.type==='yhzz'?'selected':''}>银行转账</option>
<option value="wx" ${t.type==='wx'?'selected':''}>微信</option>
<option value="zfb" ${t.type==='zfb'?'selected':''}>支付宝</option>
<option value="xj" ${t.type==='xj'?'selected':''}>现金</option>
</select>`
const amountCell = locked
? `<span class="amount">${fmt(t.amount)}</span>`
: `<input class="cell-input" value="${t.amount}" onchange="patch(${idx},'amount',this.value)" />`
const timeCell = locked
? (t.time || '—')
: `<input class="cell-input" value="${t.time}" onchange="patch(${idx},'time',this.value)" />`
const remarkCell = locked
? (t.remark || '—')
: `<input class="cell-input" value="${t.remark||''}" placeholder="选填" onchange="patch(${idx},'remark',this.value)" />`
const voucherCell = locked
? (t.voucher ? '<span class="voucher-chip">回单</span>' : '—')
: `<div class="upload-mini" onclick="attach(${idx})">${t.voucher ? '回单' : '+ 上传'}</div>`
let ops = ''
if (!locked) {
ops += `<button class="btn link" onclick="saveRow(${idx})">保存</button>`
}
if (t.saved && t.status === 'dfj') {
ops += `<button class="btn link" onclick="submitRow(${idx})">提交财务确认</button>`
}
if (t.status === 'jfqrz') {
ops += `<button class="btn link warn" onclick="withdrawRow(${idx})">撤回</button>`
}
if (!locked) {
ops += `<button class="btn link danger" onclick="removeRow(${idx})">删除</button>`
}
if (locked && t.status !== 'jfqrz') ops = '<span style="color:var(--text-muted)">—</span>'
return `<tr>
<td>${idx + 1}</td>
<td>${typeCell}</td>
<td>${amountCell}</td>
<td>${timeCell}</td>
<td>${voucherCell}</td>
<td>${remarkCell}</td>
<td>${t.finance || '—'}</td>
<td>${statusTag(t.status)}</td>
<td><div class="row-ops">${ops}</div></td>
</tr>`
}).join('')
updateHead()
}
function addTransfer() {
const arrears = Math.max(38000 - paidSum(), 0)
transfers.push({
type: 'yhzz',
amount: String(arrears > 0 ? arrears.toFixed(2) : '0.00'),
time: '2026-10-10 10:30:00',
remark: '',
finance: '',
status: 'dfj',
saved: false,
voucher: false
})
render()
toast('已添加转账行(未保存)')
}
function patch(idx, key, val) {
transfers[idx][key] = val
transfers[idx].saved = false
}
function attach(idx) {
transfers[idx].voucher = true
transfers[idx].saved = false
render()
toast('Demo:已挂上转账凭证')
}
function saveRow(idx) {
const t = transfers[idx]
if (!t.type) return toast('请选择转账方式')
if (!(Number(t.amount) > 0)) return toast('请填写转账金额')
t.saved = true
t.id = t.id || 'tr_' + Date.now()
render()
toast('转账草稿已保存(状态:已缴费 dfj)')
}
function submitRow(idx) {
const t = transfers[idx]
if (!t.saved) return toast('请先保存再提交')
t.status = 'jfqrz'
render()
toast('已提交财务确认 → 缴费确认中')
}
function withdrawRow(idx) {
transfers[idx].status = 'dfj'
render()
toast('已撤回 → 恢复为可编辑草稿')
}
function removeRow(idx) {
transfers.splice(idx, 1)
render()
toast('已删除转账行')
}
detailOverlay.addEventListener('click', (e) => {
if (e.target === detailOverlay) closeDetail()
})
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && detailOverlay.classList.contains('open')) closeDetail()
})
</script>
</body>
</html>