feat: 真机反馈 13 项修改(下拉/拍照页/语音时机/预热/报告样式/翻转镜头)
1. 城市改自定义下拉(原生 select 各端样式不一) 2. 姿态提示浮层下移,不再压住脸部轮廓 3. 倒计时 321 前不再抢播「保持不动」(被切断听着像吞字) 4. 相机预热:分辨率稳定两拍后才出实时画面+放开检测,开头 1-2s 大脸闪变消除 5. 隐私协议页去掉头部文字条,返回键改悬浮 6. 肌龄小字常显「与实际年龄相差X岁」,钳制时追加「触发(原X→Y)」(vm 增 realAge 字段) 7. 删除设计稿没有的「6个维度观察/状态动态流动」两段文字 8. 维度卡关注行改设计稿两行式:灰小字+深色口号(稳定力口号用设计稿原文,另两条待确认) 9. Tab 黄色指示条宽度跟随文字(原固定 78px) 10. 抓拍只取取景框可见区域(object-fit cover 中心裁切),照片=所见画幅;拍照页顶栏黑条删除;无进展提示上移不再压快门 11. 相册选择器「拍照」项为系统行为,代码层已是图库意图(无 capture),详见回复 12. 切换镜头实现:前后置切换+镜像/姿态参数跟随,替换 Toast 占位 13. 报告分类卡去掉得分与档位徽章(设计稿只有话术),图片改 100px 见方圆角 10 e2e 全量绿(E0-E7)
This commit is contained in:
+81
-37
@@ -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 '<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 直返内置样本
|
||||
@@ -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) + '<br>' + esc(vm.focus.tip)
|
||||
: '近期优先关注:<br>---';
|
||||
? '<span class="dims-note-l1">近期优先关注:' + esc(vm.focus.name) + '</span><br><span class="dims-note-l2">' + esc(DIM_SLOGAN[vm.focus.name] || '') + '</span>'
|
||||
: '<span class="dims-note-l1">近期优先关注:---</span>';
|
||||
|
||||
// 三个 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'); },
|
||||
|
||||
Reference in New Issue
Block a user