/* ═════ 巨子测肤 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'); }); /* ─── 肤质轮播 ─── */ 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 camVideo = $('#cam-video'), camFile = $('#cam-file'); var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头 var photos = []; // 三连拍的照片(dataURL),photos[0] 为正面照,送检用 function startCamera() { photos = []; if (camStream) { stopCamera(); } if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; } camSeqCam++; var token = camSeqCam; navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 1280 } }, audio: false }).then(function (stream) { if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放 camStream = stream; camVideo.srcObject = stream; camLive = true; $('#s-cam').classList.add('cam-live'); }).catch(function () { if (token !== camSeqCam) return; enableFallback(); }); } function stopCamera() { camSeqCam++; // 使进行中的 getUserMedia 回调失效 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; 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 () { 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); }); /* ─── Face++ 结果 → 设计稿报告数据(映射层) ─── */ function num(node, key) { if (node == null) return 0; if (key != null) return Number(node[key] && (node[key].value != null ? node[key].value : node[key])) || 0; return Number(node.value != null ? node.value : node) || 0; } function one(x) { return Math.round(x * 10) / 10; } function clamp(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); } function mapSkinResult(r) { var fw = num(r.forehead_wrinkle, 'value') === 1; var cf = num(r.crows_feet, 'value') === 1; var ef = num(r.eye_finelines, 'value') === 1; var gw = num(r.glabella_wrinkle, 'value') === 1; var nf = num(r.nasolabial_fold, 'value') === 1, nfSev = num(r.nasolabial_fold_severity, 'value'); var ep = num(r.eye_pouch, 'value') === 1, epSev = num(r.eye_pouch_severity, 'value'); var dc = num(r.dark_circle, 'value'); var bh = num(r.blackhead, 'value'); var poreCount = ['pores_forehead', 'pores_left_cheek', 'pores_right_cheek', 'pores_jaw'] .filter(function (k) { return num(r[k], 'value') === 1; }).length; var acneN = (r.acne && r.acne.rectangle) ? r.acne.rectangle.length : 0; var ccN = (r.closed_comedones && r.closed_comedones.rectangle) ? r.closed_comedones.rectangle.length : 0; var spotN = (r.skin_spot && r.skin_spot.rectangle) ? r.skin_spot.rectangle.length : 0; var sens = r.sensitivity || {}; var sensArea = Number(sens.sensitivity_area) || 0; var sensInt = Number(sens.sensitivity_intensity) || 0; var age = num(r.skin_age, 'value'); // 紧塑力:法令纹 + 眼袋(松弛下垂类) var firm = 10 - (nf ? [0.9, 1.5, 2.2][clamp(nfSev, 0, 2)] : 0) - (ep ? [0.7, 1.2, 1.8][clamp(epSev, 0, 2)] : 0); // 活力感:黑眼圈 + 眼部细纹/鱼尾纹/抬头纹/眉间纹(倦怠神采类) var vita = 10 - (dc > 0 ? (dc === 2 ? 1.3 : 1.0) : 0) - (ef ? 0.6 : 0) - (cf ? 0.5 : 0) - (fw ? 0.4 : 0) - (gw ? 0.3 : 0); // 稳定力:敏感泛红 + 痘痘/闭口/毛孔/黑头(屏障稳定类) var stab = 10 - (sensArea * 2.5 + sensInt / 100 * 2.0) - Math.min(acneN, 5) * 0.5 - Math.min(ccN, 5) * 0.35 - poreCount * 0.4 - bh * 0.3; firm = one(clamp(firm, 4, 9.8)); vita = one(clamp(vita, 4, 9.8)); stab = one(clamp(stab, 3.5, 9.8)); var total = Math.round(clamp((firm * 0.35 + vita * 0.33 + stab * 0.32) * 10, 40, 98)); var dims = [ { name: '紧塑力', score: firm, tip: '轮廓支撑与紧致度,护肤配合由下向上的提拉手法' }, { name: '活力感', score: vita, tip: '神采与倦怠感,规律作息改善眼周循环' }, { name: '稳定力', score: stab, tip: '强韧稳态力,锁水保湿抵御刺激' } ].sort(function (a, b) { return a.score - b.score; }); // 具体描述两行文案 var sevName = ['轻度', '中度', '重度']; var para1; if (!nf && !ep) { para1 = '面部轮廓饱满舒展,下颌线清晰立体,中面部容积充足,肌肤回弹速度快;这种紧致状态,通常和内在胶原合成充足、整体作息节奏平稳直接相关。'; } else { var parts = []; if (nf) parts.push('观察到' + sevName[clamp(nfSev, 0, 2)] + '法令纹痕迹,面部支撑有松弛趋势'); if (ep) parts.push('眼部有' + sevName[clamp(epSev, 0, 2)] + '眼袋'); para1 = parts.join(',') + '。护肤时请避免用力向下拉扯皮肤,涂抹护肤品时配合由下向上的提拉手法,重点照顾下颌线与苹果肌位置。'; } var para2; var wrinkles = []; if (fw) wrinkles.push('抬头纹'); if (cf) wrinkles.push('鱼尾纹'); if (ef) wrinkles.push('眼部细纹'); if (gw) wrinkles.push('眉间纹'); if (nf) wrinkles.push('法令纹'); if (wrinkles.length === 0) { para2 = '面部各区域平整舒展,没有明显的纹路沟壑,只有做夸张表情时会出现极浅的动态纹路;纹路的多少,和内在胶原支撑力、日常面部肌肉活动习惯都有关系。'; } else { para2 = '当前观察到' + wrinkles.join('、') + '的痕迹;纹路的多少,和内在胶原支撑力、日常面部肌肉活动习惯都有关系,日常注意表情管理与防晒,有助于减缓纹路加深。'; } var found = []; if (spotN > 0) found.push('斑点' + spotN + '处'); if (acneN > 0) found.push('痘痘' + acneN + '处'); if (ccN > 0) found.push('闭口' + ccN + '处'); if (found.length) para2 += '(本次检测到' + found.join(',') + ')'; return { total: total, age: age, firm: firm, vita: vita, stab: stab, focusName: dims[0].name, focusTip: dims[0].tip, para1: para1, para2: para2, lowest: dims[0].score }; } function renderReport(m, frontPhoto, sidePhoto) { $('#stat-score').textContent = m.total + '分'; $('#stat-age').textContent = m.age + '岁'; var setDim = function (id, score) { var el = $(id); el.textContent = score.toFixed(1); el.classList.toggle('dim-bad', score < 6); }; setDim('#dim-firm', m.firm); setDim('#dim-vita', m.vita); setDim('#dim-stab', m.stab); $('#dims-note').innerHTML = '近期优先关注:' + m.focusName + '
' + m.focusTip; $('#ditem-para1').textContent = m.para1; $('#ditem-para2').textContent = m.para2; if (frontPhoto) $('#ditem-img1').src = frontPhoto; if (sidePhoto) $('#ditem-img2').src = sidePhoto; } /* ─── 分析请求(mock=1 用内置样本,便于离线/自动化验证) ─── */ var MOCK_RESULT = {"skin_color":{"value":2,"confidence":0.65},"skin_age":{"value":42},"left_eyelids":{"value":0,"confidence":0.99},"right_eyelids":{"value":0,"confidence":0.99},"eye_pouch":{"value":0,"confidence":0.75},"dark_circle":{"value":1,"confidence":0.63},"forehead_wrinkle":{"value":0,"confidence":0.76},"crows_feet":{"value":1,"confidence":0.76},"eye_finelines":{"value":1,"confidence":0.86},"glabella_wrinkle":{"value":0,"confidence":0.76},"nasolabial_fold":{"value":1,"confidence":0.80},"nasolabial_fold_severity":{"value":1,"confidence":0.30},"skin_type":{"skin_type":3},"pores_forehead":{"value":0,"confidence":0.82},"pores_left_cheek":{"value":0,"confidence":0.76},"pores_right_cheek":{"value":0,"confidence":0.95},"pores_jaw":{"value":0,"confidence":0.98},"blackhead":{"value":0,"confidence":0.99},"acne":{"rectangle":[],"confidence":[]},"closed_comedones":{"rectangle":[],"confidence":[]},"mole":{"rectangle":[],"confidence":[]},"skin_spot":{"rectangle":[{"left":356,"top":494,"width":11,"height":13}],"confidence":[0.50]},"sensitivity":{"sensitivity_area":0.26,"sensitivity_intensity":65.36}}; function requestAnalyze(imageDataUrl, seq, done) { if (/[?&]mock=1/.test(location.search)) { setTimeout(function () { done(null, MOCK_RESULT); }, 900); return; } fetch('/api/skin-analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image_base64: imageDataUrl }) }).then(function (res) { return res.json(); }) .then(function (data) { if (seq !== camSeq) return; // 流程已被重置,丢弃过期响应 if (!data.ok) done(data.message || '检测失败,请重试'); else done(null, data.result); }) .catch(function () { if (seq !== camSeq) return; done('网络异常,请检查网络后重试'); }); } /* ─── 拍照流程 ─── */ var CAM_STEPS = [ { bg: 'assets/img/cam-front.jpg', step: '1/3
请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'] }, { bg: 'assets/img/cam-left.jpg', step: '2/3
请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'] }, { bg: 'assets/img/cam-right.jpg', step: '3/3
请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'] } ]; 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 camSeq = 0; // 流程令牌:cam-back/开始拍照时递增,使未决的快门/分析回调失效 var shutterLock = false; // 快门防连点锁:流程进行中直接忽略 // 照片就绪后推进流程(实时截帧与降级选图共用) function onPhotoCaptured(dataUrl) { if (shutterLock && !camFallback) return; shutterLock = true; photos[camStep] = dataUrl; var flash = $('#cam-flash'); flash.classList.add('on'); setTimeout(function () { flash.classList.remove('on'); }, 220); var seq = camSeq; setTimeout(function () { if (seq !== camSeq) return; // 期间已 cam-back/重新开始:放弃本次推进 if (camStep < CAM_STEPS.length - 1) { camStep++; renderCamStep(); shutterLock = false; // 进入下一步即解锁 } else { // 三张拍完:送检正面照 renderCamStep(); camStep = 0; openOverlay('p-analyzing'); var t0 = Date.now(); requestAnalyze(photos[0], seq, function (errMsg, result) { 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(); if (errMsg) { toast(errMsg); showScreen('s-profile'); // 回档案页,允许重新提交再拍 shutterLock = false; return; } renderReport(mapSkinResult(result), photos[0], photos[1]); showScreen('s-report'); shutterLock = false; // 流程结束进入报告后解锁 }, wait); }); } }, 260); } function shutter() { if (shutterLock) return; if (camLive) { var shot = captureFromVideo(); if (!shot) { toast('相机尚未就绪,请稍候'); return; } onPhotoCaptured(shot); } else if (camFallback) { camFile.click(); // 降级:调起系统相机,回调里推进 } else { // 相机还在启动中 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; shutterLock = false; // 重新进入流程,保险解锁 renderCamStep(); showScreen('s-cam'); startCamera(); }, 'cam-back': function () { camSeq++; // 使未决的快门/分析回调失效,避免回退被旧定时器顶回 shutterLock = false; // 中途退出也解锁 if (camStep > 0) { camStep--; 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(); }); /* ─── 初始化 ─── */ renderCamStep(); })();