diff --git a/MP_verify_kpx234XimQGtFPo6.txt b/MP_verify_kpx234XimQGtFPo6.txt
new file mode 100644
index 0000000..9530954
--- /dev/null
+++ b/MP_verify_kpx234XimQGtFPo6.txt
@@ -0,0 +1 @@
+kpx234XimQGtFPo6
\ No newline at end of file
diff --git a/h5/README.md b/h5/README.md
index 727a6f0..f286660 100644
--- a/h5/README.md
+++ b/h5/README.md
@@ -62,21 +62,21 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
三步严格按顺序,本步拍到合格照片才进入下一步(左右一律以用户本人为准):1/3 正对屏幕(target `front`)→ 2/3 右脸出镜(用户头向自己的左侧转,target `right`,产物作 `right_image`)→ 3/3 左脸出镜(头向自己的右侧转,target `left`,产物作 `left_image`)。送检前 `photosByView` 按 target 重排为 [正, 左, 右]。页面内没有返回按钮,微信/浏览器返回键经 History API 回到档案页并释放摄像头。
- **取景蒙版**:内联 SVG(`#cam-mask`)按步骤切换轮廓——正脸步为正脸剪影(含耳部),侧脸步为对应方向的侧脸剪影;**轮廓路径由 Figma 拍照示意图(`design/assets/images/*-拍照示意.png` 的 alpha 镂空区)逐像素描摹生成**,坐标为 Figma 375×812 整帧,viewBox 只取 y 86–812;框外 85% 黑,轮廓线 2.5px 半透明白。检测运行时轮廓内有自上而下的扫描带(`#cam-clip` 由 app.js 复制轮廓生成);未检测到人脸时轮廓呼吸动画;条件合格时轮廓变绿。
-- **预览缩小 15%**:`#cam-video` 按 `scale(.85)` 显示,手机离脸近一些也能把脸装进轮廓;距离阈值随之按 1/0.85 换算(人脸框高/视频高 <0.30 提示「请靠近一点」、>0.68 提示「请远一点」)。
+- **预览缩小 15%**:`#cam-video` 按 `scale(.85)` 显示。距离判定使用 object-fit:cover 和缩放后的屏幕占比(人脸高度占屏高 <26% 提示靠近、>58% 提示远一点),避免横竖视频比例不同导致距离误判。
- **动态提示**(一次一条,优先级:无人脸 > 距离 > 居中 > 角度 > 光线):「未检测到人脸」「请靠近一点 / 请远一点」「请将脸部移入取景框」(人脸中心偏离中线 >18% 屏宽)、角度类「请向左转头 / 请向右转头 / 请略微抬头 / 请略微低头」(正脸阈值 |yaw|≤15°、|pitch|≤18°,超出时按超出比例更大的一项给方向;侧脸 yaw 28°–75°、|pitch|≤20°)、光线类「请到光线充足处 / 光线过强,请避开强光」(脸部平均亮度 <65 或 >235)。
-- **合格即拍**:上述条件全部合格并连续维持 450ms(滤掉一闪而过的合格帧,防运动模糊)立即白屏补光抓拍,**无倒计时、无手动快门**。
-- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`),进入拍照页开始加载(状态提示「检测引擎加载中…」)。检测循环 ~150ms 一帧,特征点取自原始帧(预览 CSS 镜像不影响),个别前置帧已镜像的机型由侧脸自校准翻转 `sideYawFlip`;连续漏检 ≥3 帧才报「未检测到人脸」。
+- **合格即拍**:单人、距离、横纵居中、姿态和光线连续合格 450ms 后立即抓取当前帧,再闪白 180ms 给出反馈。无倒计时;任何丢帧或检测异常都会清空合格时长,防止误拍。
+- **实时检测**:本地 face-api 模型,约每 150ms 用 detectAllFaces + landmarks 判断单人及姿态。连续漏检 3 帧才更换文案,但每次漏检均中断可拍摄状态。按原始帧判断真实左右,持续转错方向也不会自动交换左右照片。切换镜头及退出页面作废旧的检测回调。
- **镜像策略**:前置镜头预览镜像(照镜子习惯);后置预览不镜像,侧脸轮廓随之翻转(`.cam-mask.flip`)。**抓帧一律不镜像**,三张照片都是摄像头原始视角。
-- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(原始视角 + 最长边 1280 JPEG),保持 ~300ms 再淡出,同步 `navigator.vibrate(30)`。
-- **快门只在无法实时识别时出现**:系统相机降级(getUserMedia 不可用/被拒,如手机走局域网 HTTP)、face-api/模型加载失败、`?mock=1` 调试。降级与相册选图拿到的照片会先用 face-api 校验(有人脸、正脸/侧脸转头幅度对;系统相机是否镜像因机型而异,侧脸不校验左右),不合格提示重拍、停在本步;模型不可用时放行,由服务端兜底。
-- **相册选图**:相机页「相册」按钮可选当前步的图片,与降级拍照同管线(压 1280 → 校验 → 推进)。
+- **抓拍反馈**:先抓取已校验的当前帧(原始视角、最长边 1280 JPEG),再显示 180ms 闪白和短振动;不在延迟后抓取可能已经偏离姿态的画面。
+- **正式页面无手动快门**:相机权限或模型加载失败时显示重试按钮;仅 `?mock=1` 调试模式保留手动快门。拍摄进度明确显示正脸→右脸→左脸,完成三张后只送检一次。
+- **相册选图**:按当前步骤选择照片,压缩后等待模型加载并校验单人、左右朝向和光线;不合格或检查失败均停留本步,不再跳过校验。读取、校验和抓拍期间锁定操作,离开页面使未完成读取失效。
- **语音播报**:步骤引导/姿态提示/拍摄完成/分析中/失败,文案与文件名契约见 `../docs/语音播报内容设计文档_V1.0.md`。新增的提示(居中、抬头/低头、光线)暂无音频,清单缺失的条目静默跳过,只显示文字。
- **无进展提示**:单步 12s 仍未拍到,底部浮出「识别不到?请正对镜头,并到光线充足处」(不阻塞流程)。
- **资源管理**:离开拍照页停止检测循环、清全部定时器、释放摄像头;`camSeq`(流程)/`camSeqCam`(相机)双令牌防竞态;face-api 模型只加载一次。
## 档案页与报告页
-- **档案页**:出生年月 / 性别 / 所在地区三个滚轮选择器同一风格(`vendor/mobile-select`,MIT),出生年月自动换算年龄;地区为省–市两级联动,数据为 `js/region-data.js`(由 china-division 2.7.0 `pc.json` 生成,另含港澳台与「海外」)。
+- **档案页**:出生年月、性别、省市用统一 mobile-select 滚轮,提供占位提示;按出生月份计算并显示年龄,限制选择结果为 1–99 岁,提交时重新计算以适应跨月。省市联动数据见 `js/region-data.js`;插件行点击已适配 44px 行高与安全区,见 vendor README。
- **报告页**:无返回按钮与页头标题;总分环形图 + 肌肤年龄与「比实际年龄年轻/大 N 岁」;三维度分数 + 进度条(最低分标红并挂「优先关注」);紧塑力/活力感/稳定力各一张卡,卡内子 Tab 切换;点检测图全屏放大,再点或按返回键回到报告。
## 检测流程与评分渲染
@@ -159,7 +159,7 @@ schtasks /create /tn "JuziSkinH5" /tr "\"C:\Windows\System32\cmd.exe\" /c \"D:\j
:: 移除: schtasks /delete /tn "JuziSkinH5" /f
```
-局域网提测访问 `http://<本机局域网IP>:8088/`。注意:① 首次监听若弹 Windows 防火墙提示需允许 node.exe;② **手机走局域网 HTTP 时 getUserMedia 不可用**,会自动降级为系统相机三连拍(照片仍按步校验,分析链路完整),要测动态捕捉自动拍照需在本机浏览器访问 `http://127.0.0.1:8088`,或上 HTTPS。
+局域网提测需设置 HOST=0.0.0.0 并允许对应防火墙端口。手机通过局域网 HTTP 访问时不能使用 getUserMedia,需使用 HTTPS 才能测试动态自动拍照;相册选图入口仍可用且逐步校验。
nginx 反代示例(静态与 API 都交给 server.js,nginx 只做 HTTPS 终结):
@@ -177,7 +177,7 @@ server {
}
```
-> **HTTPS 必需**:getUserMedia 相机权限仅在安全上下文可用(localhost 除外)。正式域名必须上 HTTPS,否则前端自动降级为系统相机拍照。
+> **HTTPS 必需**:手机自动拍摄要求安全上下文,正式域名必须使用 HTTPS;本机 localhost 例外。相机不可用时页面显示重试与相册入口。
## 已知占位清单
@@ -185,7 +185,7 @@ server {
- 语音 14 条已由 `tools/gen_voice.py` 生成;改文案后重跑脚本并同步 `manifest.json`。
- 「了解更多」为开发中占位(Toast 提示);拍照页右下按钮为切换前后镜头(系统相机降级模式下提示在系统相机内切换)。
- 产品推荐卡(COREⅡ 双仓精华液)为设计稿固定素材,不随精华推荐档位(25+/35+/50+)变化,待产品提供三档对应素材。
-- **姿态与距离参数已用模拟摄像头校准**(2026-09-27):以 C3 三视图(+45° 侧脸)生成 Y4M 模拟摄像头视频,闭环修正了 yaw 增益(2.2→3.8)、pitch 基线(0.32→0.45)、check() 双重镜像符号 bug、距离阈值(0.35/0.8→0.26/0.58;2026-10 预览缩小 15% 后按比例换算为 0.30/0.68)、检测参数(320/0.4→416/0.3)与漏检 3 帧容忍;自动三连拍在无头浏览器全程通过(e2e E7)。**真机仍建议复核一次**(尤其 2026-10 新增的居中/亮度阈值):不同机型前置镜像策略/取景比例可能微调体验(校准接口:调用方 `mirrored` 标志与 `HeadPose.config`)。
+- **真机复核**:头部姿态系数沿用既有校准,新增多人、屏幕坐标距离/居中与亮度闸门已通过可控浏览器输入测试。前后镜头、不同手机曝光及微信相机权限仍需用真实设备复核;自动化测试不替代硬件实拍。
## 技术说明
@@ -193,3 +193,9 @@ server {
- 图片性能:首屏背景 preload;首屏外 `loading="lazy"`。
- 快门防连点/双流程令牌(camSeq/camSeqCam)防竞态;拍照页取景框外纯黑(对齐 Figma),白字直接落在黑色上。
- face-api.js 0.22.2 与模型全部本地化(`vendor/faceapi/`,约 1.4MB),运行时零外部请求,微信 webview / iOS Safari 弱网与内网部署可用;许可证(MIT / Apache-2.0)见 `vendor/faceapi/README.md`。
+
+## UI 反馈验收
+
+`node h5/test/e2e-feedback.mjs` 验证出生年月、省市联动、自动三连拍的质量闸门与顺序、模型重试、相册校验、图片放大及 320/375/430px 布局。测试拦截分析请求,不调用 Face++。
+
+两个浏览器测试都支持 `BASE_URL`(例如 http://127.0.0.1:8088)、`PLAYWRIGHT_MODULE`(本机 Playwright 的 index.mjs 绝对路径)和 `E2E_OUTPUT_DIR`(截图目录),Windows 与 Linux 均可执行。未指定 Playwright 路径时使用全局安装。
diff --git a/h5/css/style.css b/h5/css/style.css
index cfe6977..33226e1 100644
--- a/h5/css/style.css
+++ b/h5/css/style.css
@@ -21,16 +21,18 @@
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; }
+html { overflow: hidden; }
body {
font-family: var(--font);
color: var(--ink);
background: #000;
overflow: hidden;
+ overflow: clip; /* 滚轮在屏外准备时不能把整个页面程序化滚走 */
-webkit-font-smoothing: antialiased;
user-select: none;
max-width: 480px; /* 桌面预览时居中显示 */
margin: 0 auto;
- position: relative;
+ position: fixed; inset: 0; width: 100%;
}
img { display: block; }
button { font-family: inherit; border: 0; background: none; cursor: pointer; color: inherit; }
@@ -124,8 +126,9 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
color: #fff; font-size: calc(14.5 * var(--u)); font-weight: 400;
margin-bottom: calc(9 * var(--u)); letter-spacing: calc(.5 * var(--u));
}
-.form-row.two { display: flex; gap: calc(23 * var(--u)); }
+.form-row.two { display: flex; gap: calc(14 * var(--u)); }
.form-row.two .field { flex: 1; min-width: 0; }
+.form-row.two .field:first-child { flex: 1.25; }
/* 选择控件(出生年月/性别/地区同一样式) */
.pick {
@@ -136,6 +139,9 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
font-size: calc(13.5 * var(--u)); color: var(--ink); text-align: left;
}
.pick-value { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pick-value.placeholder { color: #777; }
+.profile-age { color: #eee; font-size: max(12px, calc(12 * var(--u))); text-align: center; margin: calc(-9 * var(--u)) 0 calc(18 * var(--u)); }
+.pick:focus-visible, .btn:focus-visible, .ditem-img:focus-visible, .zoom-close:focus-visible { outline: 3px solid #D3BF29; outline-offset: 3px; }
.pick .chev { flex: none; width: calc(8 * var(--u)); height: calc(8 * var(--u)); margin-top: calc(-4 * var(--u)); border-width: 0 1.5px 1.5px 0; }
.pick:active { background: #F7F7F7; }
@@ -245,6 +251,15 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
.cam-shutter img { width: calc(62 * var(--u)); height: calc(62 * var(--u)); }
.cam-shutter:active { transform: scale(.92); }
#s-cam.cam-manual .cam-shutter { visibility: visible; }
+.cam-controls button:disabled { opacity: .35; pointer-events: none; }
+.cam-retry { position: absolute; bottom: 14%; left: 50%; transform: translateX(-50%); z-index: 3; padding: 10px 18px; border: 1px solid #fff; border-radius: 5px; color: #fff; background: #282828; font-size: 14px; white-space: nowrap; }
+.cam-retry[hidden] { display: none; }
+.cam-progress { position: absolute; top: 21%; left: 0; right: 0; display: flex; justify-content: center; gap: 22px; z-index: 2; color: #bbb; font-size: max(12px, calc(12 * var(--u))); }
+.cam-progress span::before { content: ''; display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: #777; vertical-align: middle; }
+.cam-progress .current { color: var(--yellow); }
+.cam-progress .current::before { background: var(--yellow); }
+.cam-progress .done { color: #70E28A; }
+.cam-progress .done::before { background: #70E28A; }
/* 白屏补光:瞬间点亮(.on 无过渡),移除后淡出 */
.cam-fill {
position: absolute; inset: 0; background: #fff;
@@ -324,7 +339,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
background: #fff; border-radius: calc(8 * var(--u));
margin-bottom: calc(12 * var(--u));
}
-.stat-label { font-size: calc(12 * var(--u)); color: var(--ink); }
+.stat-label { font-size: max(13px, calc(14 * var(--u))); color: var(--ink); }
.stat-value { color: var(--ink); line-height: 1; white-space: nowrap; }
.stat-value b { font-weight: 500; letter-spacing: calc(.5 * var(--u)); }
.stat-value i { font-style: normal; font-weight: 500; }
@@ -346,7 +361,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
}
.ring-center .stat-value b { font-size: calc(40 * var(--u)); }
.ring-center .stat-value i { font-size: calc(14 * var(--u)); }
-.ring-center .stat-label { margin-top: calc(3 * var(--u)); font-size: calc(11.5 * var(--u)); color: var(--para); }
+.ring-center .stat-label { margin-top: calc(3 * var(--u)); font-size: max(12px, calc(13 * var(--u))); color: var(--para); }
.sum-age {
flex: 1; min-width: 0;
display: flex; flex-direction: column; align-items: flex-start;
@@ -357,7 +372,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
.stat-age i { font-size: calc(19 * var(--u)); margin-left: 2px; }
.age-pill {
margin-top: calc(7 * var(--u));
- font-size: calc(9.5 * var(--u)); color: var(--grey-2);
+ font-size: max(11px, calc(11 * var(--u))); color: var(--grey-2);
background: #F2F2F2; border-radius: calc(9 * var(--u));
padding: calc(2 * var(--u)) calc(10 * var(--u));
white-space: nowrap;
@@ -373,7 +388,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
padding: 0 0 calc(4 * var(--u)) calc(14 * var(--u));
}
.dim + .dim { border-left: 1px solid #EFEFEF; }
-.dim-name { font-size: calc(12 * var(--u)); color: var(--ink); }
+.dim-name { font-size: max(13px, calc(14 * var(--u))); color: var(--ink); }
.dim-score { margin-top: calc(4 * var(--u)); font-size: calc(30 * var(--u)); font-weight: 500; color: var(--ink); line-height: 1.1; }
.dim-score b { font-size: calc(42 * var(--u)); font-weight: 500; }
.dim-score.dim-bad { color: var(--bad); }
@@ -402,9 +417,9 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
content: ""; position: absolute; left: calc(14 * var(--u)); top: calc(12 * var(--u)); bottom: calc(12 * var(--u));
width: calc(3 * var(--u)); border-radius: 2px; background: var(--yellow);
}
-.dims-note-l1 { font-size: calc(13 * var(--u)); color: var(--para); }
+.dims-note-l1 { font-size: max(14px, calc(14 * var(--u))); color: var(--para); }
.dims-note-l1 b { font-weight: 500; color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
-.dims-note-l2 { margin-top: calc(4 * var(--u)); font-size: calc(12 * var(--u)); color: var(--grey); }
+.dims-note-l2 { margin-top: calc(6 * var(--u)); font-size: max(13px, calc(13 * var(--u))); color: var(--grey-2); line-height: 1.6; }
/* 维度区块:大标题 + 卡内子 Tab + 大图 + 说明 */
.sec-title {
@@ -412,7 +427,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
text-align: center; letter-spacing: calc(1 * var(--u));
margin: calc(26 * var(--u)) 0 calc(16 * var(--u));
}
-.card-detail { padding: calc(14 * var(--u)) calc(30 * var(--u)) calc(22 * var(--u)); }
+.card-detail { padding: calc(14 * var(--u)) calc(20 * var(--u)) calc(22 * var(--u)); }
.detail-tabs { display: flex; justify-content: space-between; border-bottom: 1px solid #EFEFEF; }
.dtab {
position: relative;
@@ -429,15 +444,15 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
.dpanel.active { display: block; animation: fade .25s ease; padding-top: calc(14 * var(--u)); }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.ditem-img {
- width: calc(213 * var(--u)); height: calc(277 * var(--u));
+ width: 100%; height: auto; max-height: calc(360 * var(--u));
margin: 0 auto calc(14 * var(--u));
- object-fit: cover; border-radius: calc(6 * var(--u));
+ object-fit: contain; border-radius: calc(6 * var(--u));
background: #F5F5F5; cursor: zoom-in;
}
.ditem-img[hidden] { display: none; }
.ditem-para {
padding-top: calc(12 * var(--u)); border-top: 1px solid #EFEFEF;
- font-size: calc(13 * var(--u)); color: var(--para); line-height: 1.72;
+ font-size: max(14px, calc(15 * var(--u))); color: var(--para); line-height: 1.8;
text-align: justify; overflow-wrap: anywhere;
}
@@ -450,10 +465,10 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
/* 护理建议 / 精华推荐 */
.card-care, .card-essence { padding: calc(20 * var(--u)) calc(22 * var(--u)) calc(18 * var(--u)); }
.card-heading { font-size: calc(17 * var(--u)); font-weight: 700; color: #000; margin-bottom: calc(10 * var(--u)); }
-.care-item { font-size: calc(13.5 * var(--u)); color: var(--para); line-height: 1.7; margin-bottom: calc(10 * var(--u)); overflow-wrap: anywhere; }
+.care-item { font-size: max(14px, calc(15 * var(--u))); color: var(--para); line-height: 1.8; margin-bottom: calc(14 * var(--u)); overflow-wrap: anywhere; }
.care-summary { font-size: calc(13.5 * var(--u)); font-weight: 500; color: #000; margin-top: calc(4 * var(--u)); }
.essence-name { font-size: calc(16 * var(--u)); font-weight: 700; color: var(--ink); }
-.essence-triggers { font-size: calc(11 * var(--u)); color: var(--grey-3); margin-top: calc(6 * var(--u)); line-height: 1.5; }
+.essence-triggers { font-size: max(12px, calc(12 * var(--u))); color: var(--grey-3); margin-top: calc(8 * var(--u)); line-height: 1.7; }
/* 产品推荐:大标题落在灰底;白卡左文案右图(各占一半) */
.prod-title {
@@ -538,6 +553,9 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
/* 报告检测图放大 */
.overlay-zoom { background: rgba(0,0,0,.92); z-index: 80; cursor: zoom-out; }
.overlay-zoom img { max-width: 100%; max-height: 100%; object-fit: contain; animation: zoomIn .22s ease both; }
+.overlay-zoom img { max-height: calc(100% - 110px); }
+.zoom-close { position: absolute; top: max(16px, env(safe-area-inset-top)); right: 14px; width: 44px; height: 44px; font-size: 34px; color: #fff; border-radius: 50%; background: rgba(255,255,255,.12); }
+.zoom-tip { position: absolute; bottom: max(24px, env(safe-area-inset-bottom)); color: #eee; font-size: 14px; }
@keyframes zoomIn { from { opacity: 0; transform: scale(.88); } to { opacity: 1; transform: none; } }
/* 分析中 */
@@ -621,3 +639,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
.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; }
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
+}
diff --git a/h5/deploy-local.bat b/h5/deploy-local.bat
index 72ca3c4..4c0e5d8 100644
--- a/h5/deploy-local.bat
+++ b/h5/deploy-local.bat
@@ -1,5 +1,8 @@
@echo off
rem 巨子测肤 H5 本机部署启动(提测用)。密钥由 server.js 自动加载。
set PORT=8088
-cd /d %~dp0
+set HOST=127.0.0.1
+rem 无密钥时允许页面预览和 ?mock=1 演示,配置密钥后自动启用真实检测。
+set LOCAL_PREVIEW=1
+cd /d "%~dp0"
node server.js >> "%~dp0server.log" 2>&1
diff --git a/h5/index.html b/h5/index.html
index 9314227..43de9ce 100644
--- a/h5/index.html
+++ b/h5/index.html
@@ -39,18 +39,19 @@
+
+
![检测图放大]()
+
点击图片或空白处返回报告
diff --git a/h5/js/app.js b/h5/js/app.js
index a4fd998..e7bc451 100644
--- a/h5/js/app.js
+++ b/h5/js/app.js
@@ -70,7 +70,10 @@
if (e.key !== 'Escape' && e.key !== 'Esc') return;
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; }
+ if ($('#' + order[i]).classList.contains('show')) {
+ if (order[i] === 'p-zoom') closeZoom(); else closeOverlay(order[i]);
+ return;
+ }
}
});
@@ -169,13 +172,26 @@
}, true);
/* ─── 档案选择控件:出生年月 / 性别 / 省份城市,统一用 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 NOW_Y = new Date().getFullYear();
+ var YEARS = [];
+ for (var yy = NOW_Y - 100; yy <= NOW_Y - 1; yy++) {
+ var months = [];
+ for (var mm = 1; mm <= 12; mm++) {
+ var selectableAge = ageFromBirth(yy, mm);
+ if (selectableAge >= 1 && selectableAge <= 99) months.push({ id: mm, value: mm + '月' });
+ }
+ if (months.length) YEARS.push({ id: yy, value: yy + '年', childs: months });
+ }
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 setPick(sel, text) { var el = $(sel + ' .pick-value'); el.textContent = text; el.classList.remove('placeholder'); }
+ function ageFromBirth(y, m) { var now = new Date(); return now.getFullYear() - y - (now.getMonth() + 1 < m ? 1 : 0); }
+ function refreshAge() {
+ var birth = $('#f-birth').value.split('-');
+ if (birth.length !== 2) return;
+ var age = ageFromBirth(+birth[0], +birth[1]);
+ $('#f-age').value = age;
+ $('#profile-age').textContent = '年龄:' + age + '岁(按出生月份计算)';
+ }
function makePicker(trigger, opts) {
if (typeof MobileSelect === 'undefined') return null;
return new MobileSelect(Object.assign({
@@ -185,12 +201,12 @@
}
makePicker('#pick-birth', {
title: '出生年月',
- wheels: [{ data: YEARS }, { data: MONTHS }],
- position: [YEARS.indexOf((NOW_Y - 30) + '年'), 0],
+ wheels: [{ data: YEARS }],
+ position: [YEARS.map(function (y) { return y.id; }).indexOf(NOW_Y - 30), 0],
onChange: function (val) {
- var y = parseInt(val[0], 10), m = parseInt(val[1], 10);
+ var y = +val[0].id, m = +val[1].id;
$('#f-birth').value = y + '-' + (m < 10 ? '0' : '') + m;
- $('#f-age').value = ageFromBirth(y, m);
+ refreshAge();
setPick('#pick-birth', y + '年' + m + '月');
}
});
@@ -221,9 +237,6 @@
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换)
var camMirrored = true; // 预览镜像:前置 true(照镜子习惯,Figma 侧脸轮廓即按镜像画面绘制),后置 false
- var sideYawFlip = false; // 侧脸步语义 yaw 反向标志:个别机型前置帧本身已镜像时由自校准翻转
- var mirrorCalibrated = false; // 侧脸自校准:本轮是否已翻转过一次(防振荡,一次校准管整个会话)
- var oppFrames = 0; // 侧脸步语义 yaw 连续反向帧计数
// 预览是否镜像(仅预览;抓帧恒为原始视角)。后置不镜像时侧脸轮廓需水平翻转,才与画面中的脸朝向一致
function effMirror() { return camMirrored; }
function applyMirror() {
@@ -297,12 +310,17 @@
camLive = false;
$('#s-cam').classList.remove('cam-live');
$('#cam-mask').classList.remove('scanning');
+ $('#cam-fill').classList.remove('on');
+ if (capturing) shutterLock = false;
+ capturing = false;
+ okSince = 0;
}
function enableFallback() {
camFallback = true;
$('#cam-status').classList.remove('show'); // 与 stopCamera 对齐:去掉「检测引擎加载中」胶囊
- resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框,快门出现
- toast('已切换为系统相机拍摄');
+ resetDetectState();
+ setHint('请允许相机权限后重试', false, true);
+ toast('相机暂不可用,请重试或按顺序选择照片');
// 系统相机拍回的照片也要校验(有脸、角度对),模型照常预加载
if (!IS_MOCK && typeof faceapi !== 'undefined') loadModels().catch(function () {});
}
@@ -324,10 +342,18 @@
function readPhotoFile(f) {
if (!f) return;
var step = camStep, seq = camSeq;
+ if (currentScreen !== 's-cam' || capturing) return;
+ shutterLock = true;
+ okSince = 0;
+ var active = function () { return seq === camSeq && step === camStep && currentScreen === 's-cam'; };
+ function fail(message) { if (!active()) return; shutterLock = false; hideCamStatus(); toast(message); }
var reader = new FileReader();
+ reader.onerror = function () { fail('照片读取失败,请重新选择'); };
reader.onload = function () {
+ if (!active()) return;
var img = new Image();
img.onload = function () {
+ if (!active()) return;
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);
@@ -336,43 +362,39 @@
shutterLock = true; // 校验期间不接受新的拍摄/选图
showCamStatus('照片检查中…');
checkStill(url, CAM_STEPS[step].target).then(function (err) {
+ if (!active()) return;
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.onerror = function () { fail('照片读取失败,请重新选择'); };
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) {
+ if (IS_MOCK) return Promise.resolve('');
+ if (typeof faceapi === 'undefined') return Promise.resolve('识别服务未加载,请刷新后重试');
+ return loadModels().then(function () { 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 }))
+ .detectAllFaces(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(''); });
+ .then(function (faces) {
+ if (!faces.length) { resolve('照片中未识别到人脸,请重新选择'); return; }
+ if (faces.length !== 1) { resolve('请使用只有一人的照片'); return; }
+ var d = faces[0], pose = HeadPose.check(d.landmarks.positions, target, false);
+ if (!pose.ok) { resolve('请按顺序选择' + (target === 'front' ? '正脸' : target === 'right' ? '右侧脸' : '左侧脸') + '照片,' + pose.hint); return; }
+ resolve(lightingHint(faceLuma(d.detection.box, img)));
+ }, function () { resolve('照片检查失败,请重新选择'); });
};
img.onerror = function () { resolve('照片读取失败,请重新选择'); };
img.src = dataUrl;
- });
+ }); }).catch(function () { return '识别服务加载失败,请重试'; });
}
function showCamStatus(text) { var s = $('#cam-status'); s.textContent = text; s.classList.add('show'); }
function hideCamStatus() { $('#cam-status').classList.remove('show'); }
@@ -521,13 +543,14 @@
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;
+ // 以屏幕显示的人脸大小判断距离,预览缩小 15% 后允许用户更靠近镜头。
+ var FACE_NEAR = 0.26, FACE_FAR = 0.58; // 按缩放后的屏幕占比判断,不受横竖视频分辨率影响
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 detEpoch = 0; // 启停/切换镜头时作废上一轮未完成的异步检测
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
var nudgeTimer = null; // 12s 无进展提示
var capturing = false; // 白屏补光抓帧中(暂停姿态判定)
@@ -572,18 +595,18 @@
}
function hideNudge() { $('#cam-nudge').classList.remove('show'); }
- // 手动模式(显示快门):系统相机降级 / 识别不可用 / mock 调试;其余为实时检测自动抓拍
- function isManualMode() { return IS_MOCK || camFallback || detectDisabled; }
+ // 只有 mock 调试显示快门,正式页面在异常时提供重试入口。
+ function isManualMode() { return IS_MOCK; } // 手动快门只用于 mock 调试,正式流程始终自动捕捉
function syncCamMode() {
$('#s-cam').classList.toggle('cam-manual', isManualMode());
- $('#cam-mask').classList.toggle('scanning', camLive && !isManualMode() && modelsOk);
+ $('#cam-mask').classList.toggle('scanning', camLive && !isManualMode() && modelsOk && !detectDisabled);
+ $('[data-action="retry-camera"]').hidden = IS_MOCK || (!camFallback && !detectDisabled);
}
// 每步进入/回退时的检测状态机重置(蒙版/提示/无进展计时全部归位)
function resetDetectState() {
okSince = 0;
missFrames = 0;
- oppFrames = 0;
lastHint = '';
clearTimeout(nudgeTimer);
hideNudge();
@@ -596,7 +619,7 @@
function armNudge() {
clearTimeout(nudgeTimer);
hideNudge();
- if (isManualMode()) return;
+ if (isManualMode() || camFallback || detectDisabled) return;
if (currentScreen !== 's-cam') return;
nudgeTimer = setTimeout(function () {
if (currentScreen === 's-cam' && !shutterLock) $('#cam-nudge').classList.add('show');
@@ -607,33 +630,35 @@
if (detRunning || IS_MOCK || detectDisabled || !modelsOk || !camLive || currentScreen !== 's-cam') return;
detRunning = true;
syncCamMode();
- detTick();
+ detTick(++detEpoch);
}
function stopDetect() {
detRunning = false;
+ detEpoch++;
+ okSince = 0;
clearTimeout(detTimer);
clearTimeout(nudgeTimer);
hideNudge();
}
// 递归 setTimeout 而非 setInterval:检测是异步的,避免帧与帧堆叠
- function detTick() {
- if (!detRunning) return;
+ function detTick(epoch) {
+ if (!detRunning || epoch !== detEpoch) return;
detTimer = setTimeout(function () {
- if (!detRunning) return;
- detectFrame().then(detTick, detTick);
+ if (!detRunning || epoch !== detEpoch) return;
+ detectFrame(epoch).then(function () { detTick(epoch); }, function () { detTick(epoch); });
}, DETECT_INTERVAL);
}
- function detectFrame() {
+ function detectFrame(epoch) {
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 }))
+ .detectAllFaces(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);
+ .then(function (faces) {
+ if (step !== camStep || seq !== camSeq || epoch !== detEpoch) return;
+ if (detRunning && !shutterLock && !capturing) onDetect(faces);
})
- .catch(function () { /* 单帧异常忽略,下一帧继续 */ });
+ .catch(function () { if (epoch === detEpoch) okSince = 0; });
}
// 人脸框中心相对取景中线的水平偏移(占屏宽比例);按 object-fit:cover + 预览缩放换算到屏幕,镜像只影响符号
function faceOffsetX(box) {
@@ -642,58 +667,66 @@
var s = Math.max(W / vw, H / vh) * PREVIEW_SCALE;
return ((box.x + box.width / 2) - vw / 2) * s / W;
}
+ function displayedFaceRatio(box) {
+ var vw = camVideo.videoWidth, vh = camVideo.videoHeight, W = camVideo.clientWidth, H = camVideo.clientHeight;
+ if (!vw || !vh || !W || !H) return 0;
+ return box.height * Math.max(W / vw, H / vh) * PREVIEW_SCALE / H;
+ }
+ function faceOffsetY(box) {
+ var vw = camVideo.videoWidth, vh = camVideo.videoHeight, W = camVideo.clientWidth, H = camVideo.clientHeight;
+ if (!vw || !vh || !W || !H) return 1;
+ var s = Math.max(W / vw, H / vh) * PREVIEW_SCALE;
+ var maskScale = Math.min(W / 375, H / 726);
+ var targetY = (H - 726 * maskScale) / 2 + 320 * maskScale;
+ return (H / 2 + (box.y + box.height / 2 - vh / 2) * s - targetY) / H;
+ }
+ function lightingHint(luma) {
+ if (luma == null) return '画面读取失败,请重试';
+ if (luma < LUMA_DARK) return '请到光线充足处';
+ if (luma > LUMA_BRIGHT) return '光线过强,请避开强光';
+ return '';
+ }
// 脸部平均亮度:人脸框缩到 16×16 取均值
var lumaCanvas = null;
- function faceLuma(box) {
+ function faceLuma(box, source) {
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);
+ c.drawImage(source || 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) {
+ function onDetect(faces) {
+ if (faces.length > 1) {
+ okSince = 0; setMask(''); setHint('请保持单人出镜', false); return;
+ }
+ var d = faces[0];
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 累积
+ okSince = 0; // 文案抗抖,但任何漏检都中断连续合格时长
+ return;
} else {
// 提示优先级:无人脸 > 距离 > 居中 > 角度 > 光线
var box = d.detection.box;
- var ratio = box.height / (camVideo.videoHeight || 1);
+ var ratio = displayedFaceRatio(box);
if (ratio < FACE_NEAR) hint = '请靠近一点';
else if (ratio > FACE_FAR) hint = '请远一点';
- else if (Math.abs(faceOffsetX(box)) > CENTER_TOL) hint = '请将脸部移入取景框';
+ else if (Math.abs(faceOffsetX(box)) > CENTER_TOL || Math.abs(faceOffsetY(box)) > 0.16) hint = '请将脸部移入取景框';
else {
- // 特征点取自原始帧(预览的 CSS 镜像不影响),三步的语义方向都按原始视角判定:
- // 标准机型 sideYawFlip 恒为 false,个别前置帧本身已镜像的机型由侧脸自校准翻转
+ // 按摄像头原始帧判断真实左右;持续转错方向也不能自动交换左右照片。
var tgt = CAM_STEPS[camStep].target;
- var pose = HeadPose.check(d.landmarks.positions, tgt, 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;
- }
+ var pose = HeadPose.check(d.landmarks.positions, tgt, false);
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;
+ hint = lightingHint(faceLuma(box));
+ ok = !hint;
}
}
window.__dbgRatio = ratio; // 自动化校准/测试用(检测框高/视频高)
@@ -729,6 +762,11 @@
.map(function (g) { return '
' + g + ''; })
.join('
');
$('#cam-mask').setAttribute('data-step', cfg.mask); // 蒙版按步骤切换轮廓
+ document.querySelectorAll('[data-capture-step]').forEach(function (el, i) {
+ el.classList.toggle('done', i < camStep);
+ el.classList.toggle('current', i === camStep);
+ if (i === camStep) el.setAttribute('aria-current', 'step'); else el.removeAttribute('aria-current');
+ });
applyMirror();
resetDetectState();
voice.play(VOICE.step[camStep]); // 步骤引导语音
@@ -739,6 +777,7 @@
// 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步
function onPhotoCaptured(dataUrl) {
+ if (currentScreen !== 's-cam' || !dataUrl || photos.length !== camStep) return;
shutterLock = true; // 推进窗口上锁,直到下一步/送检
photos[camStep] = dataUrl;
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废
@@ -755,28 +794,26 @@
}, 300);
}
- // 白屏补光抓拍:全屏纯白瞬间点亮→约300ms 后抓全分辨率帧→保持300ms 再淡出(总 600–900ms)
+ // 合格即抓取当前帧,随后闪白反馈;不在延时期间抓取已偏离角度的照片。
function doCapture() {
if (shutterLock) return;
if (!camLive) return;
+ var shot = captureFromVideo();
+ if (!shot) { okSince = 0; return; }
shutterLock = true;
capturing = true;
clearTimeout(nudgeTimer);
hideNudge();
- var seq = camSeq;
+ var seq = camSeq, camera = camSeqCam;
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);
+ if (seq !== camSeq || camera !== camSeqCam) return;
+ fill.classList.remove('on');
+ capturing = false;
+ onPhotoCaptured(shot);
+ }, 180);
}
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
@@ -831,6 +868,7 @@
}
function shutter() {
+ if (!IS_MOCK) return;
if (shutterLock) return;
if (camLive) {
if (!isManualMode()) return; // 实时检测模式只自动抓拍,不接受手动快门
@@ -1178,26 +1216,29 @@
: '';
}
- /* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */
+ /* ─── 相机可用后加载检测模型,完成前等待,失败可重试 ─── */
function beginDetect() {
+ var camera = camSeqCam;
if (IS_MOCK || camFallback) { resetDetectState(); return; }
if (typeof faceapi === 'undefined') {
- detectDisabled = true; // face-api 本身缺失:全程手动,不再重试
+ detectDisabled = true;
resetDetectState();
- toast('自动识别不可用,已切换手动拍摄');
+ toast('自动识别暂不可用,请重试');
return;
}
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
resetDetectState();
if (!modelsOk) showCamStatus('检测引擎加载中…');
loadModels().then(function () {
+ if (camera !== camSeqCam || currentScreen !== 's-cam') return;
$('#cam-status').classList.remove('show');
maybeStartDetect();
}).catch(function () {
+ if (camera !== camSeqCam || currentScreen !== 's-cam') return;
$('#cam-status').classList.remove('show');
detectDisabled = true; // 仅禁用当次;下次进拍照页 beginDetect 复位后重试
resetDetectState();
- if (currentScreen === 's-cam') toast('自动识别不可用,已切换手动拍摄');
+ if (currentScreen === 's-cam') toast('自动识别暂不可用,请重试');
});
}
@@ -1218,21 +1259,39 @@
});
/* ─── 报告页:每张维度卡各自的子 Tab;点检测图放大,再点返回 ─── */
+ var zoomSource = null;
+ function openZoom(img) {
+ if (!img || !img.src || img.hidden) return;
+ zoomSource = img;
+ $('#zoom-img').src = img.src;
+ $('#zoom-img').alt = img.alt;
+ openOverlay('p-zoom');
+ history.pushState({ screen: currentScreen, overlay: 'p-zoom' }, '');
+ $('.zoom-close').focus();
+ }
+ function closeZoom() {
+ closeOverlay('p-zoom');
+ if (zoomSource) zoomSource.focus({ preventScroll: true });
+ if (history.state && history.state.overlay === 'p-zoom') popHistorySilently();
+ }
+ document.querySelectorAll('.ditem-img').forEach(function (img) {
+ img.tabIndex = 0;
+ img.setAttribute('role', 'button');
+ img.setAttribute('aria-label', img.alt + ',点击放大');
+ img.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openZoom(img); }
+ });
+ });
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' }, '');
- }
+ if (img) openZoom(img);
});
});
$('#p-zoom').addEventListener('click', function () {
- closeOverlay('p-zoom');
- popHistorySilently();
+ closeZoom();
});
/* ─── 动作分发 ─── */
@@ -1246,6 +1305,7 @@
showScreen('s-profile', 'push');
},
'submit-profile': function () {
+ refreshAge();
var age = +$('#f-age').value;
if (!$('#f-birth').value || !(age >= 1 && age <= 99)) { toast('请选择出生年月'); return; }
if (!$('#f-gender').value) { toast('请选择性别'); return; }
@@ -1265,16 +1325,23 @@
},
'shutter': shutter,
'album': function () {
+ if (shutterLock || currentScreen !== 's-cam') return;
+ okSince = 0;
shutterLock = true; // 与降级快门同策略:前置上锁,change/cancel 解锁
albumFile.click();
},
'flip': function () { // 切换前后镜头
+ if (shutterLock || currentScreen !== 's-cam') return;
if (camFallback) { toast('系统相机模式下请在系统相机内切换'); return; }
if (!camStream) { toast('相机启动中,请稍候'); return; }
camFacing = camFacing === 'user' ? 'environment' : 'user';
camMirrored = camFacing === 'user';
startCamera(); // 复用启停与预热(camSeqCam 防竞态)
},
+ 'retry-camera': function () {
+ if (shutterLock || currentScreen !== 's-cam') return;
+ startCamera();
+ },
'history': function () { openHistory(); },
'close-history': function () { closeOverlay('p-history'); },
'clear-history': function () {
diff --git a/h5/server.js b/h5/server.js
index fffea16..85d88a5 100644
--- a/h5/server.js
+++ b/h5/server.js
@@ -27,7 +27,7 @@ const RECORDS_FILE = path.join(DATA_ROOT, 'records.jsonl'); // 检测记录(一
/* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */
const SkinScore = require('./js/skin-score.js');
-/* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env ─── */
+/* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env / key.env ─── */
function loadCredentials() {
if (process.env.SKIN_API_KEY && process.env.SKIN_API_SECRET) {
return { key: process.env.SKIN_API_KEY, secret: process.env.SKIN_API_SECRET };
@@ -38,15 +38,21 @@ function loadCredentials() {
const secret = (txt.match(/SKIN_API_SECRET\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
if (key && secret) return { key, secret };
} catch (e) { /* 文件不存在则继续下一级兜底 */ }
- try {
- const txt = fs.readFileSync(path.join(ROOT, '..', 'reference', 'facepp-skinanalyze', 'credentials.env'), 'utf8');
- const key = (txt.match(/FACEPP_API_KEY\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
- const secret = (txt.match(/FACEPP_API_SECRET\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
- if (key && secret) return { key, secret };
- } catch (e) { /* 文件不存在则走下面的报错 */ }
+ for (const credentialFile of ['credentials.env', 'key.env']) {
+ try {
+ const txt = fs.readFileSync(path.join(ROOT, '..', 'reference', 'facepp-skinanalyze', credentialFile), 'utf8');
+ const key = (txt.match(/FACEPP_API_KEY\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
+ const secret = (txt.match(/FACEPP_API_SECRET\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
+ if (key && secret) return { key, secret };
+ } catch (e) { /* 文件不存在则尝试下一个配置文件 */ }
+ }
+ if (process.env.LOCAL_PREVIEW === '1') {
+ console.warn('[本机预览] 未配置 Face++ 密钥,真实检测不可用;演示入口: /?mock=1');
+ return null;
+ }
console.error('[启动失败] 找不到密钥:请设置 SKIN_API_KEY / SKIN_API_SECRET 环境变量,');
console.error(' 或在项目根目录放置 apikey.bash(内容为 export SKIN_API_KEY=... 等),');
- console.error(' 或在 reference/facepp-skinanalyze/credentials.env 放置 FACEPP_API_KEY=... 等');
+ console.error(' 或在 reference/facepp-skinanalyze/credentials.env 或 key.env 放置 FACEPP_API_KEY=... 等');
process.exit(1);
}
const CRED = loadCredentials();
@@ -306,6 +312,7 @@ async function runDetect(rec, front, left, right) {
/* 调用 Face++ 专业版三视图测肤;
* 接口超时/网络错误(fetch 抛错)、HTTP 5xx、CONCURRENCY_LIMIT_EXCEEDED → 等 4s 重试 1 次(V2.0 第七章) */
async function callFacepp(front, left, right) {
+ if (!CRED) return { error_message: 'NO_API_CREDENTIALS' };
const form = new FormData();
form.append('api_key', CRED.key);
form.append('api_secret', CRED.secret);
@@ -754,6 +761,10 @@ async function handleAdmin(req, res, pathname, url) {
http.createServer((req, res) => {
const pathname = req.url.split('?')[0];
+ if (!CRED && req.method === 'POST' && (pathname === '/api/skin-analyze' || /^\/api\/admin\/records\/[\w-]+\/retry$/.test(pathname))) {
+ req.resume();
+ return sendJson(res, 503, { ok: false, code: 'NO_API_CREDENTIALS', message: '本机尚未配置 Face++ 密钥,请先配置密钥或使用演示模式(?mock=1)' });
+ }
if (req.method === 'POST' && pathname === '/api/skin-analyze') return handleAnalyze(req, res);
if (req.method === 'GET' && pathname === '/api/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
diff --git a/h5/test/e2e-browser.mjs b/h5/test/e2e-browser.mjs
index 83fbbbd..b046fce 100644
--- a/h5/test/e2e-browser.mjs
+++ b/h5/test/e2e-browser.mjs
@@ -8,9 +8,14 @@
*/
import { execSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
+import path from 'node:path';
+import os from 'node:os';
+import fs from 'node:fs';
-const pwRoot = execSync('npm root -g', { encoding: 'utf8' }).trim();
-const { chromium } = await import(pathToFileURL(pwRoot + '/playwright/index.mjs').href);
+const pwModule = process.env.PLAYWRIGHT_MODULE || path.join(execSync('npm root -g', { encoding: 'utf8' }).trim(), 'playwright/index.mjs');
+const { chromium } = await import(pathToFileURL(pwModule).href);
+const outputDir = process.env.E2E_OUTPUT_DIR || os.tmpdir();
+fs.mkdirSync(outputDir, { recursive: true });
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080';
const EXPECTED = [
@@ -176,12 +181,12 @@ try {
check('流程语音无截断', cut.length === 0, cut.map((v) => `${v.id}@${v.cutAt}/${v.dur}`).join(','));
check('页面无脚本异常', pageErrors.length === 0, pageErrors.join(' | '));
- await page.screenshot({ path: '/tmp/juzi-e2e-report.png', fullPage: true });
- console.log('截图 /tmp/juzi-e2e-report.png');
+ await page.screenshot({ path: path.join(outputDir, 'juzi-e2e-report.png'), fullPage: true });
+ console.log('截图 ' + path.join(outputDir, 'juzi-e2e-report.png'));
} catch (err) {
console.error('RUN ERROR', err);
fails.push(String(err && err.message || err));
- try { await page.screenshot({ path: '/tmp/juzi-e2e-fail.png', fullPage: true }); } catch (e) {}
+ try { await page.screenshot({ path: path.join(outputDir, 'juzi-e2e-fail.png'), fullPage: true }); } catch (e) {}
} finally {
await browser.close();
}
diff --git a/h5/test/e2e-feedback.mjs b/h5/test/e2e-feedback.mjs
new file mode 100644
index 0000000..3c77ef6
--- /dev/null
+++ b/h5/test/e2e-feedback.mjs
@@ -0,0 +1,255 @@
+/** UI 反馈验收:真实交互 + 可控检测输入;拦截分析请求,不调用 Face++。 */
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import { execSync } from 'node:child_process';
+import { pathToFileURL } from 'node:url';
+
+const modulePath = process.env.PLAYWRIGHT_MODULE || path.join(execSync('npm root -g', { encoding: 'utf8' }).trim(), 'playwright/index.mjs');
+const { chromium } = await import(pathToFileURL(modulePath).href);
+const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080';
+const OUT = process.env.E2E_OUTPUT_DIR || path.join(os.tmpdir(), 'juzi-feedback');
+fs.mkdirSync(OUT, { recursive: true });
+const browser = await chromium.launch({ headless: true, args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
+const context = await browser.newContext({ viewport: { width: 375, height: 812 }, permissions: ['camera'], reducedMotion: 'reduce' });
+let page;
+const errors = [], requests = [];
+const pass = name => console.log('PASS ' + name);
+
+// 只替换检测输入和亮度采样;实际 HeadPose、质量判断、相机抓帧、步骤锁与送检仍运行产品代码。
+const detector = `
+window.scene = { pose: 'front', luma: 130, count: 1, ratio: .45, offX: 0, offY: 0, modelFail: false, pattern: false, calls: 0 };
+var origPixels = CanvasRenderingContext2D.prototype.getImageData;
+CanvasRenderingContext2D.prototype.getImageData = function(...args) {
+ if (this.canvas.width !== 16 || this.canvas.height !== 16) return origPixels.apply(this,args);
+ var a = new Uint8ClampedArray(16*16*4); a.fill(window.scene.luma); return { data: a };
+};
+window.faceapi = {
+ nets: { tinyFaceDetector: { loadFromUri: () => scene.modelFail ? Promise.reject(new Error('offline')) : Promise.resolve() }, faceLandmark68Net: { loadFromUri: () => Promise.resolve() } },
+ TinyFaceDetectorOptions: function() {},
+ detectAllFaces: source => ({ withFaceLandmarks: () => {
+ var s = {...scene}; scene.calls++;
+ var w = source.videoWidth || source.naturalWidth, h = source.videoHeight || source.naturalHeight;
+ var pts = Array.from({length:68},()=>({x:200,y:150}));
+ pts[36]={x:150,y:100}; pts[45]={x:250,y:100}; pts[19]={x:170,y:70}; pts[24]={x:230,y:70}; pts[8]={x:200,y:240};
+ pts[30]={x: s.pose==='right'?217.7:s.pose==='left'?182.3:200,y:140};
+ pts[33]={x:200,y: s.pose==='down'?100:s.pose==='up'?200:161.5};
+ var box={x:w*(.5+s.offX)-h*s.ratio/2,y:h*(.5+s.offY-s.ratio/2),width:h*s.ratio,height:h*s.ratio};
+ var count=s.pattern && scene.calls%3===0 ? 0 : s.count;
+ var result=Array.from({length:count},()=>({detection:{box},landmarks:{positions:pts}}));
+ return new Promise(resolve=>setTimeout(()=>resolve(result),s.delay||0));
+ } })
+};`;
+await context.route('**/vendor/faceapi/face-api.js', route => route.fulfill({ contentType: 'application/javascript', body: detector }));
+const fixture = JSON.parse(fs.readFileSync('h5/test/fixtures/case1.json', 'utf8'));
+await context.route('**/api/skin-analyze', async route => {
+ requests.push(route.request().postDataJSON());
+ await route.fulfill({ json: { ok: true, result: fixture.result || fixture, side: {} } });
+});
+
+async function chooseDefaults() {
+ for (const id of ['birth', 'gender', 'region']) {
+ await page.locator('#pick-' + id).click();
+ await page.waitForTimeout(450);
+ await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
+ await page.waitForTimeout(450); // 插件内部关闭动画锁
+ }
+}
+async function profile() {
+ await page.goto(BASE + '/');
+ await page.getByRole('button', { name: '开始检测', exact: true }).click();
+ await page.locator('#privacy-check').click();
+ await page.getByRole('button', { name: '继续', exact: true }).click();
+}
+async function start() {
+ await chooseDefaults();
+ await page.getByRole('button', { name: '提交', exact: true }).click();
+ await page.getByRole('button', { name: '开始', exact: true }).click();
+ await page.waitForFunction(() => document.querySelector('#cam-video').videoWidth > 0 && document.querySelector('#s-cam').classList.contains('cam-live'));
+}
+async function scene(values) { await page.evaluate(v => Object.assign(window.scene, v), values); }
+async function hint(text) { await page.waitForFunction(t => document.querySelector('#cam-hint').textContent === t, text); }
+async function step(n) { await page.waitForFunction(n => document.querySelector('#cam-step').textContent.startsWith(n + '/3'), n); }
+async function shot(name) { await page.screenshot({ path: path.join(OUT, name + '.png') }); }
+
+try {
+ page = await context.newPage();
+ page.on('pageerror', e => errors.push(e.message));
+ await page.goto(BASE + '/');
+ await shot('01-splash');
+ await profile();
+ assert.equal(await page.locator('.pick-value.placeholder').count(), 3);
+ await chooseDefaults();
+ const data = await page.evaluate(() => ({ birth: document.querySelector('#f-birth').value, age: +document.querySelector('#f-age').value, region: document.querySelector('#f-region').value }));
+ assert.equal(data.age, 30);
+ assert.equal(data.region, '北京市');
+ assert.match(await page.locator('#profile-age').innerText(), /30岁/);
+ assert.equal(await page.evaluate(() => document.body.getBoundingClientRect().top), 0);
+ assert.ok(await page.locator('.page-title').evaluate(el => el.getBoundingClientRect().top >= 0));
+ await shot('02-profile');
+ pass('出生年月自动计算年龄、控件占位和省市默认值');
+
+ await page.locator('#pick-birth').click();
+ await page.waitForTimeout(450);
+ for (let i = 0; i < 3; i++) {
+ const wheel = await page.locator('.ms-mobile-select.ms-show .ms-wheel').nth(1).boundingBox();
+ await page.mouse.move(wheel.x + wheel.width / 2, wheel.y + wheel.height - 22);
+ await page.mouse.down();
+ await page.mouse.move(wheel.x + wheel.width / 2, wheel.y + 22, { steps: 12 });
+ await page.mouse.up();
+ await page.waitForTimeout(200);
+ }
+ await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
+ await page.waitForTimeout(450);
+ const december = await page.evaluate(() => ({ birth: document.querySelector('#f-birth').value, age: +document.querySelector('#f-age').value, month: new Date().getMonth() + 1 }));
+ assert.ok(december.birth.endsWith('-12'));
+ assert.equal(december.age, december.month < 12 ? 29 : 30);
+ assert.ok(await page.locator('#pick-birth .pick-value').evaluate(el => el.scrollWidth <= el.clientWidth));
+ await shot('02-profile');
+ pass('滚动选择12月、未过生日月份减一岁,完整年月不被截断');
+
+ // 省份滚轮点击相邻可见项,市级选项必须跟随更新。
+ await page.locator('#pick-region').click();
+ await page.waitForTimeout(450);
+ await page.locator('.ms-mobile-select.ms-show .ms-select-container').first().getByText('河北省', { exact: true }).click();
+ await page.waitForTimeout(350);
+ assert.ok((await page.locator('.ms-mobile-select.ms-show .ms-select-container').nth(1).innerText()).includes('石家庄市'));
+ await shot('03-region');
+ await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
+ await page.waitForTimeout(450);
+ assert.match(await page.locator('#pick-region').innerText(), /河北省.*石家庄市/);
+ pass('省市联动滚轮实际选择');
+
+ await page.getByRole('button', { name: '提交', exact: true }).click();
+ await page.locator('#p-photoguide .popup').waitFor({ state: 'visible' });
+ await page.waitForTimeout(150);
+ await shot('04-guide');
+ assert.equal(await page.locator('#p-photoguide .profile-body').count(), 0);
+ await scene({ luma: 20 });
+ await page.getByRole('button', { name: '开始', exact: true }).click();
+ await hint('请到光线充足处');
+ assert.equal(await page.locator('.cam-shutter').isVisible(), false);
+ await scene({ luma: 250 }); await hint('光线过强,请避开强光');
+ await scene({ luma: 130, ratio: .2 }); await hint('请靠近一点');
+ await scene({ ratio: .8 }); await hint('请远一点');
+ await scene({ ratio: .45, offY: .45 }); await hint('请将脸部移入取景框');
+ await scene({ offY: 0, count: 2 }); await hint('请保持单人出镜');
+ await scene({ count: 1, pose: 'down' }); await hint('请略微抬头');
+ await scene({ pose: 'up' }); await hint('请略微低头');
+ await scene({ pose: 'front', pattern: true });
+ await page.waitForTimeout(2200);
+ assert.match(await page.locator('#cam-step').innerText(), /^1\/3/);
+ assert.equal(requests.length, 0);
+ pass('光线、距离、居中、多人、抬低头与丢帧均阻止误拍;正式页面无手动快门');
+
+ await scene({ pattern: false });
+ await step(2);
+ assert.equal(await page.locator('.cam-progress .done').count(), 1);
+ await scene({ pose: 'left' }); await hint('请向左转头');
+ // 持续错误侧脸超过旧版 30 帧自校准阈值,仍不允许交换左右。
+ const calls = await page.evaluate(() => scene.calls);
+ await page.waitForFunction(n => scene.calls > n + 32, calls);
+ assert.match(await page.locator('#cam-step').innerText(), /^2\/3/);
+ await shot('05-camera');
+ await scene({ pose: 'right' }); await step(3);
+ assert.equal(await page.locator('.cam-progress .done').count(), 2);
+ await scene({ pose: 'left' });
+ await page.locator('#s-report.active').waitFor();
+ assert.equal(requests.length, 1);
+ assert.ok(requests[0].front_image && requests[0].left_image && requests[0].right_image);
+ pass('条件合格自动抓拍,正→右→左严格顺序,三张齐备后只送检一次');
+
+ await page.goto(BASE + '/?mock=1&screen=report');
+ await page.waitForFunction(() => document.querySelector('#ditem-img1').naturalWidth > 0);
+ await shot('06-report');
+ await page.locator('.card-detail').first().scrollIntoViewIfNeeded();
+ await shot('07-report-detail');
+ await page.locator('#ditem-img1').click();
+ await page.locator('#p-zoom.show').waitFor();
+ await shot('08-zoom');
+ await page.locator('#zoom-img').click();
+ await page.waitForFunction(() => !history.state?.overlay);
+ assert.equal(await page.locator('#p-zoom').isVisible(), false);
+ await page.locator('#ditem-img1').press('Enter');
+ await page.keyboard.press('Escape');
+ await page.waitForFunction(() => !history.state?.overlay);
+ assert.equal(await page.locator('#p-zoom').isVisible(), false);
+ pass('图片点击/键盘放大,再点或 Escape 返回,历史栈正确');
+
+ for (const width of [320, 375, 430]) {
+ await page.setViewportSize({ width, height: 812 });
+ assert.ok(await page.evaluate(() => document.querySelector('#s-report').scrollWidth <= document.body.clientWidth));
+ assert.ok(await page.locator('.ditem-para').first().evaluate(el => parseFloat(getComputedStyle(el).fontSize) >= 14));
+ }
+ pass('320/375/430px 报告无横向溢出,正文不小于14px');
+
+ await page.setViewportSize({ width: 375, height: 812 });
+ await profile();
+ await scene({ modelFail: true });
+ await start();
+ await page.locator('.cam-retry:visible').waitFor();
+ assert.equal(await page.locator('.cam-shutter').isVisible(), false);
+ await shot('09-retry');
+ await scene({ modelFail: false, luma: 20 });
+ await page.locator('.cam-retry').click();
+ await hint('请到光线充足处');
+ assert.equal(await page.locator('.cam-retry').isVisible(), false);
+ pass('模型失败可重试,恢复后继续当前步且不会降级手动快门');
+
+ // 相册错误侧脸/光线不合格必须留在同一步;失败后仍可以再次选择。
+ await scene({ pose: 'left', luma: 130 });
+ const photo = path.resolve('reference/facepp-skinanalyze/examples/images/C3_180.jpg');
+ await page.locator('#album-file').setInputFiles(photo);
+ await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('请按顺序选择正脸'));
+ assert.match(await page.locator('#cam-step').innerText(), /^1\/3/);
+ await scene({ pose: 'front', luma: 20 });
+ await page.locator('#album-file').setInputFiles(photo);
+ await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('光线充足'));
+ assert.match(await page.locator('#cam-step').innerText(), /^1\/3/);
+ pass('相册照片同样校验视角和光线,不合格不推进');
+
+ await page.close();
+ page = await context.newPage();
+ page.on('pageerror', e => errors.push(e.message));
+ await page.addInitScript(() => {
+ const original = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
+ let first = true;
+ navigator.mediaDevices.getUserMedia = options => {
+ if (first) { first = false; return Promise.reject(new DOMException('Denied', 'NotAllowedError')); }
+ return original(options);
+ };
+ });
+ await profile();
+ await chooseDefaults();
+ await scene({ luma: 20 });
+ await page.getByRole('button', { name: '提交', exact: true }).click();
+ await page.getByRole('button', { name: '开始', exact: true }).click();
+ await page.locator('.cam-retry:visible').waitFor();
+ assert.equal(await page.locator('.cam-shutter').isVisible(), false);
+ await page.locator('.cam-retry').click();
+ await hint('请到光线充足处');
+ pass('相机拒绝授权后重试恢复,仍停留正脸步骤');
+
+ const native = await browser.newPage(); // 新 context 使用仓库实际 face-api 和模型
+ await native.goto(BASE + '/?mock=1&screen=report');
+ await native.locator('#ditem-img1').scrollIntoViewIfNeeded(); // lazy 图片须进入视口才会加载
+ const faceCount = await native.evaluate(async () => {
+ await Promise.all([faceapi.nets.tinyFaceDetector.loadFromUri('vendor/faceapi/models'), faceapi.nets.faceLandmark68Net.loadFromUri('vendor/faceapi/models')]);
+ const img = document.querySelector('#ditem-img1');
+ await img.decode();
+ const result = await faceapi.detectAllFaces(img, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .3 })).withFaceLandmarks();
+ return result.filter(d => d.landmarks.positions.length === 68).length;
+ });
+ assert.ok(faceCount > 0);
+ await native.close();
+ pass('实际本地模型可加载并返回人脸与68个特征点');
+ assert.deepEqual(errors, []);
+ pass('页面无脚本异常');
+} catch (e) {
+ console.error(e);
+ if (page) await shot('FAIL');
+ process.exitCode = 1;
+} finally {
+ await browser.close();
+}
diff --git a/h5/vendor/mobile-select/README.md b/h5/vendor/mobile-select/README.md
index 97b35f1..114186c 100644
--- a/h5/vendor/mobile-select/README.md
+++ b/h5/vendor/mobile-select/README.md
@@ -1,4 +1,6 @@
# mobile-select 1.4.0
-来源:npm `mobile-select@1.4.0`(MIT,见同目录 `LICENSE`),取 `dist/mobile-select.iife.min.js` 与 `dist/style/mobile-select.css`,未改动。
+来源:npm `mobile-select@1.4.0`(MIT,见同目录 `LICENSE`),取 `dist/mobile-select.iife.min.js` 与 `dist/style/mobile-select.css`。
档案页的出生年月 / 性别 / 地区滚轮由它渲染,样式覆盖写在 `css/style.css` 的 mobile-select 段。
+
+本地补丁:点击滚轮行时,使用当前滚轮底部位置与实际 `optionHeight` 计算选中项,替代原版的 `window.innerHeight` 和固定 `40px`;保证 44px 行高、底部安全区和页面滚动时点击项与显示项一致。拖动与联动逻辑保持原版。
diff --git a/h5/vendor/mobile-select/mobile-select.iife.min.js b/h5/vendor/mobile-select/mobile-select.iife.min.js
index af754ba..5ad4e78 100644
--- a/h5/vendor/mobile-select/mobile-select.iife.min.js
+++ b/h5/vendor/mobile-select/mobile-select.iife.min.js
@@ -4,4 +4,4 @@
* Released under the MIT License.
* (c) 2017-present
*/
-"use strict";function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}function _toConsumableArray(arr){return _arrayWithoutHoles(arr)||_iterableToArray(arr)||_unsupportedIterableToArray(arr)||_nonIterableSpread()}function _nonIterableSpread(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _iterableToArray(iter){if("undefined"!=typeof Symbol&&null!=iter[Symbol.iterator]||null!=iter["@@iterator"])return Array.from(iter)}function _arrayWithoutHoles(arr){if(Array.isArray(arr))return _arrayLikeToArray(arr)}function _slicedToArray(arr,i){return _arrayWithHoles(arr)||_iterableToArrayLimit(arr,i)||_unsupportedIterableToArray(arr,i)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(o,minLen){if(o){if("string"==typeof o)return _arrayLikeToArray(o,minLen);var n=Object.prototype.toString.call(o).slice(8,-1);return"Map"===(n="Object"===n&&o.constructor?o.constructor.name:n)||"Set"===n?Array.from(o):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?_arrayLikeToArray(o,minLen):void 0}}function _arrayLikeToArray(arr,len){(null==len||len>arr.length)&&(len=arr.length);for(var i=0,arr2=new Array(len);i
').concat(o,"")}else for(var _s2=0;_s2"+t[_s2]+"";return i}},{key:"renderComponent",value:function(t){this.mobileSelect=document.createElement("div"),this.mobileSelect.className="ms-mobile-select",this.mobileSelect.innerHTML='\n \n
\n
\n
'.concat(this.config.cancelBtnText,'
\n
').concat(this.config.title||"",'
\n
').concat(this.config.ensureBtnText,'
\n
\n
\n
'),document.body.appendChild(this.mobileSelect);for(var e="",i=0;i
'))+this.getOptionsHtmlStr(t[i].data)+"
";this.mobileSelect.querySelector(".ms-wheels").innerHTML=e}},{key:"reRenderWheels",value:function(){var t=this.wheelList.length-this.displayJson.length;if(0'.concat(this.getOptionsHtmlStr(this.displayJson[_e4]),""),i.setAttribute("data-index",_e4.toString()),this.wheelsContain.appendChild(i))}},{key:"checkCascade",value:function(){var e,t=this.config.keyMap;if(this.isJsonType)for(var i=this.wheelsData[0].data,s=0;se.length)for(var s=this.sliderList.length-e.length,n=0;n=this.oversizeBorder&&(this.curDistance[n]=p,this.movePosition(s,this.curDistance[n]),null!=(r=(p=this.config).transitionEnd)&&r.call(p,this.getIndexArr(),this.getCurValue(),this),null!=(p=(r=this.config).onTransitionEnd)&&p.call(r,this.getCurValue(),this.getIndexArr(),this)):(this.updateCurDistance(s,n),this.curDistance[n]=this.fixPosition(this.curDistance[n]),this.curDistance[n]>2*this.optionHeight?this.curDistance[n]=2*this.optionHeight:this.curDistance[n]arr.length)&&(len=arr.length);for(var i=0,arr2=new Array(len);i').concat(o,"")}else for(var _s2=0;_s2"+t[_s2]+"";return i}},{key:"renderComponent",value:function(t){this.mobileSelect=document.createElement("div"),this.mobileSelect.className="ms-mobile-select",this.mobileSelect.innerHTML='\n \n
\n
\n
'.concat(this.config.cancelBtnText,'
\n
').concat(this.config.title||"",'
\n
').concat(this.config.ensureBtnText,'
\n
\n
\n
'),document.body.appendChild(this.mobileSelect);for(var e="",i=0;i
'))+this.getOptionsHtmlStr(t[i].data)+"
";this.mobileSelect.querySelector(".ms-wheels").innerHTML=e}},{key:"reRenderWheels",value:function(){var t=this.wheelList.length-this.displayJson.length;if(0'.concat(this.getOptionsHtmlStr(this.displayJson[_e4]),""),i.setAttribute("data-index",_e4.toString()),this.wheelsContain.appendChild(i))}},{key:"checkCascade",value:function(){var e,t=this.config.keyMap;if(this.isJsonType)for(var i=this.wheelsData[0].data,s=0;se.length)for(var s=this.sliderList.length-e.length,n=0;n=this.oversizeBorder&&(this.curDistance[n]=p,this.movePosition(s,this.curDistance[n]),null!=(r=(p=this.config).transitionEnd)&&r.call(p,this.getIndexArr(),this.getCurValue(),this),null!=(p=(r=this.config).onTransitionEnd)&&p.call(r,this.getCurValue(),this.getIndexArr(),this)):(this.updateCurDistance(s,n),this.curDistance[n]=this.fixPosition(this.curDistance[n]),this.curDistance[n]>2*this.optionHeight?this.curDistance[n]=2*this.optionHeight:this.curDistance[n]