鼠小弟生活志 鼠小弟生活志
深圳 --°

2026-08-20家校安全信息管理系统开发记录

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">南京市第三幼儿园 · 人脸识别管理系统 &copy; 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>

增加通

行增加通

行记录导出功能和班级可配置功能记录导出功能和班级可配置功能