-
+
Face⁺⁺个人信息与隐私保护政策
diff --git a/h5/js/app.js b/h5/js/app.js
index 459a16e..0935c62 100644
--- a/h5/js/app.js
+++ b/h5/js/app.js
@@ -142,14 +142,27 @@
if (drag.suppress) { e.preventDefault(); e.stopPropagation(); drag.suppress = false; }
}, true);
- /* ─── 地区选项 ─── */
+ /* ─── 地区选项(自定义下拉:原生 select 各端样式不一,改自绘弹层) ─── */
var REGIONS = ['北京', '上海', '广州', '深圳', '杭州', '成都', '重庆', '武汉', '西安', '南京', '天津', '苏州', '长沙', '郑州', '青岛', '其他'];
- var regionSel = $('#f-region');
- REGIONS.forEach(function (r) {
- var o = document.createElement('option');
- o.textContent = r;
- regionSel.appendChild(o);
+ 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 直返内置样本
@@ -158,9 +171,11 @@
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: 'user' }, width: { ideal: 1280 }, height: { ideal: 1280 } },
+ video: { facingMode: { ideal: camFacing }, width: { ideal: 1280 }, height: { ideal: 1280 } },
audio: false
}).catch(function (err) {
if (err && err.name === 'OverconstrainedError') {
@@ -169,6 +184,18 @@
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; }
@@ -178,14 +205,18 @@
if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放
camStream = stream;
camVideo.srcObject = stream;
- camLive = true;
+ camVideo.classList.toggle('mirror', camMirrored);
// iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连
stream.getTracks().forEach(function (t) {
t.onended = function () { onCamTrackEnded(token); };
});
- $('#s-cam').classList.add('cam-live');
- beginDetect(); // 确认相机可用后才加载检测模型(无相机权限设备不白加载)
- maybeStartDetect(); // 模型已就绪时即刻开跑检测循环
+ waitVideoStable(function () { // 预热完成前不出实时画面(静态示意底图还在),避免开头"大脸"闪变
+ if (token !== camSeqCam) return;
+ camLive = true;
+ $('#s-cam').classList.add('cam-live');
+ beginDetect(); // 确认相机可用后才加载检测模型(无相机权限设备不白加载)
+ maybeStartDetect(); // 模型已就绪时即刻开跑检测循环
+ });
}).catch(function () {
if (token !== camSeqCam) return;
enableFallback();
@@ -218,13 +249,18 @@
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);
+ // 只取取景框可见区域(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');
- ctx.translate(cw, 0); ctx.scale(-1, 1); // 镜像
- ctx.drawImage(camVideo, 0, 0, cw, ch);
+ 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 → 推进流程(降级系统相机与相册选图共用)
@@ -343,12 +379,12 @@
m.classList.toggle('no-face', cls === 'no-face');
m.classList.toggle('ok', cls === 'ok');
}
- function setHint(text, 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; // 同文案不重复触发布局
- voice.play(VOICE.hint[text]); // 语音同步(模块内 4s 去重)
+ if (!silent) voice.play(VOICE.hint[text]); // 语音同步(模块内 4s 去重)
}
h.classList.add('show');
h.classList.toggle('ok', !!ok);
@@ -388,7 +424,7 @@
c.textContent = cdN;
c.classList.add('show');
setMask('ok'); // 轮廓保持绿色
- setHint('保持不动', true);
+ setHint('保持不动', true, /* silent= */true); // 倒计时前不再抢播「保持不动」(会被 321 立刻切断,听着像吞字)
voice.play(VOICE.countdown); // 「三、二、一」与数字同步起播
cdTimer = setTimeout(cdTick, 1000);
}
@@ -456,8 +492,8 @@
if (ratio < FACE_NEAR) hint = '请靠近一点';
else if (ratio > FACE_FAR) hint = '请远一点';
else {
- // 前置预览为镜像画面 → mirrored=true;距离不合格时角度不判 ok
- var pose = HeadPose.check(d.landmarks.positions, CAM_STEPS[camStep].target, true);
+ // 镜像标志跟随镜头(前置 true/后置 false);距离不合格时角度不判 ok
+ var pose = HeadPose.check(d.landmarks.positions, CAM_STEPS[camStep].target, camMirrored);
hint = pose.hint;
ok = pose.ok;
}
@@ -680,19 +716,8 @@
function setCat(key, cat) {
var dom = CAT_DOM[key];
- var s = $(dom.s), lv = $(dom.lv);
- if (cat.P == null) { // 缺数据分类:显示说明,不显示分数
- s.textContent = '本次未检出有效数据';
- s.className = 'cat-s cat-na';
- lv.style.display = 'none';
- } else {
- s.textContent = cat.S.toFixed(1);
- s.className = 'cat-s';
- lv.style.display = '';
- lv.textContent = cat.level;
- lv.className = 'cat-badge ' + (LV_CLASS[cat.level] || '');
- }
- $(dom.tx).textContent = cat.text;
+ // 设计稿分类卡只有话术正文:不展示 S 与档位徽章;缺数据分类以说明代正文
+ $(dom.tx).textContent = cat.P == null ? '本次未检出有效数据' : cat.text;
}
/* ─── 报告页检测可视化(face_maps 图层 + marks 坐标叠加,数据驱动) ─── */
@@ -866,15 +891,28 @@
}
$('#stat-score').textContent = total != null ? total + '分' : '--分';
$('#stat-age').textContent = vm.skinAgeDisplay != null ? vm.skinAgeDisplay + '岁' : '--岁';
- $('#stat-age-note').textContent = vm.ageClampNote || ''; // 肤龄钳制留痕(小字灰)
+ // 肌龄小字:常显「与实际年龄相差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(vm.focus.tip)
- : '近期优先关注:
---';
+ ? '近期优先关注:' + esc(vm.focus.name) + '
' + esc(DIM_SLOGAN[vm.focus.name] || '') + ''
+ : '近期优先关注:---';
// 三个 Tab 面板:各所辖两个分类(S + 档位徽章 + 话术正文)
var keys = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne'];
@@ -990,7 +1028,13 @@
shutterLock = true; // 与降级快门同策略:前置上锁,change/cancel 解锁
albumFile.click();
},
- 'flip': function () { toast('切换镜头功能开发中'); },
+ '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'); },
diff --git a/h5/js/skin-score.js b/h5/js/skin-score.js
index 33bc5ac..7bcd5cc 100644
--- a/h5/js/skin-score.js
+++ b/h5/js/skin-score.js
@@ -722,6 +722,7 @@
skinAgeRaw: skinAge,
skinAgeDisplay: skinAgeDisplay,
ageClampNote: ageClampNote,
+ realAge: realAge,
dims: dims,
cats: cats,
group: group,
diff --git a/h5/preview/e2e-20260926/r6-region.png b/h5/preview/e2e-20260926/r6-region.png
new file mode 100644
index 0000000..a8370eb
Binary files /dev/null and b/h5/preview/e2e-20260926/r6-region.png differ
diff --git a/h5/preview/e2e-20260926/r6-report.png b/h5/preview/e2e-20260926/r6-report.png
new file mode 100644
index 0000000..8185474
Binary files /dev/null and b/h5/preview/e2e-20260926/r6-report.png differ