Files
juzi/h5/js/app.js
T

1773 lines
89 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ═════ 巨子测肤 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 = AccountClient.user().kind === 'wechat'
? '当前使用:微信登录\n同一微信账号可跨手机查看报告。'
: '当前使用:游客登录\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: '全脸干燥,容易紧绷、<br>缺乏光泽' },
{ 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 =
'<img src="' + s.img + '" draggable="false"' + (i === 0 ? '' : ' loading="lazy"') + ' alt="' + s.name + '">' +
'<span class="skin-name">' + s.name + '</span>' +
'<p class="skin-desc">' + s.desc + '</p>';
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();
profileOwner = ProfileStore.key(user);
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/<id>.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<AudioBuffer|null>(解锁后预加载,弱网下首播不等下载)
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;
}
// 扫描带裁切:复制镂空轮廓到 <clipPath id="cam-clip">,随 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<br>请正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front', mask: 0,
done: '正脸拍摄完成' },
{ step: '2/3<br>请右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right', mask: 2,
done: '右脸拍摄完成', preview: '下一张:右脸出镜<br>请向左转头' },
{ step: '3/3<br>请左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left', mask: 1,
done: '左脸拍摄完成', preview: '下一张:左脸出镜<br>请向右转头' }
];
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 '<span>' + g + '</span>'; })
.join('<i></i>');
$('#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 (!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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 维度卡:整数位大字 + 小数位小字;进度条按 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 = '<b>' + parts[0] + '</b>.' + 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 ? '<b>' + total + '</b><i>分</i>' : '<b>--</b><i>分</i>';
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 ? '<b>' + vm.skinAgeDisplay + '</b><i>岁</i>' : '<b>--</b><i>岁</i>';
// 肌龄对比胶囊(新版设计「比实际年龄年轻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 '<p class="care-item">' + (i + 1) + '、' + esc(s.text) + '</p>';
}).join('') || '<p class="care-item">本次有效数据不足,暂无个性化护理建议。</p>';
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) {
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,
onMove: height, 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 actions = {
'open-policy': function () { openOverlay('p-policy'); },
'login-browser': function () { loginBrowser(); },
'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; }
closeOverlay('p-privacy');
showScreen('s-profile', 'push');
},
'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(); },
'switch-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');
box.textContent = '正在读取报告…';
AccountClient.request('api/reports').then(function (data) {
if (seq !== historySeq) return;
$('#history-count').textContent = '您最近已进行过' + data.total + '次面部问题检测';
$('#identity-note').textContent = AccountClient.user().kind === 'wechat'
? '保留最近 5 份报告,同一微信账号可跨手机查看。'
: '保留最近 5 份报告。游客报告仅可在当前浏览器查看,不能跨手机同步;清除浏览器数据后无法找回。';
if (!data.items.length) { box.innerHTML = '<p class="history-empty">暂无报告<br>完成一次检测后,报告会自动保存在这里。</p>'; return; }
box.innerHTML = data.items.map(function (item) {
var date = new Date(item.created_at).toLocaleDateString('sv-SE');
return '<article class="card history-item"><p><span>测试时间</span> ' + esc(date) +
'</p><button class="history-detail" data-report-id="' + esc(item.id) + '">查看详情</button></article>';
}).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 loginBrowser() {
if (!loginAllowed()) return;
loginBusy = true;
$('#login-message').textContent = '正在登录…';
AccountClient.browser(true).then(function () {
loginSucceeded();
}).catch(function (e) { $('#login-message').textContent = e.message; })
.finally(function () { loginBusy = false; });
}
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) { /* 调试入口异常不阻塞正式流程 */ }
}
})();