/* ═════ 巨子测肤 H5 — 交互逻辑 ═════ */
(function () {
'use strict';
var $ = function (sel, root) { return (root || document).querySelector(sel); };
/* ─── Toast ─── */
var toastEl = $('#toast'), toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, 1800);
}
/* ─── 屏幕管理 ─── */
var currentScreen = 's-splash';
function showScreen(id) {
document.querySelectorAll('.screen').forEach(function (s) {
s.classList.toggle('active', s.id === id);
});
var sc = document.getElementById(id);
sc.scrollTop = 0;
currentScreen = id;
if (id === 's-report') resetReport(); // 每次进入报告页都还原初始状态
}
/* ─── 报告页状态重置(Tab 回到「紧塑力」、产品横滑归零) ─── */
function resetReport() {
document.querySelectorAll('.dtab').forEach(function (t, k) {
t.classList.toggle('active', k === 0);
});
document.querySelectorAll('.dpanel').forEach(function (p, k) {
p.classList.toggle('active', k === 0);
});
var ps = $('#prod-scroll');
if (ps) ps.scrollLeft = 0;
}
/* ─── 弹窗管理 ─── */
function openOverlay(id) { $('#' + id).classList.add('show'); }
function closeOverlay(id) { $('#' + id).classList.remove('show'); }
// 点遮罩空白处关闭(与各自关闭按钮同行为:隐私→回启动页、拍照引导→留在档案页)
// e.target === overlay 确保只响应遮罩本身,不误伤弹窗卡片内交互
function bindBackdropClose(id) {
var ov = $('#' + id);
ov.addEventListener('click', function (e) {
if (e.target === ov) closeOverlay(id);
});
}
bindBackdropClose('p-privacy');
bindBackdropClose('p-photoguide');
bindBackdropClose('p-policy');
// ESC 关闭(仅这三层;分析中过渡不响应;协议层最顶,优先关)
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape' && e.key !== 'Esc') return;
if ($('#p-policy').classList.contains('show')) closeOverlay('p-policy');
else if ($('#p-privacy').classList.contains('show')) closeOverlay('p-privacy');
else if ($('#p-photoguide').classList.contains('show')) closeOverlay('p-photoguide');
});
// 分析中弹层点遮罩取消:作废未决分析回调(mock 路径同样经 seq 丢弃)、关弹层、停相机、回档案页
$('#p-analyzing').addEventListener('click', function (e) {
if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片
camSeq++;
closeOverlay('p-analyzing');
stopCamera();
shutterLock = false; // 流程终止解锁
showScreen('s-profile');
});
/* ─── 肤质轮播 ─── */
var SKINS = [
{ img: 'assets/img/skin-0-dry.png', name: '干性肌肤', desc: '全脸干燥,容易紧绷、缺乏光泽' },
{ img: 'assets/img/skin-1-mixeddry.png', name: '混合偏干肌肤', desc: '两颊偏干,T区油脂较少或偶尔出油' },
{ img: 'assets/img/skin-2-neutral.png', name: '中性肌肤', desc: '水油平衡,肤感舒适,无明显干燥出油' },
{ img: 'assets/img/skin-3-oily.png', name: '油性肌肤', desc: '全脸易出油,容易泛油光、毛孔明显' },
{ img: 'assets/img/skin-4-mixedoily.png', name: '混合偏油肌肤', desc: 'T区易出油,两颊相对清爽或轻微干燥' }
];
var skinIndex = 0;
var track = $('#skin-track'), dotsBox = $('#skin-dots');
SKINS.forEach(function (s, i) {
var item = document.createElement('div');
item.className = 'skin-item';
item.innerHTML =
'
' +
'' + s.name + '' +
'
' + s.desc + '
';
track.appendChild(item);
var dot = document.createElement('i');
if (i === 0) dot.className = 'on';
dot.addEventListener('click', function () { goSkin(i); });
dotsBox.appendChild(dot);
});
function goSkin(i) {
skinIndex = Math.max(0, Math.min(SKINS.length - 1, i));
track.style.transform = 'translateX(-' + skinIndex * 100 + '%)';
dotsBox.querySelectorAll('i').forEach(function (d, k) {
d.classList.toggle('on', k === skinIndex);
});
}
// 拖拽切换(Pointer Events 统一鼠标 + 触摸)
var drag = { id: null, startX: 0, startY: 0, horizontal: false, suppress: false };
var carousel = $('#skin-carousel');
carousel.addEventListener('pointerdown', function (e) {
if (e.cancelable) e.preventDefault(); // 阻止原生图片拖拽/文字选中引发 pointercancel(click 仍会正常触发)
try { carousel.setPointerCapture(e.pointerId); } catch (err) { /* 非活动指针时忽略,退化为普通监听 */ }
// 捕获指针:斜拖出卡片、卡外松手也能收到 pointerup
drag.id = e.pointerId;
drag.startX = e.clientX;
drag.startY = e.clientY;
drag.horizontal = false;
drag.suppress = false;
});
carousel.addEventListener('pointermove', function (e) {
if (e.pointerId !== drag.id) return;
var dx = e.clientX - drag.startX, dy = e.clientY - drag.startY;
// 横向位移明显大于纵向时才判定为拖拽,避免吞掉纵向滚动
if (!drag.horizontal && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) drag.horizontal = true;
if (drag.horizontal && e.cancelable) e.preventDefault();
});
carousel.addEventListener('pointerup', function (e) {
if (e.pointerId !== drag.id) return;
var dx = e.clientX - drag.startX;
if (drag.horizontal) {
if (Math.abs(dx) > 30) goSkin(skinIndex + (dx < 0 ? 1 : -1));
drag.suppress = true; // 拖拽后吞掉紧随的 click,防止点击穿透
}
drag.id = null;
drag.horizontal = false;
});
carousel.addEventListener('pointercancel', function (e) {
if (e.pointerId !== drag.id) return; // 其它指针的 cancel 不打断当前拖拽
drag.id = null; // 拖拽被系统取消(滚轮/新指针等):干净结束,不残留状态
drag.horizontal = false;
});
carousel.addEventListener('click', function (e) {
if (drag.suppress) { e.preventDefault(); e.stopPropagation(); drag.suppress = false; }
}, true);
/* ─── 地区选项(自定义下拉:原生 select 各端样式不一,改自绘弹层) ─── */
var REGIONS = ['北京', '上海', '广州', '深圳', '杭州', '成都', '重庆', '武汉', '西安', '南京', '天津', '苏州', '长沙', '郑州', '青岛', '其他'];
var regionSel = $('#f-region'); // hidden input,存选中值
var rsBox = $('#region-rs'), rsList = $('#rs-list'), rsValue = $('#rs-value');
rsList.innerHTML = REGIONS.map(function (r) { return '' + r + ''; }).join('');
function rsClose() { rsList.hidden = true; rsBox.classList.remove('open'); }
$('#rs-current').addEventListener('click', function (e) {
e.stopPropagation();
var willOpen = rsList.hidden;
rsList.hidden = !willOpen;
rsBox.classList.toggle('open', willOpen);
});
rsList.addEventListener('click', function (e) {
var li = e.target.closest('li[data-v]');
if (!li) return;
regionSel.value = li.dataset.v;
rsValue.textContent = li.dataset.v;
rsValue.classList.remove('rs-placeholder');
rsClose();
});
document.addEventListener('click', rsClose); // 点空白收起
/* ─── 实时相机(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 同序:[0]正脸 [1]右脸(头左转) [2]左脸(头右转)
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换)
var camMirrored = true; // 预览是否镜像(前置镜像,后置不镜像)
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(); }
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;
camVideo.classList.toggle('mirror', camMirrored);
// 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');
}
function enableFallback() {
camFallback = true;
$('#cam-status').classList.remove('show'); // 与 stopCamera 对齐:去掉「检测引擎加载中」胶囊
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框
toast('已切换为系统相机拍摄');
}
// 从视频帧截取照片(镜像绘制,与预览一致;压到最长边 1280 的 JPEG)
function captureFromVideo() {
var vw = camVideo.videoWidth, vh = camVideo.videoHeight;
if (!vw || !vh) return null;
// 只取取景框可见区域(object-fit:cover 的中心裁切):照片 = 用户当时看到的画面
var box = camVideo.getBoundingClientRect();
var scaleFit = Math.max(box.width / vw, box.height / vh);
var visW = Math.min(vw, box.width / scaleFit), visH = Math.min(vh, box.height / scaleFit);
var sx = (vw - visW) / 2, sy = (vh - visH) / 2;
var scale = Math.min(1, 1280 / Math.max(visW, visH));
var cw = Math.round(visW * scale), ch = Math.round(visH * scale);
var canvas = document.createElement('canvas');
canvas.width = cw; canvas.height = ch;
var ctx = canvas.getContext('2d');
if (camMirrored) { ctx.translate(cw, 0); ctx.scale(-1, 1); } // 前置镜像,与预览一致
ctx.drawImage(camVideo, sx, sy, visW, visH, 0, 0, cw, ch);
return canvas.toDataURL('image/jpeg', 0.88);
}
// 文件 → 统一压成最长边 1280 的 JPEG → 推进流程(降级系统相机与相册选图共用)
function readPhotoFile(f) {
if (!f) return;
var reader = new FileReader();
reader.onload = function () {
var img = new Image();
img.onload = function () {
var scale = Math.min(1, 1280 / Math.max(img.width, img.height));
var c = document.createElement('canvas');
c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale);
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
onPhotoCaptured(c.toDataURL('image/jpeg', 0.88));
};
img.src = reader.result;
};
reader.readAsDataURL(f);
}
// 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程
camFile.addEventListener('change', function () {
shutterLock = false; // 选完(含空选择)即解锁;推进窗口由 onPhotoCaptured 重新上锁
var f = camFile.files && camFile.files[0];
camFile.value = '';
readPhotoFile(f);
});
// 部分系统取消相机/相册只发 cancel 不发 change:兜底解锁,防快门卡死
camFile.addEventListener('cancel', function () { shutterLock = false; });
// 相册选图(相机页「相册」入口):选当前步的图片,与实拍同管线推进
albumFile.addEventListener('change', function () {
shutterLock = false;
var f = albumFile.files && albumFile.files[0];
albumFile.value = '';
readPhotoFile(f);
});
albumFile.addEventListener('cancel', function () { shutterLock = false; });
/* ─── 语音播报(WebAudio 单通道;assets/voice/.mp3,缺失静默跳过;同条 4s 去重)
文案/文件名契约:docs/语音播报内容设计文档_V1.0.md ─── */
var VOICE = {
step: ['step1-front', 'step2-rightface', 'step3-leftface'],
hint: {
'未检测到人脸': 'hint-noface', '请靠近一点': 'hint-near', '请远一点': 'hint-far',
'请正对屏幕': 'hint-front', '请向左转头': 'hint-turn-left', '请向右转头': 'hint-turn-right',
'保持不动': 'hint-hold'
},
countdown: 'countdown-321', shot: 'shot-done', analyzing: 'analyzing', fail: 'analyze-fail'
};
var voice = (function () {
var ctx = null, lastAt = {}, cur = null, files = null; // files:null=清单未加载;{}=无可用语音
// 清单制:只播 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; }); })
.catch(function () { files = {}; });
function unlock() { // iOS/微信自动播放限制:必须在用户手势里初始化并 resume
if (!ctx) { try { ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { ctx = null; } }
if (ctx && ctx.state === 'suspended') ctx.resume();
}
function play(id) {
if (!ctx || !id || ctx.state !== 'running' || !files || !files[id]) return;
var now = Date.now();
if (lastAt[id] && now - lastAt[id] < 4000) return;
lastAt[id] = now;
fetch('assets/voice/' + id + '.mp3')
.then(function (r) { return r.ok ? r.arrayBuffer() : null; })
.then(function (ab) {
if (!ab || !ctx) return;
ctx.decodeAudioData(ab, function (buf) {
if (cur) { try { cur.stop(); } catch (e) {} }
cur = ctx.createBufferSource();
cur.buffer = buf;
cur.connect(ctx.destination);
cur.start(0);
}, function () { /* 解码失败忽略 */ });
})
.catch(function () { /* 网络异常忽略 */ });
}
return { unlock: unlock, play: play };
})();
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 = 600; // 姿态 ok 需连续维持时长,达标才进倒计时
var COUNTDOWN_S = 3; // 倒计时秒数
var NO_PROGRESS_MS = 12000; // 单步超过该时长未触发倒计时→浮出手动提示
var FACE_NEAR = 0.26, FACE_FAR = 0.58; // 人脸框高/视频高:<26% 太远,>58% 太近(以取景框≈42% 屏高、416 输入实测框 0.40–0.44 标定)
var modelsPromise = null, modelsOk = false, detectDisabled = false; // detectDisabled:face-api 缺失→全程手动;模型加载失败仅当次禁用,下次进拍照页重试
var detRunning = false, detTimer = null; // 检测循环
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
var counting = false, cdTimer = null, cdN = 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;
}
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 = ''; return; }
if (text !== lastHint) {
h.textContent = text; // 同文案不重复触发布局
if (!silent) voice.play(VOICE.hint[text]); // 语音同步(模块内 4s 去重)
}
h.classList.add('show');
h.classList.toggle('ok', !!ok);
lastHint = text;
}
function hideNudge() { $('#cam-nudge').classList.remove('show'); }
// 每步进入/回退时的检测状态机重置(蒙版/提示/倒计时/无进展计时全部归位)
function resetDetectState() {
okSince = 0;
missFrames = 0;
lastHint = '';
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
setHint('');
setMask(camLive && !IS_MOCK && !detectDisabled && !camFallback ? 'no-face' : '');
armNudge();
}
// 单步 12s 未触发倒计时→底部浮出轻提示(不阻塞流程);进入下一步/倒计时/抓帧时重置
function armNudge() {
clearTimeout(nudgeTimer);
hideNudge();
if (IS_MOCK || detectDisabled || camFallback) return; // 手动模式无需提示
if (currentScreen !== 's-cam') return;
nudgeTimer = setTimeout(function () {
if (currentScreen === 's-cam' && !counting && !shutterLock) $('#cam-nudge').classList.add('show');
}, NO_PROGRESS_MS);
}
function startCountdown() {
counting = true;
clearTimeout(nudgeTimer);
hideNudge();
cdN = COUNTDOWN_S;
var c = $('#cam-count');
c.textContent = cdN;
c.classList.add('show');
setMask('ok'); // 轮廓保持绿色
setHint('保持不动', true, /* silent= */true); // 倒计时前不再抢播「保持不动」(会被 321 立刻切断,听着像吞字)
voice.play(VOICE.countdown); // 「三、二、一」与数字同步起播
cdTimer = setTimeout(cdTick, 1000);
}
function cdTick() {
cdN--;
if (cdN <= 0) {
counting = false;
$('#cam-count').classList.remove('show');
doCapture(); // 倒计时结束:白屏补光抓帧(与手动快门同路径)
return;
}
$('#cam-count').textContent = cdN;
cdTimer = setTimeout(cdTick, 1000);
}
function cancelCountdown() {
counting = false;
clearTimeout(cdTimer);
$('#cam-count').classList.remove('show');
}
function maybeStartDetect() {
if (detRunning || IS_MOCK || detectDisabled || !modelsOk || !camLive || currentScreen !== 's-cam') return;
detRunning = true;
detTick();
}
function stopDetect() {
detRunning = false;
clearTimeout(detTimer);
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
}
// 递归 setTimeout 而非 setInterval:检测是异步的,避免帧与帧堆叠
function detTick() {
if (!detRunning) return;
detTimer = setTimeout(function () {
if (!detRunning) return;
detectFrame().then(detTick, detTick);
}, DETECT_INTERVAL);
}
function detectFrame() {
if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态
var step = camStep, seq = camSeq; // 快照:检测是异步的,帧落地时步骤/流程可能已变
return faceapi
.detectSingleFace(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
.withFaceLandmarks()
.then(function (d) {
if (step !== camStep || seq !== camSeq) return; // 跨步骤陈帧/流程已重置:丢弃
if (detRunning && !shutterLock && !capturing) onDetect(d);
})
.catch(function () { /* 单帧异常忽略,下一帧继续 */ });
}
var missFrames = 0; // 连续漏检帧计数:单帧抖动不报「未检测到人脸」
function onDetect(d) {
var hint = '', ok = false;
missFrames = d ? 0 : missFrames + 1;
var hasFace = !!d, noFace = !hasFace && missFrames >= MISS_FRAMES_MAX;
if (noFace) {
hint = '未检测到人脸';
} else if (!hasFace) {
return; // 偶发漏检帧:保持上一条提示与状态,不扰动倒计时/ok 累积
} else {
// 提示优先级:无人脸 > 距离 > 角度
var ratio = d.detection.box.height / (camVideo.videoHeight || 1);
if (ratio < FACE_NEAR) hint = '请靠近一点';
else if (ratio > FACE_FAR) hint = '请远一点';
else {
// 镜像标志跟随镜头(前置 true/后置 false);距离不合格时角度不判 ok
var pose = HeadPose.check(d.landmarks.positions, CAM_STEPS[camStep].target, camMirrored);
hint = pose.hint;
ok = pose.ok;
}
window.__dbgRatio = ratio; // 自动化校准/测试用(检测框高/视频高)
}
if (counting && !ok) cancelCountdown(); // 倒计时中任一帧不 ok→立即取消,回到对应提示
setMask(counting || ok ? 'ok' : (noFace ? 'no-face' : ''));
setHint(counting ? '保持不动' : hint, counting || ok);
if (counting) return;
if (ok) {
if (!okSince) okSince = Date.now();
else if (Date.now() - okSince >= OK_HOLD_MS) startCountdown();
} else {
okSince = 0;
}
}
/* ─── 拍照流程(三步:正脸 → 右脸出镜 → 左脸出镜) ─── */
// target 即 HeadPose.check 的 target:2/3 右脸出镜=用户头向左转(target 'right',产物作 right_image);
// 3/3 左脸出镜=头向右转(target 'left',产物作 left_image)。
var CAM_STEPS = [
{ bg: 'assets/img/cam-front.jpg', step: '1/3
请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front' },
{ bg: 'assets/img/cam-left.jpg', step: '2/3
请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right' },
{ bg: 'assets/img/cam-right.jpg', step: '3/3
请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left' }
];
var camStep = 0;
function renderCamStep() {
var cfg = CAM_STEPS[camStep];
$('#cam-bg').style.backgroundImage = 'url(' + cfg.bg + ')';
$('#cam-step').innerHTML = cfg.step;
$('#cam-guide').innerHTML = cfg.guides
.map(function (g) { return '' + g + ''; })
.join('');
// 步骤圆点:当前步高亮、完成步绿色打勾
var dots = $('#cam-dots').children;
for (var i = 0; i < dots.length; i++) {
dots[i].classList.toggle('done', i < camStep);
dots[i].classList.toggle('on', i === camStep);
}
$('#cam-mask').setAttribute('data-step', camStep); // 蒙版按步骤切换
resetDetectState();
voice.play(VOICE.step[camStep]); // 步骤引导语音
}
var camSeq = 0; // 流程令牌:cam-back/开始拍照时递增,使未决的快门/分析回调失效
var shutterLock = false; // 快门防连点锁:流程进行中直接忽略
// 照片就绪后推进流程(实时抓帧与降级选图共用):存图→步骤点变绿→300ms 过渡→下一步
function onPhotoCaptured(dataUrl) {
shutterLock = true; // 推进窗口上锁:降级模式 change 里已解锁,这里重新锁住直到下一步/送检
photos[camStep] = dataUrl;
$('#cam-dots').children[camStep].classList.add('done');
voice.play(VOICE.shot); // 「拍摄完成」
var seq = camSeq;
setTimeout(function () {
if (seq !== camSeq) return; // 期间已 cam-back/重新开始:放弃本次推进
if (camStep < CAM_STEPS.length - 1) {
camStep++;
renderCamStep(); // 内含检测状态机重置
shutterLock = false; // 进入下一步即解锁
} else {
submitPhotos(seq); // 三张拍完:送检
}
}, 300);
}
// 白屏补光抓拍:全屏纯白瞬间点亮→约300ms 后抓全分辨率帧→保持300ms 再淡出(总 600–900ms)
function doCapture() {
if (shutterLock) return;
if (!camLive) return;
shutterLock = true;
capturing = true;
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
var seq = camSeq;
var fill = $('#cam-fill');
fill.classList.add('on'); // .on 无过渡,瞬间点亮
if (navigator.vibrate) navigator.vibrate(30);
setTimeout(function () {
var shot = captureFromVideo();
setTimeout(function () {
fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出
capturing = false;
if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出)
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即重启倒计时
onPhotoCaptured(shot);
}, 300);
}, 300);
}
// 三张拍完:三视图送检(正脸=photos[0],左=photos[2],右=photos[1])
function submitPhotos(seq) {
camStep = 0;
openOverlay('p-analyzing');
voice.play(VOICE.analyzing); // 「正在生成您的肌肤报告」
var t0 = Date.now();
requestAnalyze(photos, seq, 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();
shutterLock = false; // 流程结束解锁
if (errMsg) {
voice.play(VOICE.fail);
toast(errMsg);
showScreen('s-profile'); // 回档案页,允许重新提交再拍
return;
}
var vm = null;
var profile = {
age: +($('#f-age').value || 0) || null,
gender: $('#f-gender').value || '',
region: regionSel.value || '',
skinType: SKINS[skinIndex].name
};
try {
vm = SkinScore.buildViewModel(payload.result, payload.side, {
realAge: profile.age,
skinType: profile.skinType
});
} catch (e) { vm = null; }
if (!vm) {
toast('报告生成失败,请重新拍摄');
showScreen('s-profile');
return;
}
renderReport(vm, photos[0], photos[1], payload); // img1=正脸实拍,payload 供检测可视化取 face_maps/marks
storeReport(payload, photos, profile); // 以检测为单位存三张照片+分析结果(本地历史)
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
showScreen('s-report');
}, wait);
});
}
function shutter() {
if (shutterLock) return;
if (camLive) {
if (!camVideo.videoWidth) { toast('相机尚未就绪,请稍候'); return; }
doCapture(); // 手动兜底:任何时刻可拍当前步,同样走白屏补光,不依赖检测
} 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, 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[2], right_image: ph[1] }),
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, '>');
}
function setDim(scoreSel, lvSel, dim) {
var el = $(scoreSel), lv = $(lvSel);
if (dim.S == null) {
el.textContent = '-.-';
lv.textContent = '未检出';
lv.className = 'dim-lv';
return;
}
el.textContent = dim.S.toFixed(1);
lv.textContent = dim.level;
lv.className = 'dim-lv ' + (LV_CLASS[dim.level] || '');
}
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: 'brown_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 挂到卡片
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);
}
$('#stat-score').textContent = total != null ? total + '分' : '--分';
$('#stat-age').textContent = vm.skinAgeDisplay != null ? vm.skinAgeDisplay + '岁' : '--岁';
// 肌龄小字:常显「与实际年龄相差X岁」,触发钳制时追加留痕(契约格式「触发(原X→Y)」不变)
var ageNote = '';
if (vm.skinAgeDisplay != null && vm.realAge != null) {
var ageDiff = Math.abs(vm.skinAgeDisplay - vm.realAge);
ageNote = ageDiff === 0 ? '与实际年龄相符' : '与实际年龄相差' + ageDiff + '岁';
}
if (vm.ageClampNote) ageNote += (ageNote ? ' · ' : '') + vm.ageClampNote;
$('#stat-age-note').textContent = ageNote;
// 三维度:S 值(1 位小数) + 档位(良好绿/改善琥珀/预警红)
setDim('#dim-firm', '#dim-firm-lv', vm.dims.firm);
setDim('#dim-vita', '#dim-vita-lv', vm.dims.vita);
setDim('#dim-stab', '#dim-stab-lv', vm.dims.stab);
// 关注行(设计稿两行:灰小字「近期优先关注:X」+ 深色维度口号)
var DIM_SLOGAN = {
'紧塑力': '紧塑提拉力,轮廓上扬不松垮',
'活力感': '活力透亮感,水润澎弹有光泽',
'稳定力': '强韧稳态力,锁水保湿抵御刺激' // 设计稿原文
};
$('#dims-note').innerHTML = vm.focus.name
? '近期优先关注:' + esc(vm.focus.name) + '
' + esc(DIM_SLOGAN[vm.focus.name] || '') + ''
: '近期优先关注:---';
// 三个 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); });
if (userNotes.length) {
notesEl.innerHTML = userNotes.map(function (t) { return '*' + esc(t); }).join('
');
notesEl.style.display = '';
} else {
notesEl.style.display = 'none';
}
// 综合文案 / 生活方式 / 精华推荐
$('#vm-summary').textContent = vm.summary;
$('#vm-lifestyle').textContent = vm.lifestyle;
$('#essence-name').textContent = vm.essence.name;
$('#essence-text').textContent = vm.essence.text;
$('#essence-triggers').textContent = vm.essence.triggers.length ? '触发:' + vm.essence.triggers.join('、') : '';
}
/* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */
function beginDetect() {
if (IS_MOCK || camFallback) { resetDetectState(); return; }
if (typeof faceapi === 'undefined') {
detectDisabled = true; // face-api 本身缺失:全程手动,不再重试
resetDetectState();
toast('自动识别不可用,已切换手动拍摄');
return;
}
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
resetDetectState();
if (!modelsOk) $('#cam-status').classList.add('show'); // “检测引擎加载中…”
loadModels().then(function () {
$('#cam-status').classList.remove('show');
maybeStartDetect();
}).catch(function () {
$('#cam-status').classList.remove('show');
detectDisabled = true; // 仅禁用当次;下次进拍照页 beginDetect 复位后重试
resetDetectState();
if (currentScreen === 's-cam') toast('自动识别不可用,已切换手动拍摄');
});
}
/* ─── 隐私勾选 ─── */
var agreed = false;
$('#privacy-row').addEventListener('click', function (e) {
if (e.target.closest('#privacy-policy-link')) return; // 点协议链接:只开弹层,不切换勾选
e.preventDefault();
agreed = !agreed;
$('#privacy-check').classList.toggle('checked', agreed);
});
/* ─── 隐私协议占位弹层(QA P3-2) ─── */
$('#privacy-policy-link').addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation(); // 阻止冒泡到勾选行,避免误切换
openOverlay('p-policy');
});
/* ─── 报告页 Tab ─── */
$('#detail-tabs').addEventListener('click', function (e) {
var btn = e.target.closest('.dtab');
if (!btn) return;
var idx = btn.dataset.tab;
document.querySelectorAll('.dtab').forEach(function (t) { t.classList.toggle('active', t === btn); });
document.querySelectorAll('.dpanel').forEach(function (p) {
p.classList.toggle('active', p.dataset.panel === idx);
});
});
/* ─── 动作分发 ─── */
var actions = {
'open-privacy': function () { openOverlay('p-privacy'); },
'close-privacy': function () { closeOverlay('p-privacy'); },
'close-policy': function () { closeOverlay('p-policy'); },
'agree-continue': function () {
if (!agreed) { toast('请先阅读并同意隐私协议'); return; }
closeOverlay('p-privacy');
showScreen('s-profile');
},
'back-splash': function () { showScreen('s-splash'); },
'submit-profile': function () {
var age = $('#f-age').value.trim();
if (!age || !/^\d{1,2}$/.test(age) || +age < 1 || +age > 99) { toast('请输入有效年龄'); return; }
if (!$('#f-gender').value) { toast('请选择性别'); return; }
if (!regionSel.value) { toast('请选择所在地区'); return; }
openOverlay('p-photoguide');
},
'close-photoguide': function () { closeOverlay('p-photoguide'); },
'start-camera': function () {
closeOverlay('p-photoguide');
camSeq++; // 使旧的快门/分析回调失效
camStep = 0;
photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍)
shutterLock = false; // 重新进入流程,保险解锁
renderCamStep();
showScreen('s-cam');
startCamera(); // beginDetect 在其成功回调里:确认相机可用才加载模型
},
'cam-back': function () {
camSeq++; // 使未决的快门/分析回调失效,避免回退被旧定时器顶回
shutterLock = false; // 中途退出也解锁
if (camStep > 0) { camStep--; renderCamStep(); } // renderCamStep 内含检测状态机重置
else { stopCamera(); showScreen('s-profile'); }
},
'shutter': shutter,
'album': function () {
shutterLock = true; // 与降级快门同策略:前置上锁,change/cancel 解锁
albumFile.click();
},
'flip': function () { // 切换前后镜头
if (camFallback) { toast('系统相机模式下请在系统相机内切换'); return; }
if (!camStream) { toast('相机启动中,请稍候'); return; }
camFacing = camFacing === 'user' ? 'environment' : 'user';
camMirrored = camFacing === 'user';
startCamera(); // 复用启停与预热(camSeqCam 防竞态)
},
'back-profile': function () { showScreen('s-profile'); },
'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 = '暂无历史报告
完成一次肌肤检测后会自动保存在这里
';
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] ? '
' : '';
return '' + img +
'
' + fmtTime(e.ts) + '' + sub + ' · 肌龄' + (e.skinAge != null ? e.skinAge : '--') + '岁
' +
'
' + score + '分
' +
'
';
}).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');
});
/* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 photos[] 与 camStep) ─── */
function handleCamHidden() {
if (currentScreen !== 's-cam') return;
camSeq++; // 使未决的快门/推进/分析回调失效(白屏定时器醒来即自洁退出)
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)
});
/* ─── 初始化 ─── */
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) { /* 调试入口异常不阻塞正式流程 */ }
}
})();