/* ═════ 巨子测肤 H5 — 交互逻辑 ═════ */ (function () { 'use strict'; var $ = function (sel, root) { return (root || document).querySelector(sel); }; /* ─── Toast ─── */ var toastEl = $('#toast'), toastTimer = null; function toast(msg) { toastEl.textContent = msg; toastEl.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, 1800); } /* ─── 屏幕管理 ─── */ var currentScreen = 's-splash'; function showScreen(id) { document.querySelectorAll('.screen').forEach(function (s) { s.classList.toggle('active', s.id === id); }); var sc = document.getElementById(id); sc.scrollTop = 0; currentScreen = id; if (id === 's-report') resetReport(); // 每次进入报告页都还原初始状态 } /* ─── 报告页状态重置(Tab 回到「紧塑力」、产品横滑归零) ─── */ function resetReport() { document.querySelectorAll('.dtab').forEach(function (t, k) { t.classList.toggle('active', k === 0); }); document.querySelectorAll('.dpanel').forEach(function (p, k) { p.classList.toggle('active', k === 0); }); var ps = $('#prod-scroll'); if (ps) ps.scrollLeft = 0; } /* ─── 弹窗管理 ─── */ function openOverlay(id) { $('#' + id).classList.add('show'); } function closeOverlay(id) { $('#' + id).classList.remove('show'); } // 点遮罩空白处关闭(与各自关闭按钮同行为:隐私→回启动页、拍照引导→留在档案页) // e.target === overlay 确保只响应遮罩本身,不误伤弹窗卡片内交互 function bindBackdropClose(id) { var ov = $('#' + id); ov.addEventListener('click', function (e) { if (e.target === ov) closeOverlay(id); }); } bindBackdropClose('p-privacy'); bindBackdropClose('p-photoguide'); bindBackdropClose('p-policy'); // ESC 关闭(仅这三层;分析中过渡不响应;协议层最顶,优先关) document.addEventListener('keydown', function (e) { if (e.key !== 'Escape' && e.key !== 'Esc') return; if ($('#p-policy').classList.contains('show')) closeOverlay('p-policy'); else if ($('#p-privacy').classList.contains('show')) closeOverlay('p-privacy'); else if ($('#p-photoguide').classList.contains('show')) closeOverlay('p-photoguide'); }); // 分析中弹层点遮罩取消:作废未决分析回调(mock 路径同样经 seq 丢弃)、关弹层、停相机、回档案页 $('#p-analyzing').addEventListener('click', function (e) { if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片 camSeq++; closeOverlay('p-analyzing'); stopCamera(); shutterLock = false; // 流程终止解锁 showScreen('s-profile'); }); /* ─── 肤质轮播 ─── */ var SKINS = [ { img: 'assets/img/skin-0-dry.png', name: '干性肌肤', desc: '全脸干燥,容易紧绷、缺乏光泽' }, { img: 'assets/img/skin-1-mixeddry.png', name: '混合偏干肌肤', desc: '两颊偏干,T区油脂较少或偶尔出油' }, { img: 'assets/img/skin-2-neutral.png', name: '中性肌肤', desc: '水油平衡,肤感舒适,无明显干燥出油' }, { img: 'assets/img/skin-3-oily.png', name: '油性肌肤', desc: '全脸易出油,容易泛油光、毛孔明显' }, { img: 'assets/img/skin-4-mixedoily.png', name: '混合偏油肌肤', desc: 'T区易出油,两颊相对清爽或轻微干燥' } ]; var skinIndex = 0; var track = $('#skin-track'), dotsBox = $('#skin-dots'); SKINS.forEach(function (s, i) { var item = document.createElement('div'); item.className = 'skin-item'; item.innerHTML = '' + s.name + '' + '' + s.name + '' + '

' + s.desc + '

'; track.appendChild(item); var dot = document.createElement('i'); if (i === 0) dot.className = 'on'; dot.addEventListener('click', function () { goSkin(i); }); dotsBox.appendChild(dot); }); function goSkin(i) { skinIndex = Math.max(0, Math.min(SKINS.length - 1, i)); track.style.transform = 'translateX(-' + skinIndex * 100 + '%)'; dotsBox.querySelectorAll('i').forEach(function (d, k) { d.classList.toggle('on', k === skinIndex); }); } // 拖拽切换(Pointer Events 统一鼠标 + 触摸) var drag = { id: null, startX: 0, startY: 0, horizontal: false, suppress: false }; var carousel = $('#skin-carousel'); carousel.addEventListener('pointerdown', function (e) { if (e.cancelable) e.preventDefault(); // 阻止原生图片拖拽/文字选中引发 pointercancel(click 仍会正常触发) try { carousel.setPointerCapture(e.pointerId); } catch (err) { /* 非活动指针时忽略,退化为普通监听 */ } // 捕获指针:斜拖出卡片、卡外松手也能收到 pointerup drag.id = e.pointerId; drag.startX = e.clientX; drag.startY = e.clientY; drag.horizontal = false; drag.suppress = false; }); carousel.addEventListener('pointermove', function (e) { if (e.pointerId !== drag.id) return; var dx = e.clientX - drag.startX, dy = e.clientY - drag.startY; // 横向位移明显大于纵向时才判定为拖拽,避免吞掉纵向滚动 if (!drag.horizontal && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) drag.horizontal = true; if (drag.horizontal && e.cancelable) e.preventDefault(); }); carousel.addEventListener('pointerup', function (e) { if (e.pointerId !== drag.id) return; var dx = e.clientX - drag.startX; if (drag.horizontal) { if (Math.abs(dx) > 30) goSkin(skinIndex + (dx < 0 ? 1 : -1)); drag.suppress = true; // 拖拽后吞掉紧随的 click,防止点击穿透 } drag.id = null; drag.horizontal = false; }); carousel.addEventListener('pointercancel', function (e) { if (e.pointerId !== drag.id) return; // 其它指针的 cancel 不打断当前拖拽 drag.id = null; // 拖拽被系统取消(滚轮/新指针等):干净结束,不残留状态 drag.horizontal = false; }); carousel.addEventListener('click', function (e) { if (drag.suppress) { e.preventDefault(); e.stopPropagation(); drag.suppress = false; } }, true); /* ─── 地区选项 ─── */ var REGIONS = ['北京', '上海', '广州', '深圳', '杭州', '成都', '重庆', '武汉', '西安', '南京', '天津', '苏州', '长沙', '郑州', '青岛', '其他']; var regionSel = $('#f-region'); REGIONS.forEach(function (r) { var o = document.createElement('option'); o.textContent = r; regionSel.appendChild(o); }); /* ─── 实时相机(getUserMedia)+ 系统相机降级 ─── */ var IS_MOCK = /[?&]mock=1/.test(location.search); // mock:不加载 face-api、不跑检测,analyze 直返内置样本 var camVideo = $('#cam-video'), camFile = $('#cam-file'); var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头 var photos = []; // 三连拍(dataURL),与 CAM_STEPS 同序:[0]正脸 [1]右脸(头左转) [2]左脸(头右转) // 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级 function getCamStream() { return navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'user' }, width: { ideal: 1280 }, height: { ideal: 1280 } }, audio: false }).catch(function (err) { if (err && err.name === 'OverconstrainedError') { return navigator.mediaDevices.getUserMedia({ video: true, audio: false }); } throw err; }); } function startCamera() { if (camStream) { stopCamera(); } if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; } camSeqCam++; var token = camSeqCam; getCamStream().then(function (stream) { if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放 camStream = stream; camVideo.srcObject = stream; camLive = true; // iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连 stream.getTracks().forEach(function (t) { t.onended = function () { onCamTrackEnded(token); }; }); $('#s-cam').classList.add('cam-live'); beginDetect(); // 确认相机可用后才加载检测模型(无相机权限设备不白加载) maybeStartDetect(); // 模型已就绪时即刻开跑检测循环 }).catch(function () { if (token !== camSeqCam) return; enableFallback(); }); } // track 被系统结束(iOS 打断)且页面仍在拍照前台:toast 后自动重连,重连失败走 startCamera 内的 enableFallback function onCamTrackEnded(token) { if (token !== camSeqCam || !camLive) return; // 旧流/已主动停止:忽略 if (document.hidden || currentScreen !== 's-cam') return; // 后台恢复由 visibilitychange 流程接管 camLive = false; // 多个 track 同时 ended 只触发一次重连 toast('摄像头连接中断,正在重连'); startCamera(); } function stopCamera() { camSeqCam++; // 使进行中的 getUserMedia 回调失效 stopDetect(); // 检测循环/倒计时/无进展提示一并收尾 $('#cam-status').classList.remove('show'); if (camStream) { camStream.getTracks().forEach(function (t) { t.stop(); }); camStream = null; } camVideo.srcObject = null; camLive = false; $('#s-cam').classList.remove('cam-live'); } function enableFallback() { camFallback = true; $('#cam-status').classList.remove('show'); // 与 stopCamera 对齐:去掉「检测引擎加载中」胶囊 resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框 toast('已切换为系统相机拍摄'); } // 从视频帧截取照片(镜像绘制,与预览一致;压到最长边 1280 的 JPEG) function captureFromVideo() { var vw = camVideo.videoWidth, vh = camVideo.videoHeight; if (!vw || !vh) return null; var scale = Math.min(1, 1280 / Math.max(vw, vh)); var cw = Math.round(vw * scale), ch = Math.round(vh * scale); var canvas = document.createElement('canvas'); canvas.width = cw; canvas.height = ch; var ctx = canvas.getContext('2d'); ctx.translate(cw, 0); ctx.scale(-1, 1); // 镜像 ctx.drawImage(camVideo, 0, 0, cw, ch); return canvas.toDataURL('image/jpeg', 0.88); } // 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程 camFile.addEventListener('change', function () { shutterLock = false; // 选完(含空选择)即解锁;推进窗口由 onPhotoCaptured 重新上锁 var f = camFile.files && camFile.files[0]; camFile.value = ''; if (!f) return; var reader = new FileReader(); reader.onload = function () { // 统一压成最长边 1280 的 JPEG var img = new Image(); img.onload = function () { var scale = Math.min(1, 1280 / Math.max(img.width, img.height)); var c = document.createElement('canvas'); c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale); c.getContext('2d').drawImage(img, 0, 0, c.width, c.height); onPhotoCaptured(c.toDataURL('image/jpeg', 0.88)); }; img.src = reader.result; }; reader.readAsDataURL(f); }); // 部分系统取消相机/相册只发 cancel 不发 change:兜底解锁,防快门卡死 camFile.addEventListener('cancel', function () { shutterLock = false; }); /* ─── 姿态检测(face-api + HeadPose,引导式自动三连拍) ─── */ var MODEL_URL = 'vendor/faceapi/models'; var DETECT_INTERVAL = 150; // 检测帧间隔(ms) var OK_HOLD_MS = 600; // 姿态 ok 需连续维持时长,达标才进倒计时 var COUNTDOWN_S = 3; // 倒计时秒数 var NO_PROGRESS_MS = 12000; // 单步超过该时长未触发倒计时→浮出手动提示 var FACE_NEAR = 0.35, FACE_FAR = 0.8; // 人脸框高度占画面比:<35% 太远,>80% 太近 var modelsPromise = null, modelsOk = false, detectDisabled = false; // detectDisabled:face-api 缺失→全程手动;模型加载失败仅当次禁用,下次进拍照页重试 var detRunning = false, detTimer = null; // 检测循环 var okSince = 0; // 当前连续 ok 起点(0=未在累积) var counting = false, cdTimer = null, cdN = 0; // 倒计时 var nudgeTimer = null; // 12s 无进展提示 var capturing = false; // 白屏补光抓帧中(暂停姿态判定) var lastHint = ''; // 提示文案去重 // 模型只加载一次(缓存 promise);失败清空缓存,下次进拍照页重试 function loadModels() { if (!modelsPromise) { modelsPromise = Promise.all([ faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL), faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL) ]).then(function () { modelsOk = true; }, function (err) { modelsPromise = null; throw err; }); } return modelsPromise; } function setMask(cls) { // '' 静态白框 | 'no-face' 呼吸动画 | 'ok' 品牌绿定格 var m = $('#cam-mask'); m.classList.toggle('no-face', cls === 'no-face'); m.classList.toggle('ok', cls === 'ok'); } function setHint(text, ok) { var h = $('#cam-hint'); if (!text) { h.classList.remove('show', 'ok'); lastHint = ''; return; } if (text !== lastHint) h.textContent = text; // 同文案不重复触发布局 h.classList.add('show'); h.classList.toggle('ok', !!ok); lastHint = text; } function hideNudge() { $('#cam-nudge').classList.remove('show'); } // 每步进入/回退时的检测状态机重置(蒙版/提示/倒计时/无进展计时全部归位) function resetDetectState() { okSince = 0; lastHint = ''; cancelCountdown(); clearTimeout(nudgeTimer); hideNudge(); setHint(''); setMask(camLive && !IS_MOCK && !detectDisabled && !camFallback ? 'no-face' : ''); armNudge(); } // 单步 12s 未触发倒计时→底部浮出轻提示(不阻塞流程);进入下一步/倒计时/抓帧时重置 function armNudge() { clearTimeout(nudgeTimer); hideNudge(); if (IS_MOCK || detectDisabled || camFallback) return; // 手动模式无需提示 if (currentScreen !== 's-cam') return; nudgeTimer = setTimeout(function () { if (currentScreen === 's-cam' && !counting && !shutterLock) $('#cam-nudge').classList.add('show'); }, NO_PROGRESS_MS); } function startCountdown() { counting = true; clearTimeout(nudgeTimer); hideNudge(); cdN = COUNTDOWN_S; var c = $('#cam-count'); c.textContent = cdN; c.classList.add('show'); setMask('ok'); // 轮廓保持绿色 setHint('保持不动', true); cdTimer = setTimeout(cdTick, 1000); } function cdTick() { cdN--; if (cdN <= 0) { counting = false; $('#cam-count').classList.remove('show'); doCapture(); // 倒计时结束:白屏补光抓帧(与手动快门同路径) return; } $('#cam-count').textContent = cdN; cdTimer = setTimeout(cdTick, 1000); } function cancelCountdown() { counting = false; clearTimeout(cdTimer); $('#cam-count').classList.remove('show'); } function maybeStartDetect() { if (detRunning || IS_MOCK || detectDisabled || !modelsOk || !camLive || currentScreen !== 's-cam') return; detRunning = true; detTick(); } function stopDetect() { detRunning = false; clearTimeout(detTimer); cancelCountdown(); clearTimeout(nudgeTimer); hideNudge(); } // 递归 setTimeout 而非 setInterval:检测是异步的,避免帧与帧堆叠 function detTick() { if (!detRunning) return; detTimer = setTimeout(function () { if (!detRunning) return; detectFrame().then(detTick, detTick); }, DETECT_INTERVAL); } function detectFrame() { if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态 var step = camStep, seq = camSeq; // 快照:检测是异步的,帧落地时步骤/流程可能已变 return faceapi .detectSingleFace(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: 320, scoreThreshold: 0.4 })) .withFaceLandmarks() .then(function (d) { if (step !== camStep || seq !== camSeq) return; // 跨步骤陈帧/流程已重置:丢弃 if (detRunning && !shutterLock && !capturing) onDetect(d); }) .catch(function () { /* 单帧异常忽略,下一帧继续 */ }); } function onDetect(d) { var hint = '', ok = false, hasFace = !!d; if (!hasFace) { hint = '未检测到人脸'; } else { // 提示优先级:无人脸 > 距离 > 角度 var ratio = d.detection.box.height / (camVideo.videoHeight || 1); if (ratio < FACE_NEAR) hint = '请靠近一点'; else if (ratio > FACE_FAR) hint = '请远一点'; else { // 前置预览为镜像画面 → mirrored=true;距离不合格时角度不判 ok var pose = HeadPose.check(d.landmarks.positions, CAM_STEPS[camStep].target, true); hint = pose.hint; ok = pose.ok; } } if (counting && !ok) cancelCountdown(); // 倒计时中任一帧不 ok→立即取消,回到对应提示 setMask(counting || ok ? 'ok' : (hasFace ? '' : 'no-face')); setHint(counting ? '保持不动' : hint, counting || ok); if (counting) return; if (ok) { if (!okSince) okSince = Date.now(); else if (Date.now() - okSince >= OK_HOLD_MS) startCountdown(); } else { okSince = 0; } } /* ─── 拍照流程(三步:正脸 → 右脸出镜 → 左脸出镜) ─── */ // target 即 HeadPose.check 的 target:2/3 右脸出镜=用户头向左转(target 'right',产物作 right_image); // 3/3 左脸出镜=头向右转(target 'left',产物作 left_image)。 var CAM_STEPS = [ { bg: 'assets/img/cam-front.jpg', step: '1/3
请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front' }, { bg: 'assets/img/cam-left.jpg', step: '2/3
请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right' }, { bg: 'assets/img/cam-right.jpg', step: '3/3
请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left' } ]; var camStep = 0; function renderCamStep() { var cfg = CAM_STEPS[camStep]; $('#cam-bg').style.backgroundImage = 'url(' + cfg.bg + ')'; $('#cam-step').innerHTML = cfg.step; $('#cam-guide').innerHTML = cfg.guides .map(function (g) { return '' + g + ''; }) .join(''); // 步骤圆点:当前步高亮、完成步绿色打勾 var dots = $('#cam-dots').children; for (var i = 0; i < dots.length; i++) { dots[i].classList.toggle('done', i < camStep); dots[i].classList.toggle('on', i === camStep); } $('#cam-mask').setAttribute('data-step', camStep); // 蒙版按步骤切换 resetDetectState(); } var camSeq = 0; // 流程令牌:cam-back/开始拍照时递增,使未决的快门/分析回调失效 var shutterLock = false; // 快门防连点锁:流程进行中直接忽略 // 照片就绪后推进流程(实时抓帧与降级选图共用):存图→步骤点变绿→300ms 过渡→下一步 function onPhotoCaptured(dataUrl) { shutterLock = true; // 推进窗口上锁:降级模式 change 里已解锁,这里重新锁住直到下一步/送检 photos[camStep] = dataUrl; $('#cam-dots').children[camStep].classList.add('done'); var seq = camSeq; setTimeout(function () { if (seq !== camSeq) return; // 期间已 cam-back/重新开始:放弃本次推进 if (camStep < CAM_STEPS.length - 1) { camStep++; renderCamStep(); // 内含检测状态机重置 shutterLock = false; // 进入下一步即解锁 } else { submitPhotos(seq); // 三张拍完:送检 } }, 300); } // 白屏补光抓拍:全屏纯白瞬间点亮→约300ms 后抓全分辨率帧→保持300ms 再淡出(总 600–900ms) function doCapture() { if (shutterLock) return; if (!camLive) return; shutterLock = true; capturing = true; cancelCountdown(); clearTimeout(nudgeTimer); hideNudge(); var seq = camSeq; var fill = $('#cam-fill'); fill.classList.add('on'); // .on 无过渡,瞬间点亮 if (navigator.vibrate) navigator.vibrate(30); setTimeout(function () { var shot = captureFromVideo(); setTimeout(function () { fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出 capturing = false; if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出) if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即重启倒计时 onPhotoCaptured(shot); }, 300); }, 300); } // 三张拍完:三视图送检(正脸=photos[0],左=photos[2],右=photos[1]) function submitPhotos(seq) { camStep = 0; openOverlay('p-analyzing'); var t0 = Date.now(); requestAnalyze(photos, seq, function (errMsg, payload) { if (seq !== camSeq) return; // 流程已被重置:丢弃 var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁 setTimeout(function () { if (seq !== camSeq) return; closeOverlay('p-analyzing'); stopCamera(); shutterLock = false; // 流程结束解锁 if (errMsg) { toast(errMsg); showScreen('s-profile'); // 回档案页,允许重新提交再拍 return; } var vm = null; try { vm = SkinScore.buildViewModel(payload.result, payload.side, { realAge: +($('#f-age').value || 0) || null, skinType: SKINS[skinIndex].name }); } catch (e) { vm = null; } if (!vm) { toast('报告生成失败,请重新拍摄'); showScreen('s-profile'); return; } renderReport(vm, photos[0], photos[1]); // img1=正脸实拍,img2=右脸实拍 photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存 showScreen('s-report'); }, wait); }); } function shutter() { if (shutterLock) return; if (camLive) { if (!camVideo.videoWidth) { toast('相机尚未就绪,请稍候'); return; } doCapture(); // 手动兜底:任何时刻可拍当前步,同样走白屏补光,不依赖检测 } else if (camFallback) { shutterLock = true; // 前置上锁防连点:change/cancel 回调里解锁 camFile.click(); // 降级:调起系统相机,回调里推进 } else { // 相机还在启动中 toast('相机启动中,请稍候'); } } /* ─── 分析请求(mock=1 直返 SkinScore 内置样本,便于离线/自动化验证) ─── */ var ANALYZE_TIMEOUT_MS = 45000; // 检测服务超时阈值:超时按「开小差」处理回档案页 function requestAnalyze(ph, seq, done) { if (IS_MOCK && typeof SkinScore !== 'undefined') { setTimeout(function () { if (seq !== camSeq) return; // mock 同样尊重取消/重置 done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE }); }, 900); return; } var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null; var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, ANALYZE_TIMEOUT_MS); fetch('/api/skin-analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ front_image: ph[0], left_image: ph[2], right_image: ph[1] }), signal: ctrl ? ctrl.signal : undefined // 老内核无 AbortController:仅靠令牌丢弃迟到响应 }).then(function (res) { return res.json(); }) .then(function (data) { clearTimeout(timer); if (seq !== camSeq) return; // 流程已被重置,丢弃过期响应 if (!data.ok) done(data.message || '检测失败,请重试'); else done(null, { result: data.result, side: data.side }); }) .catch(function (err) { clearTimeout(timer); if (seq !== camSeq) return; if (err && err.name === 'AbortError') done('检测服务开小差了,请稍候重试'); // 45s 超时主动中断 else done('网络异常,请检查网络后重试'); }); } /* ─── SkinScore viewModel → 报告渲染(评分公式见 js/skin-score.js,不直接向用户展示 P 值) ─── */ var LV_CLASS = { '良好': 'lv-good', '改善': 'lv-warn', '预警': 'lv-bad' }; var CAT_DOM = { contour: { s: '#s-contour', lv: '#lv-contour', tx: '#ditem-para1' }, wrinkles: { s: '#s-wrinkles', lv: '#lv-wrinkles', tx: '#ditem-para2' }, smooth: { s: '#s-smooth', lv: '#lv-smooth', tx: '#tx-smooth' }, tone: { s: '#s-tone', lv: '#lv-tone', tx: '#tx-tone' }, sens: { s: '#s-sens', lv: '#lv-sens', tx: '#tx-sens' }, acne: { s: '#s-acne', lv: '#lv-acne', tx: '#tx-acne' } }; function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>'); } function setDim(scoreSel, lvSel, dim) { var el = $(scoreSel), lv = $(lvSel); if (dim.S == null) { el.textContent = '-.-'; lv.textContent = '未检出'; lv.className = 'dim-lv'; return; } el.textContent = dim.S.toFixed(1); lv.textContent = dim.level; lv.className = 'dim-lv ' + (LV_CLASS[dim.level] || ''); } function setCat(key, cat) { var dom = CAT_DOM[key]; var s = $(dom.s), lv = $(dom.lv); if (cat.P == null) { // 缺数据分类:显示说明,不显示分数 s.textContent = '本次未检出有效数据'; s.className = 'cat-s cat-na'; lv.style.display = 'none'; } else { s.textContent = cat.S.toFixed(1); s.className = 'cat-s'; lv.style.display = ''; lv.textContent = cat.level; lv.className = 'cat-badge ' + (LV_CLASS[cat.level] || ''); } $(dom.tx).textContent = cat.text; } function renderReport(vm, frontPhoto, sidePhoto) { // 头部:综合评分(vm.total 缺失时回退三维度 S 均分×10) var total = vm.total; if (total == null) { var ds = [vm.dims.firm.S, vm.dims.vita.S, vm.dims.stab.S], sum = 0, n = 0, i; for (i = 0; i < 3; i++) if (typeof ds[i] === 'number') { sum += ds[i]; n++; } if (n) total = Math.round(sum / n * 10); } $('#stat-score').textContent = total != null ? total + '分' : '--分'; $('#stat-age').textContent = vm.skinAgeDisplay != null ? vm.skinAgeDisplay + '岁' : '--岁'; $('#stat-age-note').textContent = vm.ageClampNote || ''; // 肤龄钳制留痕(小字灰) // 三维度:S 值(1 位小数) + 档位(良好绿/改善琥珀/预警红) setDim('#dim-firm', '#dim-firm-lv', vm.dims.firm); setDim('#dim-vita', '#dim-vita-lv', vm.dims.vita); setDim('#dim-stab', '#dim-stab-lv', vm.dims.stab); $('#dims-note').innerHTML = vm.focus.name ? '近期优先关注:' + esc(vm.focus.name) + '
' + esc(vm.focus.tip) : '近期优先关注:
---'; // 三个 Tab 面板:各所辖两个分类(S + 档位徽章 + 话术正文) var keys = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne']; for (var i = 0; i < keys.length; i++) setCat(keys[i], vm.cats[keys[i]]); if (frontPhoto) $('#ditem-img1').src = frontPhoto; if (sidePhoto) $('#ditem-img2').src = sidePhoto; // 降级标注(下颌线未参与评分等,非空才展示) var notesEl = $('#vm-notes'); if (vm.notes && vm.notes.length) { notesEl.innerHTML = vm.notes.map(function (t) { return '*' + esc(t); }).join('
'); notesEl.style.display = ''; } else { notesEl.style.display = 'none'; } // 综合文案 / 生活方式 / 精华推荐 $('#vm-summary').textContent = vm.summary; $('#vm-lifestyle').textContent = vm.lifestyle; $('#essence-name').textContent = vm.essence.name; $('#essence-text').textContent = vm.essence.text; $('#essence-triggers').textContent = vm.essence.triggers.length ? '触发:' + vm.essence.triggers.join('、') : ''; } /* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */ function beginDetect() { if (IS_MOCK || camFallback) { resetDetectState(); return; } if (typeof faceapi === 'undefined') { detectDisabled = true; // face-api 本身缺失:全程手动,不再重试 resetDetectState(); toast('自动识别不可用,已切换手动拍摄'); return; } detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取) resetDetectState(); if (!modelsOk) $('#cam-status').classList.add('show'); // “检测引擎加载中…” loadModels().then(function () { $('#cam-status').classList.remove('show'); maybeStartDetect(); }).catch(function () { $('#cam-status').classList.remove('show'); detectDisabled = true; // 仅禁用当次;下次进拍照页 beginDetect 复位后重试 resetDetectState(); if (currentScreen === 's-cam') toast('自动识别不可用,已切换手动拍摄'); }); } /* ─── 隐私勾选 ─── */ var agreed = false; $('#privacy-row').addEventListener('click', function (e) { if (e.target.closest('#privacy-policy-link')) return; // 点协议链接:只开弹层,不切换勾选 e.preventDefault(); agreed = !agreed; $('#privacy-check').classList.toggle('checked', agreed); }); /* ─── 隐私协议占位弹层(QA P3-2) ─── */ $('#privacy-policy-link').addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); // 阻止冒泡到勾选行,避免误切换 openOverlay('p-policy'); }); /* ─── 报告页 Tab ─── */ $('#detail-tabs').addEventListener('click', function (e) { var btn = e.target.closest('.dtab'); if (!btn) return; var idx = btn.dataset.tab; document.querySelectorAll('.dtab').forEach(function (t) { t.classList.toggle('active', t === btn); }); document.querySelectorAll('.dpanel').forEach(function (p) { p.classList.toggle('active', p.dataset.panel === idx); }); }); /* ─── 动作分发 ─── */ var actions = { 'open-privacy': function () { openOverlay('p-privacy'); }, 'close-privacy': function () { closeOverlay('p-privacy'); }, 'close-policy': function () { closeOverlay('p-policy'); }, 'agree-continue': function () { if (!agreed) { toast('请先阅读并同意隐私协议'); return; } closeOverlay('p-privacy'); showScreen('s-profile'); }, 'back-splash': function () { showScreen('s-splash'); }, 'submit-profile': function () { var age = $('#f-age').value.trim(); if (!age || !/^\d{1,2}$/.test(age) || +age < 1 || +age > 99) { toast('请输入有效年龄'); return; } if (!$('#f-gender').value) { toast('请选择性别'); return; } if (!regionSel.value) { toast('请选择所在地区'); return; } openOverlay('p-photoguide'); }, 'close-photoguide': function () { closeOverlay('p-photoguide'); }, 'start-camera': function () { closeOverlay('p-photoguide'); camSeq++; // 使旧的快门/分析回调失效 camStep = 0; photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍) shutterLock = false; // 重新进入流程,保险解锁 renderCamStep(); showScreen('s-cam'); startCamera(); // beginDetect 在其成功回调里:确认相机可用才加载模型 }, 'cam-back': function () { camSeq++; // 使未决的快门/分析回调失效,避免回退被旧定时器顶回 shutterLock = false; // 中途退出也解锁 if (camStep > 0) { camStep--; renderCamStep(); } // renderCamStep 内含检测状态机重置 else { stopCamera(); showScreen('s-profile'); } }, 'shutter': shutter, 'album': function () { toast('相册功能开发中'); }, 'flip': function () { toast('切换镜头功能开发中'); }, 'back-profile': function () { showScreen('s-profile'); }, 'more': function () { toast('了解更多功能开发中'); } }; document.addEventListener('click', function (e) { var el = e.target.closest('[data-action]'); if (!el) return; var fn = actions[el.dataset.action]; if (fn) fn(); }); /* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 photos[] 与 camStep) ─── */ function handleCamHidden() { if (currentScreen !== 's-cam') return; camSeq++; // 使未决的快门/推进/分析回调失效(白屏定时器醒来即自洁退出) shutterLock = false; capturing = false; $('#cam-fill').classList.remove('on'); // 白屏若亮着立即熄灭 closeOverlay('p-analyzing'); // 分析中被打断:回调已作废,弹层不能残留 stopCamera(); // 停检测循环/倒计时/nudge 并释放摄像头(camSeqCam++ 令迟到授权回调失效) } function handleCamVisible() { if (currentScreen !== 's-cam') return; camSeq++; // 双保险:隐藏期间残留的迟到回调一并作废 renderCamStep(); // 重置当前步检测状态机,从当前步继续 if (!camFallback) startCamera(); // 降级模式无流可起,仅重置状态机;重走 camSeqCam 令牌防复活 } document.addEventListener('visibilitychange', function () { if (document.hidden) handleCamHidden(); else handleCamVisible(); }); window.addEventListener('pagehide', handleCamHidden); window.addEventListener('pageshow', function (e) { if (e.persisted) handleCamVisible(); // bfcache 恢复(不一定伴随 visibilitychange) }); /* ─── 初始化 ─── */ renderCamStep(); // 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口) if (IS_MOCK && /[?&]screen=report/.test(location.search) && typeof SkinScore !== 'undefined') { showScreen('s-report'); try { renderReport(SkinScore.buildViewModel(SkinScore.MOCK_RESULT, SkinScore.MOCK_SIDE, { realAge: +($('#f-age').value || 0) || 28, // 调试档案缺省 28 岁 skinType: SKINS[skinIndex].name }), null, null); } catch (e) { /* 调试入口异常不阻塞正式流程 */ } } })();