You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

211 lines
17 KiB

1 month ago
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>观众与核销</title>
<link rel="stylesheet" href="../assets/bootstrap.min.css" />
<link rel="stylesheet" href="../assets/prototype.css" />
</head>
<body class="prototype-page admin-desktop-page audience-admin-page">
<div class="container-fluid p-3 p-md-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3">
<div>
<h5 class="mb-1 section-title">观众报名与入场核销</h5>
</div>
<div class="d-flex gap-2">
<button class="btn btn-light border btn-sm" id="refreshBtn" type="button">刷新数据</button>
<button class="btn btn-primary btn-sm" id="mobileCheckinQrBtn" type="button" data-bs-toggle="modal" data-bs-target="#mobileCheckinQrModal">核销入口</button>
</div>
</div>
<section class="audience-admin-kpis mb-3" aria-label="实时数据概览">
<article><span>累计报名</span><strong id="totalRegistered">885</strong><small>人</small></article>
<article><span>已核销入场</span><strong id="totalChecked">468</strong><small>人</small></article>
<article><span>综合到场率</span><strong id="attendanceRate">52.9%</strong><small>实时</small></article>
<article><span>今日新增核销</span><strong id="todayChecked">37</strong><small>人</small></article>
</section>
<section class="audience-admin-panel mb-3">
<div class="audience-admin-panel-head">
<div>
<h6>场次管理</h6>
</div>
<span class="live-indicator"><i></i>数据实时更新</span>
</div>
<div id="sessionCards" class="row g-2"></div>
</section>
<section class="audience-admin-panel">
<div class="audience-admin-panel-head audience-list-head">
<div>
<h6>报名与核销名单</h6>
</div>
<div class="d-flex gap-2 flex-wrap audience-list-actions">
<button class="btn btn-light border btn-sm" id="exportAudienceBtn" type="button">导出名单</button>
</div>
</div>
<div class="audience-list-toolbar">
<div class="audience-session-switcher">
<div id="sessionTabs" class="audience-session-tabs" role="tablist" aria-label="切换场次"></div>
</div>
<div class="filter-panel audience-list-filter" role="search" aria-label="名单筛选">
<div class="audience-filter-controls">
<input id="audienceKeyword" class="form-control form-control-sm" type="search" placeholder="搜索姓名、手机号、单位或票号" />
<select id="checkinFilter" class="form-select form-select-sm"><option value="">全部状态</option><option value="checked">已入场</option><option value="pending">待入场</option></select>
<button id="queryAudienceBtn" class="btn btn-primary btn-sm" type="button">查询</button>
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 audience-checkin-table">
<thead><tr><th>姓名</th><th>手机号</th><th>所属单位</th><th>票号</th><th>报名时间</th><th>入场状态</th><th>核销时间</th><th>操作</th></tr></thead>
<tbody id="audienceRows"></tbody>
</table>
</div>
<div class="audience-table-footer"><span id="visibleCount">显示 5 条示例数据</span></div>
</section>
</div>
<div class="modal fade" id="ticketModal" tabindex="-1" aria-labelledby="ticketTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm"><div class="modal-content ticket-preview-card">
<div class="modal-header border-0"><h5 id="ticketTitle" class="modal-title">电子入场票</h5><button class="btn-close" data-bs-dismiss="modal" aria-label="关闭"></button></div>
<div class="modal-body text-center pt-0"><span class="ticket-event-badge">沪苏科创大赛</span><h6 id="ticketSession" class="mt-3 mb-1"></h6><p id="ticketName" class="text-secondary small"></p><div id="ticketQr" class="ticket-qr" aria-label="二维码示意"></div><code id="ticketCode" class="ticket-code"></code><p class="small text-secondary mt-3 mb-0">入场时请出示此码,每张票仅可核销一次</p></div>
</div></div>
</div>
<div class="modal fade" id="mobileCheckinQrModal" tabindex="-1" aria-labelledby="mobileCheckinQrTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"><div class="modal-content checkin-entry-card">
<div class="modal-header border-0 pb-0"><div><span class="checkin-entry-eyebrow">核销人员入口</span><h5 id="mobileCheckinQrTitle" class="modal-title mt-1">选择核销场次</h5></div><button class="btn-close" data-bs-dismiss="modal" aria-label="关闭"></button></div>
<div class="modal-body pt-2">
<section id="checkinSessionStep" aria-label="选择核销场次">
<p class="checkin-entry-guide">当前共有多个地区与场次,请确认后进入对应核销通道。</p>
<div id="checkinSessionList" class="checkin-session-list"></div>
</section>
<section id="checkinQrStep" class="text-center d-none" aria-label="场次核销二维码">
<button id="backToCheckinSessions" class="checkin-entry-back" type="button">‹ 返回场次列表</button>
<p id="checkinEntrySession" class="checkin-entry-session"></p>
<a id="checkinEntryQrLink" class="checkin-entry-qr" href="./admin-checkin-mobile.html" target="_blank" aria-label="打开手机核销页面"><span id="checkinEntryQr" aria-hidden="true"></span><i>核销</i></a>
<p class="checkin-entry-tip">请使用手机扫描二维码<br />进入该场次的核销工作台</p>
<div class="checkin-entry-link"><span id="checkinEntryUrl"></span><button id="copyCheckinLink" type="button">复制</button></div>
<a id="previewCheckinPage" class="btn btn-outline-primary btn-sm w-100 mt-3" href="./admin-checkin-mobile.html" target="_blank">本机预览手机核销页</a>
</section>
</div>
</div></div>
</div>
<script src="../assets/bootstrap.bundle.min.js"></script>
<script>
const sessions = [
{ id:'shanghai', region:'上海', name:'上海预赛路演', type:'预选赛', date:'2026-07-18', range:'06.10 — 07.10', cap:300, registered:214, checked:168, open:false },
{ id:'suzhou', region:'苏州', name:'苏州预赛路演', type:'预选赛', date:'2026-10-30', range:'09.15 — 10.20', cap:260, registered:198, checked:142, open:true },
{ id:'shenzhen', region:'深圳', name:'深圳预赛路演', type:'预选赛', date:'2026-12-25', range:'11.01 — 12.18', cap:200, registered:126, checked:81, open:false },
{ id:'final', region:'苏州', name:'苏州总决赛', type:'总决赛', date:'2027-03-19', range:'02.01 — 03.10', cap:500, registered:347, checked:77, open:false }
];
const audienceData = {
shanghai: [
['AUD-SH-2026-0214','王晓宁','13812342187','上海光芯科技有限公司','2026-08-18 09:42','pending','—'],
['AUD-SH-2026-0213','李可欣','18624685031','复旦大学','2026-08-18 09:16','checked','2026-09-18 13:32'],
['AUD-SH-2026-0212','陈阳','13935767612','苏州工业园区产业研究院','2026-08-17 18:05','checked','2026-09-18 13:28'],
['AUD-SH-2026-0211','周嘉慧','17748210926','华为技术有限公司','2026-08-17 16:51','pending','—'],
['AUD-SH-2026-0210','赵一帆','13568243948','上海交通大学','2026-08-17 15:33','checked','2026-09-18 13:19']
]
};
const fallbacks = [
['0005','张慧','13751983018','苏州创新投资集团','2026-08-16 14:20','pending','—'],
['0004','刘宇','18926476120','南京大学','2026-08-16 11:08','checked','2026-09-18 13:06'],
['0003','孙静','15837624471','光电产业联盟','2026-08-15 17:40','pending','—']
];
sessions.slice(1).forEach((s, index) => { audienceData[s.id] = fallbacks.map((r, i) => ['AUD-' + s.id.slice(0,2).toUpperCase() + '-2026-' + r[0], r[1], r[2], r[3], r[4], (i + index) % 2 ? 'pending' : 'checked', (i + index) % 2 ? '—' : '2026-09-18 13:0' + (6 + i)]); });
let selectedSession = 'shanghai';
function updateTotals() {
const registered = sessions.reduce((sum, session) => sum + session.registered, 0);
const checked = sessions.reduce((sum, session) => sum + session.checked, 0);
document.getElementById('totalRegistered').textContent = registered;
document.getElementById('totalChecked').textContent = checked;
document.getElementById('attendanceRate').textContent = (checked / registered * 100).toFixed(1) + '%';
}
function renderSessions() {
document.getElementById('sessionCards').innerHTML = sessions.map(s => {
const pct = Math.round(s.registered / s.cap * 100);
const arrival = s.registered ? Math.round(s.checked / s.registered * 100) : 0;
return `<div class="col-12 col-md-6 col-lg-3"><article class="audience-manage-card ${s.id === selectedSession ? 'is-selected' : ''}" data-id="${s.id}">
<div class="audience-manage-card-top"><span class="session-type">${s.type}</span><button class="session-switch ${s.open ? 'is-open' : ''}" type="button" aria-label="${s.open ? '关闭' : '开放'}${s.name}"><i></i>${s.open ? '报名中' : '未开放'}</button></div>
<h6>${s.name}</h6><p class="session-date">${s.date} · 报名 ${s.range}</p>
<div class="session-number-row"><div><span>已报名</span><strong>${s.registered}<small> / ${s.cap}</small></strong></div><div><span>已到场</span><strong>${s.checked}<small>人</small></strong></div></div>
<div class="session-progress"><i style="width:${pct}%"></i></div><div class="session-card-foot"><span>名额使用 ${pct}%</span><b>到场率 ${arrival}%</b></div>
</article></div>`;
}).join('');
}
function renderTabs() {
document.getElementById('sessionTabs').innerHTML = sessions.map(s => `<button class="audience-session-tab ${s.id === selectedSession ? 'is-active' : ''}" type="button" role="tab" aria-selected="${s.id === selectedSession}" data-id="${s.id}">${s.name}<span>${s.registered}</span></button>`).join('');
}
function renderRows() {
const keyword = document.getElementById('audienceKeyword').value.trim().toLowerCase();
const state = document.getElementById('checkinFilter').value;
const rows = audienceData[selectedSession].filter(r => (!keyword || r.join(' ').toLowerCase().includes(keyword)) && (!state || r[5] === state));
document.getElementById('audienceRows').innerHTML = rows.length ? rows.map(r => `<tr><td class="audience-name-cell">${r[1]}</td><td>${r[2]}</td><td>${r[3]}</td><td><code>${r[0]}</code></td><td>${r[4]}</td><td><span class="checkin-status ${r[5]}"><i></i>${r[5] === 'checked' ? '已入场' : '待入场'}</span></td><td>${r[6]}</td><td><button class="btn btn-sm btn-outline-primary ticket-btn" data-ticket="${r[0]}">查看票码</button></td></tr>`).join('') : '<tr><td colspan="8" class="text-center text-secondary py-4">暂无符合条件的数据</td></tr>';
document.getElementById('visibleCount').textContent = '显示 ' + rows.length + ' 条示例数据';
}
function makeQr(seed) { let bits=''; for(let i=0;i<225;i++) bits += ((seed.charCodeAt(i%seed.length)*7+i*13)%11<5)?'<i></i>':'<b></b>'; return bits; }
let checkinSessionId = selectedSession;
function isSessionExpired(session) {
const today = new Date();
today.setHours(0, 0, 0, 0);
return new Date(session.date + 'T23:59:59') < today;
}
function checkinUrl() {
const target = new URL('./admin-checkin-mobile.html', window.location.href);
target.searchParams.set('session', checkinSessionId);
return target.href;
}
function renderCheckinSessionList() {
const modalTitle = document.getElementById('mobileCheckinQrTitle');
modalTitle.textContent = '选择核销场次';
modalTitle.classList.remove('visually-hidden');
document.getElementById('checkinSessionStep').classList.remove('d-none');
document.getElementById('checkinQrStep').classList.add('d-none');
document.getElementById('checkinSessionList').innerHTML = sessions.map(s => { const expired=isSessionExpired(s); return `<button class="checkin-session-option ${expired ? 'is-expired' : ''}" type="button" data-id="${s.id}" ${expired ? 'disabled aria-disabled="true"' : ''}>
<span class="checkin-session-region">${s.region}</span><span class="checkin-session-info"><strong>${s.name}</strong><small>${s.type} · ${s.date}</small></span>
<span class="checkin-session-count">${expired ? '<em>已过期</em><small>不可进入核销</small>' : `<b>${s.checked}</b> / ${s.registered}<small>已核销 / 已报名</small>`}</span><i>${expired ? '—' : '›'}</i>
</button>`; }).join('');
}
function renderCheckinEntry(id) {
checkinSessionId = id;
const url = checkinUrl();
const session = sessions.find(s => s.id === checkinSessionId);
if (!session || isSessionExpired(session)) return renderCheckinSessionList();
const modalTitle = document.getElementById('mobileCheckinQrTitle');
modalTitle.textContent = '手机扫码核销';
modalTitle.classList.add('visually-hidden');
document.getElementById('checkinSessionStep').classList.add('d-none');
document.getElementById('checkinQrStep').classList.remove('d-none');
document.getElementById('checkinEntrySession').textContent = session.region + '地区 · ' + session.name + ' · ' + session.date;
document.getElementById('checkinEntryQr').innerHTML = makeQr(url);
document.getElementById('checkinEntryUrl').textContent = url;
document.getElementById('checkinEntryQrLink').href = url;
document.getElementById('previewCheckinPage').href = url;
}
function select(id) { selectedSession=id; renderSessions(); renderTabs(); renderRows(); }
document.getElementById('sessionCards').addEventListener('click', e => { const card=e.target.closest('.audience-manage-card'); if(!card)return; if(e.target.closest('.session-switch')) { const s=sessions.find(x=>x.id===card.dataset.id); s.open=!s.open; renderSessions(); return; } select(card.dataset.id); });
document.getElementById('sessionTabs').addEventListener('click', e => { const tab=e.target.closest('.audience-session-tab'); if(tab) select(tab.dataset.id); });
document.getElementById('queryAudienceBtn').addEventListener('click', renderRows);
document.getElementById('audienceKeyword').addEventListener('keydown', e => { if(e.key==='Enter') renderRows(); });
document.getElementById('audienceRows').addEventListener('click', e => { const btn=e.target.closest('.ticket-btn'); if(!btn)return; const row=audienceData[selectedSession].find(r=>r[0]===btn.dataset.ticket); document.getElementById('ticketSession').textContent=sessions.find(s=>s.id===selectedSession).name; document.getElementById('ticketName').textContent=row[1]+' · '+row[2]; document.getElementById('ticketCode').textContent=row[0]; document.getElementById('ticketQr').innerHTML=makeQr(row[0]); bootstrap.Modal.getOrCreateInstance(document.getElementById('ticketModal')).show(); });
document.getElementById('exportAudienceBtn').addEventListener('click', () => { const s=sessions.find(x=>x.id===selectedSession); const rows=[['票号','姓名','手机号','所属单位','报名时间','入场状态','核销时间'],...audienceData[selectedSession].map(r=>[...r.slice(0,5),r[5]==='checked'?'已入场':'待入场',r[6]])]; const csv='\ufeff'+rows.map(r=>r.map(v=>'"'+String(v).replaceAll('"','""')+'"').join(',')).join('\n'); const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv;charset=utf-8'})); a.download=s.name+'-观众核销名单.csv'; a.click(); URL.revokeObjectURL(a.href); });
document.getElementById('refreshBtn').addEventListener('click', e => { e.currentTarget.textContent='已更新'; setTimeout(()=>e.currentTarget.textContent='刷新数据',900); });
document.getElementById('mobileCheckinQrModal').addEventListener('show.bs.modal', renderCheckinSessionList);
document.getElementById('checkinSessionList').addEventListener('click', e => { const option=e.target.closest('.checkin-session-option'); if(option) renderCheckinEntry(option.dataset.id); });
document.getElementById('backToCheckinSessions').addEventListener('click', renderCheckinSessionList);
document.getElementById('copyCheckinLink').addEventListener('click', async function () {
try { await navigator.clipboard.writeText(checkinUrl()); this.textContent='已复制'; }
catch (error) { document.getElementById('checkinEntryUrl').scrollIntoView({block:'center'}); this.textContent='请长按复制'; }
setTimeout(() => this.textContent='复制', 1400);
});
updateTotals(); renderSessions(); renderTabs(); renderRows();
</script>
</body>
</html>