/* ═════ 巨子测肤 H5 — 交互逻辑 ═════ */ (function () { 'use strict'; var $ = function (sel, root) { return (root || document).querySelector(sel); }; /* camera-outline-pure-begin */ // Round the exported polygon's corners without changing its position/size. // Quadratic control points stay within the original corner's convex hull. function smoothCameraOutline(outline) { if (typeof outline !== 'string' || !/^M[\d.,\sL+-]+Z$/i.test(outline)) return outline; var numbers = outline.match(/[-+]?\d*\.?\d+/g); if (!numbers) return outline; var values = numbers.map(Number), points = []; if (values.length < 6 || values.length % 2 || !values.every(isFinite)) return outline; for (var i = 0; i < values.length; i += 2) { var p = [values[i], values[i + 1]], last = points[points.length - 1]; if (!last || Math.hypot(p[0] - last[0], p[1] - last[1]) > .001) points.push(p); } if (points.length > 1 && Math.hypot(points[0][0] - points[points.length - 1][0], points[0][1] - points[points.length - 1][1]) < .001) points.pop(); if (points.length < 3) return outline; var corners = points.map(function (p, j) { var prev = points[(j + points.length - 1) % points.length], next = points[(j + 1) % points.length]; var before = Math.hypot(prev[0] - p[0], prev[1] - p[1]), after = Math.hypot(next[0] - p[0], next[1] - p[1]); var radius = Math.min(2, before * .35, after * .35); return { p: p, enter: [p[0] + (prev[0] - p[0]) * radius / before, p[1] + (prev[1] - p[1]) * radius / before], exit: [p[0] + (next[0] - p[0]) * radius / after, p[1] + (next[1] - p[1]) * radius / after] }; }); function xy(p) { return p.map(function (n) { return n.toFixed(3); }).join(','); } var path = 'M' + xy(corners[0].exit); for (var k = 1; k <= corners.length; k++) { var c = corners[k % corners.length]; path += 'L' + xy(c.enter) + 'Q' + xy(c.p) + ' ' + xy(c.exit); } return path + 'Z'; } /* camera-outline-pure-end */ // The PSD geometry uses the same 375 × 812 coordinate space as the capture mask. (window.CameraGeometry || []).forEach(function (geometry, index) { var outline = smoothCameraOutline(geometry.outline); document.querySelectorAll('#cam-mask path[data-shape="' + index + '"]').forEach(function (path) { path.setAttribute('d', outline); }); var group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); group.setAttribute('data-shape', index); group.setAttribute('class', 'cam-feature-lines'); geometry.guides.forEach(function (d) { var path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', d); group.appendChild(path); }); $('#cam-mask').appendChild(group); }); /* ─── Toast ─── */ var toastEl = $('#toast'), toastTimer = null; function toast(msg) { toastEl.textContent = msg; toastEl.classList.add('show'); clearTimeout(toastTimer); // 长文案(如检测失败原因)按字数延长停留,短提示仍 1.8s toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, Math.max(1800, String(msg).length * 130)); } /* ─── 屏幕管理:页面内不放返回按钮,前进时 pushState,微信/浏览器返回键经 popstate 回到上一屏 ─── */ var currentScreen = 's-login'; var fontStylesLoaded = false; function loadAppFont() { if (fontStylesLoaded) return; fontStylesLoaded = true; var link = document.createElement('link'); link.rel = 'stylesheet'; link.href = 'css/fonts.css'; document.head.appendChild(link); } function renderAccountStatus() { var user = AccountClient.user(); document.querySelectorAll('[data-auth-status]').forEach(function (label) { label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : '游客模式') : '尚未登录'; label.parentElement.dataset.kind = user ? user.kind : ''; }); } function loginSucceeded() { showScreen('s-splash', 'replace'); $('#login-success-message').textContent = '当前使用:微信登录\n同一微信账号可跨手机查看报告。'; openOverlay('p-login-success'); document.querySelectorAll('.screen').forEach(function (screen) { screen.inert = true; }); $('[data-action="login-confirm"]').focus(); } function closeLoginSuccess() { closeOverlay('p-login-success'); document.querySelectorAll('.screen').forEach(function (screen) { screen.inert = false; }); $('#s-splash [data-action="open-privacy"]').focus({ preventScroll: true }); } function showScreen(id, nav) { // nav: 'push' 新增历史 | 'replace' 替换当前历史 | 缺省不动历史 if (id !== 's-login' && !AccountClient.user() && !(/[?&]mock=1/.test(location.search))) id = 's-login'; if (id === 's-profile' || profileOwner !== ProfileStore.key(AccountClient.user())) restoreProfile(); if (['s-profile', 's-cam', 's-report'].includes(id)) loadAppFont(); if (id === 's-profile' || id === 's-cam') prepareScanArtwork(); document.querySelectorAll('.screen').forEach(function (s) { s.classList.toggle('active', s.id === id); }); var sc = document.getElementById(id); window.scrollTo(0, 0); document.body.scrollTop = 0; sc.scrollTop = 0; currentScreen = id; renderAccountStatus(); if (id === 's-report') resetReport(); // 每次进入报告页都还原初始状态 if (nav === 'push') history.pushState({ screen: id }, ''); else if (nav === 'replace') history.replaceState({ screen: id }, ''); } // 代码里已切好屏、只需把历史栈退一格(如拍照失败回档案页):紧随的 popstate 不再切屏 var popSilentUntil = 0; function popHistorySilently() { popSilentUntil = Date.now() + 1000; history.back(); } window.addEventListener('popstate', function (e) { if (Date.now() < popSilentUntil) { popSilentUntil = 0; return; } var to = (e.state && e.state.screen) || 's-splash'; if ($('#p-login-success').classList.contains('show')) closeLoginSuccess(); ['p-privacy', 'p-photoguide', 'p-policy', 'p-zoom'].forEach(closeOverlay); if (currentScreen === 's-cam') leaveCamera(); if (to === 's-cam') { to = 's-profile'; history.replaceState({ screen: to }, ''); } // 「前进」回拍照页不自动开相机 if (to !== currentScreen) showScreen(to); }); /* ─── 报告页状态重置(各维度卡回到第一个子 Tab、产品横滑归零) ─── */ var detailSwipes = new WeakMap(); function setDetailTab(card, idx, instant) { card.querySelectorAll('.dtab').forEach(function (t) { var selected = t.dataset.tab === idx; t.classList.toggle('active', selected); t.setAttribute('aria-selected', String(selected)); t.setAttribute('tabindex', selected ? '0' : '-1'); }); card.querySelectorAll('.dpanel').forEach(function (p) { var selected = p.dataset.panel === idx; p.classList.toggle('active', selected); p.setAttribute('aria-hidden', String(!selected)); p.inert = !selected; }); var detail = detailSwipes.get(card); if (detail) { detail.swipe.go(Number(idx), instant); detail.height(Number(idx)); } } function resetReport() { document.querySelectorAll('.card-detail').forEach(function (c) { setDetailTab(c, '0', true); }); var ps = $('#prod-scroll'); if (ps) ps.scrollLeft = 0; } /* ─── 弹窗管理 ─── */ var SCAN_CYCLE_MS = 1000, scanCycle = null, scanMask = null; function restartScanCycle() { if (!scanCycle || scanCycle.done) return; var overlay = $('#p-analyzing'), cycle = scanCycle; overlay.classList.remove('scan-running'); clearTimeout(cycle.timer); // Restart from the first painted frame, including a late-loaded PSD image. requestAnimationFrame(function () { requestAnimationFrame(function () { if (scanCycle !== cycle || cycle.done) return; overlay.style.setProperty('--scan-cycle', SCAN_CYCLE_MS + 'ms'); overlay.classList.add('scan-running'); // Legacy browsers without CSS animations still get a bounded visible wait. if (getComputedStyle($('.scan-beam')).animationName === 'none') cycle.timer = setTimeout(cycle.resolve, SCAN_CYCLE_MS); }); }); } function startScanCycle() { stopScanCycle(); var cycle = { done: false, timer: null }; cycle.promise = new Promise(function (resolve) { cycle.resolve = function () { if (!cycle.done) { cycle.done = true; clearTimeout(cycle.timer); resolve(); } }; }); scanCycle = cycle; restartScanCycle(); return cycle.promise; } function stopScanCycle() { if (scanCycle) scanCycle.resolve(); scanCycle = null; $('#p-analyzing').classList.remove('scan-running'); } $('#p-analyzing').addEventListener('animationiteration', function (event) { if (!scanCycle || scanCycle.done) return; var ready = this.classList.contains('scan-artwork-ready'); if ((ready && event.target.classList.contains('scan-beam')) || (!ready && event.target.classList.contains('scan-status'))) scanCycle.resolve(); }); function prepareScanArtwork() { var image = $('#scan-artwork'); function ready() { var wasReady = $('#p-analyzing').classList.contains('scan-artwork-ready'); var loaded = image.complete && image.naturalWidth && scanMask && scanMask.complete && scanMask.naturalWidth; $('#p-analyzing').classList.toggle('scan-artwork-ready', !!loaded); if (loaded && !wasReady) restartScanCycle(); } if (!scanMask || (scanMask.complete && !scanMask.naturalWidth)) { scanMask = new Image(); scanMask.onload = ready; scanMask.onerror = ready; scanMask.src = $('#scan-face').dataset.mask; $('#scan-face').style.webkitMaskImage = 'url("' + scanMask.src + '")'; $('#scan-face').style.maskImage = 'url("' + scanMask.src + '")'; } if (!image.getAttribute('src') || (image.complete && !image.naturalWidth)) { image.onload = ready; image.onerror = ready; image.src = image.dataset.src; } ready(); } function openOverlay(id) { if (id === 'p-privacy' || id === 'p-photoguide') loadAppFont(); if (id === 'p-analyzing') prepareScanArtwork(); $('#' + id).classList.add('show'); if (id === 'p-analyzing') { startScanCycle(); $('.scan-cancel').focus({ preventScroll: true }); } } function closeOverlay(id) { $('#' + id).classList.remove('show'); if (id === 'p-analyzing') stopScanCycle(); } // 点遮罩空白处关闭(与各自关闭按钮同行为:隐私→回启动页、拍照引导→留在档案页) // 只响应遮罩本身(拍照引导另含底图层),不误伤弹窗卡片内交互 function bindBackdropClose(id) { var ov = $('#' + id); ov.addEventListener('click', function (e) { if (e.target === ov || e.target.closest('.guide-bg')) closeOverlay(id); }); } bindBackdropClose('p-privacy'); bindBackdropClose('p-photoguide'); bindBackdropClose('p-policy'); // ESC 关闭(分析中过渡不响应;放大图/协议层在最上,优先关) document.addEventListener('keydown', function (e) { if ($('#p-login-success').classList.contains('show')) { if (e.key === 'Escape' || e.key === 'Esc') closeLoginSuccess(); if (e.key === 'Tab') { e.preventDefault(); $('[data-action="login-confirm"]').focus(); } return; } if ($('#p-analyzing').classList.contains('show')) { if (e.key === 'Tab') { e.preventDefault(); $('.scan-cancel').focus(); } return; // Preserve existing Escape behavior; do not focus camera controls. } if (e.key !== 'Escape' && e.key !== 'Esc') return; var order = ['p-zoom', 'p-policy', 'p-privacy', 'p-photoguide']; for (var i = 0; i < order.length; i++) { if ($('#' + order[i]).classList.contains('show')) { closeOverlay(order[i]); return; } } }); // 退出拍照流程:作废未决快门/分析回调、掐断播报、释放摄像头 function leaveCamera() { camSeq++; voice.stop(true); closeOverlay('p-analyzing'); stopCamera(); shutterLock = false; } // 分析中弹层点遮罩取消:回档案页(mock 路径同样经 seq 丢弃) $('#p-analyzing').addEventListener('click', function (e) { if (e.target !== this && !e.target.closest('[data-action="cancel-analysis"]')) return; leaveCamera(); showScreen('s-profile'); popHistorySilently(); }); /* ─── 肤质轮播 ─── */ var SKINS = [ { img: 'assets/img/ui-20261009/skin-0-dry.png', name: '干性肌肤', desc: '全脸干燥,容易紧绷、
缺乏光泽' }, { img: 'assets/img/ui-20261009/skin-1-mixeddry.png', name: '混合偏干肌肤', desc: '两颊偏干,T区油脂较少或偶尔出油' }, { img: 'assets/img/ui-20261009/skin-2-neutral.png', name: '中性肌肤', desc: '水油平衡,肤感舒适,无明显干燥出油' }, { img: 'assets/img/ui-20261009/skin-3-oily.png', name: '油性肌肤', desc: '全脸易出油,容易泛油光、毛孔明显' }, { img: 'assets/img/ui-20261009/skin-4-mixedoily.png', name: '混合偏油肌肤', desc: 'T区易出油,两颊相对清爽或轻微干燥' } ]; var skinIndex = 0, skinSwipe; 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, persist) { skinIndex = Math.max(0, Math.min(SKINS.length - 1, i)); if (skinSwipe) skinSwipe.go(skinIndex); else track.style.transform = 'translateX(-' + skinIndex * 100 + '%)'; dotsBox.querySelectorAll('i').forEach(function (d, k) { d.classList.toggle('on', k === skinIndex); }); $('.skin-arrow.prev').disabled = skinIndex === 0; $('.skin-arrow.next').disabled = skinIndex === SKINS.length - 1; $('#skin-carousel').setAttribute('aria-label', '日常肤质:' + SKINS[skinIndex].name); if (persist !== false) saveProfile(); } document.querySelectorAll('[data-skin-step]').forEach(function (b) { b.addEventListener('click', function () { goSkin(skinIndex + (+b.dataset.skinStep)); }); }); goSkin(0); var carousel = $('#skin-carousel'); skinSwipe = SwipeTrack.create(carousel, track, { count: SKINS.length, onSelect: goSkin }); // Limit the iOS rubber-band fallback to the fixed profile itself. The mobile // wheel pickers live under body, so their vertical scrolling is untouched. $('#s-profile').addEventListener('touchmove', function (e) { if (e.cancelable) e.preventDefault(); }, { passive: false }); function cancelSkinDrag() { skinSwipe.cancel(); } carousel.addEventListener('keydown', function (e) { if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); goSkin(skinIndex + (e.key === 'ArrowRight' ? 1 : -1)); } }); /* ─── 档案选择控件:出生年月 / 性别 / 省份城市,统一用 mobile-select 底部滚轮(选中值写入同名 hidden input) ─── */ var NOW_Y = new Date().getFullYear(); var YEARS = [], MONTHS = []; for (var yy = NOW_Y - 99; yy <= NOW_Y - 1; yy++) YEARS.push(yy + '年'); for (var mm = 1; mm <= 12; mm++) MONTHS.push(mm + '月'); var regionSel = $('#f-region'), regionSelection = null; var profileOwner = null, profileStore = ProfileStore.create(function () { return window.localStorage; }); function setPick(sel, text) { $(sel + ' .pick-value').textContent = text; } function ageFromBirth(y, m) { return ProfileStore.age(y + '-' + (m < 10 ? '0' : '') + m); } function saveProfile() { var user = AccountClient.user(); if (!profileStore || !profileOwner || profileOwner !== ProfileStore.key(user)) return; profileStore.write(user, ProfileStore.clean({ birth: $('#f-birth').value, gender: $('#f-gender').value, region: regionSelection, skinIndex: skinIndex }, window.REGION_DATA, SKINS.length)); } function restoreProfile() { var user = AccountClient.user(); var owner = user && user.kind === 'browser' ? 'guest:' + user.id : ProfileStore.key(user); // Keep the current form while a guest retries a capture in this page only. // Reload/new guest session discards it; saveProfile never writes this owner. if (user && user.kind === 'browser' && profileOwner === owner) return; profileOwner = owner; var saved = ProfileStore.clean(profileStore.read(user), window.REGION_DATA, SKINS.length); var y = Number(saved.birth.slice(0, 4)), m = Number(saved.birth.slice(5)); if (saved.birth && YEARS.indexOf(y + '年') < 0) saved.birth = ''; $('#f-birth').value = saved.birth; $('#f-age').value = saved.birth ? ProfileStore.age(saved.birth) : ''; $('#f-gender').value = saved.gender; regionSelection = saved.region; var region = saved.region, text = region ? region.province + (region.city === region.province ? '' : ' ' + region.city) : ''; regionSel.value = text; setPick('#pick-birth', saved.birth ? y + '年' + m + '月' : ''); setPick('#pick-gender', saved.gender); setPick('#pick-region', text); if (birthPicker) birthPicker.setValue(saved.birth ? [y + '年', m + '月'] : [(NOW_Y - 30) + '年', '1月']); if (genderPicker) genderPicker.setValue([saved.gender || '女']); var initial = (window.REGION_DATA || [])[0]; if (regionPicker && (region || initial)) regionPicker.setValue([ { value: region ? region.province : initial[0] }, { value: region ? region.city : initial[1][0] } ]); cancelSkinDrag(); goSkin(saved.skinIndex, false); } function makePicker(trigger, opts) { if (typeof MobileSelect === 'undefined') return null; return new MobileSelect(Object.assign({ trigger: trigger, triggerDisplayValue: false, ensureBtnText: '确定', cancelBtnText: '取消' }, opts)); } var birthPicker = makePicker('#pick-birth', { title: '出生年月', wheels: [{ data: YEARS }, { data: MONTHS }], position: [YEARS.indexOf((NOW_Y - 30) + '年'), 0], onChange: function (val) { var y = parseInt(val[0], 10), m = parseInt(val[1], 10); $('#f-birth').value = y + '-' + (m < 10 ? '0' : '') + m; $('#f-age').value = ageFromBirth(y, m); setPick('#pick-birth', y + '年' + m + '月'); saveProfile(); } }); var genderPicker = makePicker('#pick-gender', { title: '性别', wheels: [{ data: ['女', '男'] }], onChange: function (val) { $('#f-gender').value = val[0]; setPick('#pick-gender', val[0]); saveProfile(); } }); var regionPicker = makePicker('#pick-region', { title: '所在地区', wheels: [{ data: (window.REGION_DATA || []).map(function (p) { return { id: p[0], value: p[0], childs: p[1].map(function (c) { return { id: c, value: c }; }) }; }) }], onChange: function (val) { var prov = val[0] && val[0].value, city = val[1] && val[1].value; var text = !city || city === prov ? prov : prov + ' ' + city; // 直辖市/港澳台只显示一级 regionSel.value = text; regionSelection = { province: prov, city: city || prov }; setPick('#pick-region', text); saveProfile(); } }); /* ─── 实时相机(getUserMedia)+ 系统相机降级 ─── */ var IS_MOCK = /[?&]mock=1/.test(location.search); // mock:不加载 face-api、不跑检测,analyze 直返内置样本 var camVideo = $('#cam-video'), camFile = $('#cam-file'), albumFile = $('#album-file'); var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头 var photos = []; // 三连拍(dataURL),拍摄中与 CAM_STEPS 同序;送检前经 photosByView 重排为 [正脸, 左脸, 右脸] var captureDiagnostics = [], holdState = null; // 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级 var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换) var camMirrored = true; // 预览镜像:前置 true(照镜子习惯,Figma 侧脸轮廓即按镜像画面绘制),后置 false var sideYawFlip = false; // 侧脸步语义 yaw 反向标志:个别机型前置帧本身已镜像时由自校准翻转 var mirrorCalibrated = false; // 侧脸自校准:本轮是否已翻转过一次(防振荡,一次校准管整个会话) var oppFrames = 0; // 侧脸步语义 yaw 连续反向帧计数 // 预览是否镜像(仅预览;抓帧恒为原始视角)。后置不镜像时侧脸轮廓需水平翻转,才与画面中的脸朝向一致 function effMirror() { return camMirrored; } function applyMirror() { camVideo.classList.toggle('mirror', effMirror()); $('#cam-mask').classList.toggle('flip', !effMirror() && CAM_STEPS[camStep].target !== 'front'); } function getCamStream() { return navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: camFacing }, 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; }); } // 相机预热:部分设备开头 1~2s 以低分辨率出画(画面显脸巨大),等分辨率稳定两拍再放开取景与检测 function waitVideoStable(done) { var lastW = 0, stableN = 0, tries = 0; var t = setInterval(function () { var w = camVideo.videoWidth; tries++; if (w && w === lastW) { if (++stableN >= 2) { clearInterval(t); done(); } } else { stableN = 0; lastW = w; } if (tries > 40) { clearInterval(t); done(); } // 12s 兜底,异常也不卡死 }, 300); } function startCamera() { if (camStream) { stopCamera(); } camFallback = false; 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; applyMirror(); // iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连 stream.getTracks().forEach(function (t) { t.onended = function () { onCamTrackEnded(token); }; }); waitVideoStable(function () { // 预热完成前不出实时画面(静态示意底图还在),避免开头"大脸"闪变 if (token !== camSeqCam) return; camLive = true; $('#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'); $('#cam-mask').classList.remove('scanning'); } function enableFallback() { camFallback = true; $('#cam-status').classList.remove('show'); // 与 stopCamera 对齐:去掉「检测引擎加载中」胶囊 resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框,快门出现 toast('已切换为系统相机拍摄'); // 系统相机拍回的照片也要校验(有脸、角度对),模型照常预加载 if (!IS_MOCK) loadModels().catch(function () {}); } // 从视频帧截取照片(完整帧、摄像头原始视角,压到最长边 1280 的 JPEG) // 抓完整帧而非取景框可见区:新 UI 轮廓外全黑,用户只关注轮廓内人脸;按可见区裁切在侧脸/偏站位时会切掉人脸(Face++ INVALID_IMAGE_FACE) // 正脸步预览虽镜像,照片不镜像:Face++ 的左右分区(左嘴角纹/左右脸颊等)按真实左右出文案,且与两张侧脸及真值样本同口径 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.drawImage(camVideo, 0, 0, vw, vh, 0, 0, cw, ch); return canvas.toDataURL('image/jpeg', 0.88); } // 文件 → 统一压成最长边 1280 的 JPEG → 校验合格才推进流程(降级系统相机与相册选图共用) function readPhotoFile(f) { if (!f) return; var step = camStep, seq = camSeq; var reader = new FileReader(); reader.onload = function () { var img = new Image(); img.onload = function () { if (seq !== camSeq || step !== camStep) return; 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); var url = c.toDataURL('image/jpeg', 0.88); shutterLock = true; // 校验期间不接受新的拍摄/选图 showCamStatus('照片检查中…'); checkStill(url, CAM_STEPS[step].target, { mode: 'file', ignoreDirection: true, step: step, seq: seq }).then(function (err) { if (seq !== camSeq || step !== camStep) return; // 期间已退出/重置 hideCamStatus(); shutterLock = false; if (err) { toast(err); voice.play(VOICE.fail, { cut: true }); return; } // 不合格:停在本步重拍 onPhotoCaptured(url); }); }; img.onerror = function () { toast('照片读取失败,请重新选择'); }; img.src = reader.result; }; reader.readAsDataURL(f); } // Live final frames and file uploads share all local quality gates. Files may // be mirrored by the system camera; their side direction remains unverified. function checkStill(dataUrl, target, options) { options = options || { mode: 'file', ignoreDirection: true, step: camStep, seq: camSeq }; function remember(sample) { if (options.seq === camSeq) captureDiagnostics[options.step] = sample; } if (IS_MOCK) { remember({ mode: 'mock' }); return Promise.resolve(''); } if (!modelsOk || typeof faceapi === 'undefined') { if (modelsPromise && !options.waited) { options.waited = true; return new Promise(function (resolve) { var timer = setTimeout(resolve, 8000); modelsPromise.then(function () { clearTimeout(timer); resolve(); }, function () { clearTimeout(timer); resolve(); }); }).then(function () { return checkStill(dataUrl, target, options); }); } // Keep existing unsupported-device access, explicitly marked for admin. // Never silently treat an unavailable local engine as a passed check. remember({ mode: 'unchecked', reason: 'model-unavailable' }); return Promise.resolve(''); } return new Promise(function (resolve) { var img = new Image(); img.onload = function () { faceapi .detectAllFaces(img, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE })) .withFaceLandmarks() .then(function (faces) { var d = faces[0], box = d && d.detection.box; var verdict = CaptureQuality.evaluate(d, { count: faces.length, target: target, width: img.naturalWidth, height: img.naturalHeight, offsetX: box ? (box.x + box.width / 2) / img.naturalWidth - .5 : 0, luma: box ? faceLuma(box, img) : null, mirrored: options.mode === 'live' && sideYawFlip, ignoreDirection: options.ignoreDirection }); if (!verdict.ok) { resolve(verdict.hint + ',请重新拍摄'); return; } remember(Object.assign({ mode: options.mode, direction_checked: !options.ignoreDirection }, verdict.observation)); resolve(''); }, function () { resolve('照片检查失败,请重新拍摄'); }); }; img.onerror = function () { resolve('照片读取失败,请重新选择'); }; img.src = dataUrl; }); } function showCamStatus(text) { var s = $('#cam-status'); s.textContent = text; s.classList.add('show'); } function hideCamStatus() { $('#cam-status').classList.remove('show'); } // 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程 camFile.addEventListener('change', function () { shutterLock = false; // 选完(含空选择)即解锁;推进窗口由 onPhotoCaptured 重新上锁 var f = camFile.files && camFile.files[0]; camFile.value = ''; readPhotoFile(f); }); // 部分系统取消相机/相册只发 cancel 不发 change:兜底解锁,防快门卡死 camFile.addEventListener('cancel', function () { shutterLock = false; }); // 相册选图(相机页「相册」入口):选当前步的图片,与实拍同管线推进 albumFile.addEventListener('change', function () { shutterLock = false; var f = albumFile.files && albumFile.files[0]; albumFile.value = ''; readPhotoFile(f); }); albumFile.addEventListener('cancel', function () { shutterLock = false; }); /* ─── 语音播报(WebAudio 单通道;assets/voice/.mp3,缺失静默跳过;同条 4s 去重) 文案/文件名契约:docs/语音播报内容设计文档_V1.0.md ─── */ var VOICE = { step: ['step1-front', 'step2-rightface', 'step3-leftface'], hint: { '未检测到人脸': 'hint-noface', '请靠近一点': 'hint-near', '请远一点': 'hint-far', '请正对屏幕': 'hint-front', '请向左转头': 'hint-turn-left', '请向右转头': 'hint-turn-right', '保持不动': 'hint-hold' }, countdown: 'countdown-321', shot: 'shot-done', analyzing: 'analyzing', fail: 'analyze-fail' }; // 两类播报:流程音(步骤引导/拍摄完成/倒计时/分析中/失败)排队顺序播,互不截断; // 姿态提示可互相打断,但不截断流程音——流程音播放期间只挂起最新一条,收尾后补播 var voice = (function () { var ctx = null, lastAt = {}, files = null; // files:null=清单未加载;{}=无可用语音 var bufs = {}; // id → Promise(解锁后预加载,弱网下首播不等下载) var cur = null; // { id, kind:'flow'|'hint', token, src, end } var queue = []; // 排队中的流程音 var pendingHint = null; // 流程音期间到来的姿态提示(只留最新) var waiters = []; // afterCurrent 回调:全部播报收尾后执行 // 清单制:只播 assets/voice/manifest.json 里声明的文件,不逐个探文件(避免控制台 404 噪音) fetch('assets/voice/manifest.json') .then(function (r) { return r.ok ? r.json() : { files: [] }; }) .then(function (m) { files = {}; (m.files || []).forEach(function (f) { files[f] = 1; }); preload(); }) .catch(function () { files = {}; }); function decode(ab) { return new Promise(function (resolve) { try { var p = ctx.decodeAudioData(ab, resolve, function () { resolve(null); }); if (p && p.catch) p.catch(function () { resolve(null); }); } catch (e) { resolve(null); } }); } function load(id) { if (!bufs[id]) { bufs[id] = fetch('assets/voice/' + id + '.mp3') .then(function (r) { return r.ok ? r.arrayBuffer() : null; }) .then(function (ab) { return ab ? decode(ab) : null; }) .catch(function () { return null; }) .then(function (buf) { if (!buf) delete bufs[id]; return buf; }); // 失败不缓存,下次再试 } return bufs[id]; } function preload() { if (ctx && files) Object.keys(files).forEach(load); } function unlock() { // iOS/微信自动播放限制:必须在用户手势里初始化并 resume if (!ctx) { try { ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { ctx = null; } preload(); } if (ctx && ctx.state === 'suspended') ctx.resume(); } function stopCur() { if (!cur) return; var c = cur; cur = null; if (c.src) { c.src.onended = null; try { c.src.stop(); } catch (e) {} } } function start(id, kind) { stopCur(); lastAt[id] = Date.now(); var token = {}; cur = { id: id, kind: kind, token: token, src: null, end: 0 }; load(id).then(function (buf) { if (!cur || cur.token !== token) return; // 加载期间已被打断 if (!buf || !ctx) { cur = null; drain(); return; } var src = ctx.createBufferSource(); src.buffer = buf; src.connect(ctx.destination); cur.src = src; cur.end = Date.now() + buf.duration * 1000; src.onended = function () { if (cur && cur.token === token) { cur = null; drain(); } }; src.start(0); }); } function drain() { if (cur) return; if (queue.length) { start(queue.shift(), 'flow'); return; } if (pendingHint) { var h = pendingHint; pendingHint = null; start(h, 'hint'); return; } var w = waiters; waiters = []; w.forEach(function (fn) { fn(); }); } function busyWithFlow() { return (cur && cur.kind === 'flow') || queue.length > 0; } // opts.hint:姿态提示(随检测帧反复触发,同条 4s 去重);opts.cut:打断在播与排队(失败提示不必等「分析中」播完) // 流程音是离散事件,每次都播(连拍的「拍摄完成」、取消后重来的倒计时) function play(id, opts) { opts = opts || {}; if (!ctx || !id || ctx.state !== 'running' || !files || !files[id]) return; if (opts.hint) { if (lastAt[id] && Date.now() - lastAt[id] < 4000) return; if (busyWithFlow()) { pendingHint = id; return; } start(id, 'hint'); return; } if (opts.cut) { queue = []; pendingHint = null; start(id, 'flow'); return; } if (busyWithFlow()) { if (queue.indexOf(id) < 0) queue.push(id); return; } start(id, 'flow'); // 在播的姿态提示让位给流程音 } function dropHint() { pendingHint = null; } // 提示已清空/换步:挂起的旧提示作废 // 流程切走:作废排队/挂起/等待;hard 连在播一并掐断(退出拍照、页面隐藏),否则让在播自然收尾(出报告) function stop(hard) { queue = []; pendingHint = null; waiters = []; if (hard) stopCur(); } // 等全部播报(含排队与挂起提示)自然收尾后执行 cb(倒计时接续「保持不动」,不拦腰切断);空闲则立即执行 function afterCurrent(cb) { var tail = cur && cur.src && Date.now() >= cur.end - 60; // 只剩尾音视为已收尾 if ((!cur || tail) && !queue.length && !pendingHint) { cb(); return null; } var fn = function () { cb(); }; waiters.push(fn); return function () { waiters = waiters.filter(function (w) { return w !== fn; }); }; } return { unlock: unlock, play: play, dropHint: dropHint, afterCurrent: afterCurrent, stop: stop }; })(); document.addEventListener('click', voice.unlock); // 任意点击即完成解锁 /* ─── 姿态检测(face-api + HeadPose):动态捕捉——距离/居中/角度/光线全部合格并稳住片刻即自动抓拍,不倒计时、无手动快门 ─── */ var MODEL_URL = 'vendor/faceapi/models'; var DETECT_INTERVAL = 150; // 检测帧间隔(ms) var DETECT_INPUT_SIZE = 416; // tinyFaceDetector 输入尺寸:320 下正脸置信度仅 0.67 且框偏松,416 置信度 ~0.99 框更准(CPU 端略慢,移动端 GPU 无感) var DETECT_SCORE = CaptureQuality.config.score; var MISS_FRAMES_MAX = 3; // 连续漏检 ≥3 帧(~450ms)才报「未检测到人脸」,容忍单帧抖动 var NO_PROGRESS_MS = 12000; // 单步超过该时长仍未抓拍→浮出调整提示 var PREVIEW_SCALE = 1; // 与 CSS #cam-video 铺满预览一致,镜像只改变方向 var modelsPromise = null, modelsOk = false, detectDisabled = false; var faceApiPromise = null; function loadFaceApi() { if (window.faceapi) return Promise.resolve(); if (!faceApiPromise) { faceApiPromise = new Promise(function (resolve, reject) { var script = document.createElement('script'); var timer = setTimeout(function () { finish(new Error('检测引擎加载超时')); }, 20000); function finish(error) { clearTimeout(timer); script.onload = script.onerror = null; if (error) { script.remove(); faceApiPromise = null; reject(error); } else resolve(); } script.src = 'vendor/faceapi/face-api.js'; script.onload = function () { finish(window.faceapi ? null : new Error('检测引擎不可用')); }; script.onerror = function () { finish(new Error('检测引擎加载失败')); }; document.head.appendChild(script); }); } return faceApiPromise; } var detRunning = false, detTimer = null; // 检测循环 var okSince = 0; // 当前连续 ok 起点(0=未在累积) var readyHapticAt = 0; // 抖动重试时最多每两秒提醒一次 var nudgeTimer = null; // 12s 无进展提示 var capturing = false; // 白屏补光抓帧中(暂停姿态判定) var lastHint = ''; // 提示文案去重 // 模型只加载一次(缓存 promise);失败清空缓存,下次进拍照页重试 function loadModels() { if (!modelsPromise) { modelsPromise = loadFaceApi().then(function () { return 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; } // 扫描带裁切:复制镂空轮廓到 ,随 data-step 的 CSS 显隐一起切换 (function buildScanClip() { var NS = 'http://www.w3.org/2000/svg'; var clip = document.createElementNS(NS, 'clipPath'); clip.id = 'cam-clip'; document.querySelectorAll('#cam-cut [data-shape]').forEach(function (p) { clip.appendChild(p.cloneNode(false)); }); $('#cam-mask defs').appendChild(clip); })(); function setMask(cls) { // State also drives readiness hints; no visible outline stroke. var m = $('#cam-mask'); m.classList.toggle('no-face', cls === 'no-face'); m.classList.toggle('ok', cls === 'ok'); } /* camera-hint-stage-pure-begin */ function captureStageHint(text, target, hasFrontPhoto) { if ((target === 'front' || !hasFrontPhoto) && (text === '请向左转头' || text === '请向右转头')) return '请正对屏幕'; return text; } /* camera-hint-stage-pure-end */ function setHint(text, ok, silent) { text = captureStageHint(text, CAM_STEPS[camStep].target, !!photos[0]); var h = $('#cam-hint'); if (!text) { h.classList.remove('show', 'ok'); lastHint = ''; voice.dropHint(); return; } if (text !== lastHint) { h.textContent = text; // 同文案不重复触发布局 if (!silent) voice.play(VOICE.hint[text], { hint: true }); // 语音同步(模块内 4s 去重) } h.classList.add('show'); h.classList.toggle('ok', !!ok); lastHint = text; } function hideNudge() { $('#cam-nudge').classList.remove('show'); } // 手动模式(显示快门):系统相机降级 / 识别不可用 / mock 调试;其余为实时检测自动抓拍 function isManualMode() { return IS_MOCK || camFallback || detectDisabled; } function syncCamMode() { $('#s-cam').classList.toggle('cam-manual', isManualMode()); $('#cam-mask').classList.toggle('scanning', camLive && !isManualMode() && modelsOk); } // 每步进入/回退时的检测状态机重置(蒙版/提示/无进展计时全部归位) function resetDetectState() { okSince = 0; holdState = null; readyHapticAt = 0; missFrames = 0; oppFrames = 0; lastHint = ''; clearTimeout(nudgeTimer); hideNudge(); setHint(''); setMask(camLive && !isManualMode() ? 'no-face' : ''); syncCamMode(); armNudge(); } // 单步 12s 仍未抓拍→底部浮出调整提示(不阻塞流程);进入下一步/抓帧时重置 function armNudge() { clearTimeout(nudgeTimer); hideNudge(); if (isManualMode()) return; if (currentScreen !== 's-cam') return; nudgeTimer = setTimeout(function () { if (currentScreen === 's-cam' && !shutterLock) $('#cam-nudge').classList.add('show'); }, NO_PROGRESS_MS); } function maybeStartDetect() { if (detRunning || IS_MOCK || detectDisabled || !modelsOk || !camLive || currentScreen !== 's-cam') return; detRunning = true; syncCamMode(); detTick(); } function stopDetect() { detRunning = false; clearTimeout(detTimer); 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 .detectAllFaces(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE })) .withFaceLandmarks() .then(function (d) { if (step !== camStep || seq !== camSeq) return; // 跨步骤陈帧/流程已重置:丢弃 if (detRunning && !shutterLock && !capturing) onDetect(d); }) .catch(function () { if (step === camStep && seq === camSeq) { holdState = null; okSince = 0; } }); // Unchecked frames break continuous readiness. } // 人脸框中心相对取景中线的水平偏移(占屏宽比例);按 object-fit:cover + 预览缩放换算到屏幕,镜像只影响符号 function faceOffsetX(box) { var vw = camVideo.videoWidth, vh = camVideo.videoHeight, W = camVideo.clientWidth, H = camVideo.clientHeight; if (!vw || !vh || !W || !H) return 0; var s = Math.max(W / vw, H / vh) * PREVIEW_SCALE; return ((box.x + box.width / 2) - vw / 2) * s / W; } // 脸部平均亮度:人脸框缩到 16×16 取均值 var lumaCanvas = null; function faceLuma(box, source) { try { if (!lumaCanvas) { lumaCanvas = document.createElement('canvas'); lumaCanvas.width = lumaCanvas.height = 16; } var c = lumaCanvas.getContext('2d', { willReadFrequently: true }); c.drawImage(source || camVideo, box.x, box.y, box.width, box.height, 0, 0, 16, 16); var px = c.getImageData(0, 0, 16, 16).data, sum = 0; for (var i = 0; i < px.length; i += 4) sum += 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2]; return sum / (px.length / 4); } catch (e) { return null; } } var missFrames = 0; // 连续漏检帧计数:单帧抖动不报「未检测到人脸」 /* camera-haptic-pure-begin */ function captureHaptic(nav) { // Unsupported/blocked vibration must never interrupt automatic capture. try { return typeof nav.vibrate === 'function' && nav.vibrate([70, 40, 70]) !== false; } catch (e) { return false; } } /* camera-haptic-pure-end */ function onDetect(d) { var count = Array.isArray(d) ? d.length : d ? 1 : 0; if (Array.isArray(d)) d = d[0]; var hint = '', ok = false; missFrames = d ? 0 : missFrames + 1; var hasFace = !!d, noFace = !hasFace && missFrames >= MISS_FRAMES_MAX; if (noFace) { hint = '未检测到人脸'; } else if (!hasFace) { holdState = null; okSince = 0; return; // Keep the hint quiet, but never count a missing frame as stable. } else { // 提示优先级:无人脸 > 距离 > 居中 > 角度 > 光线 var box = d.detection.box; var ratio = box.height / (camVideo.videoHeight || 1); // 原始帧判断语义方向;只有已确认镜像的机型才翻转。 var tgt = CAM_STEPS[camStep].target; var pose = HeadPose.check(d.landmarks.positions, tgt, sideYawFlip); // 侧脸自校准需连续 30 帧强反向才翻转一次,避免短暂转错方向误判为镜像。 if (tgt !== 'front' && pose.yaw != null && !mirrorCalibrated) { var wantSign = tgt === 'right' ? 1 : -1; if (pose.yaw * wantSign < -12) { if (++oppFrames >= 30) { sideYawFlip = !sideYawFlip; mirrorCalibrated = true; oppFrames = 0; } } else oppFrames = 0; } var verdict = CaptureQuality.evaluate(d, { count: count, target: tgt, mirrored: sideYawFlip, width: camVideo.videoWidth, height: camVideo.videoHeight, offsetX: faceOffsetX(box), luma: faceLuma(box) }); hint = verdict.hint; ok = verdict.ok; window.__dbgRatio = ratio; // 自动化校准/测试用(检测框高/视频高) } setMask(ok ? 'ok' : (noFace ? 'no-face' : '')); // 合格时「保持不动」只显示不播报:随即抓拍,「拍摄完成」会截断它 setHint(ok ? '保持不动' : hint, ok, ok); if (!ok) { okSince = 0; holdState = null; return; } var held = CaptureQuality.hold(holdState, verdict.observation, Date.now()); holdState = held.state; if (holdState.frames === 1) { okSince = holdState.since; if (!readyHapticAt || okSince - readyHapticAt >= 2000) { readyHapticAt = okSince; captureHaptic(navigator); } } if (held.ready) { okSince = 0; holdState = null; doCapture(); } } /* ─── 拍照流程(三步,顺序对齐 Figma:正脸 → 右脸出镜 → 左脸出镜) ─── */ // 左右一律按用户本人的左右:右脸出镜=用户头向自己的左侧转(target 'right',产物作 right_image); // 左脸出镜=头向自己的右侧转(target 'left',产物作 left_image)。 // mask 为 index.html 取景轮廓编号(1=左脸出镜 耳在画面左,2=右脸出镜 耳在画面右,均按镜像预览绘制) var CAM_STEPS = [ { step: '1/3
请正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front', mask: 0, done: '正脸拍摄完成' }, { step: '2/3
请右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right', mask: 2, done: '右脸拍摄完成', preview: '下一张:右脸出镜
请向左转头' }, { step: '3/3
请左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left', mask: 1, done: '左脸拍摄完成', preview: '下一张:左脸出镜
请向右转头' } ]; var NEXT_PAUSE_MS = 2000; // 两张之间的停顿:给用户「下一张要拍什么」的心理预期 function photosByView(ph) { // 拍摄顺序 → 送检/报告/历史统一口径 [正脸, 左脸, 右脸] var at = {}; CAM_STEPS.forEach(function (s, i) { at[s.target] = ph[i]; }); return [at.front, at.left, at.right]; } var camStep = 0; function renderCamStep() { var cfg = CAM_STEPS[camStep]; $('#cam-step').innerHTML = cfg.step; $('#cam-guide').innerHTML = cfg.guides .map(function (g) { return '' + g + ''; }) .join(''); $('#cam-mask').setAttribute('data-step', cfg.mask); // 蒙版按步骤切换轮廓 $('#cam-next').classList.remove('show'); applyMirror(); resetDetectState(); if (camStep === 0 || photos[camStep - 1]) voice.play(VOICE.step[camStep]); // 前一步合格照片已保存,才播下一步引导 } var camSeq = 0; // 流程令牌:退出拍照/开始拍照时递增,使未决的快门/分析回调失效 var shutterLock = false; // 快门防连点锁:流程进行中直接忽略 // 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步 function onPhotoCaptured(dataUrl) { shutterLock = true; // 推进窗口上锁(停顿期间不检测、不接受快门),直到下一步/送检 photos[camStep] = dataUrl; voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废 var seq = camSeq, hasNext = camStep < CAM_STEPS.length - 1; if (hasNext) { // 停顿卡:本张完成 + 下一张拍什么、往哪转;停顿结束再切到下一步(播下一步引导语音) setHint(''); hideNudge(); clearTimeout(nudgeTimer); setMask(''); $('#cam-next-done').textContent = CAM_STEPS[camStep].done; $('#cam-next-tip').innerHTML = CAM_STEPS[camStep + 1].preview; $('#cam-next').classList.add('show'); } setTimeout(function () { if (seq !== camSeq) return; // 期间已退出/重新开始:放弃本次推进 if (hasNext) { camStep++; renderCamStep(); // 内含停顿卡收起与检测状态机重置 shutterLock = false; // 进入下一步即解锁 } else { submitPhotos(seq); // 三张拍完:送检 } }, hasNext ? NEXT_PAUSE_MS : 300); } // 白屏补光抓拍:全屏纯白瞬间点亮→约300ms 后抓全分辨率帧→保持300ms 再淡出(总 600–900ms) function doCapture() { if (shutterLock) return; if (!camLive) return; shutterLock = true; capturing = true; if (isManualMode()) captureHaptic(navigator); // Manual shutter has no readiness callback. clearTimeout(nudgeTimer); hideNudge(); var seq = camSeq; var fill = $('#cam-fill'); fill.classList.add('on'); // .on 无过渡,瞬间点亮 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:避免陈旧累积立即再抓 var step = camStep; checkStill(shot, CAM_STEPS[step].target, { mode: 'live', ignoreDirection: false, step: step, seq: seq }).then(function (error) { if (seq !== camSeq || step !== camStep) return; if (error) { shutterLock = false; holdState = null; okSince = 0; setHint(error, false); armNudge(); return; } onPhotoCaptured(shot); }); }, 300); }, 300); } // 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2]) function submitPhotos(seq) { camStep = 0; photos = photosByView(photos); openOverlay('p-analyzing'); voice.play(VOICE.analyzing); // 「正在生成您的肌肤报告」 // 档案与检测一起上报(后台记录用):年龄非法值服务端按 null 处理 var profile = { age: +($('#f-age').value || 0) || null, gender: $('#f-gender').value || '', region: regionSel.value || '', skinType: SKINS[skinIndex].name }; var animationComplete = scanCycle.promise; requestAnalyze(photos, seq, profile, function (errMsg, payload) { if (seq !== camSeq) return; // 流程已被重置:丢弃 // A fast successful response waits for one actual visible animation cycle; // cancellation/errors never get held hostage by the presentation animation. (errMsg ? Promise.resolve() : animationComplete).then(function () { if (seq !== camSeq) return; closeOverlay('p-analyzing'); stopCamera(); voice.stop(); // 结果已出:排队中的「分析中」等不再补播 shutterLock = false; // 流程结束解锁 if (errMsg) { voice.play(VOICE.fail, { cut: true }); toast(errMsg); showScreen('s-profile'); // 回档案页,允许重新提交再拍 popHistorySilently(); return; } var vm = null; try { vm = payload.vm || SkinScore.buildViewModel(payload.result, payload.side, { realAge: profile.age, skinType: profile.skinType }); } catch (e) { vm = null; } if (!vm) { toast('报告生成失败,请重新拍摄'); showScreen('s-profile'); popHistorySilently(); return; } renderReport(vm, photos[0], photos[1], payload); // img1=正脸实拍,payload 供检测可视化取 face_maps/marks if (IS_MOCK) toast('演示报告,不保存至我的报告'); else if (AccountClient.user() && AccountClient.user().kind !== 'wechat') toast('游客报告仅供本次查看,不保存历史记录'); else if (!payload.report_saved) toast('报告生成成功,但保存失败,请勿关闭页面并联系管理员'); photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存 showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机 }); }); } function shutter() { if (shutterLock) return; if (camLive) { if (!isManualMode()) return; // 实时检测模式只自动抓拍,不接受手动快门 if (!camVideo.videoWidth) { toast('相机尚未就绪,请稍候'); return; } doCapture(); // 识别不可用/mock:手动拍当前步,同样走白屏补光 } else if (camFallback) { shutterLock = true; // 前置上锁防连点:change/cancel 回调里解锁 camFile.click(); // 降级:调起系统相机,回调里推进 } else { // 相机还在启动中 toast('相机启动中,请稍候'); } } /* ─── 分析请求(mock=1 直返内置样本;有 MOCK_FIXTURE 时用报告页可视化 fixture,便于离线/自动化验证) ─── */ // 后端最多请求两次(各 60s),中间等待 4s;另留上传、落盘和响应传输余量。 var ANALYZE_TIMEOUT_MS = 150000; function requestAnalyze(ph, seq, profile, done) { if (IS_MOCK && typeof SkinScore !== 'undefined') { setTimeout(function () { if (seq !== camSeq) return; // mock 同样尊重取消/重置 var fx = window.MOCK_FIXTURE; // assets/mock/report-fixture.js(仅 mock 模式注入) if (fx) { // marks 坐标空间=fixture 照片空间:三连拍替换为 fixture 照片,保证可视化叠加对齐 ph[0] = fx.photos[0]; ph[1] = fx.photos[1]; ph[2] = fx.photos[2]; done(null, { result: fx.result, side: fx.side, face_rectangle: fx.face_rectangle }); } else { 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[1], right_image: ph[2], capture_diagnostics: { version: CaptureQuality.config.version, views: { front: captureDiagnostics[0] || null, right: captureDiagnostics[1] || null, left: captureDiagnostics[2] || null } }, profile: { // 档案随检测上报,后台记录用(服务端缺省 null) real_age: profile && profile.age, gender: profile && profile.gender, region: profile && profile.region, daily_skin_type: profile && profile.skinType } }), 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, data); }) .catch(function (err) { clearTimeout(timer); if (seq !== camSeq) return; if (err && err.name === 'AbortError') done('检测服务开小差了,请稍候重试'); 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, '>'); } // 维度卡:整数位大字 + 小数位小字;进度条按 S/10;最高分浅绿、最低分橙色并标红挂「优先关注」 function setDim(key, dim, rank) { var box = document.querySelector('.dim[data-dim="' + key + '"]'); var el = $('#dim-' + key), has = dim.S != null; var parts = has ? dim.S.toFixed(1).split('.') : ['-', '-']; el.innerHTML = '' + parts[0] + '.' + parts[1]; el.classList.toggle('dim-bad', has && rank === 'worst'); box.classList.toggle('worst', has && rank === 'worst'); box.classList.toggle('best', has && rank === 'best'); box.querySelector('.dim-bar i').style.width = has ? Math.max(0, Math.min(100, dim.S * 10)) + '%' : '0'; } function setCat(key, cat) { var dom = CAT_DOM[key]; // 设计稿分类卡只有话术正文:不展示 S 与档位徽章;缺数据分类以说明代正文 $(dom.tx).textContent = cat.P == null ? '本次未检出有效数据' : cat.text; } /* ─── 报告页检测可视化(face_maps 图层 + marks 坐标叠加,数据驱动) ─── */ // 6 个分类各声明图片来源:map=整幅图层直接出图;overlay=透明增强图层盖在正脸上;marks=坐标标记画在正脸上 // 增删标记类型/图层:改对应分类的一行即可 var VIZ_CAT = { contour: { img: '#ditem-img1', photo: true }, // 轮廓:用户正脸实拍 wrinkles: { img: '#ditem-img2', overlay: 'texture_enhanced_lines', dim: 0.55 }, // 皱纹:正脸压暗+皱纹增强线(图层几乎全透明,直挂在白卡上不可读,故合成) smooth: { img: '#viz-smooth', overlays: ['texture_enhanced_pores', 'texture_enhanced_blackheads'] }, water: { img: '#viz-water', blendMap: 'water_area' }, tone: { img: '#viz-tone', blendMap: 'brown_area' }, // 肤色水润:正脸×水分图正片叠底(water_area,接口返回) sens: { img: '#viz-sens', blendMap: 'red_area' }, // 敏感:正脸×红区图正片叠底(直挂白底过淡不可读) acne: { img: '#viz-acne', photo: true, marks: [ // 痘痘:正脸+闭口粉刺/痤疮标记 { key: 'closed_comedones', color: '#E8A13D' }, // 闭口粉刺 { key: 'acne', color: '#D30C0C' }, // 痤疮 { key: 'acne_pustule', color: '#D30C0C' }, // 脓包 { key: 'acne_nodule', color: '#8E0000' } // 结节 ] } }; /* viz-pure-begin(纯函数段:与 DOM 无关,node 冒烟按锚点抽离断言,勿改锚点注释) */ // Rectangle coordinates come from the original upload; crop only the rendered // composite, never the photo sent to the algorithm or saved in the report. function vizFaceCrop(width, height, rectangle) { var full = { left: 0, top: 0, width: width, height: height, focused: false }; if (!rectangle) return full; var keys = ['left', 'top', 'width', 'height']; if (!keys.every(function (key) { return typeof rectangle[key] === 'number' && isFinite(rectangle[key]); })) return full; if (rectangle.width <= 0 || rectangle.height <= 0 || rectangle.left < 0 || rectangle.top < 0 || rectangle.left + rectangle.width > width || rectangle.top + rectangle.height > height) return full; var ratio = 215 / 278; var ch = Math.min(Math.max(rectangle.height * 1.55, rectangle.width * 1.18 / ratio), height, width / ratio); var cw = ch * ratio; if (cw < rectangle.width || ch < rectangle.height) return full; var left = Math.max(0, Math.min(width - cw, rectangle.left + rectangle.width / 2 - cw / 2)); var top = Math.max(0, Math.min(height - ch, rectangle.top - rectangle.height * .3)); // Never omit a part of the detected face in an edge-of-frame photograph. if (top + ch < rectangle.top + rectangle.height) top = rectangle.top + rectangle.height - ch; return { left: left, top: top, width: cw, height: ch, focused: true }; } // 从 result 提取某分类的标记集合:polygon 与 rectangle 都保留,缺该标记类型则跳过 function vizCollectMarks(markDefs, result) { var out = []; if (!markDefs || !result) return out; for (var i = 0; i < markDefs.length; i++) { var src = result[markDefs[i].key]; if (!src || typeof src !== 'object') continue; var m = { color: markDefs[i].color, polygons: [], rects: [] }; var polys = src.polygon || []; for (var p = 0; p < polys.length; p++) { var pts = []; for (var q = 0; q < (polys[p] || []).length; q++) { var pt = polys[p][q]; if (pt && typeof pt.x === 'number' && typeof pt.y === 'number') pts.push([pt.x, pt.y]); } if (pts.length > 2) m.polygons.push(pts); } var rects = src.rectangle || []; for (var r = 0; r < rects.length; r++) { var rc = rects[r]; if (rc && typeof rc.left === 'number' && typeof rc.top === 'number') { m.rects.push({ left: rc.left, top: rc.top, width: +rc.width || 0, height: +rc.height || 0 }); } } if (m.polygons.length || m.rects.length) out.push(m); } return out; } // marks 坐标空间 = 上传图片像素空间:按「画布自然宽 / 坐标空间宽」换算(画布=照片自然尺寸时比值为 1) function vizScaleMarks(marks, scale) { var out = []; for (var i = 0; i < marks.length; i++) { var m = marks[i], nm = { color: m.color, polygons: [], rects: [] }; for (var p = 0; p < m.polygons.length; p++) { var pts = []; for (var q = 0; q < m.polygons[p].length; q++) pts.push([m.polygons[p][q][0] * scale, m.polygons[p][q][1] * scale]); nm.polygons.push(pts); } for (var r = 0; r < m.rects.length; r++) { var rc = m.rects[r]; nm.rects.push({ left: rc.left * scale, top: rc.top * scale, width: rc.width * scale, height: rc.height * scale }); } out.push(nm); } return out; } /* viz-pure-end */ // face_maps 值兼容:已是 dataURL 直接用;裸 base64 按魔数补前缀;其它一律视为缺失(优雅降级) function vizMapURL(v) { if (typeof v !== 'string' || !v) return null; if (v.indexOf('data:image/') === 0) return v; if (v.indexOf('/9j/') === 0) return 'data:image/jpeg;base64,' + v; if (v.indexOf('iVBOR') === 0) return 'data:image/png;base64,' + v; return null; } function vizLoadImage(url, done) { var img = new Image(); img.onload = function () { done(img.naturalWidth ? img : null); }; img.onerror = function () { done(null); }; img.src = url; } // canvas 合成:照片和图层使用同一人脸裁切区 → (可选压暗/增强)→ 坐标标记 → 展示图 var vizEpoch = 0; function vizCompose(photoURL, overlayURL, marks, imgEl, opts) { var epoch = vizEpoch; vizLoadImage(photoURL, function (photo) { if (!photo) return; // 照片不可用:保留占位 var finish = function (overlays) { if (epoch !== vizEpoch) return; var crop = vizFaceCrop(photo.naturalWidth, photo.naturalHeight, opts && opts.faceRectangle); var scale = Math.min(1, 960 / crop.width); var w = Math.round(crop.width * scale), h = Math.round(crop.height * scale); var cv = document.createElement('canvas'); cv.width = w; cv.height = h; var ctx = cv.getContext('2d'); ctx.drawImage(photo, crop.left, crop.top, crop.width, crop.height, 0, 0, w, h); if (opts && opts.dim) { ctx.fillStyle = 'rgba(0,0,0,' + opts.dim + ')'; ctx.fillRect(0, 0, w, h); } // 压暗底色让增强线可读 overlays.forEach(function (overlay) { if (!overlay) return; if (opts && opts.blend) ctx.globalCompositeOperation = opts.blend; // 正片叠底:白底图层(红区/色沉)叠到脸上 var ox = overlay.naturalWidth / photo.naturalWidth, oy = overlay.naturalHeight / photo.naturalHeight; ctx.drawImage(overlay, crop.left * ox, crop.top * oy, crop.width * ox, crop.height * oy, 0, 0, w, h); ctx.globalCompositeOperation = 'source-over'; }); ctx.save(); ctx.translate(-crop.left * scale, -crop.top * scale); var scaled = vizScaleMarks(marks, scale); var lw = Math.max(3, Math.round(w / 200)); // 线宽随图走,卡片缩略后仍可见 var minBox = Math.round(w / 36); // 过小标记放大到最小框,保证缩略可见 for (var i = 0; i < scaled.length; i++) { var m = scaled[i]; ctx.strokeStyle = m.color; ctx.fillStyle = m.color; ctx.lineWidth = lw; var p, q, pts, rc; for (p = 0; p < m.polygons.length; p++) { pts = m.polygons[p]; ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); for (q = 1; q < pts.length; q++) ctx.lineTo(pts[q][0], pts[q][1]); ctx.closePath(); ctx.globalAlpha = 0.22; ctx.fill(); ctx.globalAlpha = 1; ctx.stroke(); } for (p = 0; p < m.rects.length; p++) { rc = m.rects[p]; var rw = Math.max(rc.width, minBox), rh = Math.max(rc.height, minBox); var cx = rc.left + rc.width / 2, cy = rc.top + rc.height / 2; ctx.strokeRect(cx - rw / 2, cy - rh / 2, rw, rh); } } ctx.restore(); try { imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false; imgEl.dataset.crop = crop.focused ? 'face' : 'full'; } catch (e) { /* 合成失败保留占位 */ } }; var urls = Array.isArray(overlayURL) ? overlayURL : overlayURL ? [overlayURL] : []; Promise.all(urls.map(function (url) { return new Promise(function (resolve) { vizLoadImage(url, resolve); }); })).then(finish); }); } // 报告页 7 张检测图统一入口:缺失输入按分类降级(不报错) function renderViz(payload, frontPhoto) { vizEpoch++; document.querySelectorAll('.ditem-img').forEach(function (img) { img.hidden = true; img.removeAttribute('src'); }); var result = payload && payload.result; var maps = (result && result.face_maps) || {}; var rectangle = payload && payload.face_rectangle; for (var key in VIZ_CAT) { var cfg = VIZ_CAT[key]; var imgEl = $(cfg.img); if (!imgEl) continue; if (cfg.overlays) { var mapNotes = [], overlays = []; cfg.overlays.forEach(function (name) { var url = vizMapURL(maps[name]); if (!url) return; overlays.push(url); mapNotes.push(name === 'texture_enhanced_pores' ? '蓝色为毛孔增强标记' : '红色为黑头增强标记'); }); $('#smooth-map-note').textContent = overlays.length ? mapNotes.join(',') + ';标记不代表严重程度,具体以检测计数和结果为准。' : '本次未获取毛孔、黑头增强图;此处仅展示拍摄照片,不绘制色斑、痣或痤疮框。'; if (frontPhoto) vizCompose(frontPhoto, overlays, [], imgEl, { faceRectangle: rectangle }); continue; } if (cfg.map) { // 整幅图层:图层缺失 → 保留占位 var u = vizMapURL(maps[cfg.map]); if (u) { imgEl.src = u; imgEl.hidden = false; } continue; } if (cfg.blendMap) { // 图层×正脸正片叠底(色沉/红区):无照片时直挂图层;图层缺失 → 保留占位 var bu = vizMapURL(maps[cfg.blendMap]); if (!bu) continue; if (!frontPhoto) { imgEl.src = bu; imgEl.hidden = false; continue; } vizCompose(frontPhoto, bu, [], imgEl, { blend: 'multiply', faceRectangle: rectangle }); continue; } if (cfg.overlay) { // 透明增强图层(皱纹):有照片则合成(可按 cfg.dim 压暗),无照片单挂图层;图层缺失 → 保留占位 var ov = vizMapURL(maps[cfg.overlay]); if (!ov) continue; if (!frontPhoto) { imgEl.src = ov; imgEl.hidden = false; continue; } vizCompose(frontPhoto, ov, [], imgEl, { dim: cfg.dim, faceRectangle: rectangle }); continue; } // 照片类(轮廓/光滑/痘痘):无照片 → 保留占位;有坐标则 canvas 叠加,无坐标纯照片 if (!frontPhoto) continue; var marks = cfg.marks ? vizCollectMarks(cfg.marks, result) : []; vizCompose(frontPhoto, null, marks, imgEl, { faceRectangle: rectangle }); } } function renderReport(vm, frontPhoto, sidePhoto, payload) { // 头部:综合评分(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); } // 环形综合评分:满分 100,环长 2πr(r=52)≈326.73 $('#stat-score').innerHTML = total != null ? '' + total + '分' : '--分'; var ring = $('#ring-fg'); ring.style.strokeDashoffset = total != null ? 326.73 * (1 - Math.max(0, Math.min(100, total)) / 100) : 326.73; $('#stat-age').innerHTML = vm.skinAgeDisplay != null ? '' + vm.skinAgeDisplay + '岁' : '--岁'; // 肌龄对比胶囊(新版设计「比实际年龄年轻2岁」);缺实际年龄或肌龄时不展示 var pill = $('#stat-age-note'); var diff = vm.skinAgeDisplay != null && vm.realAge ? vm.skinAgeDisplay - vm.realAge : null; pill.hidden = diff == null; pill.textContent = diff == null ? '' : diff < 0 ? '比实际年龄年轻' + (-diff) + '岁' : diff > 0 ? '比实际年龄大' + diff + '岁' : '与实际年龄相当'; // 三维度:按 P 排名,最低分标红 + 优先关注,最高分浅绿进度条 var dimPs = { firm: vm.dims.firm.P, vita: vm.dims.vita.P, stab: vm.dims.stab.P }; var ranked = Object.keys(dimPs).filter(function (k) { return dimPs[k] != null; }) .sort(function (a, b) { return dimPs[a] - dimPs[b]; }); var worstDimKey = ranked[0], bestDimKey = ranked.length > 1 ? ranked[ranked.length - 1] : null; ['firm', 'vita', 'stab'].forEach(function (k) { setDim(k, vm.dims[k], k === worstDimKey ? 'worst' : k === bestDimKey ? 'best' : ''); }); var presentation = payload && payload.presentation || ReportUI.build(vm, payload && payload.result); var focus = ReportUI.focusOf(vm), focusEl = $('#dims-note'); focusEl.hidden = !focus; focusEl.replaceChildren(); if (focus) { var first = document.createElement('p'), name = document.createElement('b'), second = document.createElement('p'); first.className = 'dims-note-l1'; first.textContent = '近期优先关注:'; name.textContent = focus.name; first.appendChild(name); second.className = 'dims-note-l2'; second.textContent = focus.tip; focusEl.append(first, second); } var textIds = { contour: '#ditem-para1', wrinkles: '#ditem-para2', color: '#tx-tone', smooth: '#tx-smooth', water: '#tx-water', sens: '#tx-sens', acne: '#tx-acne' }; presentation.views.forEach(function (v) { $(textIds[v.key]).textContent = ReportUI.customerText(v); }); // 检测可视化:轮廓=正脸实拍,其余按 VIZ_CAT 取 face_maps 图层或叠加 marks(payload 缺失时各卡保留占位) renderViz(payload, frontPhoto); // Fourth feedback: capture quality belongs in the protected admin record, // not in the customer report. Preserve vm.notes/snapshots unchanged. var notesEl = $('#vm-notes'); notesEl.textContent = ''; notesEl.style.display = 'none'; $('#care-list').innerHTML = presentation.care.map(function (s, i) { return '

' + (i + 1) + '、' + esc(s.text) + '

'; }).join('') || '

本次有效数据不足,暂无个性化护理建议。

'; var product = presentation.product; $('.card-product').hidden = !product; $('.prod-title').hidden = !product; $('#product-unavailable').hidden = !!product; if (product) { $('#product-title').textContent = product.title; $('#product-name').replaceChildren(); [product.name, '双仓精华液'].forEach(function (line) { var span = document.createElement('span'); span.className = 'product-line'; span.textContent = line; $('#product-name').appendChild(span); }); // Presentation snapshots retain historical copy/recommendations. Only the // artwork crop is refreshed by tier, without mutating stored report data. var productArt = ReportUI.products.filter(function (p) { return p.tier === product.tier; })[0]; $('#product-image').src = productArt ? productArt.image : product.image; $('#product-image').alt = product.name + '双仓精华液'; } } /* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */ function beginDetect() { if (IS_MOCK || camFallback) { resetDetectState(); return; } detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取) resetDetectState(); if (!modelsOk) showCamStatus('检测引擎加载中…'); 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;点检测图放大,再点返回 ─── */ document.querySelectorAll('.card-detail').forEach(function (card, group) { card.querySelector('.detail-tabs').setAttribute('role', 'tablist'); var tabs = Array.from(card.querySelectorAll('.dtab')), panels = Array.from(card.querySelectorAll('.dpanel')); var viewport = card.querySelector('.detail-viewport'), track = card.querySelector('.detail-panels'), position = 0; tabs.forEach(function (tab, i) { tab.setAttribute('role', 'tab'); tab.id = 'detail-tab-' + group + '-' + i; tab.setAttribute('aria-controls', 'detail-panel-' + group + '-' + i); panels[i].id = 'detail-panel-' + group + '-' + i; panels[i].setAttribute('role', 'tabpanel'); panels[i].setAttribute('aria-labelledby', tab.id); }); function height(at) { // Fonts/images may resize while dragging. Keep the current card height // until release so the rest of the report does not move under the finger. if (swipe && swipe.isDragging()) return; position = at; var lower = Math.floor(at), upper = Math.min(panels.length - 1, Math.ceil(at)), ratio = at - lower; viewport.style.height = (panels[lower].offsetHeight * (1 - ratio) + panels[upper].offsetHeight * ratio) + 'px'; } var swipe = SwipeTrack.create(viewport, track, { count: panels.length, gestureTarget: card, gestureSelector: '.viz-frame', startThreshold: 12, directionRatio: 2 / 3, maxOverscroll: 16, preventNativeHorizontalPan: true, onStart: function () { viewport.style.height = viewport.getBoundingClientRect().height + 'px'; }, onCancel: height, onSelect: function (i) { setDetailTab(card, String(i)); } }); detailSwipes.set(card, { swipe: swipe, height: height }); setDetailTab(card, '0', true); if (typeof ResizeObserver !== 'undefined') { var observer = new ResizeObserver(function () { height(position); }); panels.forEach(function (panel) { observer.observe(panel); }); } else { window.addEventListener('resize', function () { height(position); }); card.addEventListener('load', function () { height(position); }, true); if (document.fonts) document.fonts.ready.then(function () { height(position); }); } card.querySelectorAll('.ditem-img').forEach(function (image) { image.draggable = false; }); card.addEventListener('keydown', function (e) { var tab = e.target.closest('.dtab'); if (!tab || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return; e.preventDefault(); var tabs = Array.from(card.querySelectorAll('.dtab')), index = tabs.indexOf(tab); index = e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : (index + (e.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length; setDetailTab(card, tabs[index].dataset.tab); tabs[index].focus({ preventScroll: true }); }); card.addEventListener('click', function (e) { var btn = e.target.closest('.dtab'); if (btn) { setDetailTab(card, btn.dataset.tab); return; } var img = e.target.closest('.ditem-img'); if (img && img.src && !img.hidden && img.closest('.dpanel').classList.contains('active')) { $('#zoom-img').src = img.src; openOverlay('p-zoom'); history.pushState({ screen: currentScreen, overlay: 'p-zoom' }, ''); } }); }); $('#p-zoom').addEventListener('click', function () { closeOverlay('p-zoom'); popHistorySilently(); }); /* ─── 动作分发 ─── */ var privacyBusy = false; var actions = { 'open-policy': function () { openOverlay('p-policy'); }, 'guest-experience': function () { enterGuest(); }, 'login-wechat': function () { loginWechat(); }, 'login-confirm': closeLoginSuccess, 'open-privacy': function () { if (!AccountClient.user()) { showScreen('s-login', 'replace'); return; } openOverlay('p-privacy'); }, 'close-privacy': function () { closeOverlay('p-privacy'); }, 'close-policy': function () { closeOverlay('p-policy'); }, 'agree-continue': function () { if (!agreed) { toast('请先阅读并同意隐私协议'); return; } if (privacyBusy) return; privacyBusy = true; var button = $('#p-privacy [data-action="agree-continue"]'); button.disabled = true; AccountClient.ensureGuest().then(function () { if (!$('#p-privacy').classList.contains('show')) return; closeOverlay('p-privacy'); showScreen('s-profile', 'push'); }).catch(function (e) { toast(e.message); }).finally(function () { privacyBusy = false; button.disabled = false; }); }, 'submit-profile': function () { $('#f-age').value = ProfileStore.age($('#f-birth').value) || ''; var age = +$('#f-age').value; if (!$('#f-birth').value || !(age >= 1 && age <= 99)) { toast('请选择出生年月'); return; } if (!$('#f-gender').value) { toast('请选择性别'); return; } if (!regionSel.value) { toast('请选择所在地区'); return; } saveProfile(); openOverlay('p-photoguide'); }, 'close-photoguide': function () { closeOverlay('p-photoguide'); }, 'start-camera': function () { closeOverlay('p-photoguide'); camSeq++; // 使旧的快门/分析回调失效 camStep = 0; photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍) captureDiagnostics = []; shutterLock = false; // 重新进入流程,保险解锁 showScreen('s-cam', 'push'); startCamera(); // beginDetect 在其成功回调里:确认相机可用才加载模型 renderCamStep(); // 在 startCamera 复位降级标记之后渲染,快门显隐按本次模式 }, 'shutter': shutter, 'album': function () { shutterLock = true; // 与降级快门同策略:前置上锁,change/cancel 解锁 albumFile.click(); }, 'flip': function () { // 切换前后镜头 if (camFallback) { toast('系统相机模式下请在系统相机内切换'); return; } if (!camStream) { toast('相机启动中,请稍候'); return; } camFacing = camFacing === 'user' ? 'environment' : 'user'; camMirrored = camFacing === 'user'; startCamera(); // 复用启停与预热(camSeqCam 防竞态) }, 'history': function () { openHistory(); }, 'history-retry': function () { renderHistoryList(); }, 'open-wechat-login': function () { historySeq++; $('#login-consent').checked = false; $('#login-message').textContent = '微信登录后,后续检测报告可保存和跨手机查看。'; showScreen('s-login', 'push'); } }; document.addEventListener('click', function (e) { var el = e.target.closest('[data-action]'); if (!el) return; var fn = actions[el.dataset.action]; if (fn) fn(); }); /* Reports and complete immutable snapshots live on the authenticated backend. */ var historySeq = 0; function renderHistoryList() { var seq = ++historySeq, box = $('#history-list'); if (!AccountClient.user() || AccountClient.user().kind !== 'wechat') { $('#history-count').textContent = '游客模式不保存检测记录'; $('#identity-note').textContent = '游客不保存个人信息和检测记录,报告仅供本次查看。微信登录后可保留历史报告。'; box.innerHTML = '

暂无历史报告
请使用微信登录,后续检测报告会自动保存。

'; return; } box.textContent = '正在读取报告…'; AccountClient.request('api/reports').then(function (data) { if (seq !== historySeq) return; $('#history-count').textContent = '您最近已进行过' + data.total + '次面部问题检测'; $('#identity-note').textContent = '保留最近 5 份报告,同一微信账号可跨手机查看。'; if (!data.items.length) { box.innerHTML = '

暂无报告
完成一次检测后,报告会自动保存在这里。

'; return; } box.innerHTML = data.items.map(function (item) { var date = new Date(item.created_at).toLocaleDateString('sv-SE'); return '

测试时间 ' + esc(date) + '

'; }).join(''); }).catch(function (e) { if (seq !== historySeq) return; $('#history-count').textContent = '暂时无法读取'; box.textContent = e.message + ',请刷新列表重试。'; }); } function openHistory() { showScreen('s-history', 'push'); renderHistoryList(); } $('#history-list').addEventListener('click', function (event) { var btn = event.target.closest('[data-report-id]'); if (!btn || btn.disabled) return; btn.disabled = true; AccountClient.request('api/reports/' + encodeURIComponent(btn.dataset.reportId)).then(function (data) { if (currentScreen !== 's-history') return; var entry = data.report; renderReport(entry.vm, entry.photos[0], entry.photos[1], Object.assign({}, entry.payload, { presentation: entry.presentation })); showScreen('s-report', 'push'); }).catch(function (e) { toast(e.message); }).finally(function () { btn.disabled = false; }); }); var loginBusy = false; function loginAllowed() { if (loginBusy) return false; if (!$('#login-consent').checked) { toast('请先阅读并同意用户协议和隐私政策'); return false; } return true; } function enterGuest() { if (loginBusy) return; AccountClient.guest(); showScreen('s-splash', 'replace'); } function loginWechat() { if (!loginAllowed()) return; if (!/MicroMessenger/i.test(navigator.userAgent)) { $('#login-message').textContent = '请在微信内打开本页面,或选择免登录体验。'; return; } loginBusy = true; $('#login-message').textContent = '正在前往微信授权…'; AccountClient.request('api/auth/wechat', { consent: true }).then(function (data) { location.assign(data.url); }).catch(function (e) { $('#login-message').textContent = e.message; }) .finally(function () { loginBusy = false; }); } /* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 photos[] 与 camStep) ─── */ function handleCamHidden() { if (currentScreen !== 's-cam') return; camSeq++; // 使未决的快门/推进/分析回调失效(白屏定时器醒来即自洁退出) voice.stop(true); 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) }); /* ─── 微信安卓端:字体大小设置不走 text-size-adjust,需经 JSBridge 重置为标准并屏蔽后续调整 ─── */ (function lockWeixinFontSize() { function lock() { var B = window.WeixinJSBridge; if (!B) return; B.invoke('setFontSizeCallback', { fontSize: 0 }); B.on('menu:setfont', function () { B.invoke('setFontSizeCallback', { fontSize: 0 }); }); } if (window.WeixinJSBridge) lock(); else document.addEventListener('WeixinJSBridgeReady', lock, false); })(); /* ─── 初始化 ─── */ history.replaceState({ screen: 's-login' }, ''); // 刷新后从启动页开始,历史栈的屏标记与之对齐 renderCamStep(); if (!(/[?&]mock=1/.test(location.search) && /[?&]screen=report/.test(location.search))) { var loginError = /[?&]login_error=([^&#]*)/.exec(location.search); var loginSuccess = /[?&]login=success(?:&|$)/.test(location.search); var oauthReturn = !!loginError || loginSuccess; loginBusy = true; AccountClient.init({ restoreBrowser: !oauthReturn }).then(function () { if (loginError) { var code = loginError[1]; $('#login-message').textContent = code === 'state' ? '登录链接已过期或会话丢失,请在同一微信窗口重新点击微信登录。' : code === 'network' ? '暂时无法连接微信服务,请稍后重新登录。' : '微信授权未完成,请重试;如仍失败,请将错误页面截图发给管理员。也可选择免登录体验。'; } else if (loginSuccess && (!AccountClient.user() || AccountClient.user().kind !== 'wechat')) { $('#login-message').textContent = '微信返回后登录会话未能保存,请在同一微信窗口重新登录。'; } else if (AccountClient.user()) { if (loginSuccess) loginSucceeded(); else showScreen('s-splash', 'replace'); } else $('#login-message').textContent = '微信登录可保留历史报告;游客不保存个人信息和检测记录。'; }).catch(function (e) { $('#login-message').textContent = e.message; }) .finally(function () { loginBusy = false; if (oauthReturn) { var clean = new URL(location.href); clean.searchParams.delete('login'); clean.searchParams.delete('login_error'); if (clean.hash === '#wechat_redirect') clean.hash = ''; history.replaceState(history.state, '', clean.pathname + clean.search + clean.hash); } }); } // 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口) // 有 MOCK_FIXTURE 时走 fixture(检测可视化同步出图),否则回退 SkinScore 内置样本 if (IS_MOCK && /[?&]screen=report/.test(location.search) && typeof SkinScore !== 'undefined') { showScreen('s-report'); try { var fx = window.MOCK_FIXTURE; var mockRes = fx ? fx.result : SkinScore.MOCK_RESULT; var mockSide = fx ? fx.side : SkinScore.MOCK_SIDE; renderReport(SkinScore.buildViewModel(mockRes, mockSide, { realAge: +($('#f-age').value || 0) || 28, // 调试档案缺省 28 岁 skinType: SKINS[skinIndex].name }), fx ? fx.photos[0] : null, null, fx ? { result: mockRes, side: mockSide, face_rectangle: fx.face_rectangle } : null); } catch (e) { /* 调试入口异常不阻塞正式流程 */ } } })();