1483 lines
72 KiB
JavaScript
1483 lines
72 KiB
JavaScript
/* ═════ 巨子测肤 H5 — 交互逻辑 ═════ */
|
|
(function () {
|
|
'use strict';
|
|
|
|
var $ = function (sel, root) { return (root || document).querySelector(sel); };
|
|
|
|
/* ─── Toast ─── */
|
|
var toastEl = $('#toast'), toastTimer = null;
|
|
function toast(msg) {
|
|
toastEl.textContent = msg;
|
|
toastEl.classList.add('show');
|
|
clearTimeout(toastTimer);
|
|
// 长文案(如检测失败原因)按字数延长停留,短提示仍 1.8s
|
|
toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, Math.max(1800, String(msg).length * 130));
|
|
}
|
|
|
|
/* ─── 屏幕管理:页面内不放返回按钮,前进时 pushState,微信/浏览器返回键经 popstate 回到上一屏 ─── */
|
|
var currentScreen = 's-splash';
|
|
function showScreen(id, nav) { // nav: 'push' 新增历史 | 'replace' 替换当前历史 | 缺省不动历史
|
|
document.querySelectorAll('.screen').forEach(function (s) {
|
|
s.classList.toggle('active', s.id === id);
|
|
});
|
|
var sc = document.getElementById(id);
|
|
sc.scrollTop = 0;
|
|
currentScreen = id;
|
|
if (id === 's-report') resetReport(); // 每次进入报告页都还原初始状态
|
|
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';
|
|
['p-privacy', 'p-photoguide', 'p-policy', 'p-zoom', 'p-history'].forEach(closeOverlay);
|
|
if (currentScreen === 's-cam') leaveCamera();
|
|
if (to === 's-cam') { to = 's-profile'; history.replaceState({ screen: to }, ''); } // 「前进」回拍照页不自动开相机
|
|
if (to !== currentScreen) showScreen(to);
|
|
});
|
|
|
|
/* ─── 报告页状态重置(各维度卡回到第一个子 Tab、产品横滑归零) ─── */
|
|
function setDetailTab(card, idx) {
|
|
card.querySelectorAll('.dtab').forEach(function (t) { t.classList.toggle('active', t.dataset.tab === idx); });
|
|
card.querySelectorAll('.dpanel').forEach(function (p) { p.classList.toggle('active', p.dataset.panel === idx); });
|
|
}
|
|
function resetReport() {
|
|
document.querySelectorAll('.card-detail').forEach(function (c) { setDetailTab(c, '0'); });
|
|
var ps = $('#prod-scroll');
|
|
if (ps) ps.scrollLeft = 0;
|
|
}
|
|
|
|
/* ─── 弹窗管理 ─── */
|
|
function openOverlay(id) { $('#' + id).classList.add('show'); }
|
|
function closeOverlay(id) { $('#' + id).classList.remove('show'); }
|
|
|
|
// 点遮罩空白处关闭(与各自关闭按钮同行为:隐私→回启动页、拍照引导→留在档案页)
|
|
// 只响应遮罩本身(拍照引导另含底图层),不误伤弹窗卡片内交互
|
|
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 (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')) {
|
|
if (order[i] === 'p-zoom') closeZoom(); else closeOverlay(order[i]);
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
// 退出拍照流程:作废未决快门/分析回调、掐断播报、释放摄像头
|
|
function leaveCamera() {
|
|
camSeq++;
|
|
voice.stop(true);
|
|
closeOverlay('p-analyzing');
|
|
stopCamera();
|
|
shutterLock = false;
|
|
}
|
|
// 分析中弹层点遮罩取消:回档案页(mock 路径同样经 seq 丢弃)
|
|
$('#p-analyzing').addEventListener('click', function (e) {
|
|
if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片
|
|
leaveCamera();
|
|
showScreen('s-profile');
|
|
popHistorySilently();
|
|
});
|
|
|
|
/* ─── 肤质轮播 ─── */
|
|
var SKINS = [
|
|
{ img: 'assets/img/skin-0-dry.png', name: '干性肌肤', desc: '全脸干燥,容易紧绷、缺乏光泽' },
|
|
{ img: 'assets/img/skin-1-mixeddry.png', name: '混合偏干肌肤', desc: '两颊偏干,T区油脂较少或偶尔出油' },
|
|
{ img: 'assets/img/skin-2-neutral.png', name: '中性肌肤', desc: '水油平衡,肤感舒适,无明显干燥出油' },
|
|
{ img: 'assets/img/skin-3-oily.png', name: '油性肌肤', desc: '全脸易出油,容易泛油光、毛孔明显' },
|
|
{ img: 'assets/img/skin-4-mixedoily.png', name: '混合偏油肌肤', desc: 'T区易出油,两颊相对清爽或轻微干燥' }
|
|
];
|
|
var skinIndex = 0;
|
|
var track = $('#skin-track'), dotsBox = $('#skin-dots');
|
|
|
|
SKINS.forEach(function (s, i) {
|
|
var item = document.createElement('div');
|
|
item.className = 'skin-item';
|
|
item.innerHTML =
|
|
'<img src="' + s.img + '"' + (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) {
|
|
skinIndex = Math.max(0, Math.min(SKINS.length - 1, i));
|
|
track.style.transform = 'translateX(-' + skinIndex * 100 + '%)';
|
|
dotsBox.querySelectorAll('i').forEach(function (d, k) {
|
|
d.classList.toggle('on', k === skinIndex);
|
|
});
|
|
$('.skin-arrow.prev').disabled = skinIndex === 0;
|
|
$('.skin-arrow.next').disabled = skinIndex === SKINS.length - 1;
|
|
}
|
|
document.querySelectorAll('[data-skin-step]').forEach(function (b) {
|
|
b.addEventListener('click', function () { goSkin(skinIndex + (+b.dataset.skinStep)); });
|
|
});
|
|
goSkin(0);
|
|
|
|
// 拖拽切换(Pointer Events 统一鼠标 + 触摸)
|
|
var drag = { id: null, startX: 0, startY: 0, horizontal: false, suppress: false };
|
|
var carousel = $('#skin-carousel');
|
|
carousel.addEventListener('pointerdown', function (e) {
|
|
if (e.cancelable) e.preventDefault(); // 阻止原生图片拖拽/文字选中引发 pointercancel(click 仍会正常触发)
|
|
try { carousel.setPointerCapture(e.pointerId); } catch (err) { /* 非活动指针时忽略,退化为普通监听 */ }
|
|
// 捕获指针:斜拖出卡片、卡外松手也能收到 pointerup
|
|
drag.id = e.pointerId;
|
|
drag.startX = e.clientX;
|
|
drag.startY = e.clientY;
|
|
drag.horizontal = false;
|
|
drag.suppress = false;
|
|
});
|
|
carousel.addEventListener('pointermove', function (e) {
|
|
if (e.pointerId !== drag.id) return;
|
|
var dx = e.clientX - drag.startX, dy = e.clientY - drag.startY;
|
|
// 横向位移明显大于纵向时才判定为拖拽,避免吞掉纵向滚动
|
|
if (!drag.horizontal && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) drag.horizontal = true;
|
|
if (drag.horizontal && e.cancelable) e.preventDefault();
|
|
});
|
|
carousel.addEventListener('pointerup', function (e) {
|
|
if (e.pointerId !== drag.id) return;
|
|
var dx = e.clientX - drag.startX;
|
|
if (drag.horizontal) {
|
|
if (Math.abs(dx) > 30) goSkin(skinIndex + (dx < 0 ? 1 : -1));
|
|
drag.suppress = true; // 拖拽后吞掉紧随的 click,防止点击穿透
|
|
}
|
|
drag.id = null;
|
|
drag.horizontal = false;
|
|
});
|
|
carousel.addEventListener('pointercancel', function (e) {
|
|
if (e.pointerId !== drag.id) return; // 其它指针的 cancel 不打断当前拖拽
|
|
drag.id = null; // 拖拽被系统取消(滚轮/新指针等):干净结束,不残留状态
|
|
drag.horizontal = false;
|
|
});
|
|
carousel.addEventListener('click', function (e) {
|
|
if (drag.suppress) { e.preventDefault(); e.stopPropagation(); drag.suppress = false; }
|
|
}, true);
|
|
|
|
/* ─── 档案选择控件:出生年月 / 性别 / 省份城市,统一用 mobile-select 底部滚轮(选中值写入同名 hidden input) ─── */
|
|
var NOW_Y = new Date().getFullYear();
|
|
var YEARS = [];
|
|
for (var yy = NOW_Y - 100; yy <= NOW_Y - 1; yy++) {
|
|
var months = [];
|
|
for (var mm = 1; mm <= 12; mm++) {
|
|
var selectableAge = ageFromBirth(yy, mm);
|
|
if (selectableAge >= 1 && selectableAge <= 99) months.push({ id: mm, value: mm + '月' });
|
|
}
|
|
if (months.length) YEARS.push({ id: yy, value: yy + '年', childs: months });
|
|
}
|
|
var regionSel = $('#f-region');
|
|
function setPick(sel, text) { var el = $(sel + ' .pick-value'); el.textContent = text; el.classList.remove('placeholder'); }
|
|
function ageFromBirth(y, m) { var now = new Date(); return now.getFullYear() - y - (now.getMonth() + 1 < m ? 1 : 0); }
|
|
function refreshAge() {
|
|
var birth = $('#f-birth').value.split('-');
|
|
if (birth.length !== 2) return;
|
|
var age = ageFromBirth(+birth[0], +birth[1]);
|
|
$('#f-age').value = age;
|
|
$('#profile-age').textContent = '年龄:' + age + '岁(按出生月份计算)';
|
|
}
|
|
function makePicker(trigger, opts) {
|
|
if (typeof MobileSelect === 'undefined') return null;
|
|
return new MobileSelect(Object.assign({
|
|
trigger: trigger, triggerDisplayValue: false,
|
|
ensureBtnText: '确定', cancelBtnText: '取消'
|
|
}, opts));
|
|
}
|
|
makePicker('#pick-birth', {
|
|
title: '出生年月',
|
|
wheels: [{ data: YEARS }],
|
|
position: [YEARS.map(function (y) { return y.id; }).indexOf(NOW_Y - 30), 0],
|
|
onChange: function (val) {
|
|
var y = +val[0].id, m = +val[1].id;
|
|
$('#f-birth').value = y + '-' + (m < 10 ? '0' : '') + m;
|
|
refreshAge();
|
|
setPick('#pick-birth', y + '年' + m + '月');
|
|
}
|
|
});
|
|
makePicker('#pick-gender', {
|
|
title: '性别',
|
|
wheels: [{ data: ['女', '男'] }],
|
|
onChange: function (val) { $('#f-gender').value = val[0]; setPick('#pick-gender', val[0]); }
|
|
});
|
|
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;
|
|
setPick('#pick-region', text);
|
|
}
|
|
});
|
|
|
|
/* ─── 实时相机(getUserMedia)+ 系统相机降级 ─── */
|
|
var IS_MOCK = /[?&]mock=1/.test(location.search); // mock:不加载 face-api、不跑检测,analyze 直返内置样本
|
|
var camVideo = $('#cam-video'), camFile = $('#cam-file'), albumFile = $('#album-file');
|
|
var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头
|
|
var photos = []; // 三连拍(dataURL),拍摄中与 CAM_STEPS 同序;送检前经 photosByView 重排为 [正脸, 左脸, 右脸]
|
|
|
|
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
|
|
var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换)
|
|
var camMirrored = true; // 预览镜像:前置 true(照镜子习惯,Figma 侧脸轮廓即按镜像画面绘制),后置 false
|
|
// 预览是否镜像(仅预览;抓帧恒为原始视角)。后置不镜像时侧脸轮廓需水平翻转,才与画面中的脸朝向一致
|
|
function effMirror() { return camMirrored; }
|
|
function applyMirror() {
|
|
camVideo.classList.toggle('mirror', effMirror());
|
|
$('#cam-mask').classList.toggle('flip', !effMirror() && CAM_STEPS[camStep].target !== 'front');
|
|
}
|
|
function getCamStream() {
|
|
return navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: { ideal: camFacing }, width: { ideal: 1280 }, height: { ideal: 1280 } },
|
|
audio: false
|
|
}).catch(function (err) {
|
|
if (err && err.name === 'OverconstrainedError') {
|
|
return navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
|
}
|
|
throw err;
|
|
});
|
|
}
|
|
// 相机预热:部分设备开头 1~2s 以低分辨率出画(画面显脸巨大),等分辨率稳定两拍再放开取景与检测
|
|
function waitVideoStable(done) {
|
|
var lastW = 0, stableN = 0, tries = 0;
|
|
var t = setInterval(function () {
|
|
var w = camVideo.videoWidth;
|
|
tries++;
|
|
if (w && w === lastW) {
|
|
if (++stableN >= 2) { clearInterval(t); done(); }
|
|
} else { stableN = 0; lastW = w; }
|
|
if (tries > 40) { clearInterval(t); done(); } // 12s 兜底,异常也不卡死
|
|
}, 300);
|
|
}
|
|
function startCamera() {
|
|
if (camStream) { stopCamera(); }
|
|
camFallback = false;
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; }
|
|
camSeqCam++;
|
|
var token = camSeqCam;
|
|
getCamStream().then(function (stream) {
|
|
if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放
|
|
camStream = stream;
|
|
camVideo.srcObject = stream;
|
|
applyMirror();
|
|
// iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连
|
|
stream.getTracks().forEach(function (t) {
|
|
t.onended = function () { onCamTrackEnded(token); };
|
|
});
|
|
waitVideoStable(function () { // 预热完成前不出实时画面(静态示意底图还在),避免开头"大脸"闪变
|
|
if (token !== camSeqCam) return;
|
|
camLive = true;
|
|
$('#s-cam').classList.add('cam-live');
|
|
beginDetect(); // 确认相机可用后才加载检测模型(无相机权限设备不白加载)
|
|
maybeStartDetect(); // 模型已就绪时即刻开跑检测循环
|
|
});
|
|
}).catch(function () {
|
|
if (token !== camSeqCam) return;
|
|
enableFallback();
|
|
});
|
|
}
|
|
// track 被系统结束(iOS 打断)且页面仍在拍照前台:toast 后自动重连,重连失败走 startCamera 内的 enableFallback
|
|
function onCamTrackEnded(token) {
|
|
if (token !== camSeqCam || !camLive) return; // 旧流/已主动停止:忽略
|
|
if (document.hidden || currentScreen !== 's-cam') return; // 后台恢复由 visibilitychange 流程接管
|
|
camLive = false; // 多个 track 同时 ended 只触发一次重连
|
|
toast('摄像头连接中断,正在重连');
|
|
startCamera();
|
|
}
|
|
function stopCamera() {
|
|
camSeqCam++; // 使进行中的 getUserMedia 回调失效
|
|
stopDetect(); // 检测循环/无进展提示一并收尾
|
|
$('#cam-status').classList.remove('show');
|
|
if (camStream) { camStream.getTracks().forEach(function (t) { t.stop(); }); camStream = null; }
|
|
camVideo.srcObject = null;
|
|
camLive = false;
|
|
$('#s-cam').classList.remove('cam-live');
|
|
$('#cam-mask').classList.remove('scanning');
|
|
$('#cam-fill').classList.remove('on');
|
|
if (capturing) shutterLock = false;
|
|
capturing = false;
|
|
okSince = 0;
|
|
}
|
|
function enableFallback() {
|
|
camFallback = true;
|
|
$('#cam-status').classList.remove('show'); // 与 stopCamera 对齐:去掉「检测引擎加载中」胶囊
|
|
resetDetectState();
|
|
setHint('请允许相机权限后重试', false, true);
|
|
toast('相机暂不可用,请重试或按顺序选择照片');
|
|
// 系统相机拍回的照片也要校验(有脸、角度对),模型照常预加载
|
|
if (!IS_MOCK && typeof faceapi !== 'undefined') 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;
|
|
if (currentScreen !== 's-cam' || capturing) return;
|
|
shutterLock = true;
|
|
okSince = 0;
|
|
var active = function () { return seq === camSeq && step === camStep && currentScreen === 's-cam'; };
|
|
function fail(message) { if (!active()) return; shutterLock = false; hideCamStatus(); toast(message); }
|
|
var reader = new FileReader();
|
|
reader.onerror = function () { fail('照片读取失败,请重新选择'); };
|
|
reader.onload = function () {
|
|
if (!active()) return;
|
|
var img = new Image();
|
|
img.onload = function () {
|
|
if (!active()) return;
|
|
var scale = Math.min(1, 1280 / Math.max(img.width, img.height));
|
|
var c = document.createElement('canvas');
|
|
c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale);
|
|
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
|
|
var url = c.toDataURL('image/jpeg', 0.88);
|
|
shutterLock = true; // 校验期间不接受新的拍摄/选图
|
|
showCamStatus('照片检查中…');
|
|
checkStill(url, CAM_STEPS[step].target).then(function (err) {
|
|
if (!active()) return;
|
|
hideCamStatus();
|
|
shutterLock = false;
|
|
if (err) { toast(err); voice.play(VOICE.fail, { cut: true }); return; } // 不合格:停在本步重拍
|
|
onPhotoCaptured(url);
|
|
});
|
|
};
|
|
img.onerror = function () { fail('照片读取失败,请重新选择'); };
|
|
img.src = reader.result;
|
|
};
|
|
reader.readAsDataURL(f);
|
|
}
|
|
// 相册也须等模型就绪后校验单人、正确侧脸和光线,失败停留在当前步骤。
|
|
function checkStill(dataUrl, target) {
|
|
if (IS_MOCK) return Promise.resolve('');
|
|
if (typeof faceapi === 'undefined') return Promise.resolve('识别服务未加载,请刷新后重试');
|
|
return loadModels().then(function () { return new Promise(function (resolve) {
|
|
var img = new Image();
|
|
img.onload = function () {
|
|
faceapi
|
|
.detectAllFaces(img, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
|
|
.withFaceLandmarks()
|
|
.then(function (faces) {
|
|
if (!faces.length) { resolve('照片中未识别到人脸,请重新选择'); return; }
|
|
if (faces.length !== 1) { resolve('请使用只有一人的照片'); return; }
|
|
var d = faces[0], pose = HeadPose.check(d.landmarks.positions, target, false);
|
|
if (!pose.ok) { resolve('请按顺序选择' + (target === 'front' ? '正脸' : target === 'right' ? '右侧脸' : '左侧脸') + '照片,' + pose.hint); return; }
|
|
resolve(lightingHint(faceLuma(d.detection.box, img)));
|
|
}, function () { resolve('照片检查失败,请重新选择'); });
|
|
};
|
|
img.onerror = function () { resolve('照片读取失败,请重新选择'); };
|
|
img.src = dataUrl;
|
|
}); }).catch(function () { return '识别服务加载失败,请重试'; });
|
|
}
|
|
function showCamStatus(text) { var s = $('#cam-status'); s.textContent = text; s.classList.add('show'); }
|
|
function hideCamStatus() { $('#cam-status').classList.remove('show'); }
|
|
// 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程
|
|
camFile.addEventListener('change', function () {
|
|
shutterLock = false; // 选完(含空选择)即解锁;推进窗口由 onPhotoCaptured 重新上锁
|
|
var f = camFile.files && camFile.files[0];
|
|
camFile.value = '';
|
|
readPhotoFile(f);
|
|
});
|
|
// 部分系统取消相机/相册只发 cancel 不发 change:兜底解锁,防快门卡死
|
|
camFile.addEventListener('cancel', function () { shutterLock = false; });
|
|
// 相册选图(相机页「相册」入口):选当前步的图片,与实拍同管线推进
|
|
albumFile.addEventListener('change', function () {
|
|
shutterLock = false;
|
|
var f = albumFile.files && albumFile.files[0];
|
|
albumFile.value = '';
|
|
readPhotoFile(f);
|
|
});
|
|
albumFile.addEventListener('cancel', function () { shutterLock = false; });
|
|
|
|
/* ─── 语音播报(WebAudio 单通道;assets/voice/<id>.mp3,缺失静默跳过;同条 4s 去重)
|
|
文案/文件名契约:docs/语音播报内容设计文档_V1.0.md ─── */
|
|
var VOICE = {
|
|
step: ['step1-front', 'step2-rightface', 'step3-leftface'],
|
|
hint: {
|
|
'未检测到人脸': 'hint-noface', '请靠近一点': 'hint-near', '请远一点': 'hint-far',
|
|
'请正对屏幕': 'hint-front', '请向左转头': 'hint-turn-left', '请向右转头': 'hint-turn-right',
|
|
'保持不动': 'hint-hold'
|
|
},
|
|
countdown: 'countdown-321', shot: 'shot-done', analyzing: 'analyzing', fail: 'analyze-fail'
|
|
};
|
|
// 两类播报:流程音(步骤引导/拍摄完成/倒计时/分析中/失败)排队顺序播,互不截断;
|
|
// 姿态提示可互相打断,但不截断流程音——流程音播放期间只挂起最新一条,收尾后补播
|
|
var voice = (function () {
|
|
var ctx = null, lastAt = {}, files = null; // files:null=清单未加载;{}=无可用语音
|
|
var bufs = {}; // id → Promise<AudioBuffer|null>(解锁后预加载,弱网下首播不等下载)
|
|
var cur = null; // { id, kind:'flow'|'hint', token, src, end }
|
|
var queue = []; // 排队中的流程音
|
|
var pendingHint = null; // 流程音期间到来的姿态提示(只留最新)
|
|
var waiters = []; // afterCurrent 回调:全部播报收尾后执行
|
|
// 清单制:只播 assets/voice/manifest.json 里声明的文件,不逐个探文件(避免控制台 404 噪音)
|
|
fetch('assets/voice/manifest.json')
|
|
.then(function (r) { return r.ok ? r.json() : { files: [] }; })
|
|
.then(function (m) { files = {}; (m.files || []).forEach(function (f) { files[f] = 1; }); preload(); })
|
|
.catch(function () { files = {}; });
|
|
function decode(ab) {
|
|
return new Promise(function (resolve) {
|
|
try {
|
|
var p = ctx.decodeAudioData(ab, resolve, function () { resolve(null); });
|
|
if (p && p.catch) p.catch(function () { resolve(null); });
|
|
} catch (e) { resolve(null); }
|
|
});
|
|
}
|
|
function load(id) {
|
|
if (!bufs[id]) {
|
|
bufs[id] = fetch('assets/voice/' + id + '.mp3')
|
|
.then(function (r) { return r.ok ? r.arrayBuffer() : null; })
|
|
.then(function (ab) { return ab ? decode(ab) : null; })
|
|
.catch(function () { return null; })
|
|
.then(function (buf) { if (!buf) delete bufs[id]; return buf; }); // 失败不缓存,下次再试
|
|
}
|
|
return bufs[id];
|
|
}
|
|
function preload() { if (ctx && files) Object.keys(files).forEach(load); }
|
|
function unlock() { // iOS/微信自动播放限制:必须在用户手势里初始化并 resume
|
|
if (!ctx) {
|
|
try { ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { ctx = null; }
|
|
preload();
|
|
}
|
|
if (ctx && ctx.state === 'suspended') ctx.resume();
|
|
}
|
|
function stopCur() {
|
|
if (!cur) return;
|
|
var c = cur;
|
|
cur = null;
|
|
if (c.src) { c.src.onended = null; try { c.src.stop(); } catch (e) {} }
|
|
}
|
|
function start(id, kind) {
|
|
stopCur();
|
|
lastAt[id] = Date.now();
|
|
var token = {};
|
|
cur = { id: id, kind: kind, token: token, src: null, end: 0 };
|
|
load(id).then(function (buf) {
|
|
if (!cur || cur.token !== token) return; // 加载期间已被打断
|
|
if (!buf || !ctx) { cur = null; drain(); return; }
|
|
var src = ctx.createBufferSource();
|
|
src.buffer = buf;
|
|
src.connect(ctx.destination);
|
|
cur.src = src;
|
|
cur.end = Date.now() + buf.duration * 1000;
|
|
src.onended = function () { if (cur && cur.token === token) { cur = null; drain(); } };
|
|
src.start(0);
|
|
});
|
|
}
|
|
function drain() {
|
|
if (cur) return;
|
|
if (queue.length) { start(queue.shift(), 'flow'); return; }
|
|
if (pendingHint) { var h = pendingHint; pendingHint = null; start(h, 'hint'); return; }
|
|
var w = waiters;
|
|
waiters = [];
|
|
w.forEach(function (fn) { fn(); });
|
|
}
|
|
function busyWithFlow() { return (cur && cur.kind === 'flow') || queue.length > 0; }
|
|
// opts.hint:姿态提示(随检测帧反复触发,同条 4s 去重);opts.cut:打断在播与排队(失败提示不必等「分析中」播完)
|
|
// 流程音是离散事件,每次都播(连拍的「拍摄完成」、取消后重来的倒计时)
|
|
function play(id, opts) {
|
|
opts = opts || {};
|
|
if (!ctx || !id || ctx.state !== 'running' || !files || !files[id]) return;
|
|
if (opts.hint) {
|
|
if (lastAt[id] && Date.now() - lastAt[id] < 4000) return;
|
|
if (busyWithFlow()) { pendingHint = id; return; }
|
|
start(id, 'hint');
|
|
return;
|
|
}
|
|
if (opts.cut) { queue = []; pendingHint = null; start(id, 'flow'); return; }
|
|
if (busyWithFlow()) { if (queue.indexOf(id) < 0) queue.push(id); return; }
|
|
start(id, 'flow'); // 在播的姿态提示让位给流程音
|
|
}
|
|
function dropHint() { pendingHint = null; } // 提示已清空/换步:挂起的旧提示作废
|
|
// 流程切走:作废排队/挂起/等待;hard 连在播一并掐断(退出拍照、页面隐藏),否则让在播自然收尾(出报告)
|
|
function stop(hard) {
|
|
queue = [];
|
|
pendingHint = null;
|
|
waiters = [];
|
|
if (hard) stopCur();
|
|
}
|
|
// 等全部播报(含排队与挂起提示)自然收尾后执行 cb(倒计时接续「保持不动」,不拦腰切断);空闲则立即执行
|
|
function afterCurrent(cb) {
|
|
var tail = cur && cur.src && Date.now() >= cur.end - 60; // 只剩尾音视为已收尾
|
|
if ((!cur || tail) && !queue.length && !pendingHint) { cb(); return null; }
|
|
var fn = function () { cb(); };
|
|
waiters.push(fn);
|
|
return function () { waiters = waiters.filter(function (w) { return w !== fn; }); };
|
|
}
|
|
return { unlock: unlock, play: play, dropHint: dropHint, afterCurrent: afterCurrent, stop: stop };
|
|
})();
|
|
document.addEventListener('click', voice.unlock); // 任意点击即完成解锁
|
|
|
|
/* ─── 姿态检测(face-api + HeadPose):动态捕捉——距离/居中/角度/光线全部合格并稳住片刻即自动抓拍,不倒计时、无手动快门 ─── */
|
|
var MODEL_URL = 'vendor/faceapi/models';
|
|
var DETECT_INTERVAL = 150; // 检测帧间隔(ms)
|
|
var DETECT_INPUT_SIZE = 416; // tinyFaceDetector 输入尺寸:320 下正脸置信度仅 0.67 且框偏松,416 置信度 ~0.99 框更准(CPU 端略慢,移动端 GPU 无感)
|
|
var DETECT_SCORE = 0.3; // 置信度阈值:0.4 在噪声帧易漏检,0.3 配合距离/角度闸门足够抗误检
|
|
var MISS_FRAMES_MAX = 3; // 连续漏检 ≥3 帧(~450ms)才报「未检测到人脸」,容忍单帧抖动
|
|
var OK_HOLD_MS = 450; // 条件连续合格时长:滤掉一闪而过的合格帧(防运动模糊),达标立即抓拍
|
|
var NO_PROGRESS_MS = 12000; // 单步超过该时长仍未抓拍→浮出调整提示
|
|
var PREVIEW_SCALE = 0.85; // 与 CSS #cam-video 的 scale 一致:预览缩小 15%
|
|
// 以屏幕显示的人脸大小判断距离,预览缩小 15% 后允许用户更靠近镜头。
|
|
var FACE_NEAR = 0.26, FACE_FAR = 0.58; // 按缩放后的屏幕占比判断,不受横竖视频分辨率影响
|
|
var CENTER_TOL = 0.18; // 人脸中心偏离取景中线超过屏宽 18% → 提示移入取景框
|
|
var LUMA_DARK = 65, LUMA_BRIGHT = 235; // 脸部平均亮度(0–255):过暗/过曝提示调整光线
|
|
|
|
var modelsPromise = null, modelsOk = false, detectDisabled = false; // detectDisabled:face-api 缺失→全程手动;模型加载失败仅当次禁用,下次进拍照页重试
|
|
var detRunning = false, detTimer = null; // 检测循环
|
|
var detEpoch = 0; // 启停/切换镜头时作废上一轮未完成的异步检测
|
|
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
|
|
var nudgeTimer = null; // 12s 无进展提示
|
|
var capturing = false; // 白屏补光抓帧中(暂停姿态判定)
|
|
var lastHint = ''; // 提示文案去重
|
|
|
|
// 模型只加载一次(缓存 promise);失败清空缓存,下次进拍照页重试
|
|
function loadModels() {
|
|
if (!modelsPromise) {
|
|
modelsPromise = Promise.all([
|
|
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
|
|
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL)
|
|
]).then(function () { modelsOk = true; },
|
|
function (err) { modelsPromise = null; throw err; });
|
|
}
|
|
return modelsPromise;
|
|
}
|
|
|
|
// 扫描带裁切:复制镂空轮廓到 <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) { // '' 静态白框 | 'no-face' 呼吸动画 | 'ok' 品牌绿定格
|
|
var m = $('#cam-mask');
|
|
m.classList.toggle('no-face', cls === 'no-face');
|
|
m.classList.toggle('ok', cls === 'ok');
|
|
}
|
|
function setHint(text, ok, silent) {
|
|
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; } // 手动快门只用于 mock 调试,正式流程始终自动捕捉
|
|
function syncCamMode() {
|
|
$('#s-cam').classList.toggle('cam-manual', isManualMode());
|
|
$('#cam-mask').classList.toggle('scanning', camLive && !isManualMode() && modelsOk && !detectDisabled);
|
|
$('[data-action="retry-camera"]').hidden = IS_MOCK || (!camFallback && !detectDisabled);
|
|
}
|
|
|
|
// 每步进入/回退时的检测状态机重置(蒙版/提示/无进展计时全部归位)
|
|
function resetDetectState() {
|
|
okSince = 0;
|
|
missFrames = 0;
|
|
lastHint = '';
|
|
clearTimeout(nudgeTimer);
|
|
hideNudge();
|
|
setHint('');
|
|
setMask(camLive && !isManualMode() ? 'no-face' : '');
|
|
syncCamMode();
|
|
armNudge();
|
|
}
|
|
// 单步 12s 仍未抓拍→底部浮出调整提示(不阻塞流程);进入下一步/抓帧时重置
|
|
function armNudge() {
|
|
clearTimeout(nudgeTimer);
|
|
hideNudge();
|
|
if (isManualMode() || camFallback || detectDisabled) return;
|
|
if (currentScreen !== 's-cam') return;
|
|
nudgeTimer = setTimeout(function () {
|
|
if (currentScreen === 's-cam' && !shutterLock) $('#cam-nudge').classList.add('show');
|
|
}, NO_PROGRESS_MS);
|
|
}
|
|
|
|
function maybeStartDetect() {
|
|
if (detRunning || IS_MOCK || detectDisabled || !modelsOk || !camLive || currentScreen !== 's-cam') return;
|
|
detRunning = true;
|
|
syncCamMode();
|
|
detTick(++detEpoch);
|
|
}
|
|
function stopDetect() {
|
|
detRunning = false;
|
|
detEpoch++;
|
|
okSince = 0;
|
|
clearTimeout(detTimer);
|
|
clearTimeout(nudgeTimer);
|
|
hideNudge();
|
|
}
|
|
// 递归 setTimeout 而非 setInterval:检测是异步的,避免帧与帧堆叠
|
|
function detTick(epoch) {
|
|
if (!detRunning || epoch !== detEpoch) return;
|
|
detTimer = setTimeout(function () {
|
|
if (!detRunning || epoch !== detEpoch) return;
|
|
detectFrame(epoch).then(function () { detTick(epoch); }, function () { detTick(epoch); });
|
|
}, DETECT_INTERVAL);
|
|
}
|
|
function detectFrame(epoch) {
|
|
if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态
|
|
var step = camStep, seq = camSeq; // 快照:检测是异步的,帧落地时步骤/流程可能已变
|
|
return faceapi
|
|
.detectAllFaces(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
|
|
.withFaceLandmarks()
|
|
.then(function (faces) {
|
|
if (step !== camStep || seq !== camSeq || epoch !== detEpoch) return;
|
|
if (detRunning && !shutterLock && !capturing) onDetect(faces);
|
|
})
|
|
.catch(function () { if (epoch === detEpoch) okSince = 0; });
|
|
}
|
|
// 人脸框中心相对取景中线的水平偏移(占屏宽比例);按 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;
|
|
}
|
|
function displayedFaceRatio(box) {
|
|
var vw = camVideo.videoWidth, vh = camVideo.videoHeight, W = camVideo.clientWidth, H = camVideo.clientHeight;
|
|
if (!vw || !vh || !W || !H) return 0;
|
|
return box.height * Math.max(W / vw, H / vh) * PREVIEW_SCALE / H;
|
|
}
|
|
function faceOffsetY(box) {
|
|
var vw = camVideo.videoWidth, vh = camVideo.videoHeight, W = camVideo.clientWidth, H = camVideo.clientHeight;
|
|
if (!vw || !vh || !W || !H) return 1;
|
|
var s = Math.max(W / vw, H / vh) * PREVIEW_SCALE;
|
|
var maskScale = Math.min(W / 375, H / 726);
|
|
var targetY = (H - 726 * maskScale) / 2 + 320 * maskScale;
|
|
return (H / 2 + (box.y + box.height / 2 - vh / 2) * s - targetY) / H;
|
|
}
|
|
function lightingHint(luma) {
|
|
if (luma == null) return '画面读取失败,请重试';
|
|
if (luma < LUMA_DARK) return '请到光线充足处';
|
|
if (luma > LUMA_BRIGHT) return '光线过强,请避开强光';
|
|
return '';
|
|
}
|
|
// 脸部平均亮度:人脸框缩到 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; // 连续漏检帧计数:单帧抖动不报「未检测到人脸」
|
|
function onDetect(faces) {
|
|
if (faces.length > 1) {
|
|
okSince = 0; setMask(''); setHint('请保持单人出镜', false); return;
|
|
}
|
|
var d = faces[0];
|
|
var hint = '', ok = false;
|
|
missFrames = d ? 0 : missFrames + 1;
|
|
var hasFace = !!d, noFace = !hasFace && missFrames >= MISS_FRAMES_MAX;
|
|
if (noFace) {
|
|
hint = '未检测到人脸';
|
|
} else if (!hasFace) {
|
|
okSince = 0; // 文案抗抖,但任何漏检都中断连续合格时长
|
|
return;
|
|
} else {
|
|
// 提示优先级:无人脸 > 距离 > 居中 > 角度 > 光线
|
|
var box = d.detection.box;
|
|
var ratio = displayedFaceRatio(box);
|
|
if (ratio < FACE_NEAR) hint = '请靠近一点';
|
|
else if (ratio > FACE_FAR) hint = '请远一点';
|
|
else if (Math.abs(faceOffsetX(box)) > CENTER_TOL || Math.abs(faceOffsetY(box)) > 0.16) hint = '请将脸部移入取景框';
|
|
else {
|
|
// 按摄像头原始帧判断真实左右;持续转错方向也不能自动交换左右照片。
|
|
var tgt = CAM_STEPS[camStep].target;
|
|
var pose = HeadPose.check(d.landmarks.positions, tgt, false);
|
|
if (!pose.ok) hint = pose.hint;
|
|
else {
|
|
hint = lightingHint(faceLuma(box));
|
|
ok = !hint;
|
|
}
|
|
}
|
|
window.__dbgRatio = ratio; // 自动化校准/测试用(检测框高/视频高)
|
|
}
|
|
setMask(ok ? 'ok' : (noFace ? 'no-face' : ''));
|
|
// 合格时「保持不动」只显示不播报:随即抓拍,「拍摄完成」会截断它
|
|
setHint(ok ? '保持不动' : hint, ok, ok);
|
|
if (!ok) { okSince = 0; return; }
|
|
if (!okSince) okSince = Date.now();
|
|
else if (Date.now() - okSince >= OK_HOLD_MS) { okSince = 0; 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 },
|
|
{ step: '2/3<br>请右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right', mask: 2 },
|
|
{ step: '3/3<br>请左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left', mask: 1 }
|
|
];
|
|
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); // 蒙版按步骤切换轮廓
|
|
document.querySelectorAll('[data-capture-step]').forEach(function (el, i) {
|
|
el.classList.toggle('done', i < camStep);
|
|
el.classList.toggle('current', i === camStep);
|
|
if (i === camStep) el.setAttribute('aria-current', 'step'); else el.removeAttribute('aria-current');
|
|
});
|
|
applyMirror();
|
|
resetDetectState();
|
|
voice.play(VOICE.step[camStep]); // 步骤引导语音
|
|
}
|
|
|
|
var camSeq = 0; // 流程令牌:退出拍照/开始拍照时递增,使未决的快门/分析回调失效
|
|
var shutterLock = false; // 快门防连点锁:流程进行中直接忽略
|
|
|
|
// 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步
|
|
function onPhotoCaptured(dataUrl) {
|
|
if (currentScreen !== 's-cam' || !dataUrl || photos.length !== camStep) return;
|
|
shutterLock = true; // 推进窗口上锁,直到下一步/送检
|
|
photos[camStep] = dataUrl;
|
|
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废
|
|
var seq = camSeq;
|
|
setTimeout(function () {
|
|
if (seq !== camSeq) return; // 期间已退出/重新开始:放弃本次推进
|
|
if (camStep < CAM_STEPS.length - 1) {
|
|
camStep++;
|
|
renderCamStep(); // 内含检测状态机重置
|
|
shutterLock = false; // 进入下一步即解锁
|
|
} else {
|
|
submitPhotos(seq); // 三张拍完:送检
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
// 合格即抓取当前帧,随后闪白反馈;不在延时期间抓取已偏离角度的照片。
|
|
function doCapture() {
|
|
if (shutterLock) return;
|
|
if (!camLive) return;
|
|
var shot = captureFromVideo();
|
|
if (!shot) { okSince = 0; return; }
|
|
shutterLock = true;
|
|
capturing = true;
|
|
clearTimeout(nudgeTimer);
|
|
hideNudge();
|
|
var seq = camSeq, camera = camSeqCam;
|
|
var fill = $('#cam-fill');
|
|
fill.classList.add('on'); // .on 无过渡,瞬间点亮
|
|
if (navigator.vibrate) navigator.vibrate(30);
|
|
setTimeout(function () {
|
|
if (seq !== camSeq || camera !== camSeqCam) return;
|
|
fill.classList.remove('on');
|
|
capturing = false;
|
|
onPhotoCaptured(shot);
|
|
}, 180);
|
|
}
|
|
|
|
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
|
|
function submitPhotos(seq) {
|
|
camStep = 0;
|
|
photos = photosByView(photos);
|
|
openOverlay('p-analyzing');
|
|
voice.play(VOICE.analyzing); // 「正在生成您的肌肤报告」
|
|
// 档案与检测一起上报(后台记录用):年龄非法值服务端按 null 处理
|
|
var profile = {
|
|
age: +($('#f-age').value || 0) || null,
|
|
gender: $('#f-gender').value || '',
|
|
region: regionSel.value || '',
|
|
skinType: SKINS[skinIndex].name
|
|
};
|
|
var t0 = Date.now();
|
|
requestAnalyze(photos, seq, profile, function (errMsg, payload) {
|
|
if (seq !== camSeq) return; // 流程已被重置:丢弃
|
|
var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁
|
|
setTimeout(function () {
|
|
if (seq !== camSeq) return;
|
|
closeOverlay('p-analyzing');
|
|
stopCamera();
|
|
voice.stop(); // 结果已出:排队中的「分析中」等不再补播
|
|
shutterLock = false; // 流程结束解锁
|
|
if (errMsg) {
|
|
voice.play(VOICE.fail, { cut: true });
|
|
toast(errMsg);
|
|
showScreen('s-profile'); // 回档案页,允许重新提交再拍
|
|
popHistorySilently();
|
|
return;
|
|
}
|
|
var vm = null;
|
|
try {
|
|
vm = SkinScore.buildViewModel(payload.result, payload.side, {
|
|
realAge: profile.age,
|
|
skinType: profile.skinType
|
|
});
|
|
} catch (e) { vm = null; }
|
|
if (!vm) {
|
|
toast('报告生成失败,请重新拍摄');
|
|
showScreen('s-profile');
|
|
popHistorySilently();
|
|
return;
|
|
}
|
|
renderReport(vm, photos[0], photos[1], payload); // img1=正脸实拍,payload 供检测可视化取 face_maps/marks
|
|
storeReport(payload, photos, profile); // 以检测为单位存三张照片+分析结果(本地历史)
|
|
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
|
|
showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机
|
|
}, wait);
|
|
});
|
|
}
|
|
|
|
function shutter() {
|
|
if (!IS_MOCK) return;
|
|
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,便于离线/自动化验证) ─── */
|
|
var ANALYZE_TIMEOUT_MS = 45000; // 检测服务超时阈值:超时按「开小差」处理回档案页
|
|
function requestAnalyze(ph, seq, profile, done) {
|
|
if (IS_MOCK && typeof SkinScore !== 'undefined') {
|
|
setTimeout(function () {
|
|
if (seq !== camSeq) return; // mock 同样尊重取消/重置
|
|
var fx = window.MOCK_FIXTURE; // assets/mock/report-fixture.js(仅 mock 模式注入)
|
|
if (fx) {
|
|
// marks 坐标空间=fixture 照片空间:三连拍替换为 fixture 照片,保证可视化叠加对齐
|
|
ph[0] = fx.photos[0]; ph[1] = fx.photos[1]; ph[2] = fx.photos[2];
|
|
done(null, { result: fx.result, side: fx.side, face_rectangle: fx.face_rectangle });
|
|
} else {
|
|
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
|
|
}
|
|
}, 900);
|
|
return;
|
|
}
|
|
var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, ANALYZE_TIMEOUT_MS);
|
|
fetch('/api/skin-analyze', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
front_image: ph[0], left_image: ph[1], right_image: ph[2],
|
|
profile: { // 档案随检测上报,后台记录用(服务端缺省 null)
|
|
real_age: profile && profile.age,
|
|
gender: profile && profile.gender,
|
|
region: profile && profile.region,
|
|
daily_skin_type: profile && profile.skinType
|
|
}
|
|
}),
|
|
signal: ctrl ? ctrl.signal : undefined // 老内核无 AbortController:仅靠令牌丢弃迟到响应
|
|
}).then(function (res) { return res.json(); })
|
|
.then(function (data) {
|
|
clearTimeout(timer);
|
|
if (seq !== camSeq) return; // 流程已被重置,丢弃过期响应
|
|
if (!data.ok) done(data.message || '检测失败,请重试');
|
|
else done(null, { result: data.result, side: data.side });
|
|
})
|
|
.catch(function (err) {
|
|
clearTimeout(timer);
|
|
if (seq !== camSeq) return;
|
|
if (err && err.name === 'AbortError') done('检测服务开小差了,请稍候重试'); // 45s 超时主动中断
|
|
else done('网络异常,请检查网络后重试');
|
|
});
|
|
}
|
|
|
|
/* ─── SkinScore viewModel → 报告渲染(评分公式见 js/skin-score.js,不直接向用户展示 P 值) ─── */
|
|
var LV_CLASS = { '良好': 'lv-good', '改善': 'lv-warn', '预警': 'lv-bad' };
|
|
var CAT_DOM = {
|
|
contour: { s: '#s-contour', lv: '#lv-contour', tx: '#ditem-para1' },
|
|
wrinkles: { s: '#s-wrinkles', lv: '#lv-wrinkles', tx: '#ditem-para2' },
|
|
smooth: { s: '#s-smooth', lv: '#lv-smooth', tx: '#tx-smooth' },
|
|
tone: { s: '#s-tone', lv: '#lv-tone', tx: '#tx-tone' },
|
|
sens: { s: '#s-sens', lv: '#lv-sens', tx: '#tx-sens' },
|
|
acne: { s: '#s-acne', lv: '#lv-acne', tx: '#tx-acne' }
|
|
};
|
|
|
|
function esc(s) {
|
|
return String(s == null ? '' : s)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
// 维度卡:整数位大字 + 小数位小字;进度条按 S/10;最高分浅绿、最低分橙色并标红挂「优先关注」
|
|
function setDim(key, dim, rank) {
|
|
var box = document.querySelector('.dim[data-dim="' + key + '"]');
|
|
var el = $('#dim-' + key), has = dim.S != null;
|
|
var parts = has ? dim.S.toFixed(1).split('.') : ['-', '-'];
|
|
el.innerHTML = '<b>' + parts[0] + '</b>.' + parts[1];
|
|
el.classList.toggle('dim-bad', has && rank === 'worst');
|
|
box.classList.toggle('worst', has && rank === 'worst');
|
|
box.classList.toggle('best', has && rank === 'best');
|
|
box.querySelector('.dim-bar i').style.width = has ? Math.max(0, Math.min(100, dim.S * 10)) + '%' : '0';
|
|
}
|
|
|
|
function setCat(key, cat) {
|
|
var dom = CAT_DOM[key];
|
|
// 设计稿分类卡只有话术正文:不展示 S 与档位徽章;缺数据分类以说明代正文
|
|
$(dom.tx).textContent = cat.P == null ? '本次未检出有效数据' : cat.text;
|
|
}
|
|
|
|
/* ─── 报告页检测可视化(face_maps 图层 + marks 坐标叠加,数据驱动) ─── */
|
|
// 6 个分类各声明图片来源:map=整幅图层直接出图;overlay=透明增强图层盖在正脸上;marks=坐标标记画在正脸上
|
|
// 增删标记类型/图层:改对应分类的一行即可
|
|
var VIZ_CAT = {
|
|
contour: { img: '#ditem-img1', photo: true }, // 轮廓:用户正脸实拍
|
|
wrinkles: { img: '#ditem-img2', overlay: 'texture_enhanced_lines', dim: 0.55 }, // 皱纹:正脸压暗+皱纹增强线(图层几乎全透明,直挂在白卡上不可读,故合成)
|
|
smooth: { img: '#viz-smooth', photo: true, marks: [ // 光滑:正脸+色斑/痣/痤疮标记
|
|
{ key: 'brown_spot', color: '#B26A00' }, // 色斑
|
|
{ key: 'mole', color: '#282828' }, // 痣
|
|
{ key: 'acne', color: '#D30C0C' } // 痤疮
|
|
] },
|
|
tone: { img: '#viz-tone', blendMap: 'water_area' }, // 肤色水润:正脸×水分图正片叠底(water_area,接口返回)
|
|
sens: { img: '#viz-sens', blendMap: 'red_area' }, // 敏感:正脸×红区图正片叠底(直挂白底过淡不可读)
|
|
acne: { img: '#viz-acne', photo: true, marks: [ // 痘痘:正脸+闭口粉刺/痤疮标记
|
|
{ key: 'closed_comedones', color: '#E8A13D' }, // 闭口粉刺
|
|
{ key: 'acne', color: '#D30C0C' }, // 痤疮
|
|
{ key: 'acne_pustule', color: '#D30C0C' }, // 脓包
|
|
{ key: 'acne_nodule', color: '#8E0000' } // 结节
|
|
] }
|
|
};
|
|
|
|
/* viz-pure-begin(纯函数段:与 DOM 无关,node 冒烟按锚点抽离断言,勿改锚点注释) */
|
|
// 从 result 提取某分类的标记集合:polygon 与 rectangle 都保留,缺该标记类型则跳过
|
|
function vizCollectMarks(markDefs, result) {
|
|
var out = [];
|
|
if (!markDefs || !result) return out;
|
|
for (var i = 0; i < markDefs.length; i++) {
|
|
var src = result[markDefs[i].key];
|
|
if (!src || typeof src !== 'object') continue;
|
|
var m = { color: markDefs[i].color, polygons: [], rects: [] };
|
|
var polys = src.polygon || [];
|
|
for (var p = 0; p < polys.length; p++) {
|
|
var pts = [];
|
|
for (var q = 0; q < (polys[p] || []).length; q++) {
|
|
var pt = polys[p][q];
|
|
if (pt && typeof pt.x === 'number' && typeof pt.y === 'number') pts.push([pt.x, pt.y]);
|
|
}
|
|
if (pts.length > 2) m.polygons.push(pts);
|
|
}
|
|
var rects = src.rectangle || [];
|
|
for (var r = 0; r < rects.length; r++) {
|
|
var rc = rects[r];
|
|
if (rc && typeof rc.left === 'number' && typeof rc.top === 'number') {
|
|
m.rects.push({ left: rc.left, top: rc.top, width: +rc.width || 0, height: +rc.height || 0 });
|
|
}
|
|
}
|
|
if (m.polygons.length || m.rects.length) out.push(m);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// marks 坐标空间 = 上传图片像素空间:按「画布自然宽 / 坐标空间宽」换算(画布=照片自然尺寸时比值为 1)
|
|
function vizScaleMarks(marks, scale) {
|
|
var out = [];
|
|
for (var i = 0; i < marks.length; i++) {
|
|
var m = marks[i], nm = { color: m.color, polygons: [], rects: [] };
|
|
for (var p = 0; p < m.polygons.length; p++) {
|
|
var pts = [];
|
|
for (var q = 0; q < m.polygons[p].length; q++) pts.push([m.polygons[p][q][0] * scale, m.polygons[p][q][1] * scale]);
|
|
nm.polygons.push(pts);
|
|
}
|
|
for (var r = 0; r < m.rects.length; r++) {
|
|
var rc = m.rects[r];
|
|
nm.rects.push({ left: rc.left * scale, top: rc.top * scale, width: rc.width * scale, height: rc.height * scale });
|
|
}
|
|
out.push(nm);
|
|
}
|
|
return out;
|
|
}
|
|
/* viz-pure-end */
|
|
|
|
// face_maps 值兼容:已是 dataURL 直接用;裸 base64 按魔数补前缀;其它一律视为缺失(优雅降级)
|
|
function vizMapURL(v) {
|
|
if (typeof v !== 'string' || !v) return null;
|
|
if (v.indexOf('data:image/') === 0) return v;
|
|
if (v.indexOf('/9j/') === 0) return 'data:image/jpeg;base64,' + v;
|
|
if (v.indexOf('iVBOR') === 0) return 'data:image/png;base64,' + v;
|
|
return null;
|
|
}
|
|
|
|
function vizLoadImage(url, done) {
|
|
var img = new Image();
|
|
img.onload = function () { done(img.naturalWidth ? img : null); };
|
|
img.onerror = function () { done(null); };
|
|
img.src = url;
|
|
}
|
|
|
|
// canvas 合成:照片绘制到自然尺寸 → (可选压暗)→ (可选盖透明增强图层)→ 画坐标标记 → dataURL 挂到卡片 <img>
|
|
function vizCompose(photoURL, overlayURL, marks, imgEl, opts) {
|
|
vizLoadImage(photoURL, function (photo) {
|
|
if (!photo) return; // 照片不可用:保留占位
|
|
var finish = function (overlay) {
|
|
var w = photo.naturalWidth, h = photo.naturalHeight;
|
|
var cv = document.createElement('canvas');
|
|
cv.width = w; cv.height = h;
|
|
var ctx = cv.getContext('2d');
|
|
ctx.drawImage(photo, 0, 0, w, h);
|
|
if (opts && opts.dim) { ctx.fillStyle = 'rgba(0,0,0,' + opts.dim + ')'; ctx.fillRect(0, 0, w, h); } // 压暗底色让增强线可读
|
|
if (overlay) {
|
|
if (opts && opts.blend) ctx.globalCompositeOperation = opts.blend; // 正片叠底:白底图层(红区/色沉)叠到脸上
|
|
ctx.drawImage(overlay, 0, 0, w, h);
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
}
|
|
var scaled = vizScaleMarks(marks, w / photo.naturalWidth);
|
|
var lw = Math.max(3, Math.round(w / 200)); // 线宽随图走,卡片缩略后仍可见
|
|
var minBox = Math.round(w / 36); // 过小标记放大到最小框,保证缩略可见
|
|
for (var i = 0; i < scaled.length; i++) {
|
|
var m = scaled[i];
|
|
ctx.strokeStyle = m.color;
|
|
ctx.fillStyle = m.color;
|
|
ctx.lineWidth = lw;
|
|
var p, q, pts, rc;
|
|
for (p = 0; p < m.polygons.length; p++) {
|
|
pts = m.polygons[p];
|
|
ctx.beginPath();
|
|
ctx.moveTo(pts[0][0], pts[0][1]);
|
|
for (q = 1; q < pts.length; q++) ctx.lineTo(pts[q][0], pts[q][1]);
|
|
ctx.closePath();
|
|
ctx.globalAlpha = 0.22;
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
ctx.stroke();
|
|
}
|
|
for (p = 0; p < m.rects.length; p++) {
|
|
rc = m.rects[p];
|
|
var rw = Math.max(rc.width, minBox), rh = Math.max(rc.height, minBox);
|
|
var cx = rc.left + rc.width / 2, cy = rc.top + rc.height / 2;
|
|
ctx.strokeRect(cx - rw / 2, cy - rh / 2, rw, rh);
|
|
}
|
|
}
|
|
try { imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false; } catch (e) { /* 合成失败保留占位 */ }
|
|
};
|
|
if (overlayURL) vizLoadImage(overlayURL, finish);
|
|
else finish(null);
|
|
});
|
|
}
|
|
|
|
// 报告页 6 张检测图统一入口:任一输入缺失 → 该卡保留既有占位(不报错)
|
|
function renderViz(payload, frontPhoto) {
|
|
var result = payload && payload.result;
|
|
var maps = (result && result.face_maps) || {};
|
|
for (var key in VIZ_CAT) {
|
|
var cfg = VIZ_CAT[key];
|
|
var imgEl = $(cfg.img);
|
|
if (!imgEl) continue;
|
|
if (cfg.map) { // 整幅图层:图层缺失 → 保留占位
|
|
var u = vizMapURL(maps[cfg.map]);
|
|
if (u) { imgEl.src = u; imgEl.hidden = false; }
|
|
continue;
|
|
}
|
|
if (cfg.blendMap) { // 图层×正脸正片叠底(色沉/红区):无照片时直挂图层;图层缺失 → 保留占位
|
|
var bu = vizMapURL(maps[cfg.blendMap]);
|
|
if (!bu) continue;
|
|
if (!frontPhoto) { imgEl.src = bu; imgEl.hidden = false; continue; }
|
|
vizCompose(frontPhoto, bu, [], imgEl, { blend: 'multiply' });
|
|
continue;
|
|
}
|
|
if (cfg.overlay) { // 透明增强图层(皱纹):有照片则合成(可按 cfg.dim 压暗),无照片单挂图层;图层缺失 → 保留占位
|
|
var ov = vizMapURL(maps[cfg.overlay]);
|
|
if (!ov) continue;
|
|
if (!frontPhoto) { imgEl.src = ov; imgEl.hidden = false; continue; }
|
|
vizCompose(frontPhoto, ov, [], imgEl, { dim: cfg.dim });
|
|
continue;
|
|
}
|
|
// 照片类(轮廓/光滑/痘痘):无照片 → 保留占位;有坐标则 canvas 叠加,无坐标纯照片
|
|
if (!frontPhoto) continue;
|
|
var marks = cfg.marks ? vizCollectMarks(cfg.marks, result) : [];
|
|
if (!marks.length) { imgEl.src = frontPhoto; imgEl.hidden = false; continue; }
|
|
vizCompose(frontPhoto, null, marks, imgEl);
|
|
}
|
|
}
|
|
|
|
function renderReport(vm, frontPhoto, sidePhoto, payload) {
|
|
// 头部:综合评分(vm.total 缺失时回退三维度 S 均分×10)
|
|
var total = vm.total;
|
|
if (total == null) {
|
|
var ds = [vm.dims.firm.S, vm.dims.vita.S, vm.dims.stab.S], sum = 0, n = 0, i;
|
|
for (i = 0; i < 3; i++) if (typeof ds[i] === 'number') { sum += ds[i]; n++; }
|
|
if (n) total = Math.round(sum / n * 10);
|
|
}
|
|
// 环形综合评分:满分 100,环长 2πr(r=52)≈326.73
|
|
$('#stat-score').innerHTML = total != null ? '<b>' + total + '</b><i>分</i>' : '<b>--</b><i>分</i>';
|
|
var ring = $('#ring-fg');
|
|
ring.style.strokeDashoffset = total != null ? 326.73 * (1 - Math.max(0, Math.min(100, total)) / 100) : 326.73;
|
|
$('#stat-age').innerHTML = vm.skinAgeDisplay != null ? '<b>' + vm.skinAgeDisplay + '</b><i>岁</i>' : '<b>--</b><i>岁</i>';
|
|
// 肌龄对比胶囊(新版设计「比实际年龄年轻2岁」);缺实际年龄或肌龄时不展示
|
|
var pill = $('#stat-age-note');
|
|
var diff = vm.skinAgeDisplay != null && vm.realAge ? vm.skinAgeDisplay - vm.realAge : null;
|
|
pill.hidden = diff == null;
|
|
pill.textContent = diff == null ? '' : diff < 0 ? '比实际年龄年轻' + (-diff) + '岁' : diff > 0 ? '比实际年龄大' + diff + '岁' : '与实际年龄相当';
|
|
|
|
// 三维度:按 P 排名,最低分标红 + 优先关注,最高分浅绿进度条
|
|
var dimPs = { firm: vm.dims.firm.P, vita: vm.dims.vita.P, stab: vm.dims.stab.P };
|
|
var ranked = Object.keys(dimPs).filter(function (k) { return dimPs[k] != null; })
|
|
.sort(function (a, b) { return dimPs[a] - dimPs[b]; });
|
|
var worstDimKey = ranked[0], bestDimKey = ranked.length > 1 ? ranked[ranked.length - 1] : null;
|
|
['firm', 'vita', 'stab'].forEach(function (k) {
|
|
setDim(k, vm.dims[k], k === worstDimKey ? 'worst' : k === bestDimKey ? 'best' : '');
|
|
});
|
|
// 关注行(设计稿两行:「近期优先关注:」灰 + 维度名深色下划线,下行为灰色口号)
|
|
var DIM_SLOGAN = {
|
|
'紧塑力': '紧塑提拉力,轮廓上扬不松垮。',
|
|
'活力感': '活力透亮感,水润澎弹有光泽。',
|
|
'稳定力': '强韧稳态力,锁水保湿抵御刺激。' // 设计稿原文
|
|
};
|
|
$('#dims-note').innerHTML = vm.focus.name
|
|
? '<span class="dims-note-l1">近期优先关注:<b>' + esc(vm.focus.name) + '</b></span><br><span class="dims-note-l2">' + esc(DIM_SLOGAN[vm.focus.name] || '') + '</span>'
|
|
: '<span class="dims-note-l1">近期优先关注:<b>---</b></span>';
|
|
|
|
// 三个 Tab 面板:各所辖两个分类(S + 档位徽章 + 话术正文)
|
|
var keys = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne'];
|
|
for (var i = 0; i < keys.length; i++) setCat(keys[i], vm.cats[keys[i]]);
|
|
|
|
// 检测可视化:轮廓=正脸实拍,其余按 VIZ_CAT 取 face_maps 图层或叠加 marks(payload 缺失时各卡保留占位)
|
|
renderViz(payload, frontPhoto);
|
|
|
|
// 降级标注:只展示用户可感知/可执行的(下颌线、侧脸、补拍类);
|
|
// 引擎内部技术项(上限锁未执行/字段未返回等)不面向用户展示
|
|
var notesEl = $('#vm-notes');
|
|
var userNotes = (vm.notes || [])
|
|
.filter(function (t) { return /下颌线|侧脸|补拍|重拍/.test(t); })
|
|
.map(function (t) { return t.replace(/\(quality=[^)]*\)/g, ''); }); // 接口质量码仅留在引擎留痕里
|
|
if (userNotes.length) {
|
|
notesEl.innerHTML = userNotes.map(function (t) { return '*' + esc(t); }).join('<br>');
|
|
notesEl.style.display = '';
|
|
} else {
|
|
notesEl.style.display = 'none';
|
|
}
|
|
|
|
// 护理建议(V2.3 §8 五段:肤质定制/最差2分类/精华/生活方式);综合分析与生活方式通栏已按需求移除
|
|
// 展示对齐 Figma:纯「序号、正文」,不显示段名【tag】、不加粗
|
|
var careBox = $('#care-list');
|
|
if (careBox) {
|
|
careBox.innerHTML = (vm.careAdvice || []).map(function (s, i) {
|
|
return '<p class="care-item">' + (i + 1) + '、' + esc(s.text) + '</p>';
|
|
}).join('');
|
|
}
|
|
|
|
// 精华推荐卡
|
|
// 精华推荐卡只放产品名 + 命中判据:推荐正文已是护理建议第 4 段(V2.3 §8.5),卡内不再重复同一句
|
|
$('#essence-name').textContent = vm.essence.name;
|
|
// 命中判据:引擎内部名 → 用户可读措辞(引擎值被真值回归逐字锁定,仅展示层映射)
|
|
var TRIGGER_LABEL = {
|
|
'均分档': vm.avgS != null ? '六项平均分 ' + vm.avgS : '六项平均分',
|
|
'最差项约束': vm.worst ? vm.worst.name + '得分偏低' : '单项得分偏低',
|
|
'年龄组档': vm.group ? '年龄段 ' + vm.group.replace('组', '') : '年龄段'
|
|
};
|
|
$('#essence-triggers').textContent = vm.essence.triggers.length
|
|
? '推荐依据:' + vm.essence.triggers.map(function (t) { return TRIGGER_LABEL[t] || t; }).join('、')
|
|
: '';
|
|
}
|
|
|
|
/* ─── 相机可用后加载检测模型,完成前等待,失败可重试 ─── */
|
|
function beginDetect() {
|
|
var camera = camSeqCam;
|
|
if (IS_MOCK || camFallback) { resetDetectState(); return; }
|
|
if (typeof faceapi === 'undefined') {
|
|
detectDisabled = true;
|
|
resetDetectState();
|
|
toast('自动识别暂不可用,请重试');
|
|
return;
|
|
}
|
|
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
|
|
resetDetectState();
|
|
if (!modelsOk) showCamStatus('检测引擎加载中…');
|
|
loadModels().then(function () {
|
|
if (camera !== camSeqCam || currentScreen !== 's-cam') return;
|
|
$('#cam-status').classList.remove('show');
|
|
maybeStartDetect();
|
|
}).catch(function () {
|
|
if (camera !== camSeqCam || currentScreen !== 's-cam') 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;点检测图放大,再点返回 ─── */
|
|
var zoomSource = null;
|
|
function openZoom(img) {
|
|
if (!img || !img.src || img.hidden) return;
|
|
zoomSource = img;
|
|
$('#zoom-img').src = img.src;
|
|
$('#zoom-img').alt = img.alt;
|
|
openOverlay('p-zoom');
|
|
history.pushState({ screen: currentScreen, overlay: 'p-zoom' }, '');
|
|
$('.zoom-close').focus();
|
|
}
|
|
function closeZoom() {
|
|
closeOverlay('p-zoom');
|
|
if (zoomSource) zoomSource.focus({ preventScroll: true });
|
|
if (history.state && history.state.overlay === 'p-zoom') popHistorySilently();
|
|
}
|
|
document.querySelectorAll('.ditem-img').forEach(function (img) {
|
|
img.tabIndex = 0;
|
|
img.setAttribute('role', 'button');
|
|
img.setAttribute('aria-label', img.alt + ',点击放大');
|
|
img.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openZoom(img); }
|
|
});
|
|
});
|
|
document.querySelectorAll('.card-detail').forEach(function (card) {
|
|
card.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('.dtab');
|
|
if (btn) { setDetailTab(card, btn.dataset.tab); return; }
|
|
var img = e.target.closest('.ditem-img');
|
|
if (img) openZoom(img);
|
|
});
|
|
});
|
|
$('#p-zoom').addEventListener('click', function () {
|
|
closeZoom();
|
|
});
|
|
|
|
/* ─── 动作分发 ─── */
|
|
var actions = {
|
|
'open-privacy': function () { openOverlay('p-privacy'); },
|
|
'close-privacy': function () { closeOverlay('p-privacy'); },
|
|
'close-policy': function () { closeOverlay('p-policy'); },
|
|
'agree-continue': function () {
|
|
if (!agreed) { toast('请先阅读并同意隐私协议'); return; }
|
|
closeOverlay('p-privacy');
|
|
showScreen('s-profile', 'push');
|
|
},
|
|
'submit-profile': function () {
|
|
refreshAge();
|
|
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; }
|
|
openOverlay('p-photoguide');
|
|
},
|
|
'close-photoguide': function () { closeOverlay('p-photoguide'); },
|
|
'start-camera': function () {
|
|
closeOverlay('p-photoguide');
|
|
camSeq++; // 使旧的快门/分析回调失效
|
|
camStep = 0;
|
|
photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍)
|
|
shutterLock = false; // 重新进入流程,保险解锁
|
|
showScreen('s-cam', 'push');
|
|
startCamera(); // beginDetect 在其成功回调里:确认相机可用才加载模型
|
|
renderCamStep(); // 在 startCamera 复位降级标记之后渲染,快门显隐按本次模式
|
|
},
|
|
'shutter': shutter,
|
|
'album': function () {
|
|
if (shutterLock || currentScreen !== 's-cam') return;
|
|
okSince = 0;
|
|
shutterLock = true; // 与降级快门同策略:前置上锁,change/cancel 解锁
|
|
albumFile.click();
|
|
},
|
|
'flip': function () { // 切换前后镜头
|
|
if (shutterLock || currentScreen !== 's-cam') return;
|
|
if (camFallback) { toast('系统相机模式下请在系统相机内切换'); return; }
|
|
if (!camStream) { toast('相机启动中,请稍候'); return; }
|
|
camFacing = camFacing === 'user' ? 'environment' : 'user';
|
|
camMirrored = camFacing === 'user';
|
|
startCamera(); // 复用启停与预热(camSeqCam 防竞态)
|
|
},
|
|
'retry-camera': function () {
|
|
if (shutterLock || currentScreen !== 's-cam') return;
|
|
startCamera();
|
|
},
|
|
'history': function () { openHistory(); },
|
|
'close-history': function () { closeOverlay('p-history'); },
|
|
'clear-history': function () {
|
|
saveReports([]);
|
|
renderHistoryList();
|
|
toast('历史报告已清空');
|
|
},
|
|
'more': function () { toast('了解更多功能开发中'); }
|
|
};
|
|
|
|
document.addEventListener('click', function (e) {
|
|
var el = e.target.closest('[data-action]');
|
|
if (!el) return;
|
|
var fn = actions[el.dataset.action];
|
|
if (fn) fn();
|
|
});
|
|
|
|
/* ─── 报告历史(localStorage):以检测为单位,存三张照片+分析结果(数据/坐标/文案),
|
|
不含 face_maps 整幅图层(体积原因);上限 5 条,超配额逐条淘汰 ─── */
|
|
var REPORTS_KEY = 'juzi.reports.v1', REPORTS_MAX = 5;
|
|
function loadReports() {
|
|
try { var v = JSON.parse(localStorage.getItem(REPORTS_KEY)); return Array.isArray(v) ? v : []; }
|
|
catch (e) { return []; }
|
|
}
|
|
function saveReports(list) {
|
|
try { localStorage.setItem(REPORTS_KEY, JSON.stringify(list)); return true; }
|
|
catch (e) { return false; } // QuotaExceeded:交给调用方淘汰重试
|
|
}
|
|
function storeReport(payload, ph, profile) {
|
|
if (!payload || !payload.result) return;
|
|
var result = Object.assign({}, payload.result);
|
|
delete result.face_maps; // 历史不存图层;可视化中图层类(皱纹/肤色/敏感)回看时降级为占位,坐标叠加类(光滑/痘痘)仍可用
|
|
var entry = {
|
|
id: 'r' + Date.now(), ts: Date.now(), profile: profile,
|
|
total: payload.result.score_info && payload.result.score_info.total_score,
|
|
skinAge: payload.result.skin_age && payload.result.skin_age.value,
|
|
photos: [ph[0] || null, ph[1] || null, ph[2] || null],
|
|
payload: { result: result, side: payload.side || null, face_rectangle: payload.face_rectangle || null }
|
|
};
|
|
var list = loadReports();
|
|
list.unshift(entry);
|
|
while (list.length > REPORTS_MAX) list.pop();
|
|
while (!saveReports(list) && list.length > 1) list.pop(); // 淘汰至可写
|
|
saveReports(list);
|
|
}
|
|
function fmtTime(ts) {
|
|
var d = new Date(ts);
|
|
function p(n) { return (n < 10 ? '0' : '') + n; }
|
|
return d.getFullYear() + '/' + (d.getMonth() + 1) + '/' + d.getDate() + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
|
|
}
|
|
function renderHistoryList() {
|
|
var box = $('#history-list'), list = loadReports();
|
|
if (!list.length) {
|
|
box.innerHTML = '<p class="history-empty">暂无历史报告<br>完成一次肌肤检测后会自动保存在这里</p>';
|
|
return;
|
|
}
|
|
box.innerHTML = list.map(function (e) {
|
|
var sub = [e.profile && e.profile.age ? e.profile.age + '岁' : '', e.profile && e.profile.skinType || '']
|
|
.filter(Boolean).join(' · ');
|
|
var score = e.total != null ? e.total : '--';
|
|
var img = e.photos && e.photos[0] ? '<img class="his-thumb" src="' + e.photos[0] + '" alt="">' : '<i class="his-thumb"></i>';
|
|
return '<div class="his-item" data-id="' + e.id + '">' + img +
|
|
'<div class="his-main"><b>' + fmtTime(e.ts) + '</b><span>' + sub + ' · 肌龄' + (e.skinAge != null ? e.skinAge : '--') + '岁</span></div>' +
|
|
'<div class="his-score"><b>' + score + '</b><span>分</span></div>' +
|
|
'<button class="his-del" data-del="' + e.id + '">删除</button></div>';
|
|
}).join('');
|
|
}
|
|
function openHistory() {
|
|
renderHistoryList();
|
|
openOverlay('p-history');
|
|
}
|
|
$('#history-list').addEventListener('click', function (e) {
|
|
var del = e.target.closest('[data-del]');
|
|
if (del) { // 删除单条(不打开报告)
|
|
e.stopPropagation();
|
|
saveReports(loadReports().filter(function (it) { return it.id !== del.dataset.del; }));
|
|
renderHistoryList();
|
|
return;
|
|
}
|
|
var item = e.target.closest('.his-item');
|
|
if (!item) return;
|
|
var entry = loadReports().filter(function (it) { return it.id === item.dataset.id; })[0];
|
|
if (!entry) return;
|
|
var vm = null;
|
|
try {
|
|
vm = SkinScore.buildViewModel(entry.payload.result, entry.payload.side, {
|
|
realAge: entry.profile && entry.profile.age, skinType: entry.profile && entry.profile.skinType
|
|
});
|
|
} catch (err) { vm = null; }
|
|
if (!vm) { toast('该记录已损坏'); return; }
|
|
renderReport(vm, entry.photos && entry.photos[0], null, entry.payload);
|
|
closeOverlay('p-history');
|
|
showScreen('s-report', currentScreen === 's-report' ? undefined : 'push');
|
|
});
|
|
|
|
/* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 photos[] 与 camStep) ─── */
|
|
function handleCamHidden() {
|
|
if (currentScreen !== 's-cam') return;
|
|
camSeq++; // 使未决的快门/推进/分析回调失效(白屏定时器醒来即自洁退出)
|
|
voice.stop(true);
|
|
shutterLock = false;
|
|
capturing = false;
|
|
$('#cam-fill').classList.remove('on'); // 白屏若亮着立即熄灭
|
|
closeOverlay('p-analyzing'); // 分析中被打断:回调已作废,弹层不能残留
|
|
stopCamera(); // 停检测循环/倒计时/nudge 并释放摄像头(camSeqCam++ 令迟到授权回调失效)
|
|
}
|
|
function handleCamVisible() {
|
|
if (currentScreen !== 's-cam') return;
|
|
camSeq++; // 双保险:隐藏期间残留的迟到回调一并作废
|
|
renderCamStep(); // 重置当前步检测状态机,从当前步继续
|
|
if (!camFallback) startCamera(); // 降级模式无流可起,仅重置状态机;重走 camSeqCam 令牌防复活
|
|
}
|
|
document.addEventListener('visibilitychange', function () {
|
|
if (document.hidden) handleCamHidden(); else handleCamVisible();
|
|
});
|
|
window.addEventListener('pagehide', handleCamHidden);
|
|
window.addEventListener('pageshow', function (e) {
|
|
if (e.persisted) handleCamVisible(); // bfcache 恢复(不一定伴随 visibilitychange)
|
|
});
|
|
|
|
/* ─── 初始化 ─── */
|
|
history.replaceState({ screen: 's-splash' }, ''); // 刷新后从启动页开始,历史栈的屏标记与之对齐
|
|
renderCamStep();
|
|
// 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口)
|
|
// 有 MOCK_FIXTURE 时走 fixture(检测可视化同步出图),否则回退 SkinScore 内置样本
|
|
if (IS_MOCK && /[?&]screen=report/.test(location.search) && typeof SkinScore !== 'undefined') {
|
|
showScreen('s-report');
|
|
try {
|
|
var fx = window.MOCK_FIXTURE;
|
|
var mockRes = fx ? fx.result : SkinScore.MOCK_RESULT;
|
|
var mockSide = fx ? fx.side : SkinScore.MOCK_SIDE;
|
|
renderReport(SkinScore.buildViewModel(mockRes, mockSide, {
|
|
realAge: +($('#f-age').value || 0) || 28, // 调试档案缺省 28 岁
|
|
skinType: SKINS[skinIndex].name
|
|
}), fx ? fx.photos[0] : null, null, fx ? { result: mockRes, side: mockSide, face_rectangle: fx.face_rectangle } : null);
|
|
} catch (e) { /* 调试入口异常不阻塞正式流程 */ }
|
|
}
|
|
})();
|