save
This commit is contained in:
+266
-178
@@ -14,9 +14,9 @@
|
||||
toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, Math.max(1800, String(msg).length * 130));
|
||||
}
|
||||
|
||||
/* ─── 屏幕管理 ─── */
|
||||
/* ─── 屏幕管理:页面内不放返回按钮,前进时 pushState,微信/浏览器返回键经 popstate 回到上一屏 ─── */
|
||||
var currentScreen = 's-splash';
|
||||
function showScreen(id) {
|
||||
function showScreen(id, nav) { // nav: 'push' 新增历史 | 'replace' 替换当前历史 | 缺省不动历史
|
||||
document.querySelectorAll('.screen').forEach(function (s) {
|
||||
s.classList.toggle('active', s.id === id);
|
||||
});
|
||||
@@ -24,16 +24,28 @@
|
||||
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 回到「紧塑力」、产品横滑归零) ─── */
|
||||
/* ─── 报告页状态重置(各维度卡回到第一个子 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('.dtab').forEach(function (t, k) {
|
||||
t.classList.toggle('active', k === 0);
|
||||
});
|
||||
document.querySelectorAll('.dpanel').forEach(function (p, k) {
|
||||
p.classList.toggle('active', k === 0);
|
||||
});
|
||||
document.querySelectorAll('.card-detail').forEach(function (c) { setDetailTab(c, '0'); });
|
||||
var ps = $('#prod-scroll');
|
||||
if (ps) ps.scrollLeft = 0;
|
||||
}
|
||||
@@ -43,33 +55,39 @@
|
||||
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);
|
||||
if (e.target === ov || e.target.closest('.guide-bg')) closeOverlay(id);
|
||||
});
|
||||
}
|
||||
bindBackdropClose('p-privacy');
|
||||
bindBackdropClose('p-photoguide');
|
||||
bindBackdropClose('p-policy');
|
||||
// ESC 关闭(仅这三层;分析中过渡不响应;协议层最顶,优先关)
|
||||
// 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');
|
||||
var order = ['p-zoom', 'p-policy', 'p-privacy', 'p-photoguide'];
|
||||
for (var i = 0; i < order.length; i++) {
|
||||
if ($('#' + order[i]).classList.contains('show')) { closeOverlay(order[i]); return; }
|
||||
}
|
||||
});
|
||||
|
||||
// 分析中弹层点遮罩取消:作废未决分析回调(mock 路径同样经 seq 丢弃)、关弹层、停相机、回档案页
|
||||
$('#p-analyzing').addEventListener('click', function (e) {
|
||||
if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片
|
||||
// 退出拍照流程:作废未决快门/分析回调、掐断播报、释放摄像头
|
||||
function leaveCamera() {
|
||||
camSeq++;
|
||||
voice.stop(true);
|
||||
closeOverlay('p-analyzing');
|
||||
stopCamera();
|
||||
shutterLock = false; // 流程终止解锁
|
||||
shutterLock = false;
|
||||
}
|
||||
// 分析中弹层点遮罩取消:回档案页(mock 路径同样经 seq 丢弃)
|
||||
$('#p-analyzing').addEventListener('click', function (e) {
|
||||
if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片
|
||||
leaveCamera();
|
||||
showScreen('s-profile');
|
||||
popHistorySilently();
|
||||
});
|
||||
|
||||
/* ─── 肤质轮播 ─── */
|
||||
@@ -103,7 +121,13 @@
|
||||
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 };
|
||||
@@ -144,32 +168,48 @@
|
||||
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);
|
||||
/* ─── 档案选择控件:出生年月 / 性别 / 省份城市,统一用 mobile-select 底部滚轮(选中值写入同名 hidden input) ─── */
|
||||
var NOW = new Date(), NOW_Y = NOW.getFullYear(), NOW_M = NOW.getMonth() + 1;
|
||||
var YEARS = [], MONTHS = [];
|
||||
for (var yy = NOW_Y - 99; yy <= NOW_Y - 1; yy++) YEARS.push(yy + '年');
|
||||
for (var mm = 1; mm <= 12; mm++) MONTHS.push(mm + '月');
|
||||
var regionSel = $('#f-region');
|
||||
function setPick(sel, text) { $(sel + ' .pick-value').textContent = text; }
|
||||
function ageFromBirth(y, m) { return NOW_Y - y - (NOW_M < m ? 1 : 0); } // 未过生日月份按少一岁
|
||||
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 }, { data: MONTHS }],
|
||||
position: [YEARS.indexOf((NOW_Y - 30) + '年'), 0],
|
||||
onChange: function (val) {
|
||||
var y = parseInt(val[0], 10), m = parseInt(val[1], 10);
|
||||
$('#f-birth').value = y + '-' + (m < 10 ? '0' : '') + m;
|
||||
$('#f-age').value = ageFromBirth(y, m);
|
||||
setPick('#pick-birth', y + '年' + m + '月');
|
||||
}
|
||||
});
|
||||
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();
|
||||
makePicker('#pick-gender', {
|
||||
title: '性别',
|
||||
wheels: [{ data: ['女', '男'] }],
|
||||
onChange: function (val) { $('#f-gender').value = val[0]; setPick('#pick-gender', val[0]); }
|
||||
});
|
||||
document.addEventListener('click', rsClose); // 点空白收起
|
||||
|
||||
// 年龄只收数字:inputmode=numeric 在部分安卓输入法仍可切到全键盘
|
||||
$('#f-age').addEventListener('input', function () {
|
||||
var v = this.value.replace(/\D/g, '').slice(0, 2);
|
||||
if (v !== this.value) this.value = v;
|
||||
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)+ 系统相机降级 ─── */
|
||||
@@ -215,6 +255,7 @@
|
||||
}
|
||||
function startCamera() {
|
||||
if (camStream) { stopCamera(); }
|
||||
camFallback = false;
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; }
|
||||
camSeqCam++;
|
||||
var token = camSeqCam;
|
||||
@@ -249,18 +290,21 @@
|
||||
}
|
||||
function stopCamera() {
|
||||
camSeqCam++; // 使进行中的 getUserMedia 回调失效
|
||||
stopDetect(); // 检测循环/倒计时/无进展提示一并收尾
|
||||
stopDetect(); // 检测循环/无进展提示一并收尾
|
||||
$('#cam-status').classList.remove('show');
|
||||
if (camStream) { camStream.getTracks().forEach(function (t) { t.stop(); }); camStream = null; }
|
||||
camVideo.srcObject = null;
|
||||
camLive = false;
|
||||
$('#s-cam').classList.remove('cam-live');
|
||||
$('#cam-mask').classList.remove('scanning');
|
||||
}
|
||||
function enableFallback() {
|
||||
camFallback = true;
|
||||
$('#cam-status').classList.remove('show'); // 与 stopCamera 对齐:去掉「检测引擎加载中」胶囊
|
||||
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框
|
||||
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框,快门出现
|
||||
toast('已切换为系统相机拍摄');
|
||||
// 系统相机拍回的照片也要校验(有脸、角度对),模型照常预加载
|
||||
if (!IS_MOCK && typeof faceapi !== 'undefined') loadModels().catch(function () {});
|
||||
}
|
||||
// 从视频帧截取照片(完整帧、摄像头原始视角,压到最长边 1280 的 JPEG)
|
||||
// 抓完整帧而非取景框可见区:新 UI 轮廓外全黑,用户只关注轮廓内人脸;按可见区裁切在侧脸/偏站位时会切掉人脸(Face++ INVALID_IMAGE_FACE)
|
||||
@@ -276,9 +320,10 @@
|
||||
ctx.drawImage(camVideo, 0, 0, vw, vh, 0, 0, cw, ch);
|
||||
return canvas.toDataURL('image/jpeg', 0.88);
|
||||
}
|
||||
// 文件 → 统一压成最长边 1280 的 JPEG → 推进流程(降级系统相机与相册选图共用)
|
||||
// 文件 → 统一压成最长边 1280 的 JPEG → 校验合格才推进流程(降级系统相机与相册选图共用)
|
||||
function readPhotoFile(f) {
|
||||
if (!f) return;
|
||||
var step = camStep, seq = camSeq;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
var img = new Image();
|
||||
@@ -287,12 +332,50 @@
|
||||
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));
|
||||
var url = c.toDataURL('image/jpeg', 0.88);
|
||||
shutterLock = true; // 校验期间不接受新的拍摄/选图
|
||||
showCamStatus('照片检查中…');
|
||||
checkStill(url, CAM_STEPS[step].target).then(function (err) {
|
||||
hideCamStatus();
|
||||
if (seq !== camSeq || step !== camStep) return; // 期间已退出/重置
|
||||
shutterLock = false;
|
||||
if (err) { toast(err); voice.play(VOICE.fail, { cut: true }); return; } // 不合格:停在本步重拍
|
||||
onPhotoCaptured(url);
|
||||
});
|
||||
};
|
||||
img.onerror = function () { toast('照片读取失败,请重新选择'); };
|
||||
img.src = reader.result;
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
}
|
||||
// 静态照片校验:有人脸、朝向与当前步一致(检测模型不可用时放行,由服务端兜底)
|
||||
// 系统相机的前置照片是否镜像因机型而异,侧脸只校验转头幅度、不校验左右
|
||||
var STILL_FRONT_YAW_MAX = 20, STILL_SIDE_YAW_MIN = 20;
|
||||
function checkStill(dataUrl, target) {
|
||||
if (IS_MOCK || !modelsOk || typeof faceapi === 'undefined') return Promise.resolve('');
|
||||
return new Promise(function (resolve) {
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
faceapi
|
||||
.detectSingleFace(img, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
|
||||
.withFaceLandmarks()
|
||||
.then(function (d) {
|
||||
if (!d) { resolve('照片中未识别到人脸,请重新拍摄'); return; }
|
||||
var e = null;
|
||||
try { e = HeadPose.estimate(d.landmarks.positions, false); } catch (err) { e = null; }
|
||||
if (!e) { resolve(''); return; }
|
||||
var yaw = Math.abs(e.yaw);
|
||||
if (target === 'front' && yaw > STILL_FRONT_YAW_MAX) { resolve('请拍摄正脸照片'); return; }
|
||||
if (target !== 'front' && yaw < STILL_SIDE_YAW_MIN) { resolve('请拍摄' + (target === 'right' ? '右' : '左') + '侧脸照片'); return; }
|
||||
resolve('');
|
||||
}, function () { resolve(''); });
|
||||
};
|
||||
img.onerror = function () { resolve('照片读取失败,请重新选择'); };
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
function showCamStatus(text) { var s = $('#cam-status'); s.textContent = text; s.classList.add('show'); }
|
||||
function hideCamStatus() { $('#cam-status').classList.remove('show'); }
|
||||
// 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程
|
||||
camFile.addEventListener('change', function () {
|
||||
shutterLock = false; // 选完(含空选择)即解锁;推进窗口由 onPhotoCaptured 重新上锁
|
||||
@@ -429,22 +512,23 @@
|
||||
})();
|
||||
document.addEventListener('click', voice.unlock); // 任意点击即完成解锁
|
||||
|
||||
/* ─── 姿态检测(face-api + HeadPose,引导式自动三连拍) ─── */
|
||||
/* ─── 姿态检测(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 OK_HOLD_MS = 450; // 条件连续合格时长:滤掉一闪而过的合格帧(防运动模糊),达标立即抓拍
|
||||
var NO_PROGRESS_MS = 12000; // 单步超过该时长仍未抓拍→浮出调整提示
|
||||
var PREVIEW_SCALE = 0.85; // 与 CSS #cam-video 的 scale 一致:预览缩小 15%
|
||||
// 人脸框高/视频高。原标定(取景框≈42% 屏高)为 0.26/0.58;预览缩小 15% 后,同样装满轮廓需要脸在原始帧里大 1/0.85 倍
|
||||
var FACE_NEAR = 0.30, FACE_FAR = 0.68;
|
||||
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 okSince = 0; // 当前连续 ok 起点(0=未在累积)
|
||||
var counting = false, cdTimer = null, cdN = 0; // 倒计时
|
||||
var cdDeferCancel = null; // 倒计时等播报收尾的取消句柄(afterCurrent)
|
||||
var nudgeTimer = null; // 12s 无进展提示
|
||||
var capturing = false; // 白屏补光抓帧中(暂停姿态判定)
|
||||
var lastHint = ''; // 提示文案去重
|
||||
@@ -461,6 +545,15 @@
|
||||
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');
|
||||
@@ -479,76 +572,46 @@
|
||||
}
|
||||
function hideNudge() { $('#cam-nudge').classList.remove('show'); }
|
||||
|
||||
// 每步进入/回退时的检测状态机重置(蒙版/提示/倒计时/无进展计时全部归位)
|
||||
// 手动模式(显示快门):系统相机降级 / 识别不可用 / mock 调试;其余为实时检测自动抓拍
|
||||
function isManualMode() { return IS_MOCK || camFallback || detectDisabled; }
|
||||
function syncCamMode() {
|
||||
$('#s-cam').classList.toggle('cam-manual', isManualMode());
|
||||
$('#cam-mask').classList.toggle('scanning', camLive && !isManualMode() && modelsOk);
|
||||
}
|
||||
|
||||
// 每步进入/回退时的检测状态机重置(蒙版/提示/无进展计时全部归位)
|
||||
function resetDetectState() {
|
||||
okSince = 0;
|
||||
missFrames = 0;
|
||||
oppFrames = 0;
|
||||
lastHint = '';
|
||||
cancelCountdown();
|
||||
clearTimeout(nudgeTimer);
|
||||
hideNudge();
|
||||
setHint('');
|
||||
setMask(camLive && !IS_MOCK && !detectDisabled && !camFallback ? 'no-face' : '');
|
||||
setMask(camLive && !isManualMode() ? 'no-face' : '');
|
||||
syncCamMode();
|
||||
armNudge();
|
||||
}
|
||||
// 单步 12s 未触发倒计时→底部浮出轻提示(不阻塞流程);进入下一步/倒计时/抓帧时重置
|
||||
// 单步 12s 仍未抓拍→底部浮出调整提示(不阻塞流程);进入下一步/抓帧时重置
|
||||
function armNudge() {
|
||||
clearTimeout(nudgeTimer);
|
||||
hideNudge();
|
||||
if (IS_MOCK || detectDisabled || camFallback) return; // 手动模式无需提示
|
||||
if (isManualMode()) return;
|
||||
if (currentScreen !== 's-cam') return;
|
||||
nudgeTimer = setTimeout(function () {
|
||||
if (currentScreen === 's-cam' && !counting && !shutterLock) $('#cam-nudge').classList.add('show');
|
||||
if (currentScreen === 's-cam' && !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;
|
||||
syncCamMode();
|
||||
detTick();
|
||||
}
|
||||
function stopDetect() {
|
||||
detRunning = false;
|
||||
clearTimeout(detTimer);
|
||||
cancelCountdown();
|
||||
clearTimeout(nudgeTimer);
|
||||
hideNudge();
|
||||
}
|
||||
@@ -572,6 +635,25 @@
|
||||
})
|
||||
.catch(function () { /* 单帧异常忽略,下一帧继续 */ });
|
||||
}
|
||||
// 人脸框中心相对取景中线的水平偏移(占屏宽比例);按 object-fit:cover + 预览缩放换算到屏幕,镜像只影响符号
|
||||
function faceOffsetX(box) {
|
||||
var vw = camVideo.videoWidth, vh = camVideo.videoHeight, W = camVideo.clientWidth, H = camVideo.clientHeight;
|
||||
if (!vw || !vh || !W || !H) return 0;
|
||||
var s = Math.max(W / vw, H / vh) * PREVIEW_SCALE;
|
||||
return ((box.x + box.width / 2) - vw / 2) * s / W;
|
||||
}
|
||||
// 脸部平均亮度:人脸框缩到 16×16 取均值
|
||||
var lumaCanvas = null;
|
||||
function faceLuma(box) {
|
||||
try {
|
||||
if (!lumaCanvas) { lumaCanvas = document.createElement('canvas'); lumaCanvas.width = lumaCanvas.height = 16; }
|
||||
var c = lumaCanvas.getContext('2d', { willReadFrequently: true });
|
||||
c.drawImage(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(d) {
|
||||
var hint = '', ok = false;
|
||||
@@ -580,17 +662,19 @@
|
||||
if (noFace) {
|
||||
hint = '未检测到人脸';
|
||||
} else if (!hasFace) {
|
||||
return; // 偶发漏检帧:保持上一条提示与状态,不扰动倒计时/ok 累积
|
||||
return; // 偶发漏检帧:保持上一条提示与状态,不扰动 ok 累积
|
||||
} else {
|
||||
// 提示优先级:无人脸 > 距离 > 角度
|
||||
var ratio = d.detection.box.height / (camVideo.videoHeight || 1);
|
||||
// 提示优先级:无人脸 > 距离 > 居中 > 角度 > 光线
|
||||
var box = d.detection.box;
|
||||
var ratio = box.height / (camVideo.videoHeight || 1);
|
||||
if (ratio < FACE_NEAR) hint = '请靠近一点';
|
||||
else if (ratio > FACE_FAR) hint = '请远一点';
|
||||
else if (Math.abs(faceOffsetX(box)) > CENTER_TOL) hint = '请将脸部移入取景框';
|
||||
else {
|
||||
// 距离不合格时角度不判 ok。特征点取自原始帧(预览的 CSS 镜像不影响),侧脸步语义方向按原始视角判定:
|
||||
// 标准机型恒为 false,个别前置帧本身已镜像的机型由自校准翻转 sideYawFlip;正脸步只看 |yaw|,与符号无关
|
||||
// 特征点取自原始帧(预览的 CSS 镜像不影响),三步的语义方向都按原始视角判定:
|
||||
// 标准机型 sideYawFlip 恒为 false,个别前置帧本身已镜像的机型由侧脸自校准翻转
|
||||
var tgt = CAM_STEPS[camStep].target;
|
||||
var pose = HeadPose.check(d.landmarks.positions, tgt, tgt === 'front' ? camMirrored : sideYawFlip);
|
||||
var pose = HeadPose.check(d.landmarks.positions, tgt, sideYawFlip);
|
||||
// 侧脸自校准:个别机型前置帧本身已镜像(语义 yaw 与实际转向持续相反)时自救——
|
||||
// 需连续 30 帧(~10s)强反向才翻转一次:阈值要高,否则用户转错方向 1~2s 就会被误判为镜像机,
|
||||
// 反而把错误的侧脸当合格抓走(左右图归属错乱)。翻转只动判定方向,不动预览画面
|
||||
@@ -604,21 +688,22 @@
|
||||
}
|
||||
} else oppFrames = 0;
|
||||
}
|
||||
hint = pose.hint;
|
||||
ok = pose.ok;
|
||||
if (!pose.ok) hint = pose.hint;
|
||||
else {
|
||||
var luma = faceLuma(box);
|
||||
if (luma != null && luma < LUMA_DARK) hint = '请到光线充足处';
|
||||
else if (luma != null && luma > LUMA_BRIGHT) hint = '光线过强,请避开强光';
|
||||
else ok = true;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
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:正脸 → 右脸出镜 → 左脸出镜) ─── */
|
||||
@@ -626,9 +711,9 @@
|
||||
// 左脸出镜=头向自己的右侧转(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 }
|
||||
{ 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 = {};
|
||||
@@ -643,30 +728,23 @@
|
||||
$('#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', cfg.mask); // 蒙版按步骤切换轮廓
|
||||
applyMirror();
|
||||
resetDetectState();
|
||||
voice.play(VOICE.step[camStep]); // 步骤引导语音
|
||||
}
|
||||
|
||||
var camSeq = 0; // 流程令牌:cam-back/开始拍照时递增,使未决的快门/分析回调失效
|
||||
var camSeq = 0; // 流程令牌:退出拍照/开始拍照时递增,使未决的快门/分析回调失效
|
||||
var shutterLock = false; // 快门防连点锁:流程进行中直接忽略
|
||||
|
||||
// 照片就绪后推进流程(实时抓帧与降级选图共用):存图→步骤点变绿→300ms 过渡→下一步
|
||||
// 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步
|
||||
function onPhotoCaptured(dataUrl) {
|
||||
shutterLock = true; // 推进窗口上锁:降级模式 change 里已解锁,这里重新锁住直到下一步/送检
|
||||
shutterLock = true; // 推进窗口上锁,直到下一步/送检
|
||||
photos[camStep] = dataUrl;
|
||||
$('#cam-dots').children[camStep].classList.add('done');
|
||||
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废(自动拍时倒计时本就等播报收尾)
|
||||
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废
|
||||
var seq = camSeq;
|
||||
setTimeout(function () {
|
||||
if (seq !== camSeq) return; // 期间已 cam-back/重新开始:放弃本次推进
|
||||
if (seq !== camSeq) return; // 期间已退出/重新开始:放弃本次推进
|
||||
if (camStep < CAM_STEPS.length - 1) {
|
||||
camStep++;
|
||||
renderCamStep(); // 内含检测状态机重置
|
||||
@@ -683,7 +761,6 @@
|
||||
if (!camLive) return;
|
||||
shutterLock = true;
|
||||
capturing = true;
|
||||
cancelCountdown();
|
||||
clearTimeout(nudgeTimer);
|
||||
hideNudge();
|
||||
var seq = camSeq;
|
||||
@@ -696,7 +773,7 @@
|
||||
fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出
|
||||
capturing = false;
|
||||
if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出)
|
||||
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即重启倒计时
|
||||
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即再抓
|
||||
onPhotoCaptured(shot);
|
||||
}, 300);
|
||||
}, 300);
|
||||
@@ -729,6 +806,7 @@
|
||||
voice.play(VOICE.fail, { cut: true });
|
||||
toast(errMsg);
|
||||
showScreen('s-profile'); // 回档案页,允许重新提交再拍
|
||||
popHistorySilently();
|
||||
return;
|
||||
}
|
||||
var vm = null;
|
||||
@@ -741,12 +819,13 @@
|
||||
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');
|
||||
showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机
|
||||
}, wait);
|
||||
});
|
||||
}
|
||||
@@ -754,8 +833,9 @@
|
||||
function shutter() {
|
||||
if (shutterLock) return;
|
||||
if (camLive) {
|
||||
if (!isManualMode()) return; // 实时检测模式只自动抓拍,不接受手动快门
|
||||
if (!camVideo.videoWidth) { toast('相机尚未就绪,请稍候'); return; }
|
||||
doCapture(); // 手动兜底:任何时刻可拍当前步,同样走白屏补光,不依赖检测
|
||||
doCapture(); // 识别不可用/mock:手动拍当前步,同样走白屏补光
|
||||
} else if (camFallback) {
|
||||
shutterLock = true; // 前置上锁防连点:change/cancel 回调里解锁
|
||||
camFile.click(); // 降级:调起系统相机,回调里推进
|
||||
@@ -828,11 +908,16 @@
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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);
|
||||
// 维度卡:整数位大字 + 小数位小字;进度条按 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) {
|
||||
@@ -1021,26 +1106,30 @@
|
||||
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(分/岁为小字后缀)
|
||||
// 环形综合评分:满分 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>';
|
||||
$('#stat-age-note').textContent = ''; // 肌龄小字按产品要求不展示(钳制留痕 vm.ageClampNote 仍随数据返回)
|
||||
// 肌龄对比胶囊(新版设计「比实际年龄年轻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 + '岁' : '与实际年龄相当';
|
||||
|
||||
// 三维度:S 值(1 位小数),最低分标红(设计稿无档位徽章)
|
||||
// 三维度:按 P 排名,最低分标红 + 优先关注,最高分浅绿进度条
|
||||
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;
|
||||
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' : '');
|
||||
});
|
||||
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>'
|
||||
@@ -1100,7 +1189,7 @@
|
||||
}
|
||||
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
|
||||
resetDetectState();
|
||||
if (!modelsOk) $('#cam-status').classList.add('show'); // “检测引擎加载中…”
|
||||
if (!modelsOk) showCamStatus('检测引擎加载中…');
|
||||
loadModels().then(function () {
|
||||
$('#cam-status').classList.remove('show');
|
||||
maybeStartDetect();
|
||||
@@ -1128,16 +1217,23 @@
|
||||
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);
|
||||
/* ─── 报告页:每张维度卡各自的子 Tab;点检测图放大,再点返回 ─── */
|
||||
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 && img.src && !img.hidden) {
|
||||
$('#zoom-img').src = img.src;
|
||||
openOverlay('p-zoom');
|
||||
history.pushState({ screen: currentScreen, overlay: 'p-zoom' }, '');
|
||||
}
|
||||
});
|
||||
});
|
||||
$('#p-zoom').addEventListener('click', function () {
|
||||
closeOverlay('p-zoom');
|
||||
popHistorySilently();
|
||||
});
|
||||
|
||||
/* ─── 动作分发 ─── */
|
||||
var actions = {
|
||||
@@ -1147,12 +1243,11 @@
|
||||
'agree-continue': function () {
|
||||
if (!agreed) { toast('请先阅读并同意隐私协议'); return; }
|
||||
closeOverlay('p-privacy');
|
||||
showScreen('s-profile');
|
||||
showScreen('s-profile', 'push');
|
||||
},
|
||||
'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; }
|
||||
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');
|
||||
@@ -1164,16 +1259,9 @@
|
||||
camStep = 0;
|
||||
photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍)
|
||||
shutterLock = false; // 重新进入流程,保险解锁
|
||||
renderCamStep();
|
||||
showScreen('s-cam');
|
||||
showScreen('s-cam', 'push');
|
||||
startCamera(); // beginDetect 在其成功回调里:确认相机可用才加载模型
|
||||
},
|
||||
'cam-back': function () {
|
||||
camSeq++; // 使未决的快门/分析回调失效,避免回退被旧定时器顶回
|
||||
voice.stop(true); // 回退/退出:上一步的播报不再接着念
|
||||
shutterLock = false; // 中途退出也解锁
|
||||
if (camStep > 0) { camStep--; renderCamStep(); } // renderCamStep 内含检测状态机重置
|
||||
else { stopCamera(); showScreen('s-profile'); }
|
||||
renderCamStep(); // 在 startCamera 复位降级标记之后渲染,快门显隐按本次模式
|
||||
},
|
||||
'shutter': shutter,
|
||||
'album': function () {
|
||||
@@ -1187,7 +1275,6 @@
|
||||
camMirrored = camFacing === 'user';
|
||||
startCamera(); // 复用启停与预热(camSeqCam 防竞态)
|
||||
},
|
||||
'back-profile': function () { showScreen('s-profile'); },
|
||||
'history': function () { openHistory(); },
|
||||
'close-history': function () { closeOverlay('p-history'); },
|
||||
'clear-history': function () {
|
||||
@@ -1280,7 +1367,7 @@
|
||||
if (!vm) { toast('该记录已损坏'); return; }
|
||||
renderReport(vm, entry.photos && entry.photos[0], null, entry.payload);
|
||||
closeOverlay('p-history');
|
||||
showScreen('s-report');
|
||||
showScreen('s-report', currentScreen === 's-report' ? undefined : 'push');
|
||||
});
|
||||
|
||||
/* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 photos[] 与 camStep) ─── */
|
||||
@@ -1309,6 +1396,7 @@
|
||||
});
|
||||
|
||||
/* ─── 初始化 ─── */
|
||||
history.replaceState({ screen: 's-splash' }, ''); // 刷新后从启动页开始,历史栈的屏标记与之对齐
|
||||
renderCamStep();
|
||||
// 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口)
|
||||
// 有 MOCK_FIXTURE 时走 fixture(检测可视化同步出图),否则回退 SkinScore 内置样本
|
||||
|
||||
Reference in New Issue
Block a user