save
This commit is contained in:
@@ -0,0 +1 @@
|
||||
kpx234XimQGtFPo6
|
||||
+16
-10
@@ -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 路径时使用全局安装。
|
||||
|
||||
+36
-14
@@ -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; }
|
||||
}
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+15
-8
@@ -39,18 +39,19 @@
|
||||
<!-- 三个选择控件同一套样式:点击后底部弹出滚轮(vendor/mobile-select) -->
|
||||
<div class="form-row two">
|
||||
<div class="field">
|
||||
<label class="field-label">您的出生年月</label>
|
||||
<button type="button" class="pick" id="pick-birth"><span class="pick-value"></span><i class="chev down"></i></button>
|
||||
<label class="field-label" for="pick-birth">您的出生年月</label>
|
||||
<button type="button" class="pick" id="pick-birth" aria-haspopup="dialog"><span class="pick-value placeholder">请选择年月</span><i class="chev down"></i></button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">您的性别</label>
|
||||
<button type="button" class="pick" id="pick-gender"><span class="pick-value"></span><i class="chev down"></i></button>
|
||||
<label class="field-label" for="pick-gender">您的性别</label>
|
||||
<button type="button" class="pick" id="pick-gender" aria-haspopup="dialog"><span class="pick-value placeholder">请选择性别</span><i class="chev down"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="profile-age" id="profile-age" aria-live="polite">根据出生年月自动计算年龄</p>
|
||||
<div class="field">
|
||||
<label class="field-label">您的所在地区</label>
|
||||
<button type="button" class="pick" id="pick-region"><span class="pick-value"></span><i class="chev down"></i></button>
|
||||
<label class="field-label" for="pick-region">您所在的地区</label>
|
||||
<button type="button" class="pick" id="pick-region" aria-haspopup="dialog"><span class="pick-value placeholder">请选择省份和城市</span><i class="chev down"></i></button>
|
||||
</div>
|
||||
<input type="hidden" id="f-birth">
|
||||
<input type="hidden" id="f-age">
|
||||
@@ -116,7 +117,11 @@
|
||||
</g>
|
||||
</svg>
|
||||
<p class="cam-status" id="cam-status">检测引擎加载中…</p>
|
||||
<p class="cam-hint" id="cam-hint"></p>
|
||||
<p class="cam-hint" id="cam-hint" role="status" aria-live="polite"></p>
|
||||
<div class="cam-progress" aria-label="拍摄进度">
|
||||
<span data-capture-step="0">正脸</span><span data-capture-step="1">右脸</span><span data-capture-step="2">左脸</span>
|
||||
</div>
|
||||
<button type="button" class="cam-retry" data-action="retry-camera" hidden>重新开启自动识别</button>
|
||||
<p class="cam-nudge" id="cam-nudge">识别不到?请正对镜头,并到光线充足处</p>
|
||||
<div class="cam-guide" id="cam-guide"></div>
|
||||
<video id="cam-video" autoplay playsinline muted></video>
|
||||
@@ -286,8 +291,10 @@
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ 弹层:报告检测图放大(点击任意处返回) ═══════════ -->
|
||||
<div class="overlay overlay-zoom" id="p-zoom">
|
||||
<div class="overlay overlay-zoom" id="p-zoom" role="dialog" aria-modal="true" aria-label="检测图片预览">
|
||||
<button type="button" class="zoom-close" aria-label="关闭图片预览">×</button>
|
||||
<img id="zoom-img" alt="检测图放大">
|
||||
<p class="zoom-tip">点击图片或空白处返回报告</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ 弹层:隐私政策与服务协议(全文) ═══════════ -->
|
||||
|
||||
+168
-101
@@ -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 '<span>' + g + '</span>'; })
|
||||
.join('<i></i>');
|
||||
$('#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 () {
|
||||
|
||||
+19
-8
@@ -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' });
|
||||
|
||||
+10
-5
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Vendored
+3
-1
@@ -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 行高、底部安全区和页面滚动时点击项与显示项一致。拖动与联动逻辑保持原版。
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user