Files
juzi/h5/js/app.js
T
Ubuntu 4e3d915b7d feat: 拍摄流程对齐 Figma(正→左→右顺序/描摹轮廓蒙版/正脸只提示回正) + 报告页按 Figma 08 重排
- 拍照顺序 正脸→左脸→右脸:步骤文案/语音文件(step2-leftface/step3-rightface)/payload 归属对齐
- 蒙版轮廓由 Figma 拍照示意位图 alpha 镂空区逐像素描摹生成(0 正脸含耳/1 左脸鼻朝左/2 右脸鼻朝右),框外纯黑
- 侧脸步预览与抓帧不镜像(左右不再迷惑),HeadPose 侧脸自校准阈值 30 帧防误翻转
- 语音:「保持不动」播完再进倒计时(afterCurrent 接续,不再拦腰切断)
- 正脸步角度不合格只提示「请正对屏幕」,阈值放宽 |yaw|≤15°/|pitch|≤18°;转头提示仅侧脸步
- 抓帧改完整帧(修 Face++ INVALID_IMAGE_FACE)
- 报告页按 Figma 08:内联白底顶栏(返回+标题)、总分双独立白卡(30px 大数字+15px 单位后缀)、
  维度短竖分隔线+最低分标红、Tab 黄条、免责左对齐、护理建议去 tag 加粗+收尾句、
  产品大标题移出白卡、COREⅡ 换 Figma 导出 SVG 字标、产品图 151×174、卡圆角 10px
- 精华卡判据措辞用户化(均分档→综合均分/最差项约束→最低分维度/年龄组档→年龄段,引擎值不动)
- 修报告页滚动 P0(去 position:relative 覆写),文档同步(语音文档/README)
2026-09-28 00:57:28 +08:00

1233 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ═════ 巨子测肤 H5 — 交互逻辑 ═════ */
(function () {
'use strict';
var $ = function (sel, root) { return (root || document).querySelector(sel); };
/* ─── 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 =
'<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);
});
}
// 拖拽切换(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 '<li data-v="' + r + '">' + r + '</li>'; }).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; // 前置镜像开关:仅正脸步生效(自拍习惯);侧脸步始终不镜像(左右不再迷惑,与取景轮廓方向一致)
var sideYawFlip = false; // 侧脸步语义 yaw 反向标志:个别机型前置帧本身已镜像时由自校准翻转
var mirrorCalibrated = false; // 侧脸自校准:本轮是否已翻转过一次(防振荡,一次校准管整个会话)
var oppFrames = 0; // 侧脸步语义 yaw 连续反向帧计数
// 预览/抓帧是否镜像:正脸步跟随 camMirrored,侧脸步恒不镜像
function effMirror() { return camMirrored && 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(); }
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', effMirror());
// 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)
// 抓完整帧而非取景框可见区:新 UI 轮廓外全黑,用户只关注轮廓内人脸;按可见区裁切在侧脸/偏站位时会切掉人脸(Face++ INVALID_IMAGE_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');
if (effMirror()) { ctx.translate(cw, 0); ctx.scale(-1, 1); } // 与预览一致:正脸步前置镜像,侧脸步不镜像(照片=摄像头原始视角)
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 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/<id>.mp3,缺失静默跳过;同条 4s 去重)
文案/文件名契约:docs/语音播报内容设计文档_V1.0.md ─── */
var VOICE = {
step: ['step1-front', 'step2-leftface', 'step3-rightface'],
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, curEnd = 0, 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) {} }
var src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
cur = src;
curEnd = Date.now() + buf.duration * 1000;
src.onended = function () { if (cur === src) cur = null; };
src.start(0);
}, function () { /* 解码失败忽略 */ });
})
.catch(function () { /* 网络异常忽略 */ });
}
// 等当前播报自然收尾后执行 cb(倒计时接续「保持不动」,不拦腰切断);无播报在执行则立即执行
// 返回取消函数;注意:cur 被新播报 stop 也会触发 onended,调用方需配合 cancelCountdown 等路径取消
function afterCurrent(cb) {
if (!cur || Date.now() >= curEnd - 60) { cb(); return null; }
var src = cur, cancelled = false;
src.onended = function () {
if (cur === src) cur = null;
if (!cancelled) cb();
};
return function () { cancelled = true; };
}
return { unlock: unlock, play: play, afterCurrent: afterCurrent };
})();
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 cdDeferCancel = null; // 倒计时等播报收尾的取消句柄(afterCurrent)
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;
oppFrames = 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; // 立即置位:等待播报收尾期间姿态被破坏,走 cancelCountdown 统一取消
clearTimeout(nudgeTimer);
hideNudge();
setMask('ok'); // 轮廓保持绿色
setHint('保持不动', true, /* silent= */true); // 倒计时前不再抢播「保持不动」(会被 321 立刻切断,听着像吞字)
// 「保持不动」等仍在播放的提示音自然播完再进倒计时,画面数字与「三、二、一」同步起播
cdDeferCancel = voice.afterCurrent(beginCountdown);
}
function beginCountdown() {
cdDeferCancel = null;
if (!counting) return; // 等待期间已被取消(姿态破坏/退出拍照页)
cdN = COUNTDOWN_S;
var c = $('#cam-count');
c.textContent = cdN;
c.classList.add('show');
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;
if (cdDeferCancel) { cdDeferCancel(); cdDeferCancel = null; } // 取消尚未起播的倒计时接续
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 {
// 距离不合格时角度不判 ok。镜像参数:正脸步跟随镜头(前置 true/后置 false);
// 侧脸步预览不镜像,特征点即原始视角,标准机型恒为 false,个别前置帧已镜像的机型由自校准翻转 sideYawFlip
var tgt = CAM_STEPS[camStep].target;
var pose = HeadPose.check(d.landmarks.positions, tgt, tgt === 'front' ? camMirrored : sideYawFlip);
// 侧脸自校准:个别机型前置帧本身已镜像(语义 yaw 与实际转向持续相反)时自救——
// 需连续 30 帧(~10s)强反向才翻转一次:阈值要高,否则用户转错方向 1~2s 就会被误判为镜像机,
// 反而把错误的侧脸当合格抓走(左右图归属错乱)。翻转只动判定方向,不动预览画面
if (tgt !== 'front' && pose.yaw != null && !mirrorCalibrated) {
var wantSign = tgt === 'right' ? 1 : -1;
if (pose.yaw * wantSign < -12) {
if (++oppFrames >= 30) {
sideYawFlip = !sideYawFlip;
mirrorCalibrated = true;
oppFrames = 0;
}
} else oppFrames = 0;
}
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 'left',产物作 left_image);
// 3/3 右脸出镜=头向左转(target 'right',产物作 right_image)。
var CAM_STEPS = [
{ bg: 'assets/img/cam-front.jpg', step: '1/3<br>请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front' },
{ bg: 'assets/img/cam-left.jpg', step: '2/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left' },
{ bg: 'assets/img/cam-right.jpg', step: '3/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right' }
];
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 '<span>' + g + '</span>'; })
.join('<i></i>');
// 步骤圆点:当前步高亮、完成步绿色打勾
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); // 蒙版按步骤切换
camVideo.classList.toggle('mirror', effMirror()); // 侧脸步预览切回不镜像(正脸步恢复前置镜像)
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[1],右=photos[2])
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[1], right_image: ph[2] }),
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function setDim(scoreSel, dim, isWorst) {
var el = $(scoreSel);
// 设计稿:最低分标红,其余深色;无档位徽章
el.textContent = dim.S == null ? '-.-' : dim.S.toFixed(1);
el.classList.toggle('dim-bad', !!isWorst && dim.S != null);
}
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);
}
// 设计稿:大数字 30px/400 + 单位 15px/500(分/岁为小字后缀)
$('#stat-score').innerHTML = total != null ? '<b>' + total + '</b><i>分</i>' : '<b>--</b><i>分</i>';
$('#stat-age').innerHTML = vm.skinAgeDisplay != null ? '<b>' + vm.skinAgeDisplay + '</b><i>岁</i>' : '<b>--</b><i>岁</i>';
$('#stat-age-note').textContent = ''; // 肌龄小字按产品要求不展示(钳制留痕 vm.ageClampNote 仍随数据返回)
// 三维度:S 值(1 位小数),最低分标红(设计稿无档位徽章)
var dimPs = { firm: vm.dims.firm.P, vita: vm.dims.vita.P, stab: vm.dims.stab.P };
var worstDimKey = Object.keys(dimPs).reduce(function (a, b) {
if (dimPs[a] == null) return b;
if (dimPs[b] == null) return a;
return dimPs[b] < dimPs[a] ? b : a;
});
setDim('#dim-firm', vm.dims.firm, worstDimKey === 'firm');
setDim('#dim-vita', vm.dims.vita, worstDimKey === 'vita');
setDim('#dim-stab', vm.dims.stab, worstDimKey === 'stab');
// 关注行(设计稿两行:「近期优先关注:」灰 + 维度名深色,下行为深色口号)
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); });
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('');
}
// 精华推荐卡
$('#essence-name').textContent = vm.essence.name;
$('#essence-text').textContent = vm.essence.text;
// 命中判据:引擎内部名 → 用户可读措辞(引擎值被真值回归逐字锁定,仅展示层映射)
var TRIGGER_LABEL = { '均分档': '综合均分', '最差项约束': '最低分维度', '年龄组档': '年龄段' };
$('#essence-triggers').textContent = vm.essence.triggers.length
? '推荐依据:' + vm.essence.triggers.map(function (t) { return TRIGGER_LABEL[t] || t; }).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 = '<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');
});
/* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 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) { /* 调试入口异常不阻塞正式流程 */ }
}
})();