2137 lines
110 KiB
JavaScript
2137 lines
110 KiB
JavaScript
/* ═════ 巨子测肤 H5 — 交互逻辑 ═════ */
|
||
(function () {
|
||
'use strict';
|
||
|
||
var $ = function (sel, root) { return (root || document).querySelector(sel); };
|
||
var REDNOTE = !!(window.JuziEntry && window.JuziEntry.mode === 'rednote');
|
||
function savedAccount(user) { return !!(user && (user.kind === 'wechat' || (REDNOTE && user.kind === 'browser_persistent'))); }
|
||
|
||
var captureJourney = null;
|
||
function beginCaptureJourney() {
|
||
captureJourney = null;
|
||
if (IS_MOCK) return;
|
||
try {
|
||
var bytes = new Uint8Array(16); window.crypto.getRandomValues(bytes);
|
||
captureJourney = { id: Array.from(bytes).map(function (b) { return ('0' + b.toString(16)).slice(-2); }).join(''), started: Date.now(), events: [] };
|
||
trackCapture('started');
|
||
} catch (_) {} // Telemetry must never block capture on old kernels.
|
||
}
|
||
function trackCapture(stage) {
|
||
try {
|
||
var j = captureJourney;
|
||
if (!j || IS_MOCK || j.events.some(function (e) { return e.stage === stage || e.stage === 'report' || e.stage === 'failed' || e.stage === 'canceled'; })) return;
|
||
j.events.push({ stage: stage, ms: stage === 'started' ? 0 : Math.min(86400000, Math.max(j.events.length ? j.events[j.events.length - 1].ms : 0, Date.now() - j.started)) });
|
||
fetch('api/capture-funnel', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: j.id, events: j.events }), keepalive: true }).catch(function () {});
|
||
} catch (_) {}
|
||
}
|
||
|
||
/* 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', reportOwner = null, logoutBusy = false;
|
||
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' ? '微信已登录' : REDNOTE ? (savedAccount(user) ? '浏览器已登录' : '浏览器体验') : '游客模式') : '尚未登录';
|
||
label.parentElement.dataset.kind = user ? user.kind : '';
|
||
});
|
||
document.querySelectorAll('[data-action="logout-wechat"]').forEach(function (button) {
|
||
button.hidden = !savedAccount(user);
|
||
button.disabled = logoutBusy;
|
||
button.textContent = logoutBusy ? '正在退出…' : REDNOTE ? '清除本机身份' : '退出登录';
|
||
});
|
||
}
|
||
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-report' && !IS_MOCK && reportOwner !== (AccountClient.user() && AccountClient.user().id)) id = 's-splash';
|
||
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 (id === 's-report') {
|
||
// Let the report paint before initiating optional side annotation work.
|
||
requestAnimationFrame(function(){requestAnimationFrame(loadReportWrinkles);});
|
||
}
|
||
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;
|
||
var SCAN_WAIT_TIMEOUT_MS = 30000, analysisWait = null;
|
||
function stopAnalysisWait() {
|
||
var wait = analysisWait;
|
||
analysisWait = null;
|
||
if (!wait) return;
|
||
clearTimeout(wait.timer);
|
||
if (typeof wait.cancel === 'function') wait.cancel();
|
||
}
|
||
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') { stopAnalysisWait(); 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() {
|
||
trackCapture('canceled');
|
||
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();
|
||
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 camBusy = false, camReadyTimer = null, camOpenTimer = null;
|
||
var camFrozen = document.createElement('canvas');
|
||
camFrozen.id = 'cam-frozen';
|
||
camVideo.parentNode.insertBefore(camFrozen, camVideo);
|
||
function freezeCamera() {
|
||
if (!camLive || !camVideo.videoWidth) return;
|
||
camFrozen.width = camVideo.videoWidth;
|
||
camFrozen.height = camVideo.videoHeight;
|
||
camFrozen.getContext('2d').drawImage(camVideo, 0, 0);
|
||
camFrozen.classList.toggle('mirror', camMirrored);
|
||
$('#s-cam').classList.add('cam-switching');
|
||
}
|
||
function setCameraBusy(busy) {
|
||
camBusy = busy;
|
||
$('.cam-flip').disabled = busy;
|
||
$('.cam-flip').setAttribute('aria-busy', String(busy));
|
||
}
|
||
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;
|
||
});
|
||
}
|
||
// 首帧可用即展示,不再额外等待三轮 300ms。定时器由 stopCamera 统一取消。
|
||
function waitVideoStable(done) {
|
||
var started = Date.now();
|
||
camReadyTimer = setInterval(function () {
|
||
var ready = camVideo.readyState >= 2 && camVideo.videoWidth > 0 && camVideo.videoHeight > 0;
|
||
if (ready || Date.now() - started >= 12000) {
|
||
clearInterval(camReadyTimer); camReadyTimer = null;
|
||
done(ready);
|
||
}
|
||
}, 50);
|
||
}
|
||
function startCamera(restoreFacing, keepFrozen) {
|
||
stopCamera(keepFrozen);
|
||
camFallback = false;
|
||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; }
|
||
setCameraBusy(true);
|
||
if (keepFrozen) showCamStatus('正在切换摄像头…');
|
||
camSeqCam++;
|
||
var token = camSeqCam;
|
||
function failed() {
|
||
if (token !== camSeqCam) return;
|
||
stopCamera(keepFrozen);
|
||
if (restoreFacing) {
|
||
camFacing = restoreFacing;
|
||
camMirrored = camFacing === 'user';
|
||
toast('切换失败,正在恢复原摄像头');
|
||
startCamera(null, keepFrozen);
|
||
} else { stopCamera(); enableFallback(); }
|
||
}
|
||
// 翻转/恢复不涉及首次权限询问,超时恢复;迟到的流仍会被令牌检查释放。
|
||
if (keepFrozen) camOpenTimer = setTimeout(failed, 12000);
|
||
getCamStream().then(function (stream) {
|
||
if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放
|
||
clearTimeout(camOpenTimer); camOpenTimer = null;
|
||
camStream = stream;
|
||
camVideo.srcObject = stream;
|
||
var track = stream.getVideoTracks()[0];
|
||
var actualFacing = track && track.getSettings && track.getSettings().facingMode;
|
||
if (actualFacing === 'user' || actualFacing === 'environment') {
|
||
if (restoreFacing && actualFacing !== camFacing) { failed(); return; }
|
||
camFacing = actualFacing;
|
||
}
|
||
camMirrored = camFacing === 'user';
|
||
applyMirror();
|
||
// iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连
|
||
stream.getTracks().forEach(function (t) {
|
||
t.onended = function () { onCamTrackEnded(token); };
|
||
});
|
||
waitVideoStable(function (ready) {
|
||
if (token !== camSeqCam) return;
|
||
if (!ready) { failed(); return; }
|
||
camLive = true;
|
||
$('#s-cam').classList.add('cam-live');
|
||
$('#s-cam').classList.remove('cam-switching');
|
||
camFrozen.width = camFrozen.height = 0;
|
||
setCameraBusy(false);
|
||
$('#cam-status').classList.remove('show');
|
||
beginDetect(); // 确认相机可用后才加载检测模型(无相机权限设备不白加载)
|
||
maybeStartDetect(); // 模型已就绪时即刻开跑检测循环
|
||
});
|
||
}).catch(failed);
|
||
}
|
||
// 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(keepFrozen) {
|
||
clearCaptureCountdown();
|
||
camSeqCam++; // 使进行中的 getUserMedia 回调失效
|
||
clearInterval(camReadyTimer); clearTimeout(camOpenTimer);
|
||
camReadyTimer = camOpenTimer = null;
|
||
setCameraBusy(false);
|
||
if (!keepFrozen) {
|
||
$('#s-cam').classList.remove('cam-switching');
|
||
camFrozen.width = camFrozen.height = 0;
|
||
}
|
||
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);
|
||
captureDiagnostics[step] = { mode: IS_MOCK ? 'mock' : 'file', check_stage: 'none', direction_checked: false };
|
||
onPhotoCaptured(url);
|
||
};
|
||
img.onerror = function () { toast('照片读取失败,请重新选择'); };
|
||
img.src = reader.result;
|
||
};
|
||
reader.readAsDataURL(f);
|
||
}
|
||
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, onStart) {
|
||
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) { if (onStart) onStart(3000); 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);
|
||
if (onStart) onStart(buf.duration * 1000);
|
||
});
|
||
}
|
||
function drain() {
|
||
if (cur) return;
|
||
if (queue.length) { var next = queue.shift(); start(next.id, 'flow', next.onStart); 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]) { if (opts.onStart) opts.onStart(3000); 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', opts.onStart); return; }
|
||
if (busyWithFlow()) { if (!queue.some(function(item) { return item.id === id; })) queue.push({ id:id, onStart:opts.onStart }); return; }
|
||
start(id, 'flow', opts.onStart); // 在播的姿态提示让位给流程音
|
||
}
|
||
function dropHint() { pendingHint = null; } // 提示已清空/换步:挂起的旧提示作废
|
||
// 流程切走:作废排队/挂起/等待;hard 连在播一并掐断(退出拍照、页面隐藏),否则让在播自然收尾(出报告)
|
||
function stop(hard) {
|
||
queue = [];
|
||
pendingHint = null;
|
||
waiters = [];
|
||
if (hard) stopCur();
|
||
}
|
||
// 等全部播报(含排队与挂起提示)自然收尾后执行 cb(倒计时接续「保持不动」,不拦腰切断);空闲则立即执行
|
||
function afterCurrent(cb, waitForEnded) {
|
||
var tail = !waitForEnded && cur && cur.src && Date.now() >= cur.end - 60; // 倒计时必须等 ended,不能提前抓拍
|
||
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):全部合格并稳住片刻后播321,播完自动抓拍 ─── */
|
||
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 MISS_FRAMES_MAX = 3; // 连续漏检 ≥3 帧(~450ms)才报「未检测到人脸」,容忍单帧抖动
|
||
var FRONT_MISS_FRAMES_MAX = 6; // 正面首次发现人脸前,多给相机对焦/曝光几次检测机会。
|
||
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, detGeneration = 0, detInFlight = false; // 检测循环
|
||
var okSince = 0; // 当前连续 ok 起点(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;
|
||
resetAngleHaptic();
|
||
missFrames = 0;
|
||
frontFaceSeen = false;
|
||
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;
|
||
detGeneration++;
|
||
syncCamMode();
|
||
detTick(detGeneration);
|
||
}
|
||
function stopDetect() {
|
||
resetAngleHaptic();
|
||
detRunning = false;
|
||
detGeneration++;
|
||
clearTimeout(detTimer);
|
||
clearTimeout(nudgeTimer);
|
||
hideNudge();
|
||
}
|
||
// 递归 setTimeout 而非 setInterval:检测是异步的,避免帧与帧堆叠
|
||
function detTick(generation) {
|
||
if (!detRunning || generation !== detGeneration) return;
|
||
detTimer = setTimeout(function () {
|
||
if (!detRunning || generation !== detGeneration) return;
|
||
if (detInFlight) { detTick(generation); return; }
|
||
detInFlight = true;
|
||
function finished() {
|
||
detInFlight = false;
|
||
detTick(generation);
|
||
}
|
||
Promise.resolve().then(detectFrame).then(finished, finished);
|
||
}, DETECT_INTERVAL);
|
||
}
|
||
function detectFrame() {
|
||
if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态
|
||
var step = camStep, seq = camSeq, generation = detGeneration; // 旧镜头推理结果不能污染新镜头
|
||
return faceapi
|
||
.detectAllFaces(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: CaptureQuality.scoreThreshold(CAM_STEPS[step].target) }))
|
||
.withFaceLandmarks()
|
||
.then(function (d) {
|
||
if (step !== camStep || seq !== camSeq || generation !== detGeneration) return;
|
||
if (detRunning && !shutterLock && !capturing) onDetect(d);
|
||
})
|
||
.catch(function () { if (step === camStep && seq === camSeq && generation === detGeneration) { holdState = null; okSince = 0; resetAngleHaptic(); } });
|
||
}
|
||
// 人脸框中心相对取景中线的水平偏移(占屏宽比例);按 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; // 连续漏检帧计数:单帧抖动不报「未检测到人脸」
|
||
var frontFaceSeen = false;
|
||
/* camera-haptic-pure-begin */
|
||
function captureHaptic(nav) {
|
||
// Unsupported/blocked vibration must never interrupt automatic capture.
|
||
try { return !!nav && typeof nav.vibrate === 'function' && nav.vibrate(30) !== false; }
|
||
catch (e) { return false; }
|
||
}
|
||
var angleHapticState = { since: null, lastFrame: null, lastBuzz: null, vibrating: false };
|
||
function resetAngleHaptic() {
|
||
if (angleHapticState.vibrating) {
|
||
try { if (typeof navigator.vibrate === 'function') navigator.vibrate(0); } catch (_) {}
|
||
}
|
||
angleHapticState.since = angleHapticState.lastFrame = null;
|
||
angleHapticState.vibrating = false;
|
||
}
|
||
function updateAngleHaptic(invalid, now) {
|
||
if (!invalid) { resetAngleHaptic(); return; }
|
||
var s = angleHapticState;
|
||
// A missing/failed frame breaks continuity; a stalled detector must not
|
||
// produce a reminder as though it observed the user throughout the gap.
|
||
if (s.since === null || now < s.lastFrame || now - s.lastFrame > 2000) s.since = now;
|
||
s.lastFrame = now;
|
||
if (now - s.since >= 1000 && (s.lastBuzz === null || now - s.lastBuzz >= 4000)) {
|
||
s.lastBuzz = now;
|
||
s.vibrating = captureHaptic(navigator);
|
||
}
|
||
}
|
||
/* camera-haptic-pure-end */
|
||
function onDetect(d) {
|
||
if (shutterLock || capturing || camBusy || !camLive || currentScreen !== 's-cam' || document.hidden) {
|
||
resetAngleHaptic(); return;
|
||
}
|
||
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;
|
||
if (hasFace && CAM_STEPS[camStep].target === 'front') frontFaceSeen = true;
|
||
var missLimit = CAM_STEPS[camStep].target === 'front' && !frontFaceSeen ? FRONT_MISS_FRAMES_MAX : MISS_FRAMES_MAX;
|
||
var noFace = !hasFace && missFrames >= missLimit;
|
||
if (noFace) {
|
||
hint = '未检测到人脸';
|
||
} else if (!hasFace) {
|
||
resetAngleHaptic();
|
||
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; // 自动化校准/测试用(检测框高/视频高)
|
||
}
|
||
updateAngleHaptic(!!(verdict && verdict.reason === 'angle'), Date.now());
|
||
setMask(ok ? 'ok' : (noFace ? 'no-face' : ''));
|
||
// 合格时「保持不动」只显示不播报:随即抓拍,「拍摄完成」会截断它
|
||
setHint(ok ? (verdict.warning ? verdict.warning + ',保持不动即可拍摄' : '保持不动') : 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 (held.ready) {
|
||
// Observations describe the last accepted PREVIEW, not the saved JPEG.
|
||
captureDiagnostics[camStep] = Object.assign({ mode: 'live', check_stage: 'preview', direction_checked: true }, verdict.observation);
|
||
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) {
|
||
if (!captureDiagnostics[camStep]) captureDiagnostics[camStep] = { mode: IS_MOCK ? 'mock' : 'unchecked', check_stage: 'none', direction_checked: false };
|
||
trackCapture(['front', 'right', 'left'][camStep]);
|
||
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);
|
||
}
|
||
|
||
var captureCountdownTimers = [];
|
||
function clearCaptureCountdown() {
|
||
captureCountdownTimers.forEach(clearTimeout);
|
||
captureCountdownTimers = [];
|
||
var el = $('#cam-countdown');
|
||
el.hidden = true; el.textContent = '';
|
||
}
|
||
function startCaptureCountdown(seq, duration, done) {
|
||
clearCaptureCountdown();
|
||
function tick(number) {
|
||
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') { clearCaptureCountdown(); return; }
|
||
var el = $('#cam-countdown'), digit = document.createElement('span');
|
||
digit.className = 'cam-countdown-tick'; digit.textContent = String(number);
|
||
el.replaceChildren(digit); el.hidden = false;
|
||
}
|
||
tick(3);
|
||
[2,1].forEach(function(number,i) { captureCountdownTimers.push(setTimeout(function() { tick(number); }, duration * (i+1) / 3)); });
|
||
captureCountdownTimers.push(setTimeout(function() {
|
||
clearCaptureCountdown();
|
||
if (seq === camSeq && camLive && currentScreen === 's-cam') done();
|
||
}, duration));
|
||
}
|
||
// 白屏补光抓拍:数字倒计时和语音都结束后才点亮补光、抓帧。
|
||
function doCapture() {
|
||
if (shutterLock) return;
|
||
if (!camLive) return;
|
||
shutterLock = true;
|
||
capturing = true;
|
||
resetAngleHaptic(); // Only invalid angles vibrate, never the shutter itself.
|
||
clearTimeout(nudgeTimer);
|
||
hideNudge();
|
||
var seq = camSeq;
|
||
voice.dropHint();
|
||
var audioDone = false, visualDone = false, started = false;
|
||
function finishCountdown() { if (audioDone && visualDone) capture(); }
|
||
voice.play(VOICE.countdown, { onStart:function(duration) {
|
||
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') return;
|
||
started = true;
|
||
startCaptureCountdown(seq, duration, function() { visualDone = true; finishCountdown(); });
|
||
} });
|
||
voice.afterCurrent(function () {
|
||
audioDone = true;
|
||
if (!started) { started = true; startCaptureCountdown(seq, 3000, function() { visualDone = true; finishCountdown(); }); }
|
||
finishCountdown();
|
||
}, true);
|
||
function capture() {
|
||
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') return;
|
||
var fill = $('#cam-fill');
|
||
fill.classList.add('on'); // .on 无过渡,瞬间点亮
|
||
setTimeout(function () {
|
||
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') { fill.classList.remove('on'); return; }
|
||
var shot = captureFromVideo();
|
||
setTimeout(function () {
|
||
fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出
|
||
capturing = false;
|
||
if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出)
|
||
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即再抓
|
||
onPhotoCaptured(shot); // No second local detection after capture.
|
||
}, 300);
|
||
}, 300);
|
||
}
|
||
}
|
||
|
||
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
|
||
function submitPhotos(seq) {
|
||
trackCapture('submitted');
|
||
camStep = 0;
|
||
photos = photosByView(photos);
|
||
openOverlay('p-analyzing');
|
||
var wait = { deadline: Date.now() + SCAN_WAIT_TIMEOUT_MS, timer: null, cancel: null };
|
||
analysisWait = wait;
|
||
function expireWait() {
|
||
if (analysisWait !== wait || seq !== camSeq) return;
|
||
trackCapture('failed');
|
||
camSeq++; // Invalidate request/animation callbacks BEFORE resolving/aborting them.
|
||
closeOverlay('p-analyzing');
|
||
stopCamera(); voice.stop(); shutterLock = false;
|
||
photos = []; captureDiagnostics = []; camStep = 0;
|
||
toast('检测服务开小差了,请重试');
|
||
showScreen('s-profile');
|
||
popHistorySilently();
|
||
}
|
||
wait.timer = setTimeout(expireWait, SCAN_WAIT_TIMEOUT_MS);
|
||
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;
|
||
wait.cancel = requestAnalyze(photos, seq, profile, function (errMsg, payload) {
|
||
if (seq !== camSeq) return; // 流程已被重置:丢弃
|
||
if (Date.now() >= wait.deadline) { expireWait(); return; }
|
||
trackCapture(errMsg ? 'failed' : 'response');
|
||
// 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;
|
||
if (Date.now() >= wait.deadline) { expireWait(); 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) {
|
||
trackCapture('failed');
|
||
toast('报告生成失败,请重新拍摄');
|
||
showScreen('s-profile');
|
||
popHistorySilently();
|
||
return;
|
||
}
|
||
renderReport(vm, photos[0], photos[1], payload, photos); // photos 已统一为正/左/右
|
||
if (IS_MOCK) toast('演示报告,不保存至我的报告');
|
||
else if (AccountClient.user() && !savedAccount(AccountClient.user())) toast('游客报告仅供本次查看,不保存历史记录');
|
||
else if (!payload.report_saved) toast('报告生成成功,但保存失败,请勿关闭页面并联系管理员');
|
||
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
|
||
showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机
|
||
if (currentScreen === 's-report' && $('#s-report').classList.contains('active')) trackCapture('report');
|
||
}).catch(function (error) {
|
||
trackCapture('failed');
|
||
console.error('Report presentation failed', error);
|
||
});
|
||
});
|
||
}
|
||
|
||
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,便于离线/自动化验证) ─── */
|
||
// Request timeout complements the independent 30s presentation watchdog.
|
||
var ANALYZE_TIMEOUT_MS = 30000;
|
||
function requestAnalyze(ph, seq, profile, done) {
|
||
if (IS_MOCK && typeof SkinScore !== 'undefined') {
|
||
var mockTimer = 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, wrinkle_views: fx.wrinkle_views, face_rectangle: fx.face_rectangle });
|
||
} else {
|
||
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
|
||
}
|
||
}, 900);
|
||
return function () { clearTimeout(mockTimer); };
|
||
}
|
||
var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||
var completed = false;
|
||
function finish(error, payload) { if (completed) return; completed = true; clearTimeout(timer); if (seq === camSeq) done(error, payload); }
|
||
var timer = setTimeout(function () {
|
||
finish('检测服务开小差了,请重试');
|
||
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) {
|
||
if (!data.ok) finish(data.message || '检测失败,请重试');
|
||
else finish(null, data);
|
||
})
|
||
.catch(function (err) {
|
||
if (err && err.name === 'AbortError') finish('检测服务开小差了,请重试');
|
||
else finish('网络异常,请检查网络后重试');
|
||
});
|
||
return function () { completed = true; clearTimeout(timer); if (ctrl) ctrl.abort(); };
|
||
}
|
||
|
||
/* ─── 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, '<').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 ? ReportUI.dimensionScore(dim.S).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, SkinScore.capDimensionScore(dim.S) * 10) + '%' : '0';
|
||
}
|
||
|
||
function setCat(key, cat) {
|
||
var dom = CAT_DOM[key];
|
||
// 分类卡只展示有效话术正文;缺数据不在报告中提示重拍。
|
||
$(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', alpha: 0.3, marks: [
|
||
{ key: 'melanin_mark', color: '#9F641F' },
|
||
{ key: 'melasma', color: '#E68A2E' }, { key: 'freckle', color: '#8750C8' }
|
||
] }, // 仅使用接口真实坐标;黄褐斑/雀斑只有分类值时不画伪造框。
|
||
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' }, // 结节
|
||
{ key: 'acne_mark', color: '#8750C8' } // 痘印:紫色,与红色炎性痘痘区分
|
||
] }
|
||
};
|
||
|
||
/* 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 };
|
||
}
|
||
function vizShiftContentDown(crop, rectangle, fraction) {
|
||
if (!crop.focused || !rectangle || !fraction) return crop;
|
||
// Move the source window upward, so its subject moves DOWN inside the
|
||
// fixed frame. Keep the full face in bounds and never create a blank edge.
|
||
var minimumTop = Math.max(0, rectangle.top + rectangle.height - crop.height);
|
||
return Object.assign({}, crop, { top: Math.max(minimumTop, crop.top - crop.height * fraction) });
|
||
}
|
||
// 从 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;
|
||
}
|
||
function vizWrinkleMarks(result) {
|
||
var outlines = result && result.wrinkle_mark;
|
||
if (!outlines || typeof outlines !== 'object') return [];
|
||
var normalized = {}, defs = [];
|
||
Object.keys(outlines).forEach(function(key) {
|
||
if (!/_wrinkle_outline$/.test(key) || !Array.isArray(outlines[key])) return;
|
||
normalized[key] = { polygon: [outlines[key]] };
|
||
defs.push({ key: key, color: '#6E9900' });
|
||
});
|
||
return vizCollectMarks(defs, normalized);
|
||
}
|
||
|
||
// 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;
|
||
if (!overlays.some(Boolean) && opts && opts.fallbackMarks) marks = opts.fallbackMarks;
|
||
var crop = vizFaceCrop(photo.naturalWidth, photo.naturalHeight, opts && opts.faceRectangle);
|
||
var originalTop = crop.top;
|
||
crop = vizShiftContentDown(crop, opts && opts.faceRectangle, opts && opts.contentDown);
|
||
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; // 正片叠底:白底图层(红区/色沉)叠到脸上
|
||
ctx.globalAlpha = opts && typeof opts.alpha === 'number' ? opts.alpha : 1;
|
||
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.globalAlpha = 1;
|
||
});
|
||
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); // 过小标记放大到最小框,保证缩略可见
|
||
var markAlpha = opts && typeof opts.alpha === 'number' ? opts.alpha : 1;
|
||
ctx.globalAlpha = markAlpha;
|
||
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 = markAlpha * 0.22;
|
||
ctx.fill();
|
||
ctx.globalAlpha = markAlpha;
|
||
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';
|
||
imgEl.dataset.contentOffset = String((originalTop - crop.top) / crop.height);
|
||
imgEl.dataset.hasAnnotation = String(overlays.some(Boolean) || marks.length > 0);
|
||
imgEl.dataset.overlayAlpha = String(opts && typeof opts.alpha === 'number' ? opts.alpha : 1);
|
||
} 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 (key === 'tone') {
|
||
var toneDefs = bu ? cfg.marks.filter(function(def) { return def.key !== 'melanin_mark'; }) : cfg.marks;
|
||
var toneMarks = vizCollectMarks(toneDefs, result);
|
||
if (frontPhoto) vizCompose(frontPhoto, bu, toneMarks, imgEl, { blend: 'multiply', alpha: cfg.alpha, faceRectangle: rectangle });
|
||
continue;
|
||
}
|
||
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]);
|
||
var wrinkleMarks = key === 'wrinkles' && !ov ? vizWrinkleMarks(result) : [];
|
||
if (!ov && !frontPhoto) continue;
|
||
if (!frontPhoto) { imgEl.src = ov; imgEl.hidden = false; continue; }
|
||
vizCompose(frontPhoto, ov, wrinkleMarks, imgEl, { dim: ov || wrinkleMarks.length ? cfg.dim : 0, faceRectangle: rectangle, contentDown: key === 'wrinkles' ? .09 : 0, fallbackMarks: key === 'wrinkles' ? vizWrinkleMarks(result) : null });
|
||
continue;
|
||
}
|
||
// 照片类(轮廓/光滑/痘痘):无照片 → 保留占位;有坐标则 canvas 叠加,无坐标纯照片
|
||
if (!frontPhoto) continue;
|
||
var marks = cfg.marks ? vizCollectMarks(cfg.marks, result) : [];
|
||
// The visible contour frame is 90% of the source height: 9% of the
|
||
// source equals a 10% downward shift relative to that visible frame.
|
||
vizCompose(frontPhoto, null, marks, imgEl, { faceRectangle: rectangle, contentDown: key === 'contour' ? .09 : 0 });
|
||
}
|
||
}
|
||
|
||
var contourPhotoEpoch = 0;
|
||
function renderContourSides(reportPhotos) {
|
||
var epoch = ++contourPhotoEpoch, wrap = $('#contour-sides');
|
||
function refreshHeight() {
|
||
var card = wrap.closest('.card-detail'), state = detailSwipes.get(card);
|
||
if (state) state.height(Number(card.querySelector('.dtab.active').dataset.tab));
|
||
}
|
||
wrap.hidden = true;
|
||
['left', 'right'].forEach(function(view, i) {
|
||
var img = $('#contour-' + view), figure = img.closest('figure');
|
||
img.onload = img.onerror = null; img.removeAttribute('src'); figure.hidden = true;
|
||
var src = reportPhotos && reportPhotos[i + 1];
|
||
if (!src) return;
|
||
img.draggable = false;
|
||
img.onload = function() { if (epoch === contourPhotoEpoch) { figure.hidden = false; wrap.hidden = false; refreshHeight(); } };
|
||
img.onerror = function() {
|
||
if (epoch !== contourPhotoEpoch) return;
|
||
figure.hidden = true; img.removeAttribute('src');
|
||
wrap.hidden = !wrap.querySelector('figure:not([hidden])');
|
||
refreshHeight();
|
||
};
|
||
img.src = src;
|
||
});
|
||
refreshHeight();
|
||
}
|
||
var wrinklePhotoEpoch = 0;
|
||
var activeWrinkleReport = null;
|
||
function loadReportWrinkles() {
|
||
var report=activeWrinkleReport, job=report && report.payload && report.payload.wrinkle_job;
|
||
if(report && job && report.started && job.status!=='pending' && currentScreen==='s-report' && report.owner===(AccountClient.user()&&AccountClient.user().id)) {
|
||
renderWrinkleSides(report.photos,report.payload);return;
|
||
}
|
||
if (!report || !job || job.status!=='pending' || report.started || IS_MOCK || currentScreen!=='s-report' ||
|
||
report.owner!==(AccountClient.user() && AccountClient.user().id)) return;
|
||
report.started=true;
|
||
var controller=typeof AbortController!=='undefined'?new AbortController():null;
|
||
var timer=setTimeout(function(){if(controller)controller.abort();fail();},65000);
|
||
var finished=false;
|
||
function visible(){return report===activeWrinkleReport && currentScreen==='s-report' && report.owner===(AccountClient.user()&&AccountClient.user().id);}
|
||
function fail(){
|
||
if(finished)return;finished=true;clearTimeout(timer);job.status='unavailable';
|
||
if(visible())renderWrinkleSides(report.photos,report.payload);
|
||
}
|
||
fetch('api/wrinkle-views',{method:'POST',headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({token:job.token}),signal:controller?controller.signal:undefined})
|
||
.then(function(res){if(!res.ok)throw Error('annotation unavailable');return res.json();})
|
||
.then(function(data){
|
||
if(finished)return;if(!data.ok||!data.wrinkle_views){fail();return;}
|
||
finished=true;clearTimeout(timer);job.status='complete';report.payload.wrinkle_views=data.wrinkle_views;
|
||
if(visible())renderWrinkleSides(report.photos,report.payload); // Images only: never re-score/rewrite conclusions.
|
||
}).catch(fail);
|
||
}
|
||
function renderWrinkleSides(reportPhotos, payload) {
|
||
var epoch = ++wrinklePhotoEpoch, wrap = $('#wrinkle-sides'), note = $('#wrinkle-layer-note');
|
||
function refresh() {
|
||
var pending=!!(payload && payload.wrinkle_job && payload.wrinkle_job.status==='pending');
|
||
note.textContent='部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。';
|
||
wrap.querySelectorAll('figure').forEach(function(figure){
|
||
figure.querySelector('.wrinkle-loading').hidden=!pending || figure.hidden || figure.querySelector('img').dataset.hasAnnotation==='true';
|
||
});
|
||
var visible = Array.from(wrap.querySelectorAll('figure:not([hidden])'));
|
||
wrap.hidden = !visible.length;
|
||
note.hidden = pending || !visible.some(function(f) { return f.querySelector('img').dataset.hasAnnotation !== 'true'; });
|
||
var card = wrap.closest('.card-detail'), state = detailSwipes.get(card);
|
||
if (state) state.height(Number(card.querySelector('.dtab.active').dataset.tab));
|
||
}
|
||
wrap.hidden = note.hidden = true;
|
||
['left', 'right'].forEach(function(view, i) {
|
||
var img = $('#wrinkle-' + view), figure = img.closest('figure');
|
||
figure.querySelector('.wrinkle-loading').hidden=true;
|
||
img.onload = img.onerror = null; img.removeAttribute('src');
|
||
img.dataset.hasAnnotation = 'false'; img.draggable = false; figure.hidden = true;
|
||
var src = reportPhotos && reportPhotos[i + 1];
|
||
if (!src) return;
|
||
// Only this photograph's own API data may be used. The front-face map
|
||
// is NEVER a fallback for a side photo.
|
||
var side = payload && payload.wrinkle_views && payload.wrinkle_views[view];
|
||
if (!side || !side.result) side = payload && payload.side && payload.side[view];
|
||
var result = side && (side.result || side);
|
||
var overlay = vizMapURL(result && result.face_maps && result.face_maps.texture_enhanced_lines);
|
||
var marks = overlay ? [] : vizWrinkleMarks(result);
|
||
img.onload = function() { if (epoch === wrinklePhotoEpoch) { figure.hidden = false; refresh(); } };
|
||
img.onerror = function() { if (epoch === wrinklePhotoEpoch) { figure.hidden = true; img.removeAttribute('src'); refresh(); } };
|
||
if (overlay || marks.length) vizCompose(src, overlay, marks, img, { dim: .55, fallbackMarks: vizWrinkleMarks(result) });
|
||
else img.src = src;
|
||
});
|
||
refresh();
|
||
}
|
||
function renderReport(vm, frontPhoto, sidePhoto, payload, reportPhotos) {
|
||
vm=SkinScore.withDisplayRecommendation(vm,payload && payload.result);
|
||
activeWrinkleReport={payload:payload,photos:reportPhotos || [frontPhoto,sidePhoto,null],owner:AccountClient.user()&&AccountClient.user().id,started:false};
|
||
renderContourSides(reportPhotos || [frontPhoto, sidePhoto, null]);
|
||
reportOwner = AccountClient.user() && AccountClient.user().id;
|
||
// Historical snapshots also use their saved dimensions, never the old API total.
|
||
var total = SkinScore.weightedTotal(vm.dims);
|
||
// 环形综合评分:满分 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;
|
||
var displayedAge = SkinScore.reportSkinAge(vm, payload && payload.result);
|
||
$('#stat-age').innerHTML = displayedAge != null ? '<b>' + displayedAge + '</b><i>岁</i>' : '<b>--</b><i>岁</i>';
|
||
// 肌龄对比胶囊(新版设计「比实际年龄年轻2岁」);缺实际年龄或肌龄时不展示
|
||
var pill = $('#stat-age-note');
|
||
var diff = displayedAge != null && vm.realAge ? displayedAge - 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 = ReportUI.forDisplay(vm, payload && payload.result, payload && payload.presentation);
|
||
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) {
|
||
var text = ReportUI.customerText(v), el = $(textIds[v.key]);
|
||
el.textContent = text;
|
||
el.hidden = !text;
|
||
});
|
||
|
||
// 检测可视化:轮廓=正脸实拍,其余按 VIZ_CAT 取 face_maps 图层或叠加 marks(payload 缺失时各卡保留占位)
|
||
renderViz(payload, frontPhoto);
|
||
renderWrinkleSides(reportPhotos || [frontPhoto, sidePhoto, null], payload);
|
||
|
||
// 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;
|
||
$('#product-unavailable').hidden = !!product;
|
||
if (product) {
|
||
// Presentation snapshots retain historical copy/recommendations. Only the
|
||
// product display/artwork is refreshed by selected tier, never re-selected.
|
||
var productArt = ReportUI.products.filter(function (p) { return p.tier === product.tier; })[0];
|
||
$('#product-title').textContent = productArt ? productArt.title : product.title;
|
||
$('#product-name').textContent = productArt ? productArt.name : product.name;
|
||
$('#product-image').src = productArt ? productArt.image : product.image;
|
||
$('#product-image').alt = (productArt ? productArt.title : product.title) + '推荐产品';
|
||
} else {
|
||
$('#product-image').removeAttribute('src');
|
||
$('#product-title').textContent = '';
|
||
$('#product-name').textContent = '';
|
||
}
|
||
}
|
||
|
||
/* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */
|
||
function beginDetect() {
|
||
var token = camSeqCam;
|
||
if (IS_MOCK || camFallback) { resetDetectState(); return; }
|
||
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
|
||
resetDetectState();
|
||
if (!modelsOk) showCamStatus('检测引擎加载中…');
|
||
loadModels().then(function () {
|
||
if (token !== camSeqCam || !camLive) return;
|
||
$('#cam-status').classList.remove('show');
|
||
maybeStartDetect();
|
||
}).catch(function () {
|
||
if (token !== camSeqCam || !camLive) return;
|
||
$('#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;点检测图放大,再点返回 ─── */
|
||
function reportZoomSource(img, frame, fillScale, contentDown) {
|
||
// Bake the thumbnail's CSS zoom/translation and clipping into the large
|
||
// image. Annotations are already composited with their own photograph.
|
||
try {
|
||
var box=img.getBoundingClientRect(), windowBox=frame.getBoundingClientRect();
|
||
if (!img.naturalWidth || !box.width || !windowBox.width || !windowBox.height) return img.src;
|
||
var style=getComputedStyle(img), scaleX=box.width/img.naturalWidth, scaleY=box.height/img.naturalHeight;
|
||
if (style.objectFit==='cover' || style.objectFit==='contain') {
|
||
scaleX=scaleY=style.objectFit==='cover' ? Math.max(scaleX,scaleY) : Math.min(scaleX,scaleY);
|
||
}
|
||
fillScale=fillScale || 1;
|
||
var width=img.naturalWidth*scaleX*fillScale, height=img.naturalHeight*scaleY*fillScale;
|
||
var position=style.objectPosition.split(' ');
|
||
var px=position[0].endsWith('%') ? parseFloat(position[0])/100 : .5;
|
||
var py=position[1] && position[1].endsWith('%') ? parseFloat(position[1])/100 : .5;
|
||
var left=box.left-windowBox.left+(box.width-width)*px;
|
||
var top=box.top-windowBox.top+(box.height-height)*py;
|
||
// Smaller side-photo fill keeps its center, but never exposes a blank
|
||
// edge when the earlier vertical offset would push the image outside.
|
||
if (fillScale < 1) {
|
||
if (width >= windowBox.width) left=Math.max(windowBox.width-width,Math.min(0,left));
|
||
if (height >= windowBox.height) top=Math.max(windowBox.height-height,Math.min(0,top));
|
||
}
|
||
// Explicit preview adjustment uses the scaled photo's own height,
|
||
// not the frame height. Do not clamp away the requested displacement.
|
||
top += height * (contentDown || 0);
|
||
var ratio=Math.min(4,1600/Math.max(windowBox.width,windowBox.height));
|
||
var cv=document.createElement('canvas');
|
||
cv.width=Math.round(windowBox.width*ratio);cv.height=Math.round(windowBox.height*ratio);
|
||
var ctx=cv.getContext('2d');
|
||
ctx.fillStyle=getComputedStyle(frame).backgroundColor;ctx.fillRect(0,0,cv.width,cv.height);
|
||
ctx.drawImage(img,left*ratio,top*ratio,width*ratio,height*ratio);
|
||
return cv.toDataURL('image/jpeg',.95);
|
||
} catch (_) { return img.src; }
|
||
}
|
||
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 sideButton = e.target.closest('.contour-side-photo, .wrinkle-side-photo');
|
||
var img = sideButton ? sideButton.querySelector('img') : e.target.closest('.ditem-img');
|
||
if (img && img.src && !img.hidden && img.closest('.dpanel').classList.contains('active')) {
|
||
$('#zoom-img').src = reportZoomSource(img, sideButton || img.closest('.viz-frame'), sideButton ? .5 : 1, sideButton ? .25 : 0);
|
||
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 () {
|
||
beginCaptureJourney();
|
||
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 (camBusy || !camLive) { toast('相机启动中,请稍候'); return; }
|
||
if (shutterLock || capturing) { toast('拍摄中,请稍候'); return; }
|
||
var previousFacing = camFacing;
|
||
freezeCamera();
|
||
camFacing = camFacing === 'user' ? 'environment' : 'user';
|
||
startCamera(previousFacing, true);
|
||
},
|
||
'history': function () { openHistory(); },
|
||
'history-retry': function () { renderHistoryList(); },
|
||
'logout-wechat': logoutWechat,
|
||
'open-wechat-login': function () {
|
||
if (REDNOTE) return;
|
||
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 (!savedAccount(AccountClient.user())) {
|
||
if (REDNOTE) {
|
||
$('#history-count').textContent = '暂无历史报告';
|
||
$('#identity-note').textContent = '同意隐私说明并完成检测后,将在此浏览器保留最近 5 份报告。';
|
||
box.textContent = '请返回首页开始检测。'; return;
|
||
}
|
||
$('#history-count').textContent = '游客模式不保存检测记录';
|
||
$('#identity-note').textContent = '游客不保存个人信息和检测记录,报告仅供本次查看。微信登录后可保留历史报告。';
|
||
box.innerHTML = '<p class="history-empty">暂无历史报告<br>请使用微信登录,后续检测报告会自动保存。<br><button class="history-retry" data-action="open-wechat-login">微信登录,保存报告</button></p>';
|
||
return;
|
||
}
|
||
box.textContent = '正在读取报告…';
|
||
AccountClient.request('api/reports').then(function (data) {
|
||
if (seq !== historySeq) return;
|
||
$('#history-count').textContent = '您最近已进行过' + data.total + '次面部问题检测';
|
||
$('#identity-note').textContent = REDNOTE ? '保留最近 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;
|
||
var seq = historySeq, owner = AccountClient.user() && AccountClient.user().id;
|
||
AccountClient.request('api/reports/' + encodeURIComponent(btn.dataset.reportId)).then(function (data) {
|
||
if (currentScreen !== 's-history' || seq !== historySeq || owner !== (AccountClient.user() && AccountClient.user().id)) return;
|
||
var entry = data.report;
|
||
renderReport(entry.vm, entry.photos[0], entry.photos[1], Object.assign({}, entry.payload, { presentation: entry.presentation }), entry.photos);
|
||
showScreen('s-report', 'push');
|
||
}).catch(function (e) { toast(e.message); }).finally(function () { btn.disabled = false; });
|
||
});
|
||
|
||
var loginBusy = false;
|
||
function logoutWechat() {
|
||
var user = AccountClient.user();
|
||
if (logoutBusy || !savedAccount(user)) return;
|
||
if (!window.confirm(REDNOTE ? '确认清除本机浏览器身份?\n清除后将无法恢复该身份的历史报告,下次体验会生成新身份。' : '确认退出微信登录?\n历史报告将保留,重新微信登录后可查看。')) return;
|
||
logoutBusy = true; renderAccountStatus();
|
||
AccountClient.logout().then(function () {
|
||
historySeq++; reportOwner = null;
|
||
$('#history-list').textContent = ''; $('#history-count').textContent = ''; $('#identity-note').textContent = '';
|
||
$('#login-consent').checked = false;
|
||
if (REDNOTE) {
|
||
agreed = false; $('#privacy-check').classList.remove('checked');
|
||
AccountClient.guest(); showScreen('s-splash', 'replace'); return;
|
||
}
|
||
$('#login-message').textContent = '已退出微信登录,历史报告已保留。';
|
||
showScreen('s-login', 'replace');
|
||
$('[data-action="login-wechat"]').focus({ preventScroll: true });
|
||
}).catch(function (e) { toast('退出失败:' + e.message); }).finally(function () {
|
||
logoutBusy = false; renderAccountStatus();
|
||
});
|
||
}
|
||
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 (REDNOTE) return;
|
||
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;
|
||
trackCapture('interrupted');
|
||
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 = REDNOTE ? null : /[?&]login_error=([^&#]*)/.exec(location.search);
|
||
var loginSuccess = !REDNOTE && /[?&]login=success(?:&|$)/.test(location.search);
|
||
var oauthReturn = !!loginError || loginSuccess;
|
||
loginBusy = true;
|
||
AccountClient.init({ restoreBrowser: !oauthReturn }).then(function () {
|
||
if (REDNOTE && !AccountClient.user()) {
|
||
AccountClient.guest(); showScreen('s-splash', 'replace'); return;
|
||
}
|
||
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, wrinkle_views: fx.wrinkle_views, face_rectangle: fx.face_rectangle } : null, fx && fx.photos);
|
||
} catch (e) { /* 调试入口异常不阻塞正式流程 */ }
|
||
}
|
||
})();
|