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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!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>