V1.0首次发布,仅人脸录入及人脸核验功能
V2.1优化人脸识别算法部分,增加4个源可以选择,选择最优源进行缓存
V2.2优化摄像头调用算法,支持摄像头监测功能
V2.3增加通行记录导出功能和班级可配置功能
V2.4增加同一人识别间隔功能
V2.5增加时间间隔功能
V2.6增加语音功能
V2.7增加多音字识别功能
V2.8优化部分Bug后发布
V1.0开源版代码分享
<!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">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>南京市第三幼儿园大公馆部 · 家校安全信息管理系统</title>
<script src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js"></script>
<style>
:root {
--primary: #1a3a5c;
--primary-light: #2c5f8a;
--bg: #f4f6f9;
--card: #ffffff;
--text: #2c3e50;
--text-sec: #7f8c9b;
--border: #e2e8f0;
--success: #27ae60;
--danger: #c0392b;
--radius: 8px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "PingFang SC","Hiragino Sans GB","Microsoft YaHei",-apple-system,sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
.header {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: #fff; padding: 16px 20px; display: flex; align-items: center; gap: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,.12);
}
.header .logo { width:44px;height:44px;background:rgba(255,255,255,.15);border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0; }
.header h1 { font-size:17px;font-weight:600;letter-spacing:.5px; }
.header p { font-size:12px;opacity:.8;margin-top:2px; }
.status-bar {
background:var(--card);border-bottom:1px solid var(--border);
padding:10px 20px;display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-sec);
}
.dot { width:8px;height:8px;border-radius:50%;flex-shrink:0; }
.dot.loading { background:#f39c12;animation:blink 1s infinite; }
.dot.ready { background:var(--success); }
.dot.error { background:var(--danger); }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
.progress-wrap { background:var(--card);border-bottom:1px solid var(--border);padding:0 20px 12px;display:none; }
.progress-wrap.show { display:block; }
.progress-bar { height:4px;background:#e9ecef;border-radius:2px;overflow:hidden;margin-top:6px; }
.progress-bar .fill { height:100%;background:var(--primary-light);border-radius:2px;transition:width .3s;width:0%; }
.progress-text { font-size:12px;color:var(--text-sec);margin-top:4px; }
.nav { display:flex;background:var(--card);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100; }
.nav-item { flex:1;padding:13px 8px;text-align:center;font-size:14px;color:var(--text-sec);cursor:pointer;border-bottom:2px solid transparent;transition:all .2s;user-select:none; }
.nav-item.active { color:var(--primary);border-bottom-color:var(--primary);font-weight:600; }
.page { display:none;padding:16px; }
.page.active { display:block; }
.cam-wrap { position:relative;width:100%;max-width:480px;margin:0 auto 16px;border-radius:var(--radius);overflow:hidden;background:#1a1a2e;box-shadow:0 4px 16px rgba(0,0,0,.1); }
.cam-wrap video { width:100%;display:block;border-radius:var(--radius); }
.cam-wrap canvas.overlay { position:absolute;top:0;left:0;width:100%;height:100%;border-radius:var(--radius); }
.cam-error { background:#fef3f2;border:1px solid #fecaca;border-radius:var(--radius);padding:16px;margin:0 auto 16px;max-width:480px;display:none; }
.cam-error.show { display:block; }
.cam-error .err-t { font-size:14px;font-weight:600;color:var(--danger);margin-bottom:8px; }
.cam-error .err-d { font-size:13px;color:#666;line-height:1.6; }
.cam-error .err-d code { background:#f3f4f6;padding:2px 6px;border-radius:3px;font-size:12px; }
.cam-error .err-tips { margin-top:10px;padding-top:10px;border-top:1px solid #fecaca; }
.cam-error .err-tips li { font-size:13px;color:#555;margin-bottom:4px;list-style:disc inside; }
.cam-sel-wrap { max-width:480px;margin:0 auto 12px;display:none; }
.cam-sel-wrap.show { display:block; }
.cam-sel-wrap select { width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:6px;font-size:14px;background:var(--card);color:var(--text); }
.form-card { background:var(--card);border-radius:var(--radius);padding:20px;box-shadow:0 1px 4px rgba(0,0,0,.04);margin-bottom:16px; }
.form-card .card-title { font-size:15px;font-weight:600;color:var(--primary);margin-bottom:16px;padding-bottom:10px;border-bottom:1px solid var(--border); }
.form-row { margin-bottom:14px; }
.form-row label { display:block;font-size:13px;color:var(--text-sec);margin-bottom:6px;font-weight:500; }
.form-row input,.form-row select { width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:6px;font-size:15px;color:var(--text);outline:none;transition:border-color .2s;background:#fafbfc; }
.form-row input:focus,.form-row select:focus { border-color:var(--primary-light);background:#fff; }
.btn { display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:12px 24px;border:none;border-radius:6px;font-size:15px;font-weight:500;cursor:pointer;transition:all .2s;user-select:none; }
.btn-primary { background:var(--primary);color:#fff; }
.btn-success { background:var(--success);color:#fff; }
.btn-danger { background:var(--danger);color:#fff; }
.btn-outline { background:transparent;border:1px solid var(--border);color:var(--text-sec); }
.btn:disabled { opacity:.4;cursor:not-allowed; }
.btn-block { display:flex;width:100%; }
.btn-sm { padding:8px 16px;font-size:13px; }
.btn-group { display:flex;gap:10px; }
.btn-group .btn { flex:1; }
.result-card { background:var(--card);border-radius:var(--radius);padding:24px;text-align:center;margin:16px auto;max-width:480px;box-shadow:0 1px 4px rgba(0,0,0,.04);border-left:4px solid var(--border); }
.result-card.matched { border-left-color:var(--success); }
.result-card.unmatched { border-left-color:var(--danger); }
.result-card .r-name { font-size:22px;font-weight:700;color:var(--primary);margin-bottom:4px; }
.result-card.unmatched .r-name { color:var(--danger); }
.result-card .r-info { font-size:14px;color:var(--text-sec); }
.result-card .r-meta { font-size:12px;color:#aaa;margin-top:8px; }
.list-card { background:var(--card);border-radius:var(--radius);padding:12px 14px;margin-bottom:10px;display:flex;align-items:center;gap:12px;box-shadow:0 1px 3px rgba(0,0,0,.04); }
.list-card .avatar { width:52px;height:52px;border-radius:8px;object-fit:cover;flex-shrink:0;background:#eee; }
.list-card .info { flex:1;min-width:0; }
.list-card .info .name { font-size:15px;font-weight:600;margin-bottom:3px; }
.list-card .info .meta { font-size:12px;color:var(--text-sec); }
.list-card .del-btn { background:none;border:none;color:var(--danger);font-size:13px;cursor:pointer;padding:6px 10px;border-radius:4px; }
.list-card .del-btn:hover { background:#fef2f2; }
.empty { text-align:center;padding:48px 20px;color:var(--text-sec); }
.empty .icon { font-size:48px;margin-bottom:12px;opacity:.3; }
.empty p { font-size:14px; }
.toast { position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(26,58,92,.92);color:#fff;padding:12px 28px;border-radius:8px;font-size:14px;z-index:9999;display:none;backdrop-filter:blur(4px);text-align:center;max-width:80%; }
.toast.show { display:block; }
.footer { text-align:center;padding:20px;font-size:12px;color:#bbb; }
</style>
</head>
<body>
<div class="header">
<div class="logo">🏫</div>
<div><h1>南京市第三幼儿园大公馆部</h1><p>家校安全信息管理系统 v2.2</p></div>
</div>
<div class="status-bar">
<div class="dot loading" id="statusDot"></div>
<span id="statusText">系统初始化中...</span>
</div>
<div class="progress-wrap" id="progressWrap">
<div class="progress-bar"><div class="fill" id="progressFill"></div></div>
<div class="progress-text" id="progressText">准备中...</div>
</div>
<div class="nav">
<div class="nav-item active" data-page="register">人员录入</div>
<div class="nav-item" data-page="recognize">人脸核验</div>
<div class="nav-item" data-page="persons">人员管理</div>
<div class="nav-item" data-page="records">通行记录</div>
</div>
<!-- 人员录入 -->
<div class="page active" id="page-register">
<div class="cam-error" id="camError">
<div class="err-t" id="errTitle">⚠️ 摄像头无法访问</div>
<div class="err-d" id="errDetail"></div>
<div class="err-tips" id="errTips"></div>
</div>
<div class="cam-sel-wrap" id="camSelWrap">
<select id="camSelect"></select>
</div>
<div class="cam-wrap">
<video id="regVideo" autoplay muted playsinline></video>
</div>
<div class="form-card">
<div class="card-title">录入信息</div>
<div class="form-row"><label>姓名</label><input type="text" id="inputName" placeholder="请输入姓名"></div>
<div class="form-row"><label>班级</label>
<select id="inputClass"><option value="中一班">中一班</option><option value="中二班">中二班</option></select>
</div>
<div class="form-row"><label>身份</label>
<select id="inputRole"><option value="幼儿">幼儿</option><option value="教师">教师</option><option value="家长">家长</option><option value="其他">其他</option></select>
</div>
<button class="btn btn-primary btn-block" id="btnRegister" disabled>拍照录入</button>
</div>
</div>
<!-- 人脸核验 -->
<div class="page" id="page-recognize">
<div class="cam-wrap">
<video id="recVideo" autoplay muted playsinline></video>
<canvas class="overlay" id="recCanvas"></canvas>
</div>
<div class="result-card" id="resultCard">
<div class="r-name">等待核验</div>
<div class="r-info">点击下方按钮启动识别</div>
</div>
<div class="btn-group" style="max-width:480px;margin:0 auto;">
<button class="btn btn-success" id="btnStartRec" disabled>启动核验</button>
<button class="btn btn-danger" id="btnStopRec" disabled>停止核验</button>
</div>
</div>
<!-- 人员管理 -->
<div class="page" id="page-persons"><div id="personsList"></div></div>
<!-- 通行记录 -->
<div class="page" id="page-records">
<div style="margin-bottom:12px;"><button class="btn btn-outline btn-sm" id="btnClearRec">清空全部记录</button></div>
<div id="recordsList"></div>
</div>
<div class="footer">南京市第三幼儿园 · 人脸识别管理系统 © 2026</div>
<div class="toast" id="toast"></div>
<script>
const DB_NAME='NJKinderFaceDB',DB_VER=1;
let db=null,modelReady=false,recTimer=null,regStream=null,recStream=null;
let lastMatchId=null,lastMatchTime=0,curCamId=null,allCams=[];
function showToast(m,d=2000){const t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),d);}
function fmtTime(ts){const d=new Date(ts),p=n=>String(n).padStart(2,'0');return ${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}{p(d.getMinutes())}:${p(d.getSeconds())};}
function setStatus(s,t){document.getElementById('statusDot').className='dot '+s;document.getElementById('statusText').textContent=t;}
function setProgress(p,t){const w=document.getElementById('progressWrap'),f=document.getElementById('progressFill'),pt=document.getElementById('progressText');if(p>=0){w.classList.add('show');f.style.width=p+'%';}else w.classList.remove('show');if(t)pt.textContent=t;}
// ========== IndexedDB ==========
function openDB(){return new Promise((res,rej)=>{const r=indexedDB.open(DB_NAME,DB_VER);r.onupgradeneeded=e=>{const d=e.target.result;if(!d.objectStoreNames.contains('persons'))d.createObjectStore('persons',{keyPath:'id',autoIncrement:true});if(!d.objectStoreNames.contains('records'))d.createObjectStore('records',{keyPath:'id',autoIncrement:true});};r.onsuccess=e=>{db=e.target.result;res(db);};r.onerror=e=>rej(e.target.error);});}
function dbAdd(s,d){return new Promise((res,rej)=>{const r=db.transaction(s,'readwrite').objectStore(s).add(d);r.onsuccess=()=>res(r.result);r.onerror=e=>rej(e.target.error);});}
function dbGetAll(s){return new Promise((res,rej)=>{const r=db.transaction(s,'readonly').objectStore(s).getAll();r.onsuccess=()=>res(r.result);r.onerror=e=>rej(e.target.error);});}
function dbDel(s,id){return new Promise((res,rej)=>{const r=db.transaction(s,'readwrite').objectStore(s).delete(id);r.onsuccess=()=>res();r.onerror=e=>rej(e.target.error);});}
function dbClear(s){return new Promise((res,rej)=>{const r=db.transaction(s,'readwrite').objectStore(s).clear();r.onsuccess=()=>res();r.onerror=e=>rej(e.target.error);});}
// ========== 摄像头错误诊断 ==========
function showCamError(err){
const box=document.getElementById('camError'),title=document.getElementById('errTitle'),detail=document.getElementById('errDetail'),tips=document.getElementById('errTips');
box.classList.add('show');let t=[];
if(err.name==='NotAllowedError'||err.name==='PermissionDeniedError'){
title.textContent='⚠️ 摄像头权限被拒绝';
detail.innerHTML=浏览器错误:<code>${err.name}</code><br>您或系统拒绝了摄像头访问请求。;
t=['点击浏览器地址栏左侧的🔒图标,允许摄像头权限','进入手机「设置」→ 找到当前浏览器 → 允许摄像头权限','确保页面通过 https:// 网址访问(而非直接打开本地文件)'];
}else if(err.name==='NotFoundError'){
title.textContent='⚠️ 未找到摄像头设备';
detail.innerHTML=浏览器错误:<code>${err.name}</code><br>系统未检测到可用的摄像头。;
t=['确认手机摄像头是否正常工作','尝试重启手机后再打开页面'];
}else if(err.name==='NotReadableError'){
title.textContent='⚠️ 摄像头被占用';
detail.innerHTML=浏览器错误:<code>${err.name}</code><br>摄像头可能正被其他应用占用。;
t=['关闭其他正在使用摄像头的应用','关闭其他打开此页面的标签页','重启手机后重试'];
}else{
title.textContent='⚠️ 摄像头访问失败';
detail.innerHTML=错误信息:<code>${err.name}: ${err.message||'未知错误'}</code>;
t=['确保通过 https:// 网址访问本页面','使用Chrome、Safari或Firefox浏览器','不要在微信/QQ内置浏览器中打开'];
}
tips.innerHTML='<strong>解决方案:</strong>'+t.map(x=><li>${x}</li>).join('');
setStatus('error','摄像头访问失败');
}
function hideCamError(){document.getElementById('camError').classList.remove('show');}
// ========== 摄像头 ==========
async function startCam(type,deviceId){
const vid=type==='register'?'regVideo':'recVideo';
const video=document.getElementById(vid);
hideCamError();
const constraints=[];
if(deviceId) constraints.push({video:{deviceId:{exact:deviceId},width:{ideal:640},height:{ideal:480}},audio:false});
constraints.push({video:{facingMode:'user',width:{ideal:640},height:{ideal:480}},audio:false},{video:{facingMode:'user'},audio:false},{video:true,audio:false});
let stream=null,lastErr=null;
for(const c of constraints){try{stream=await navigator.mediaDevices.getUserMedia(c);break;}catch(e){lastErr=e;}}
if(!stream){showCamError(lastErr||new Error('无法访问摄像头'));return false;}
video.srcObject=stream;
await new Promise(r=>{if(video.readyState>=2){r();return;}video.onloadedmetadata=()=>r();setTimeout(r,3000);});
if(type==='register')regStream=stream;else recStream=stream;
const track=stream.getVideoTracks()[0];
if(track){const s=track.getSettings();curCamId=s.deviceId||null;if(curCamId)document.getElementById('camSelect').value=curCamId;}
// 枚举设备(需先获取权限后label才会显示)
try{const devs=await navigator.mediaDevices.enumerateDevices();allCams=devs.filter(d=>d.kind==='videoinput');if(allCams.length>1){const sel=document.getElementById('camSelect');sel.innerHTML='';allCams.forEach((c,i)=>{const o=document.createElement('option');o.value=c.deviceId;o.textContent=c.label||摄像头 ${i+1};sel.appendChild(o);});if(curCamId)sel.value=curCamId;document.getElementById('camSelWrap').classList.add('show');}}catch(e){}
setStatus('ready','系统就绪 · 摄像头已连接');
return true;
}
async function switchCam(id){curCamId=id;const pg=document.querySelector('.nav-item.active').dataset.page;stopCam('register');stopCam('recognize');await startCam(pg==='recognize'?'recognize':'register',id);}
function stopCam(type){const s=type==='register'?regStream:recStream;if(s){s.getTracks().forEach(t=>t.stop());if(type==='register')regStream=null;else recStream=null;}}
// ========== 模型加载 ==========
async function loadModels(){
const sources=['https://cdn.jsdelivr.net/npm/@vladmandic/face-api@1.7.12/model','https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/weights','https://raw.githubusercontent.com/niconielsen32/ComputerVision/master/faceDetection/models','https://justadudewhohacks.github.io/face-api.js/models'];
const nets=[{n:'人脸检测引擎',net:faceapi.nets.tinyFaceDetector},{n:'面部关键点引擎',net:faceapi.nets.faceLandmark68Net},{n:'人脸识别引擎',net:faceapi.nets.faceRecognitionNet}];
const total=nets.length;let done=0;
for(let i=0;i<nets.length;i++){
const m=nets[i];let ok=false;
for(let s=0;s<sources.length;s++){
try{setProgress(Math.round(done/total*100),正在加载${m.n}(源{sources.length})...);setStatus('loading',正在加载${m.n}...);await m.net.loadFromUri(sources[s]);ok=true;console.log(✅{sources[s]});break;}catch(e){console.warn(⚠️{s+1} fail:,e.message||e);}
}
if(!ok){setStatus('error',${m.n}加载失败,请检查网络后刷新);setProgress(-1);return false;}
done++;setProgress(Math.round(done/total*100),已加载{total} 个引擎);
}
modelReady=true;setProgress(100,'全部引擎加载完成');setTimeout(()=>setProgress(-1),1500);
setStatus('ready','系统就绪 · AI引擎已加载');
document.getElementById('btnRegister').disabled=false;
document.getElementById('btnStartRec').disabled=false;
showToast('系统初始化完成');
return true;
}
// ========== 录入 ==========
async function handleRegister(){
const name=document.getElementById('inputName').value.trim(),cls=document.getElementById('inputClass').value,role=document.getElementById('inputRole').value;
if(!name){showToast('请输入姓名');return;}
const video=document.getElementById('regVideo');
if(!video.srcObject||video.readyState<2){showToast('摄像头未就绪,请稍候');return;}
showToast('正在检测人脸...');
const det=await faceapi.detectSingleFace(video,new faceapi.TinyFaceDetectorOptions({inputSize:416,scoreThreshold:0.5})).withFaceLandmarks().withFaceDescriptor();
if(!det){showToast('未检测到人脸,请正对摄像头并保持光线充足');return;}
const c=document.createElement('canvas');c.width=video.videoWidth;c.height=video.videoHeight;c.getContext('2d').drawImage(video,0,0);
const photo=c.toDataURL('image/jpeg',0.8);
await dbAdd('persons',{name,className:cls,role,photo,descriptor:Array.from(det.descriptor),createTime:Date.now()});
showToast(已成功录入:${name}(${cls} · ${role}));
document.getElementById('inputName').value='';
renderPersons();
}
// ========== 识别 ==========
async function startRec(){
const persons=await dbGetAll('persons');
if(!persons.length){showToast('请先录入人员信息');return;}
document.getElementById('btnStartRec').disabled=true;document.getElementById('btnStopRec').disabled=false;
lastMatchId=null;lastMatchTime=0;
const video=document.getElementById('recVideo'),canvas=document.getElementById('recCanvas');
recTimer=setInterval(async()=>{
if(video.readyState<2)return;
const ds={width:video.videoWidth,height:video.videoHeight};
faceapi.matchDimensions(canvas,ds);
const dets=await faceapi.detectAllFaces(video,new faceapi.TinyFaceDetectorOptions({inputSize:416,scoreThreshold:0.5})).withFaceLandmarks().withFaceDescriptors();
const resized=faceapi.resizeResults(dets,ds);
const ctx=canvas.getContext('2d');ctx.clearRect(0,0,canvas.width,canvas.height);
if(!dets.length)return;
let maxA=0,mainD=null,mainR=null;
dets.forEach((d,i)=>{const a=d.detection.box.width*d.detection.box.height;if(a>maxA){maxA=a;mainD=d;mainR=resized[i];}});
const desc=mainD.descriptor;let best=null,minD=Infinity;
for(const p of persons){const dist=faceapi.euclideanDistance(desc,new Float32Array(p.descriptor));if(dist<minD){minD=dist;best=p;}}
const box=mainR.detection.box,matched=minD<0.55;
new faceapi.draw.DrawBox(box,{label:matched?${best.name}(${best.className}):'未识别',boxColor:matched?'#27ae60':'#c0392b',lineWidth:2}).draw(canvas);
const card=document.getElementById('resultCard');
if(matched){
card.className='result-card matched';
card.innerHTML=<div class="r-name">${best.name}</div><div class="r-info">${best.className} ·{((1-minD)*100).toFixed(1)}% · ${fmtTime(Date.now())}</div>;
const now=Date.now();
if(best.id!==lastMatchId||now-lastMatchTime>5000){
lastMatchId=best.id;lastMatchTime=now;
const rc=document.createElement('canvas');rc.width=video.videoWidth;rc.height=video.videoHeight;rc.getContext('2d').drawImage(video,0,0);
await dbAdd('records',{personId:best.id,personName:best.name,className:best.className,role:best.role,photo:rc.toDataURL('image/jpeg',0.7),time:now,distance:minD});
renderRecords();
}
}else{
card.className='result-card unmatched';
card.innerHTML=<div class="r-name">未识别</div><div class="r-info">未在系统中找到匹配人员</div><div class="r-meta">最小距离:${minD.toFixed(3)}(阈值:0.55)</div>;
}
},600);
}
function stopRec(){if(recTimer){clearInterval(recTimer);recTimer=null;}document.getElementById('btnStartRec').disabled=false;document.getElementById('btnStopRec').disabled=true;document.getElementById('recCanvas').getContext('2d').clearRect(0,0,9999,9999);}
// ========== 列表 ==========
async function renderPersons(){
const list=await dbGetAll('persons'),c=document.getElementById('personsList');
if(!list.length){c.innerHTML='<div class="empty"><div class="icon">👤</div><p>暂无已录入人员</p></div>';return;}
c.innerHTML=list.map(p=><div class="list-card"><img class="avatar" src="${p.photo}" alt="${p.name}"><div class="info"><div class="name">${p.name}</div><div class="meta">${p.className} ·{fmtTime(p.createTime)}</div></div><button class="del-btn" onclick="delPerson(${p.id})">删除</button></div>).join('');
}
async function renderRecords(){
const list=await dbGetAll('records'),c=document.getElementById('recordsList');
if(!list.length){c.innerHTML='<div class="empty"><div class="icon">📋</div><p>暂无通行记录</p></div>';return;}
list.sort((a,b)=>b.time-a.time);
c.innerHTML=list.map(r=><div class="list-card"><img class="avatar" src="${r.photo}" alt="${r.personName}"><div class="info"><div class="name">${r.personName}(${r.className} ·{fmtTime(r.time)} · 匹配距离:${r.distance.toFixed(3)}</div></div><button class="del-btn" onclick="delRecord(${r.id})">删除</button></div>).join('');
}
async function delPerson(id){if(confirm('确定删除该人员?')){await dbDel('persons',id);renderPersons();showToast('已删除');}}
async function delRecord(id){await dbDel('records',id);renderRecords();}
async function clearRecs(){if(confirm('确定清空全部通行记录?')){await dbClear('records');renderRecords();showToast('已清空');}}
// ========== 页面切换 ==========
document.querySelectorAll('.nav-item').forEach(item=>{
item.addEventListener('click',async()=>{
const pg=item.dataset.page;
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
item.classList.add('active');
document.getElementById('page-'+pg).classList.add('active');
stopCam('register');stopCam('recognize');stopRec();
if(pg==='register')await startCam('register',curCamId);
else if(pg==='recognize')await startCam('recognize',curCamId);
else if(pg==='persons')renderPersons();
else if(pg==='records')renderRecords();
});
});
document.getElementById('camSelect').addEventListener('change',function(){switchCam(this.value);});
document.getElementById('btnRegister').addEventListener('click',handleRegister);
document.getElementById('btnStartRec').addEventListener('click',startRec);
document.getElementById('btnStopRec').addEventListener('click',stopRec);
document.getElementById('btnClearRec').addEventListener('click',clearRecs);
// ========== 启动 ==========
(async()=>{await openDB();await loadModels();if(modelReady)await startCam('register');})();
</script>
</body>
</html>增加通
行增加通
行记录导出功能和班级可配置功能记录导出功能和班级可配置功能