/* ═════ 巨子测肤 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.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