diff --git a/h5/css/style.css b/h5/css/style.css index a41e00c..0c63572 100644 --- a/h5/css/style.css +++ b/h5/css/style.css @@ -594,3 +594,61 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; } .his-score b { font-size: 22px; font-weight: 500; color: var(--ink); } .his-score span { font-size: 11px; color: var(--grey); margin-left: 2px; } .his-del { flex: none; font-size: 11px; color: var(--grey-2); padding: 6px 4px; } + +/* ═══════════ 第6轮:真机反馈 13 项 ═══════════ */ +/* #2 姿态提示浮层:移到取景框下方(原 29% 与脸部轮廓/下巴重叠) */ +.cam-hint { bottom: 21%; } +/* #10 无进展提示与快门重叠:上移 */ +.cam-nudge { bottom: 16%; } +/* #4/#10 拍照页顶栏取消,返回键改悬浮白圆(对齐设计稿) */ +.cam-back-float { + position: absolute; top: 12px; left: 12px; z-index: 3; + background: rgba(255,255,255,.9); border-color: transparent; +} +/* #12 预览镜像改为类切换(前置镜像/后置不镜像) */ +#cam-video { transform: none; } +#cam-video.mirror { transform: scaleX(-1); } + +/* #1 城市自定义下拉(替代原生 select) */ +.rs { position: relative; } +.rs-current { + width: 100%; display: flex; align-items: center; justify-content: space-between; + background: #fff; border: 1px solid var(--line); border-radius: 8px; + padding: 12px 14px; font-size: 13px; color: var(--ink); text-align: left; +} +.rs-current .chev { transition: transform .2s; } +.rs.open .rs-current .chev { transform: rotate(225deg); } +.rs-value.rs-placeholder { color: #B3B3B3; } +.rs-list { + position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 30; + max-height: 264px; overflow-y: auto; -webkit-overflow-scrolling: touch; + background: #fff; border: 1px solid var(--line); border-radius: 8px; + box-shadow: 0 8px 24px rgba(0,0,0,.08); +} +.rs-list li { padding: 12px 14px; font-size: 13px; color: var(--ink); } +.rs-list li + li { border-top: 1px solid #F3F3F3; } +.rs-list li:active { background: #F7F7F7; } + +/* #5 隐私页去头部条,返回键悬浮 */ +.policy-float-back { + position: absolute; top: 12px; left: 12px; z-index: 2; + background: #fff; box-shadow: 0 1px 6px rgba(0,0,0,.12); +} +.overlay-policy .policy-page { position: relative; } + +/* #8 维度卡关注行:灰小字 + 深色口号(对齐设计稿) */ +.dims-note { margin-top: 12px; } +.dims-note-l1 { font-size: 10px; font-weight: 400; color: var(--grey); } +.dims-note-l2 { font-size: 11.5px; font-weight: 500; color: var(--ink); line-height: 1.8; } + +/* #9 Tab 黄色指示条:宽度跟随文字(原固定 78px 过宽) */ +.dtab { padding: 8px 0 0; } +.dtab.active::after { content: none; } +.dtab-tx { position: relative; display: inline-block; padding: 0 5px 9px; } +.dtab.active .dtab-tx::after { + content: ""; position: absolute; left: 0; right: 0; bottom: -1px; + height: 7px; border-radius: 3.5px; background: var(--yellow); +} + +/* #13 分类卡图片对齐设计稿(约 100px 见方、圆角 10) */ +.ditem-img { width: 100px; height: 100px; border-radius: 10px; } diff --git a/h5/index.html b/h5/index.html index 45cbe50..a95cb12 100644 --- a/h5/index.html +++ b/h5/index.html @@ -58,12 +58,14 @@
-
- - +
+ +
+
@@ -88,10 +90,7 @@
- +

1/3
请单人正脸出镜

-
- - 隐私政策与服务协议 -
+

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