This commit is contained in:
root
2026-10-09 23:22:28 +08:00
parent f7902b86c8
commit 658b232a39
13 changed files with 1019 additions and 705 deletions
+22 -17
View File
@@ -54,25 +54,30 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
调试:
- `http://127.0.0.1:8080/?mock=1` 使用内置样本(`SkinScore.MOCK_RESULT`/`MOCK_SIDE`)跳过真实检测;不加载 face-api、不跑姿态检测,快门手动逐步推进(离线/自动化验证用)。
- `http://127.0.0.1:8080/?mock=1` 使用内置样本(`SkinScore.MOCK_RESULT`/`MOCK_SIDE`)跳过真实检测;不加载 face-api、不跑姿态检测,显示快门手动逐步推进(离线/自动化验证用)。
- `http://127.0.0.1:8080/?mock=1&screen=report` 直渲 mock 报告页(**仅调试用**,供无头浏览器截图验证布局;档案缺省按 28 岁/干性肌肤);mock 数据源为 `assets/mock/report-fixture.js`(真实案例1三视图检测返回,含 face_maps 图层 data URL 与 marks 坐标),改该文件即可换样本。
## 拍照交互(引导式自动三连拍)
## 拍照交互(动态捕捉自动三连拍)
三步(顺序对齐 Figma,左右一律以用户本人为准):1/3 正脸(target `front`)→ 2/3 右脸出镜(用户头向自己的左侧转,target `right`,产物作 `right_image`)→ 3/3 左脸出镜(头向自己的右侧转,target `left`,产物作 `left_image`)。送检前 `photosByView` 按 target 重排为 [正, 左, 右]。
三步严格按顺序,本步拍到合格照片才进入下一步(左右一律以用户本人为准):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(帧顶 86px 是微信导航栏,H5 视口从其下方开始;步骤文案/引导等纵向百分比也以 726 高为基准);框外 85% 黑(SVG mask 镂空,对齐 Figma 图层不透明度),轮廓线 2.5px 半透明白。未检测到人脸时轮廓呼吸动画(1.8s);姿态 ok 时轮廓变绿并停止动效。
- **正脸步提示口径**:正脸步角度不合格一律只提示「请正对屏幕」(不再提示左右转头/抬头低头),阈值 |yaw|≤15°、|pitch|≤18°(2026-09-28 产品要求放宽);侧脸步仍按方向提示「请向左/右转头」。
- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`)。进入拍照页即开始加载模型(状态提示"检测引擎加载中…"),加载完成前快门为手动模式。检测循环 ~150ms 一帧,`detectSingleFace(video, TinyFaceDetectorOptions({inputSize:416, scoreThreshold:0.3})).withFaceLandmarks()` 后拿 68 特征点调 `HeadPose.check(pos, target, mirrored)`——特征点取自原始帧(预览 CSS 镜像不影响),侧脸步 mirrored 恒 false(个别前置帧已镜像的机型由侧脸自校准翻转 `sideYawFlip`,只动判定不动画面),正脸步只看 |yaw|;连续漏检 ≥3 帧才报"未检测到人脸"(容忍单帧抖动);人脸框高度占比 <26% 提示"请靠近一点"、>58% 提示"请远一点"(以取景框≈42% 屏高标定)。提示一次一条,优先级:无人脸 > 距离 > 角度。
- **倒计时自动拍**:姿态 ok 连续维持 ≥600ms 后,先等「保持不动」等在播提示音自然收尾(画面保持绿框+保持不动),再进 3s 倒计时(中央大数字,轮廓保持绿色,数字与「三、二、一」语音同步起播,不再拦腰切断);倒计时中任一帧不 ok 立即取消并回到对应提示。倒计时结束进入白屏补光。
- **镜像策略**:前置镜头三步**预览**都镜像(照镜子习惯;Figma 侧脸轮廓即按镜像画面绘制:右脸出镜耳在画面右、左脸出镜耳在画面左);后置镜头预览不镜像,侧脸轮廓随之水平翻转(`.cam-mask.flip`)。**抓帧一律不镜像**:三张照片都是摄像头原始视角,Face++ 左右分区文案(左嘴角纹/左右脸颊)对应用户真实左右,与真值样本同口径。
- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(原始视角 + 最长边 1280 JPEG),保持 ~300ms 再淡出(总 600–900ms),同步 `navigator.vibrate(30)`。
- **手动兜底**:快门任何时刻可拍当前步(同样走白屏补光),不依赖检测;face-api/模型加载失败 → toast"自动识别不可用,已切换手动拍摄",全程手动。系统相机降级模式(getUserMedia 不可用/被拒)维持原样:无检测、无补光。
- **相册选图**:相机页「相册」按钮可选当前步的图片(任何模式下可用),与实拍同管线(压 1280 → 推进流程)。
- **语音播报**:步骤引导/姿态提示/倒计时/拍摄完成/分析中/失败共 14 条,文案与文件名契约见 `../docs/语音播报内容设计文档_V1.0.md`。WebAudio 单通道、姿态提示同条 4s 去重、解锁后预加载全部条目;流程音(步骤/拍摄完成/倒计时/分析中)排队顺序播放互不截断,姿态提示不截断流程音(期间只挂起最新一条,收尾后补播),失败提示可打断「分析中」。`assets/voice/manifest.json` 清单制(14 条已生成),清单缺失的条目静默跳过。
- **无进展提示**:单步 12s 未触发倒计时,底部浮出"识别不到?可点下方快门手动拍摄"(不阻塞流程)。
- **资源管理**:离开拍照页(cam-back/完成)停止检测循环、清全部定时器、释放摄像头;`camSeq`(流程)/`camSeqCam`(相机)双令牌防竞态;face-api 模型只加载一次(缓存 promise,失败后下次进入重试)。
- **取景蒙版**:内联 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 提示「请远一点」)。
- **动态提示**(一次一条,优先级:无人脸 > 距离 > 居中 > 角度 > 光线):「未检测到人脸」「请靠近一点 / 请远一点」「请将脸部移入取景框」(人脸中心偏离中线 >18% 屏宽)、角度类「请向左转头 / 请向右转头 / 请略微抬头 / 请略微低头」(正脸阈值 |yaw|≤15°、|pitch|≤18°,超出时按超出比例更大的一项给方向;侧脸 yaw 28°–75°、|pitch|≤20°)、光线类「请到光线充足处 / 光线过强,请避开强光」(脸部平均亮度 <65 或 >235)。
- **合格即拍**:上述条件全部合格并连续维持 450ms(滤掉一闪而过的合格帧,防运动模糊)立即白屏补光抓拍,**无倒计时、无手动快门**。
- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`),进入拍照页开始加载(状态提示「检测引擎加载中…」)。检测循环 ~150ms 一帧,特征点取自原始帧(预览 CSS 镜像不影响),个别前置帧已镜像的机型由侧脸自校准翻转 `sideYawFlip`;连续漏检 ≥3 帧才报「未检测到人脸」。
- **镜像策略**:前置镜头预览镜像(照镜子习惯);后置预览不镜像,侧脸轮廓随之翻转(`.cam-mask.flip`)。**抓帧一律不镜像**,三张照片都是摄像头原始视角。
- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(原始视角 + 最长边 1280 JPEG),保持 ~300ms 再淡出,同步 `navigator.vibrate(30)`。
- **快门只在无法实时识别时出现**:系统相机降级(getUserMedia 不可用/被拒,如手机走局域网 HTTP)、face-api/模型加载失败、`?mock=1` 调试。降级与相册选图拿到的照片会先用 face-api 校验(有人脸、正脸/侧脸转头幅度对;系统相机是否镜像因机型而异,侧脸不校验左右),不合格提示重拍、停在本步;模型不可用时放行,由服务端兜底。
- **相册选图**:相机页「相册」按钮可选当前步的图片,与降级拍照同管线(压 1280 → 校验 → 推进)。
- **语音播报**:步骤引导/姿态提示/拍摄完成/分析中/失败,文案与文件名契约见 `../docs/语音播报内容设计文档_V1.0.md`。新增的提示(居中、抬头/低头、光线)暂无音频,清单缺失的条目静默跳过,只显示文字。
- **无进展提示**:单步 12s 仍未拍到,底部浮出「识别不到?请正对镜头,并到光线充足处」(不阻塞流程)。
- **资源管理**:离开拍照页停止检测循环、清全部定时器、释放摄像头;`camSeq`(流程)/`camSeqCam`(相机)双令牌防竞态;face-api 模型只加载一次。
## 档案页与报告页
- **档案页**:出生年月 / 性别 / 所在地区三个滚轮选择器同一风格(`vendor/mobile-select`,MIT),出生年月自动换算年龄;地区为省–市两级联动,数据为 `js/region-data.js`(由 china-division 2.7.0 `pc.json` 生成,另含港澳台与「海外」)。
- **报告页**:无返回按钮与页头标题;总分环形图 + 肌肤年龄与「比实际年龄年轻/大 N 岁」;三维度分数 + 进度条(最低分标红并挂「优先关注」);紧塑力/活力感/稳定力各一张卡,卡内子 Tab 切换;点检测图全屏放大,再点或按返回键回到报告。
## 检测流程与评分渲染
@@ -154,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。
局域网提测访问 `http://<本机局域网IP>:8088/`。注意:① 首次监听若弹 Windows 防火墙提示需允许 node.exe;② **手机走局域网 HTTP 时 getUserMedia 不可用**,会自动降级为系统相机三连拍(照片仍按步校验,分析链路完整),要测动态捕捉自动拍照需在本机浏览器访问 `http://127.0.0.1:8088`,或上 HTTPS。
nginx 反代示例(静态与 API 都交给 server.js,nginx 只做 HTTPS 终结):
@@ -180,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)、检测参数(320/0.4→416/0.3)与漏检 3 帧容忍;自动三连拍在无头浏览器全程通过(e2e E7)。**真机仍建议复核一次**:不同机型前置镜像策略/取景比例可能微调体验(校准接口:调用方 `mirrored` 标志与 `HeadPose.config`)。
- **姿态与距离参数已用模拟摄像头校准**(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`)。
## 技术说明
+318 -391
View File
@@ -1,6 +1,6 @@
/* ═══════════════════════════════════════════
巨子测肤 · 1'me 在线肌肤测试 — H5 样式
设计稿 1080px 基准 → 此处 375px(÷2.88)
设计稿 1080px 基准 → 375px(÷2.88);尺寸写成 N × --u,不同宽度手机等比缩放、视觉一致
主色 #F5F078 · 文字 #282828 · 底 #EFEFEF
═══════════════════════════════════════════ */
:root {
@@ -13,10 +13,9 @@
--para: #4A4A4A;
--bg: #EFEFEF;
--line: #E5E5E5;
--dry: #EAE6E1;
--neutral: #DBF6D6;
--bad: #D30C0C;
--nav-h: 44px;
--u: calc(min(100vw, 480px) / 375); /* 1 设计像素(375 宽基准) */
--font: "Noto Sans SC","PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif;
}
@@ -34,7 +33,7 @@ body {
position: relative;
}
img { display: block; }
button { font-family: inherit; border: 0; background: none; cursor: pointer; }
button { font-family: inherit; border: 0; background: none; cursor: pointer; color: inherit; }
/* ─── 屏幕容器 ─── */
.screen {
@@ -47,15 +46,15 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.screen.active { display: block; animation: screenIn .35s ease both; }
@keyframes screenIn { from { opacity: 0; transform: translateX(14px); } to { opacity: 1; transform: none; } }
/* ─── 通用按钮 ─── */
/* ─── 主按钮:长方形 + 小倒角(设计稿 181×37) ─── */
.btn-primary {
display: block;
width: 182px; height: 39px;
line-height: 39px;
width: calc(181 * var(--u)); height: calc(37 * var(--u));
line-height: calc(37 * var(--u));
background: var(--yellow);
border-radius: 20px;
border-radius: calc(5 * var(--u));
color: var(--ink);
font-size: 14px; font-weight: 500;
font-size: calc(14.5 * var(--u)); font-weight: 500; letter-spacing: calc(.5 * var(--u));
text-align: center;
transition: transform .12s ease;
}
@@ -68,8 +67,16 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
display: inline-block; padding: 2px;
}
.chev.left { transform: rotate(135deg); }
.chev.right { transform: rotate(-45deg); }
.chev.down { transform: rotate(45deg); }
.nav-back {
width: 27px; height: 27px;
display: flex; align-items: center; justify-content: center;
border: 1px solid var(--line); border-radius: 50%;
background: #fff;
}
/* ═══════════ 01 启动页 ═══════════ */
#s-splash { background: #101823; }
.splash-bg {
@@ -77,194 +84,175 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
background: url(../assets/img/bg-splash.jpg) center/cover no-repeat;
}
.splash-slogan {
position: absolute; left: 0; right: 0; top: 20%;
position: absolute; left: 0; right: 0; top: 10.7%;
text-align: center;
color: #fff; font-size: 23px; font-weight: 500;
line-height: 1.38;
letter-spacing: 1px;
text-shadow: 0 1px 8px rgba(0,0,0,.18);
color: #fff; font-size: calc(23 * var(--u)); font-weight: 500;
line-height: 1.38; letter-spacing: calc(.6 * var(--u));
text-shadow: 0 1px 8px rgba(0,0,0,.15);
}
.splash-bottom {
position: absolute; left: 0; right: 0; bottom: 11%;
display: flex; flex-direction: column; align-items: center; gap: 18px;
position: absolute; left: 0; right: 0; bottom: 12.4%;
display: flex; flex-direction: column; align-items: center; gap: calc(16 * var(--u));
padding-bottom: env(safe-area-inset-bottom);
}
.brand-row { display: flex; align-items: center; gap: 7px; }
.brand-logo { width: 46px; height: auto; filter: brightness(0) invert(1); }
.brand-text { color: #fff; font-size: 21px; font-weight: 500; letter-spacing: 1px; }
/* ═══════════ 通用导航 ═══════════ */
.nav {
position: sticky; top: 0; z-index: 20;
height: var(--nav-h);
display: flex; align-items: center;
padding: 0 6px;
background: rgba(255,255,255,.94);
-webkit-backdrop-filter: blur(8px); /* iOS<15.4 需要前缀 */
backdrop-filter: blur(8px);
}
.nav-dark { background: rgba(20,20,20,.55); }
.nav-dark .nav-title { color: #fff; }
.nav-dark .chev { border-color: #fff; }
.nav-back {
width: 27px; height: 27px;
display: flex; align-items: center; justify-content: center;
border: 1px solid #E5E5E5; border-radius: 50%;
background: #fff;
}
/* 深色导航(拍照页):半透明白底盘 + 浅白描边 */
.nav-dark .nav-back { background: rgba(255,255,255,.18); border-color: rgba(255,255,255,.4); }
.nav-title {
position: absolute; left: 50%; transform: translateX(-50%);
font-size: 14px; font-weight: 500; color: #000;
max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.brand-row { display: flex; align-items: center; gap: calc(7 * var(--u)); }
.brand-logo { width: calc(46 * var(--u)); height: auto; filter: brightness(0) invert(1); }
.brand-text { color: #fff; font-size: calc(21.5 * var(--u)); font-weight: 500; letter-spacing: calc(1 * var(--u)); }
/* ═══════════ 03 档案填写 ═══════════ */
#s-profile { background: #0e0f18; }
.profile-bg {
position: absolute; inset: 0;
/* 设计稿:启动页照片 + 50% 正片叠底黑遮罩(矩形3) */
/* 启动页照片 + 55% 黑遮罩 */
background:
linear-gradient(rgba(0,0,0,.55), rgba(0,0,0,.55)),
url(../assets/img/bg-splash.jpg) center/cover no-repeat;
}
#s-profile .nav { position: fixed; left: 0; right: 0; }
.profile-body {
position: relative; z-index: 1;
min-height: 100%;
/* 设计:标题在导航下 23px;按钮随内容流(卡底→按钮间距按设计节奏约 22px) */
padding: calc(var(--nav-h) + 23px) 64px clamp(48px, 8vh, 121px);
display: flex; flex-direction: column; justify-content: flex-start;
gap: 16px;
padding: calc(34 * var(--u)) calc(64 * var(--u)) calc(40 * var(--u));
}
.page-title {
color: #fff; font-size: 21px; font-weight: 700;
letter-spacing: 1px; margin-bottom: 2px;
color: #fff; font-size: calc(21.5 * var(--u)); font-weight: 700;
letter-spacing: calc(1 * var(--u)); text-align: center;
margin-bottom: calc(22 * var(--u));
}
.field { margin-bottom: calc(19 * var(--u)); }
.field-label {
display: block;
color: #fff; font-size: 14px; font-weight: 400;
margin-bottom: 6px; letter-spacing: .5px;
display: block; text-align: center;
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: 22px; }
.form-row.two .field { flex: 1; }
.field-input {
display: block; width: 100%; height: 38px;
background: #fff; border-radius: 6px; border: 0; outline: none;
padding: 0 12px;
font-family: inherit; font-size: 13px; color: var(--ink);
appearance: none; -webkit-appearance: none;
}
.field-input::placeholder { color: #bbb; }
.field-select-wrap { position: relative; }
.field-select-wrap .chev { position: absolute; right: 12px; top: 15px; width: 7px; height: 7px; pointer-events: none; }
.form-row.two { display: flex; gap: calc(23 * var(--u)); }
.form-row.two .field { flex: 1; min-width: 0; }
/* 肤质卡片 */
/* 选择控件(出生年月/性别/地区同一样式) */
.pick {
display: flex; align-items: center; justify-content: space-between; gap: 6px;
width: 100%; height: calc(37 * var(--u));
padding: 0 calc(13 * var(--u)) 0 calc(12 * var(--u));
background: #fff; border-radius: calc(5 * var(--u));
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 .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; }
/* 肤质卡片(设计稿 246×278) */
.skin-card {
background: #fff; border-radius: 6px;
padding: 24px 10px 26px;
background: #fff; border-radius: calc(5 * var(--u));
padding: calc(21 * var(--u)) 0 calc(24 * var(--u));
}
.skin-axis {
display: flex; justify-content: center; gap: 8px;
margin: 0 0 14px;
display: flex; justify-content: center; gap: calc(12 * var(--u));
margin-bottom: calc(18 * var(--u));
}
.axis-item {
display: inline-flex; align-items: center; gap: 4px;
font-size: 9px; color: var(--ink);
display: inline-flex; align-items: center; gap: calc(5 * var(--u));
font-size: calc(9 * var(--u)); color: var(--ink);
}
.chip { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
.chip { width: calc(8 * var(--u)); height: calc(8 * var(--u)); border-radius: 50%; display: inline-block; }
.skin-stage { position: relative; padding: 0 calc(28 * var(--u)); }
.skin-arrow {
position: absolute; top: calc(42 * var(--u)); z-index: 1;
width: calc(26 * var(--u)); height: calc(26 * var(--u));
display: flex; align-items: center; justify-content: center;
}
.skin-arrow.prev { left: calc(5 * var(--u)); }
.skin-arrow.next { right: calc(5 * var(--u)); }
.skin-arrow .chev { width: calc(9 * var(--u)); height: calc(9 * var(--u)); border-width: 0 1.5px 1.5px 0; }
.skin-arrow[disabled] { opacity: .2; }
.skin-carousel { overflow: hidden; touch-action: pan-y; /* 纵向滚动交给浏览器,横向留给拖拽 */ }
.skin-track { display: flex; transition: transform .32s cubic-bezier(.22,.61,.36,1); }
.skin-item {
flex: 0 0 100%;
display: flex; flex-direction: column; align-items: center;
}
.skin-item img { width: 109px; height: 109px; }
.skin-name { font-size: 13px; font-weight: 500; color: #000; margin-top: 12px; }
.skin-item img { width: calc(109 * var(--u)); height: calc(109 * var(--u)); }
.skin-name { font-size: calc(12.5 * var(--u)); font-weight: 700; color: #000; margin-top: calc(16 * var(--u)); }
.skin-desc {
font-size: 10px; color: var(--ink); font-weight: 400;
line-height: 1.5; text-align: center;
margin-top: 5px; padding: 0 26px;
font-size: calc(10.3 * var(--u)); color: var(--ink);
line-height: 1.55; text-align: center;
margin-top: calc(5 * var(--u)); max-width: calc(136 * var(--u));
}
.skin-dots { display: flex; justify-content: center; gap: 7px; margin-top: 16px; }
.skin-dots { display: flex; justify-content: center; gap: calc(5 * var(--u)); margin-top: calc(22 * var(--u)); }
.skin-dots i {
width: 8px; height: 8px; border-radius: 50%;
width: calc(8 * var(--u)); height: calc(8 * var(--u)); border-radius: 50%;
background: #D6D6D6; transition: background .25s;
}
.skin-dots i.on { background: #000; }
/* 按钮随内容流:卡底→按钮间距 22px(= flex gap 16 + 此处 6),两侧 auto 居中 */
.btn-submit { margin: 6px auto 0; }
.btn-submit { margin: calc(25 * var(--u)) auto 0; }
/* ─── 底部滚轮选择器(vendor/mobile-select)外观统一 ─── */
.ms-mobile-select .ms-gray-layer { background: rgba(0,0,0,.55); }
.ms-mobile-select .ms-content {
bottom: -400px;
border-radius: 12px 12px 0 0; overflow: hidden;
padding-bottom: env(safe-area-inset-bottom);
font-family: var(--font);
}
.ms-mobile-select .ms-btn-bar { height: 50px; line-height: 50px; font-size: 15px; border-bottom-color: #F0F0F0; }
.ms-mobile-select .ms-cancel, .ms-mobile-select .ms-ensure { height: 50px; width: 64px; }
.ms-mobile-select .ms-cancel { color: var(--grey-2) !important; }
.ms-mobile-select .ms-ensure { color: var(--ink) !important; font-weight: 600; }
.ms-mobile-select .ms-title { font-size: 15px; font-weight: 600; color: var(--ink) !important; }
.ms-mobile-select .ms-wheels, .ms-mobile-select .ms-wheel, .ms-mobile-select .ms-shadow-mask { height: 220px; }
.ms-mobile-select .ms-select-container li { font-size: 16px; color: var(--ink); height: 44px; line-height: 44px; }
.ms-mobile-select .ms-select-line { height: 44px; top: 88px; border-color: #EFEFEF; background: rgba(245,240,120,.18); }
/* ═══════════ 05-07 拍照页 ═══════════ */
#s-cam { background: #000; }
/* 相机未就绪/降级时的静态态:白底经蒙版 85% 压暗 → 框外深灰、轮廓内白,即 Figma 05-07 静态稿 */
/* 相机未就绪/降级时的静态态:白底经蒙版 85% 压暗 → 框外深灰、轮廓内白 */
.cam-bg { position: absolute; inset: 0; background: #fff; }
/* 实时相机画面(getUserMedia 成功后显示,盖住静态示意图) */
/* 实时画面整体缩小 15%:脸离镜头近一点也能装进取景轮廓(检测距离阈值按同一比例放宽) */
#cam-video {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
transform: scaleX(-1); /* 自拍镜面预览 */
transform: scale(.85);
display: none; z-index: 0;
}
#cam-video.mirror { transform: scale(-.85, .85); } /* 前置镜头镜像预览 */
#s-cam.cam-live #cam-video { display: block; }
#s-cam.cam-live .cam-bg { display: none; }
/* 设计稿(Figma 05-07):压暗只在取景蒙版框外(.cam-dim-rect),不再整屏压暗 */
#s-cam::before { content: none; }
#s-cam .nav { position: fixed; left: 0; right: 0; }
/* 拍照页纵向定位按 WebView 口径:Figma 帧 812 高扣掉顶部 86px 微信导航栏 = 726 高视口,百分比均以 726 为基准 */
/* 纵向定位按 WebView 口径:Figma 帧 812 高扣掉顶部 86px 微信导航栏 = 726 高视口 */
.cam-step {
position: absolute; left: 0; right: 0; top: 6.4%;
position: absolute; left: 0; right: 0; top: 7.1%;
text-align: center;
color: #fff; font-size: 21px; font-weight: 500;
line-height: 1.5; letter-spacing: 1px;
color: #fff; font-size: calc(21.5 * var(--u)); font-weight: 500;
line-height: 1.5; letter-spacing: calc(1 * var(--u));
text-shadow: 0 1px 10px rgba(0,0,0,.4);
z-index: 2;
}
.cam-guide {
position: absolute; left: 0; right: 0; bottom: 22.4%;
display: flex; justify-content: center; align-items: center; gap: 18px;
display: flex; justify-content: center; align-items: center; gap: calc(26 * var(--u));
z-index: 2;
}
.cam-guide span { color: #fff; font-size: 14px; font-weight: 400; opacity: .92; }
.cam-guide i { width: 0; height: 15px; border-left: 1px solid #fff; }
.cam-guide span { color: #fff; font-size: calc(14.5 * var(--u)); font-weight: 400; }
.cam-guide i { width: 0; height: calc(15 * var(--u)); border-left: 1px solid rgba(255,255,255,.7); }
.cam-controls {
position: absolute; left: 0; right: 0; bottom: 5.2%;
height: calc(62 * var(--u));
display: flex; align-items: center; justify-content: space-between;
padding: 0 55px;
padding-bottom: env(safe-area-inset-bottom);
padding: 0 calc(56 * var(--u));
margin-bottom: env(safe-area-inset-bottom);
z-index: 2;
}
.cam-side img { width: 31px; height: 31px; }
.cam-shutter img { width: 62px; height: 62px; }
.cam-side img { width: calc(30 * var(--u)); height: calc(30 * var(--u)); }
.cam-shutter { visibility: hidden; }
.cam-shutter img { width: calc(62 * var(--u)); height: calc(62 * var(--u)); }
.cam-shutter:active { transform: scale(.92); }
/* 白屏补光:瞬间点亮(.on 无过渡),移除后淡出(总时长 600–900ms) */
#s-cam.cam-manual .cam-shutter { visibility: visible; }
/* 白屏补光:瞬间点亮(.on 无过渡),移除后淡出 */
.cam-fill {
position: absolute; inset: 0; background: #fff;
opacity: 0; pointer-events: none; z-index: 30; /* 盖住导航(z 20) */
opacity: 0; pointer-events: none; z-index: 30;
transition: opacity .25s ease;
}
.cam-fill.on { opacity: 1; transition: none; }
/* 步骤圆点:当前步高亮、完成步绿色打勾(Figma 无此元素,放在步骤文案与取景轮廓之间的空隙) */
.cam-dots {
position: absolute; top: 16.2%; left: 0; right: 0;
display: flex; justify-content: center; gap: 12px;
z-index: 2;
}
.cam-dots i {
width: 9px; height: 9px; border-radius: 50%;
background: rgba(255,255,255,.35);
position: relative; transition: background .25s;
}
.cam-dots i.on { background: #fff; box-shadow: 0 0 0 3px rgba(255,255,255,.25); }
.cam-dots i.done { background: #34C759; }
.cam-dots i.done::after {
content: "✓"; position: absolute; left: 0; right: 0; top: -1px;
text-align: center; font-size: 8px; line-height: 10px; color: #fff;
}
/* 取景蒙版:SVG mask 镂空 + 轮廓线框,按 data-step 切换轮廓;框外 85% 黑 */
.cam-mask {
position: absolute; inset: 0;
@@ -276,53 +264,46 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.cam-mask[data-step="1"] [data-shape="1"],
.cam-mask[data-step="2"] [data-shape="2"] { display: block; }
.cam-mask.flip { transform: scaleX(-1); } /* 后置镜头预览不镜像:侧脸轮廓随之翻转 */
.cam-dim-rect { fill: #000; fill-opacity: .85; } /* Figma 05-07 框外为 85% 黑(alpha 217):环境隐约可见,人脸只在轮廓内清晰 */
.cam-dim-rect { fill: #000; fill-opacity: .85; }
.cam-line path { fill: none; stroke: rgba(255,255,255,.85); stroke-width: 2.5; }
/* 未检测到人脸:轮廓呼吸动画(~1.8s);姿态 ok:品牌绿定格 */
@keyframes maskBreath { 0%,100% { stroke-opacity: .3; } 50% { stroke-opacity: .95; } }
.cam-mask.no-face .cam-line path { animation: maskBreath 1.8s ease-in-out infinite; }
.cam-mask.ok .cam-line path { stroke: #34C759; stroke-opacity: 1; animation: none; }
/* 动态捕捉扫描带:自上而下扫过轮廓内 */
.cam-scan { display: none; }
.cam-mask.scanning .cam-scan { display: block; }
.cam-scan-band { animation: camScan 2.2s cubic-bezier(.45,.05,.55,.95) infinite; }
@keyframes camScan { from { transform: translateY(0); } to { transform: translateY(440px); } }
/* 检测引擎加载状态(小字胶囊) */
.cam-status {
position: absolute; top: 17px; left: 50%; /* 与悬浮返回键同一行,不压步骤文案 */
position: absolute; top: 17px; left: 50%;
transform: translateX(-50%);
font-size: 10px; color: rgba(255,255,255,.85);
font-size: calc(11 * var(--u)); color: rgba(255,255,255,.85);
background: rgba(0,0,0,.35);
padding: 4px 12px; border-radius: 11px;
z-index: 2; opacity: 0; transition: opacity .25s; white-space: nowrap;
}
.cam-status.show { opacity: 1; }
/* 引导提示:一次只显示一条,触发式出现/消失 */
/* 动态提示:取景框下方、引导短句上方,一次一条 */
.cam-hint {
position: absolute; left: 50%; bottom: 29%;
position: absolute; left: 50%; bottom: 27%;
transform: translateX(-50%) translateY(6px);
font-size: 13px; color: #fff;
background: rgba(0,0,0,.45);
padding: 7px 16px; border-radius: 17px;
font-size: calc(17 * var(--u)); font-weight: 500; color: #fff;
text-shadow: 0 1px 8px rgba(0,0,0,.65);
z-index: 2; opacity: 0; pointer-events: none;
transition: opacity .2s ease, transform .2s ease;
white-space: nowrap;
}
.cam-hint.show { opacity: 1; transform: translateX(-50%); }
.cam-hint.ok { background: rgba(52,199,89,.82); }
/* 倒计时大数字(取景中央 3→2→1) */
.cam-count {
position: absolute; left: 0; right: 0; top: 40%;
text-align: center;
font-size: 76px; font-weight: 600; color: #fff;
text-shadow: 0 2px 18px rgba(0,0,0,.55);
z-index: 3; opacity: 0; transition: opacity .15s; pointer-events: none;
}
.cam-count.show { opacity: 1; }
.cam-hint.ok { color: #34C759; }
/* 长时间无进展轻提示(不阻塞流程) */
.cam-nudge {
position: absolute; left: 50%; bottom: 13%;
position: absolute; left: 50%; bottom: 16.5%;
transform: translateX(-50%) translateY(6px);
font-size: 10px; color: #fff;
font-size: calc(11.5 * var(--u)); color: #fff;
background: rgba(0,0,0,.55);
padding: 6px 14px; border-radius: 14px;
z-index: 2; opacity: 0; pointer-events: none;
@@ -331,131 +312,173 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
}
.cam-nudge.show { opacity: 1; transform: translateX(-50%); }
/* ═══════════ 08 报告页(Figma 2:9 严格还原:设计稿 1080 基准 ÷2.88) ═══════════ */
.report-body { padding: 0 38px 20px; }
/* 顶栏:白底通栏横带,左返回圆钮 + 居中黑标题(设计 255px 高通栏含手机状态栏,H5 只保留标题栏) */
.report-nav {
position: relative;
margin: 0 -38px; /* 通栏:抵消 body 侧 padding */
height: calc(44px + env(safe-area-inset-top));
padding-top: env(safe-area-inset-top);
background: #fff;
display: flex; align-items: center; justify-content: center;
}
.report-nav .nav-back {
position: absolute; left: 8px;
top: calc(50% + env(safe-area-inset-top) / 2); transform: translateY(-50%);
}
.report-nav-title { font-size: 14px; font-weight: 500; color: #000; letter-spacing: .5px; }
/* ═══════════ 08 报告页 ═══════════ */
#s-report { background: var(--bg); }
.report-body { padding: 0 calc(18 * var(--u)) calc(28 * var(--u)); }
.report-title {
font-size: 20.6px; font-weight: 700; color: var(--ink);
text-align: center; margin: 20px 0 13px;
letter-spacing: .5px;
font-size: calc(23 * var(--u)); font-weight: 700; color: var(--ink);
text-align: center; letter-spacing: calc(.5 * var(--u));
padding: calc(32 * var(--u)) 0 calc(20 * var(--u));
}
.card {
background: #fff; border-radius: 10px;
padding: 14px 18px;
margin-bottom: 10px;
background: #fff; border-radius: calc(8 * var(--u));
margin-bottom: calc(12 * var(--u));
}
/* 总分:两张独立白卡(设计 420+420,间距 17px→6px) */
.stats-row { display: flex; gap: 6px; margin-bottom: 10px; }
.stats-row .card-stat { flex: 1; margin-bottom: 0; }
.card-stat {
display: flex; flex-direction: column; align-items: center;
padding: 12px 10px 13px;
.stat-label { font-size: calc(12 * 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; }
/* 总分卡:环形综合评分 | 肌肤年龄 */
.card-summary { display: flex; align-items: center; padding: calc(16 * var(--u)) 0; }
.sum-score {
position: relative; flex: 0 0 52%;
display: flex; justify-content: center;
border-right: 1px solid #EFEFEF;
}
.stat-label { font-size: 8.7px; font-weight: 500; color: var(--grey); }
/* label 下短横线(设计 99px→34px 居中) */
.stat-label::after {
content: ""; display: block;
width: 34px; height: 1px; background: #EFEFEF;
margin: 4px auto 0;
.ring { width: calc(108 * var(--u)); height: calc(108 * var(--u)); transform: rotate(-90deg); }
.ring circle { fill: none; stroke-width: 9; }
.ring-bg { stroke: #EFEFEF; }
.ring-fg { stroke: var(--yellow); stroke-linecap: round; stroke-dasharray: 326.73; stroke-dashoffset: 326.73; transition: stroke-dashoffset .9s ease; }
.ring-center {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
}
/* 大数字 30px/400 + 单位 15px/500(设计 87px 与 43.7px) */
.stat-value { margin-top: 2px; line-height: 40px; color: var(--ink); }
.stat-value b { font-size: 30px; font-weight: 400; letter-spacing: 1px; }
.stat-value i { font-style: normal; font-size: 15px; font-weight: 500; }
/* 三维度卡(设计:标签 8.7px 灰 / 分数 28.9px lh50 / 竖向短分隔线 21px / 最低分标红) */
.card-dims { padding: 17px 19px 15px; }
.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); }
.sum-age {
flex: 1; min-width: 0;
display: flex; flex-direction: column; align-items: flex-start;
padding-left: calc(26 * var(--u));
}
.stat-age { margin-top: calc(6 * var(--u)); }
.stat-age b { font-size: calc(58 * var(--u)); }
.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);
background: #F2F2F2; border-radius: calc(9 * var(--u));
padding: calc(2 * var(--u)) calc(10 * var(--u));
white-space: nowrap;
}
.age-pill[hidden] { display: none; }
/* 三维度卡:分数 + 进度条;最低分标红并挂「优先关注」 */
.card-dims { padding: calc(18 * var(--u)) calc(7 * var(--u)) calc(7 * var(--u)); }
.dims-row { display: flex; }
.dim { flex: 1; position: relative; display: flex; flex-direction: column; align-items: center; }
.dim + .dim::before {
content: ""; position: absolute; left: 0; top: 37px;
width: 1px; height: 21px; margin-top: -10.5px; background: #EFEFEF;
.dim {
flex: 1; min-width: 0; position: relative;
display: flex; flex-direction: column; align-items: flex-start;
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-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); }
.dim-bar {
display: block; margin-top: calc(6 * var(--u));
width: calc(69 * var(--u)); height: calc(6 * var(--u));
border-radius: calc(3 * var(--u)); background: #EFEFEF; overflow: hidden;
}
.dim-bar i { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--yellow); transition: width .8s ease; }
.dim.best .dim-bar i { background: #D7EE6A; }
.dim.worst .dim-bar i { background: #F4B04F; }
.dim-flag {
visibility: hidden;
margin-top: calc(6 * var(--u));
font-style: normal; font-size: calc(9 * var(--u)); color: var(--ink);
background: #F8F4A6; border-radius: calc(8 * var(--u));
padding: calc(1 * var(--u)) calc(8 * var(--u));
}
.dim.worst .dim-flag { visibility: visible; }
.dims-note {
position: relative; margin-top: calc(12 * var(--u));
background: #F2F2F2; border-radius: calc(6 * var(--u));
padding: calc(11 * var(--u)) calc(14 * var(--u)) calc(11 * var(--u)) calc(26 * var(--u));
}
.dims-note::before {
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 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); }
/* 维度区块:大标题 + 卡内子 Tab + 大图 + 说明 */
.sec-title {
font-size: calc(22 * var(--u)); font-weight: 700; color: var(--ink);
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)); }
.detail-tabs { display: flex; justify-content: space-between; border-bottom: 1px solid #EFEFEF; }
.dtab {
position: relative;
min-width: calc(80 * var(--u));
padding: calc(6 * var(--u)) calc(4 * var(--u)) calc(11 * var(--u));
font-size: calc(14.5 * var(--u)); font-weight: 500; color: var(--ink);
}
.dim-name { font-size: 8.7px; font-weight: 500; color: var(--grey); }
.dim-score { font-size: 28.9px; font-weight: 500; color: var(--ink); line-height: 50px; }
.dim-score.dim-bad { color: var(--bad); } /* 最低分标红 */
/* 关注行(设计 fs25→8.7px:前缀灰 + 维度名深色大一号;下行深色口号) */
.dims-note { margin-top: 2px; text-align: center; line-height: 1.5; }
.dims-note-l1 { font-size: 8.7px; font-weight: 500; color: #858585; }
.dims-note-l1 b { font-size: 10.1px; font-weight: 500; color: var(--ink); }
.dims-note-l2 { font-size: 8.7px; font-weight: 500; color: var(--ink); }
/* 具体描述(Tab 10.1px,选中 700 + 78×7 黄条;竖排条目标题在图左;图文垂直居中) */
.card-detail { padding: 0 19px 19px; }
.detail-tabs { display: flex; border-bottom: 1px solid #F0F0F0; }
.dtab { flex: 1; padding: 13px 0 0; font-weight: 500; color: var(--ink); }
.dtab-tx { position: relative; display: inline-block; font-size: 10.1px; padding: 0 5px 7px; }
.dtab.active { font-weight: 700; }
.dtab.active .dtab-tx::after {
content: ""; position: absolute; left: 50%; transform: translateX(-50%); bottom: -1px;
width: 78px; height: 7px; border-radius: 3.5px; background: var(--yellow);
.dtab.active::after {
content: ""; position: absolute; left: 0; right: 0; bottom: calc(-3 * var(--u));
height: calc(6 * var(--u)); border-radius: calc(3 * var(--u)); background: var(--yellow);
}
.dpanel { display: none; }
.dpanel.active { display: block; animation: fade .25s ease; padding-top: 19px; }
.dpanel.active { display: block; animation: fade .25s ease; padding-top: calc(14 * var(--u)); }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.ditem { display: flex; gap: 9px; align-items: center; }
.ditem + .ditem { margin-top: 18px; padding-top: 18px; border-top: 1px solid #F0F0F0; }
.ditem-img { width: 92px; height: 96px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.ditem-text { flex: 1; min-width: 0; }
/* 检测条目标题:竖排,位于图左(设计稿如此) */
.ditem-title {
writing-mode: vertical-rl;
letter-spacing: 4px;
font-size: 11.5px; font-weight: 500; color: var(--ink);
margin-right: 3.5px; flex-shrink: 0;
.ditem-img {
width: calc(213 * var(--u)); height: calc(277 * var(--u));
margin: 0 auto calc(14 * var(--u));
object-fit: cover; border-radius: calc(6 * var(--u));
background: #F5F5F5; cursor: zoom-in;
}
.ditem-para { font-size: 10.1px; font-weight: 400; color: var(--para); line-height: 1.41; overflow-wrap: anywhere; }
/* 护理建议(标题 13px/700/#000;条目 10.1px #4A4A4A lh1.43;收尾句 11.1px/500/#000) */
.card-care, .card-essence { padding: 18px 20px 16px 19px; }
.card-heading { font-size: 13px; font-weight: 700; color: #000; margin-bottom: 4px; }
.care-item { font-size: 10.1px; color: var(--para); line-height: 1.43; margin-bottom: 7px; overflow-wrap: anywhere; }
.care-summary { font-size: 11.1px; font-weight: 500; color: #000; margin-top: 5px; }
/* 精华推荐(Figma 无此卡,按业务文档 V2.3 §6.4 保留,样式沿用护理建议卡) */
.essence-name { font-size: 12px; font-weight: 700; color: var(--ink); margin-bottom: 5px; }
.essence-triggers { font-size: 7px; color: var(--grey-3); margin-top: 6px; }
/* 产品推荐:大标题独立落在灰底;白卡左文案右图(图 151×174 撑满卡高,COREⅡ 为 Figma 导出字标 SVG) */
.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;
text-align: justify; overflow-wrap: anywhere;
}
.vm-notes { font-size: calc(10 * var(--u)); color: var(--grey-3); line-height: 1.7; margin: calc(14 * var(--u)) calc(6 * var(--u)) 0; }
.report-disclaimer {
font-size: calc(10 * var(--u)); color: var(--para);
line-height: 1.7; margin: calc(18 * var(--u)) calc(6 * var(--u)) calc(14 * var(--u));
}
/* 护理建议 / 精华推荐 */
.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-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; }
/* 产品推荐:大标题落在灰底;白卡左文案右图(各占一半) */
.prod-title {
font-size: 21.6px; font-weight: 700; color: var(--ink);
line-height: 1.5; text-align: center; margin: 15px 0 16px;
font-size: calc(23 * var(--u)); font-weight: 700; color: var(--ink);
line-height: 1.45; text-align: center; letter-spacing: calc(1 * var(--u));
margin: calc(30 * var(--u)) 0 calc(18 * var(--u));
}
.card-product { padding: 0; overflow: hidden; }
.prod-card { display: flex; align-items: stretch; }
.card-product { overflow: hidden; }
.prod-card { display: flex; align-items: stretch; min-height: calc(176 * var(--u)); }
.prod-info {
flex: 1; min-width: 0;
padding: 44px 10px 16px 16px;
padding: calc(40 * var(--u)) calc(10 * var(--u)) calc(16 * var(--u)) calc(18 * var(--u));
display: flex; flex-direction: column; align-items: flex-start;
}
.prod-badge { width: 39px; height: auto; }
.prod-name { font-size: 13px; font-weight: 700; color: var(--ink); line-height: 1.34; margin-top: 9px; overflow-wrap: anywhere; }
.prod-sub { font-size: 7.3px; font-weight: 500; color: var(--grey-3); margin-top: auto; padding-top: 10px; }
.prod-scroll {
flex-shrink: 0; margin: 0; padding: 0;
display: flex; overflow: hidden;
scrollbar-width: none;
}
.prod-scroll::-webkit-scrollbar { display: none; }
.prod-scroll img {
width: 151px; height: 174px;
object-fit: cover; flex-shrink: 0;
}
.btn-more { margin: 20px auto 0; }
/* 免责小字:灰底独立文字,左对齐到卡片文案位(设计 x=166→57.6px;fw300 #000) */
.report-disclaimer {
font-size: 7.3px; font-weight: 300; color: #000;
line-height: 1.44; text-align: left;
margin: 13px 19px 11px;
}
.prod-badge { width: calc(44 * var(--u)); height: auto; }
.prod-name { font-size: calc(14.5 * var(--u)); font-weight: 700; color: var(--ink); line-height: 1.45; margin-top: calc(10 * var(--u)); }
.prod-sub { font-size: calc(9.5 * var(--u)); color: var(--grey-3); margin-top: auto; padding-top: calc(10 * var(--u)); }
.prod-scroll { flex: 0 0 51%; display: flex; overflow: hidden; }
.prod-scroll img { width: 100%; height: 100%; object-fit: cover; }
.btn-more { margin: calc(26 * var(--u)) auto 0; }
/* 档位配色(引擎档位 → 文字色) */
.lv-good { color: #2FA84F; }
.lv-warn { color: #E8A13D; }
.lv-bad { color: var(--bad); }
/* ═══════════ 弹窗 ═══════════ */
.overlay {
@@ -467,37 +490,31 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.overlay.show { display: flex; }
.overlay .popup { animation: popIn .3s cubic-bezier(.34,1.4,.64,1) both; }
@keyframes popIn { from { opacity: 0; transform: translateY(26px) scale(.96); } to { opacity: 1; transform: none; } }
/* 弹窗 308×312(设计稿 02/04) */
.popup {
position: relative;
width: 307px;
background: #fff; border-radius: 10px;
padding: 34px 26px 33px;
width: calc(308 * var(--u)); min-height: calc(312 * var(--u));
background: #fff; border-radius: calc(10 * var(--u));
padding: calc(40 * var(--u)) calc(32 * var(--u)) calc(35 * var(--u));
text-align: left;
display: flex; flex-direction: column;
}
.popup-close {
position: absolute; top: 12px; right: 12px;
width: 31px; height: 31px;
position: absolute; top: calc(10 * var(--u)); right: calc(10 * var(--u));
width: calc(32 * var(--u)); height: calc(32 * var(--u));
display: flex; align-items: center; justify-content: center;
}
.popup-close img { width: 17px; height: 17px; opacity: .8; }
.popup-close img { width: calc(19 * var(--u)); height: calc(19 * var(--u)); }
.popup-title {
font-size: 23px; font-weight: 500; color: var(--ink);
line-height: 1.35; letter-spacing: .5px;
margin-bottom: 15px;
font-size: calc(23 * var(--u)); font-weight: 500; color: var(--ink);
line-height: 1.38; letter-spacing: calc(.5 * var(--u));
margin-bottom: calc(14 * var(--u));
}
.popup-title-center { text-align: center; margin: 18px 0 24px; }
/* 拍照引导弹窗:近方形比例,标题垂直居中偏上,按钮贴底 */
#p-photoguide .popup {
min-height: 312px;
display: flex; flex-direction: column;
padding: 34px 26px 52px;
}
#p-photoguide .popup-title-center { margin: 56px 0 auto; }
.popup-body { font-size: 11.5px; color: var(--ink); line-height: 1.55; margin-bottom: 20px; }
.privacy-row { display: flex; align-items: center; gap: 8px; margin-bottom: 40px; cursor: pointer; }
.popup-body { font-size: calc(11.6 * var(--u)); color: var(--ink); line-height: 1.6; margin-bottom: calc(26 * var(--u)); }
.privacy-row { display: flex; align-items: center; gap: calc(9 * var(--u)); cursor: pointer; }
.checkbox {
width: 11px; height: 11px; border-radius: 50%;
border: 1px solid #282828; background: #fff;
width: calc(12 * var(--u)); height: calc(12 * var(--u)); border-radius: 50%;
border: 1px solid var(--ink); background: #fff;
flex-shrink: 0; position: relative;
}
.checkbox.checked::after {
@@ -505,17 +522,32 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
inset: 2px; border-radius: 50%;
background: var(--ink-2);
}
.privacy-text { font-size: 10px; color: var(--grey-2); }
.popup-btn { margin: 0 auto; }
.privacy-text { font-size: calc(10.3 * var(--u)); color: var(--grey-2); }
.privacy-link { color: var(--ink); font-weight: 500; text-decoration: underline; cursor: pointer; }
.privacy-link:active { opacity: .7; }
.popup-btn { margin: auto auto 0; }
#p-privacy .popup-btn { margin-top: calc(40 * var(--u)); }
/* 拍照引导:背景只留启动页底图 + Slogan,标题居中 */
.overlay-guide { background: #101823; }
.guide-bg { position: absolute; inset: 0; background: url(../assets/img/bg-splash.jpg) center/cover no-repeat; }
.guide-bg::after { content: ""; position: absolute; inset: 0; background: rgba(0,0,0,.5); }
#p-photoguide .popup { padding: 0 calc(24 * var(--u)) calc(55 * var(--u)); }
.popup-title-center { text-align: center; margin: calc(88 * var(--u)) 0 calc(40 * var(--u)); }
/* 报告检测图放大 */
.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; }
@keyframes zoomIn { from { opacity: 0; transform: scale(.88); } to { opacity: 1; transform: none; } }
/* 分析中 */
.overlay-dark { background: rgba(16,16,16,.82); }
.analyzing {
color: #fff; text-align: center;
font-size: 13px; letter-spacing: 1px;
font-size: calc(14 * var(--u)); letter-spacing: 1px;
display: flex; flex-direction: column; align-items: center; gap: 16px;
}
.analyzing-tip { font-size: 10px; color: rgba(255,255,255,.45); letter-spacing: 0; }
.analyzing-tip { font-size: calc(11 * var(--u)); color: rgba(255,255,255,.45); letter-spacing: 0; }
.spinner {
width: 34px; height: 34px;
border: 3px solid rgba(255,255,255,.25);
@@ -529,59 +561,32 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.toast {
position: absolute; left: 50%; bottom: 15%;
transform: translateX(-50%) translateY(8px);
max-width: 86%;
background: rgba(20,20,20,.85); color: #fff;
font-size: 12px;
font-size: calc(13 * var(--u)); line-height: 1.5; text-align: center;
padding: 9px 18px; border-radius: 18px;
opacity: 0; pointer-events: none;
transition: all .25s ease;
z-index: 99; white-space: nowrap;
z-index: 99;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ═══════════ 第3轮上线打磨新增(以下均为新增规则,不动以上已验收样式) ═══════════ */
/* 隐私协议链接:下划线、色值不变,点击开占位弹层 */
.privacy-link { color: var(--grey-2); text-decoration: underline; cursor: pointer; }
.privacy-link:active { opacity: .7; }
/* 隐私协议占位弹层(轻量,复用 .overlay/.popup 骨架) */
#p-policy .popup { padding: 30px 26px 34px; }
.popup-title-policy { font-size: 18px; margin-bottom: 12px; }
/* ═══════════ 第4轮:引导式自动三连拍 + SkinScore 报告接入(新增,不动以上已验收样式) ═══════════ */
/* 档位配色:良好=绿 / 改善=琥珀 / 预警=红(文字场景直接上色) */
.lv-good { color: #2FA84F; }
.lv-warn { color: #E8A13D; }
.lv-bad { color: var(--bad); }
/* 肤龄钳制留痕(小字灰) */
.stat-sub { font-size: 8px; color: var(--grey-3); min-height: 10px; line-height: 10px; }
/* 分类条目:S 值 + 档位徽章 */
.cat-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.cat-s { font-size: 15px; font-weight: 600; color: var(--ink); }
.cat-s.cat-na { font-size: 9px; font-weight: 400; color: var(--grey-2); }
.cat-badge { font-style: normal; font-size: 8px; font-weight: 500; color: #fff; padding: 2px 7px; border-radius: 8px; }
.cat-badge.lv-good { background: #2FA84F; color: #fff; }
.cat-badge.lv-warn { background: #E8A13D; color: #fff; }
.cat-badge.lv-bad { background: var(--bad); color: #fff; }
/* 降级标注(下颌线未参与评分等,小字灰) */
.vm-notes { font-size: 7px; font-weight: 400; color: var(--grey-3); line-height: 1.7; margin: 0 2px 9px; }
/* 精华推荐卡 */
.essence-name { font-size: 15px; font-weight: 700; color: var(--ink); margin-bottom: 6px; }
.essence-triggers { font-size: 8.7px; color: var(--grey-3); margin-top: 6px; } /* 设计规范 Caption 级:验收要求展示命中判据,需可读 */
/* ═══════════ 第5轮:报告页检测可视化(face_maps 图层 / marks 叠加,新增,不动以上已验收样式) ═══════════ */
/* 分类卡检测图复用 .ditem-img(左图右文、92×96 裁切);无检测图时(hidden)卡片退回文字占位 */
.ditem-img[hidden] { display: none; }
/* ═══════════ 隐私政策与服务协议(全文页) ═══════════ */
.overlay-policy { background: #F5F5F5; }
.policy-page { width: 100%; height: 100%; display: flex; flex-direction: column; background: #fff; }
.policy-page { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; background: #fff; }
.policy-float-back {
position: absolute; top: 12px; left: 12px; z-index: 2;
background: #fff; box-shadow: 0 1px 6px rgba(0,0,0,.12);
}
.policy-header {
position: relative; flex: none; height: var(--nav-h);
display: flex; align-items: center; justify-content: center;
background: #fff; border-bottom: 1px solid var(--line);
}
.policy-header .nav-back { position: absolute; left: 4px; top: 0; width: 40px; height: 100%; display: flex; align-items: center; justify-content: center; }
.policy-header .nav-back { position: absolute; left: 4px; top: 0; width: 40px; height: 100%; border: 0; }
.policy-header-title { font-size: 14px; font-weight: 500; color: #000; }
.policy-body { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 22px 20px 48px; }
#p-policy .policy-body { padding-top: 52px; }
.policy-title { font-size: 19px; font-weight: 700; color: var(--ink); line-height: 1.4; margin-bottom: 16px; }
.policy-h2 {
font-size: 15px; font-weight: 600; color: var(--ink);
@@ -598,10 +603,6 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.policy-end { text-align: center; color: var(--grey); margin-top: 28px; }
/* ═══════════ 历史报告 ═══════════ */
.nav-history {
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
font-size: 12px; color: var(--grey-2); padding: 4px 8px;
}
.policy-header .history-clear {
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
font-size: 12px; color: var(--grey-2); padding: 4px 8px;
@@ -620,77 +621,3 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.his-score b { font-size: 22px; font-weight: 500; color: var(--ink); }
.his-score span { font-size: 11px; color: var(--grey); margin-left: 2px; }
.his-del { flex: none; font-size: 11px; color: var(--grey-2); padding: 6px 4px; }
/* ═══════════ 第6轮:真机反馈 13 项 ═══════════ */
/* #2 姿态提示浮层:移到取景框下方(原 29% 与脸部轮廓/下巴重叠) */
.cam-hint { bottom: 21%; }
/* #10 无进展提示与快门重叠:上移 */
.cam-nudge { bottom: 16%; }
/* #4/#10 拍照页顶栏取消,返回键改悬浮白圆(对齐设计稿) */
.cam-back-float {
position: absolute; top: 12px; left: 12px; z-index: 3;
background: rgba(255,255,255,.9); border-color: transparent;
}
/* #12 预览镜像改为类切换(前置镜像/后置不镜像) */
#cam-video { transform: none; }
#cam-video.mirror { transform: scaleX(-1); }
/* #1 城市自定义下拉(替代原生 select) */
.rs { position: relative; }
.rs-current {
width: 100%; display: flex; align-items: center; justify-content: space-between;
background: #fff; border: 1px solid var(--line); border-radius: 8px;
padding: 12px 14px; font-size: 13px; color: var(--ink); text-align: left;
}
.rs-current .chev { transition: transform .2s; }
.rs.open .rs-current .chev { transform: rotate(225deg); }
.rs-value.rs-placeholder { color: #B3B3B3; }
.rs-list {
position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 30;
max-height: 264px; overflow-y: auto; -webkit-overflow-scrolling: touch;
background: #fff; border: 1px solid var(--line); border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.08);
}
.rs-list li { padding: 12px 14px; font-size: 13px; color: var(--ink); }
.rs-list li + li { border-top: 1px solid #F3F3F3; }
.rs-list li:active { background: #F7F7F7; }
/* #5 隐私页去头部条,返回键悬浮 */
.policy-float-back {
position: absolute; top: 12px; left: 12px; z-index: 2;
background: #fff; box-shadow: 0 1px 6px rgba(0,0,0,.12);
}
.overlay-policy .policy-page { position: relative; }
/* ═══════════ 第7轮:用户测试 agent 反馈 ═══════════ */
/* P1-1 隐私页标题被悬浮返回键遮挡:正文上留返回键位 */
#p-policy .policy-body { padding-top: 52px; }
/* P1-2 姿态提示气泡压住底部引导文字:改到步骤文案下方、取景框上方 */
.cam-hint { bottom: auto; top: 26%; }
/* ═══════════ 第8轮:真机反馈 11 项 ═══════════ */
/* #1 城市下拉浮层层级高于肤质轮播圆点 */
.rs { z-index: 50; }
.rs-list { z-index: 50; }
/* #4 无进展提示再上移(取景/拍摄区下方,引导 chips 上方) */
.cam-nudge { bottom: 17.5%; } /* 引导 chips(bottom 22.4%) 与快门之间 */
/* #6 报告页头部:按 Figma 08 还原为内联白底顶栏(见报告页主区块),不再悬浮 */
/* ═══════════ 第9轮:真机反馈 ═══════════ */
/* #1 城市下拉浮层再提级,并加实体阴影边界(部分端 stacking 表现不同) */
.rs, .rs-list { z-index: 999; }
/* #3 姿态提示浮层改设计语言:无气泡底,纯白文字+投影,置于引导 chips 上方 */
.cam-hint {
top: auto; bottom: 26%;
background: none; padding: 0; border-radius: 0;
font-size: 15px; font-weight: 500; text-shadow: 0 1px 8px rgba(0,0,0,.65);
}
.cam-hint.ok { background: none; color: #34C759; }
/* #9 档案页顶栏移除,返回键悬浮(白圆带影,压暗背景上可读) */
.profile-float-back {
position: fixed; top: 12px; left: 12px; z-index: 21;
background: rgba(255,255,255,.92); border-color: transparent;
box-shadow: 0 1px 6px rgba(0,0,0,.18);
}
/* 下拉浮层与肤质圆点之间留明确间隙 */
.rs-list { max-height: 236px; }
+103 -99
View File
@@ -12,6 +12,7 @@
<title>1'me 在线肌肤测试</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="preload" as="image" href="assets/img/bg-splash.jpg">
<link rel="stylesheet" href="vendor/mobile-select/mobile-select.css">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
@@ -32,49 +33,44 @@
<!-- ═══════════ 03 档案填写 ═══════════ -->
<section class="screen" id="s-profile">
<div class="profile-bg"></div>
<button class="nav-back profile-float-back" data-action="back-splash" aria-label="返回"><i class="chev left"></i></button>
<div class="profile-body">
<h1 class="page-title">您的肌肤状态</h1>
<!-- 三个选择控件同一套样式:点击后底部弹出滚轮(vendor/mobile-select) -->
<div class="form-row two">
<div class="field">
<label class="field-label">您的年龄</label>
<input id="f-age" class="field-input" type="text" inputmode="numeric" maxlength="2" placeholder="请输入">
<label class="field-label">您的出生年月</label>
<button type="button" class="pick" id="pick-birth"><span class="pick-value"></span><i class="chev down"></i></button>
</div>
<div class="field">
<label class="field-label">您的性别</label>
<div class="field-select-wrap">
<select id="f-gender" class="field-input field-select">
<option value="" selected disabled>请选择</option>
<option>女</option><option>男</option>
</select>
<i class="chev down"></i>
</div>
<button type="button" class="pick" id="pick-gender"><span class="pick-value"></span><i class="chev down"></i></button>
</div>
</div>
<div class="field">
<label class="field-label">您的所在地区</label>
<div class="rs" id="region-rs">
<button type="button" class="rs-current" id="rs-current">
<span id="rs-value" class="rs-value rs-placeholder">请选择</span>
<i class="chev down"></i>
</button>
<ul class="rs-list" id="rs-list" hidden></ul>
</div>
<input type="hidden" id="f-region">
<button type="button" class="pick" id="pick-region"><span class="pick-value"></span><i class="chev down"></i></button>
</div>
<input type="hidden" id="f-birth">
<input type="hidden" id="f-age">
<input type="hidden" id="f-gender">
<input type="hidden" id="f-region">
<div class="field">
<label class="field-label">您的日常肤质</label>
<div class="skin-card">
<div class="skin-axis">
<span class="axis-item"><i class="chip" style="background:#EAE6E1"></i>偏干</span>
<span class="axis-item"><i class="chip" style="background:#DBF6D6"></i>水油平衡</span>
<span class="axis-item"><i class="chip" style="background:#F5F078"></i>偏油</span>
<span class="axis-item"><i class="chip" style="background:#DBF6D6"></i>水油平衡</span>
</div>
<div class="skin-carousel" id="skin-carousel">
<div class="skin-track" id="skin-track"></div>
<div class="skin-stage">
<button type="button" class="skin-arrow prev" data-skin-step="-1" aria-label="上一个"><i class="chev left"></i></button>
<div class="skin-carousel" id="skin-carousel">
<div class="skin-track" id="skin-track"></div>
</div>
<button type="button" class="skin-arrow next" data-skin-step="1" aria-label="下一个"><i class="chev right"></i></button>
</div>
<div class="skin-dots" id="skin-dots"></div>
</div>
@@ -87,15 +83,18 @@
<!-- ═══════════ 05-07 拍照页 ═══════════ -->
<section class="screen" id="s-cam">
<div class="cam-bg" id="cam-bg"></div>
<button class="nav-back nav-dark cam-back-float" data-action="cam-back" aria-label="返回"><i class="chev left"></i></button>
<div class="cam-dots" id="cam-dots"><i></i><i></i><i></i></div>
<p class="cam-step" id="cam-step">1/3<br>请单人正脸出镜</p>
<p class="cam-step" id="cam-step">1/3<br>请正对屏幕</p>
<!-- 取景蒙版:SVG mask 镂空(框外 85% 黑,对齐 Figma 图层不透明度) + 轮廓线框;data-step 切换三种轮廓
轮廓由 Figma 拍照示意图(design/assets/images/*-拍照示意.png,alpha 镂空区)逐像素描摹生成,坐标为 Figma 375×812 整帧;viewBox 只取 y 86–812(帧顶 86px 是微信导航栏,不属于 H5 视口)
0=正脸(含耳部) 1=左脸出镜(用户头向自己右侧转,镜像预览中耳在画面左、鼻朝画面右) 2=右脸出镜(头向自己左侧转,耳在画面右、鼻朝画面左)
data-step 取 CAM_STEPS[].mask(拍摄顺序 正→右→左,对齐 Figma) -->
<svg class="cam-mask" id="cam-mask" data-step="0" viewBox="0 86 375 726" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<defs>
<linearGradient id="scan-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2EE6F0" stop-opacity="0"/>
<stop offset=".92" stop-color="#2EE6F0" stop-opacity=".32"/>
<stop offset="1" stop-color="#2EE6F0" stop-opacity=".95"/>
</linearGradient>
<mask id="cam-cut" maskUnits="userSpaceOnUse" x="-1000" y="-1000" width="2375" height="2812">
<rect x="-1000" y="-1000" width="2375" height="2812" fill="#fff"/>
<g fill="#000">
@@ -106,6 +105,10 @@
</mask>
</defs>
<rect class="cam-dim-rect" x="-1000" y="-1000" width="2375" height="2812" mask="url(#cam-cut)"/>
<!-- 动态捕捉扫描带:clipPath 由 app.js 复制当前三种轮廓生成(#cam-clip),只在轮廓内可见 -->
<g class="cam-scan" clip-path="url(#cam-clip)">
<rect class="cam-scan-band" x="40" y="150" width="295" height="84" fill="url(#scan-grad)"/>
</g>
<g class="cam-line">
<path data-shape="0" d="M54.1 388.5L56.1 387.1L58.2 386.0L60.3 383.5L62.4 382.2L64.5 381.4L66.5 370.5L68.6 357.6L70.7 343.0L72.8 327.1L74.9 310.3L76.9 302.9L79.0 297.1L81.1 292.3L83.2 288.0L85.3 284.2L87.3 280.8L89.4 277.8L91.5 275.0L93.6 272.3L95.7 269.9L97.7 267.6L99.8 265.5L101.9 263.5L104.0 261.6L106.1 259.9L108.1 258.3L110.2 256.8L112.3 255.3L114.4 254.0L116.5 252.7L118.5 251.4L120.6 250.2L122.7 249.1L124.8 248.1L126.8 247.1L128.9 246.2L131.0 245.3L133.1 244.4L135.2 243.7L137.2 242.9L139.3 242.2L141.4 241.6L143.5 241.0L145.6 240.4L147.6 239.9L149.7 239.4L151.8 238.9L153.9 238.5L156.0 238.1L158.0 237.7L160.1 237.4L162.2 237.0L164.3 236.7L166.4 236.4L168.4 236.1L170.5 235.9L172.6 235.8L174.7 235.7L176.8 235.5L178.8 235.4L180.9 235.4L183.0 235.3L185.1 235.3L187.2 235.3L189.2 235.3L191.3 235.3L193.4 235.3L195.5 235.3L197.6 235.3L199.6 235.4L201.7 235.5L203.8 235.7L205.9 235.9L207.9 236.1L210.0 236.4L212.1 236.7L214.2 237.0L216.3 237.4L218.3 237.7L220.4 238.1L222.5 238.5L224.6 238.9L226.7 239.4L228.7 239.9L230.8 240.6L232.9 241.2L235.0 241.9L237.1 242.6L239.1 243.3L241.2 244.0L243.3 244.8L245.4 245.6L247.5 246.5L249.5 247.5L251.6 248.4L253.7 249.5L255.8 250.7L257.9 251.9L259.9 253.2L262.0 254.6L264.1 256.0L266.2 257.4L268.3 259.1L270.3 260.7L272.4 262.5L274.5 264.5L276.6 266.5L278.7 268.7L280.7 271.0L282.8 273.6L284.9 276.4L287.0 279.3L289.0 282.6L291.1 286.2L293.2 290.2L295.3 294.7L297.4 300.1L299.4 306.6L301.5 315.7L303.6 332.1L305.7 347.2L307.8 360.9L309.8 373.0L311.9 381.7L314.0 382.7L316.1 384.5L318.2 387.3L320.2 388.6L321.3 390.2L321.3 408.3L320.2 412.8L318.2 417.1L316.1 425.1L314.0 432.9L311.9 439.5L309.8 445.1L307.8 449.8L305.7 453.7L303.6 456.8L301.5 459.2L299.4 461.1L297.4 462.5L295.3 464.7L293.2 467.6L291.1 471.4L289.0 476.0L287.0 481.4L284.9 486.5L282.8 491.4L280.7 496.0L278.7 500.5L276.6 504.7L274.5 508.7L272.4 512.6L270.3 516.2L268.3 519.7L266.2 523.0L264.1 526.2L262.0 529.3L259.9 532.3L257.9 535.1L255.8 537.9L253.7 540.5L251.6 543.0L249.5 545.3L247.5 547.6L245.4 549.7L243.3 551.8L241.2 553.8L239.1 555.8L237.1 557.6L235.0 559.3L232.9 560.9L230.8 562.4L228.7 563.8L226.7 565.2L224.6 566.5L222.5 567.6L220.4 568.7L218.3 569.8L216.3 570.7L214.2 571.7L212.1 572.6L210.0 573.4L207.9 574.1L205.9 574.7L203.8 575.2L201.7 575.7L199.6 576.2L197.6 576.6L195.5 576.9L193.4 577.1L191.3 577.2L189.2 577.3L187.2 577.4L185.1 577.3L183.0 577.2L180.9 577.0L178.8 576.7L176.8 576.3L174.7 576.0L172.6 575.6L170.5 575.1L168.4 574.6L166.4 573.9L164.3 573.2L162.2 572.3L160.1 571.4L158.0 570.5L156.0 569.4L153.9 568.3L151.8 567.2L149.7 566.0L147.6 564.7L145.6 563.3L143.5 561.8L141.4 560.2L139.3 558.6L137.2 556.9L135.2 555.0L133.1 553.1L131.0 551.1L128.9 549.0L126.8 546.8L124.8 544.4L122.7 542.0L120.6 539.4L118.5 536.8L116.5 534.1L114.4 531.2L112.3 528.2L110.2 525.0L108.1 521.7L106.1 518.2L104.0 514.6L101.9 510.9L99.8 507.0L97.7 502.8L95.7 498.5L93.6 493.9L91.5 489.1L89.4 484.2L87.3 479.0L85.3 473.9L83.2 469.7L81.1 466.3L79.0 463.8L76.9 461.9L74.9 460.3L72.8 458.2L70.7 455.5L68.6 452.1L66.5 447.9L64.5 442.9L62.4 436.8L60.3 429.5L58.2 421.2L56.1 417.0L54.1 412.3Z"/>
<path data-shape="1" d="M55.8 396.6L57.9 382.4L60.0 370.8L62.0 353.4L64.1 336.1L66.2 318.3L68.3 309.9L70.4 303.9L72.4 298.9L74.5 294.5L76.6 290.5L78.7 286.9L80.8 283.6L82.8 280.5L84.9 277.6L87.0 274.9L89.1 272.4L91.2 270.1L93.2 267.9L95.3 265.8L97.4 263.8L99.5 262.0L101.5 260.2L103.6 258.5L105.7 256.9L107.8 255.4L109.9 254.0L111.9 252.6L114.0 251.3L116.1 250.0L118.2 248.8L120.3 247.6L122.3 246.6L124.4 245.5L126.5 244.6L128.6 243.7L130.7 242.8L132.7 242.0L134.8 241.3L136.9 240.5L139.0 239.8L141.1 239.2L143.1 238.6L145.2 238.0L147.3 237.5L149.4 237.1L151.5 236.7L153.5 236.3L155.6 236.0L157.7 235.7L159.8 235.3L161.9 235.0L163.9 234.8L166.0 234.5L168.1 234.4L170.2 234.3L172.3 234.2L174.3 234.1L176.4 234.1L178.5 234.0L180.6 233.9L182.6 234.0L184.7 234.1L186.8 234.1L188.9 234.3L191.0 234.4L193.0 234.5L195.1 234.7L197.2 234.9L199.3 235.1L201.4 235.4L203.4 235.6L205.5 235.9L207.6 236.2L209.7 236.6L211.8 236.9L213.8 237.3L215.9 237.8L218.0 238.2L220.1 238.6L222.2 239.1L224.2 239.7L226.3 240.2L228.4 240.8L230.5 241.4L232.6 242.0L234.6 242.6L236.7 243.3L238.8 244.1L240.9 244.8L243.0 245.7L245.0 246.6L247.1 247.4L249.2 248.3L251.3 249.3L253.4 250.2L255.4 251.2L257.5 252.3L259.6 253.4L261.7 254.6L263.7 255.8L265.8 257.1L267.9 258.5L270.0 259.8L272.1 261.3L274.1 262.7L276.2 264.3L278.3 265.9L280.4 267.6L282.5 269.4L284.5 271.4L286.6 273.4L288.7 275.5L290.8 277.7L292.9 279.9L294.9 282.4L297.0 284.9L299.1 287.6L301.2 290.5L303.3 293.7L305.3 297.2L307.4 301.1L309.5 305.6L311.6 311.0L313.7 318.2L315.7 341.8L317.8 349.8L318.9 361.1L318.9 451.5L317.8 456.2L315.7 460.4L313.7 468.7L311.6 476.2L309.5 482.5L307.4 488.1L305.3 493.2L303.3 498.0L301.2 502.5L299.1 506.8L297.0 511.0L294.9 515.0L292.9 518.9L290.8 522.7L288.7 526.5L286.6 530.1L284.5 533.7L282.5 537.2L280.4 540.8L278.3 544.1L276.2 547.5L274.1 550.6L272.1 553.7L270.0 556.5L267.9 559.1L265.8 561.6L263.7 563.8L261.7 565.8L259.6 567.6L257.5 569.2L255.4 570.7L253.4 572.0L251.3 573.2L249.2 574.2L247.1 575.0L245.0 575.8L243.0 576.4L240.9 576.9L238.8 577.2L236.7 577.5L234.6 577.7L232.6 577.7L230.5 577.7L228.4 577.7L226.3 577.5L224.2 577.3L222.2 577.0L220.1 576.6L218.0 576.2L215.9 575.7L213.8 575.2L211.8 574.6L209.7 573.9L207.6 573.2L205.5 572.5L203.4 571.8L201.4 571.0L199.3 570.2L197.2 569.3L195.1 568.3L193.0 567.4L191.0 566.3L188.9 565.3L186.8 564.3L184.7 563.2L182.6 562.2L180.6 561.1L178.5 560.0L176.4 558.8L174.3 557.6L172.3 556.4L170.2 555.1L168.1 553.8L166.0 552.5L163.9 551.1L161.9 549.7L159.8 548.4L157.7 547.0L155.6 545.5L153.5 544.1L151.5 542.6L149.4 541.0L147.3 539.4L145.2 537.9L143.1 536.2L141.1 534.5L139.0 532.8L136.9 531.1L134.8 529.3L132.7 527.5L130.7 525.6L128.6 523.7L126.5 521.7L124.4 519.7L122.3 517.5L120.3 515.3L118.2 512.8L116.1 510.1L114.0 507.2L111.9 503.9L109.9 500.2L107.8 496.1L105.7 491.6L103.6 486.6L101.5 481.1L99.5 476.2L97.4 472.2L95.3 469.2L93.2 467.4L91.2 466.6L89.1 466.1L87.0 465.4L84.9 464.5L82.8 463.4L80.8 462.1L78.7 460.5L76.6 458.5L74.5 456.2L72.4 453.6L70.4 450.6L68.3 447.3L66.2 443.6L64.1 439.4L62.0 434.5L60.0 428.3L57.9 425.3L55.8 422.0Z"/>
@@ -114,8 +117,7 @@
</svg>
<p class="cam-status" id="cam-status">检测引擎加载中…</p>
<p class="cam-hint" id="cam-hint"></p>
<div class="cam-count" id="cam-count"></div>
<p class="cam-nudge" id="cam-nudge">识别不到?可点下方快门手动拍摄</p>
<p class="cam-nudge" id="cam-nudge">识别不到?请正对镜头,并到光线充足处</p>
<div class="cam-guide" id="cam-guide"></div>
<video id="cam-video" autoplay playsinline muted></video>
<input type="file" id="cam-file" accept="image/*" capture="user" hidden>
@@ -124,6 +126,7 @@
<button class="cam-side" data-action="album" aria-label="相册">
<img src="assets/svg/图标-相册.svg" alt="相册">
</button>
<!-- 实时检测时自动抓拍、不显示快门;仅系统相机降级/识别不可用/mock 调试时出现 -->
<button class="cam-shutter" data-action="shutter" aria-label="拍照">
<img src="assets/svg/拍照-快门.svg" alt="快门">
</button>
@@ -137,90 +140,82 @@
<!-- ═══════════ 08 报告页 ═══════════ -->
<section class="screen" id="s-report">
<div class="report-body">
<!-- 顶栏(Figma 08:白底横带,左返回圆钮 + 居中标题) -->
<header class="report-nav">
<button class="nav-back" data-action="back-profile" aria-label="返回"><i class="chev left"></i></button>
<span class="report-nav-title">1'me在线肌肤测试</span>
</header>
<h1 class="report-title">您的肌肤状态表现</h1>
<!-- 总分:Figma 为两张独立白卡(综合评分 / 肌肤年龄) -->
<div class="stats-row">
<div class="card card-stat">
<span class="stat-label">综合评分</span>
<span class="stat-value" id="stat-score"><b>--</b><i>分</i></span>
<!-- 总分卡:左环形综合评分 / 右肌肤年龄 + 与实际年龄对比 -->
<div class="card card-summary">
<div class="sum-score">
<svg class="ring" viewBox="0 0 120 120" aria-hidden="true">
<circle class="ring-bg" cx="60" cy="60" r="52"/>
<circle class="ring-fg" id="ring-fg" cx="60" cy="60" r="52"/>
</svg>
<div class="ring-center">
<span class="stat-value" id="stat-score"><b>--</b><i>分</i></span>
<span class="stat-label">综合评分</span>
</div>
</div>
<div class="card card-stat">
<div class="sum-age">
<span class="stat-label">肌肤年龄</span>
<span class="stat-value" id="stat-age"><b>--</b><i>岁</i></span>
<span class="stat-sub" id="stat-age-note"></span>
<span class="stat-value stat-age" id="stat-age"><b>--</b><i>岁</i></span>
<span class="age-pill" id="stat-age-note" hidden></span>
</div>
</div>
<div class="card card-dims">
<div class="dims-row">
<div class="dim"><span class="dim-name">紧塑力</span><span class="dim-score" id="dim-firm">-.-</span></div>
<div class="dim"><span class="dim-name">活力感</span><span class="dim-score" id="dim-vita">-.-</span></div>
<div class="dim"><span class="dim-name">稳定力</span><span class="dim-score" id="dim-stab">-.-</span></div>
<div class="dim" data-dim="firm"><span class="dim-name">紧塑力</span><span class="dim-score" id="dim-firm"><b>-</b>.-</span><i class="dim-bar"><i></i></i><em class="dim-flag">优先关注</em></div>
<div class="dim" data-dim="vita"><span class="dim-name">活力感</span><span class="dim-score" id="dim-vita"><b>-</b>.-</span><i class="dim-bar"><i></i></i><em class="dim-flag">优先关注</em></div>
<div class="dim" data-dim="stab"><span class="dim-name">稳定力</span><span class="dim-score" id="dim-stab"><b>-</b>.-</span><i class="dim-bar"><i></i></i><em class="dim-flag">优先关注</em></div>
</div>
<p class="dims-note" id="dims-note">近期优先关注:<br>---</p>
<div class="dims-note" id="dims-note"><p class="dims-note-l1">近期优先关注:<b>---</b></p></div>
</div>
<!-- 三个维度各一张卡:卡内子 Tab 切换所辖两个分类;检测图点击放大 -->
<h2 class="sec-title">紧塑力</h2>
<div class="card card-detail">
<div class="detail-tabs" id="detail-tabs">
<button class="dtab active" data-tab="0"><span class="dtab-tx">紧塑力</span></button>
<button class="dtab" data-tab="1"><span class="dtab-tx">活力感</span></button>
<button class="dtab" data-tab="2"><span class="dtab-tx">稳定力</span></button>
<div class="detail-tabs">
<button class="dtab active" data-tab="0">轮廓</button>
<button class="dtab" data-tab="1">皱纹</button>
</div>
<div class="detail-panels">
<div class="dpanel active" data-panel="0">
<div class="ditem">
<h3 class="ditem-title">轮廓</h3>
<img id="ditem-img1" src="assets/img/det-1.jpg" loading="lazy" class="ditem-img" alt="轮廓检测图">
<div class="ditem-text">
<p class="ditem-para" id="ditem-para1">检测完成后显示分析结果。</p>
</div>
</div>
<div class="ditem">
<h3 class="ditem-title">皱纹</h3>
<img id="ditem-img2" src="assets/img/det-2.jpg" loading="lazy" class="ditem-img" alt="皱纹检测图">
<div class="ditem-text">
<p class="ditem-para" id="ditem-para2">检测完成后显示分析结果。</p>
</div>
</div>
</div>
<div class="dpanel" data-panel="1">
<div class="ditem">
<h3 class="ditem-title">光滑</h3>
<img id="viz-smooth" loading="lazy" class="ditem-img" alt="光滑检测标记图" hidden>
<div class="ditem-text">
<p class="ditem-para" id="tx-smooth">检测完成后显示分析结果。</p>
</div>
</div>
<div class="ditem">
<h3 class="ditem-title">肤色水润</h3>
<img id="viz-tone" loading="lazy" class="ditem-img" alt="肤色色沉图" hidden>
<div class="ditem-text">
<p class="ditem-para" id="tx-tone">检测完成后显示分析结果。</p>
</div>
</div>
</div>
<div class="dpanel" data-panel="2">
<div class="ditem">
<h3 class="ditem-title">敏感</h3>
<img id="viz-sens" loading="lazy" class="ditem-img" alt="敏感红区图" hidden>
<div class="ditem-text">
<p class="ditem-para" id="tx-sens">检测完成后显示分析结果。</p>
</div>
</div>
<div class="ditem">
<h3 class="ditem-title">痘痘</h3>
<img id="viz-acne" loading="lazy" class="ditem-img" alt="痘痘检测标记图" hidden>
<div class="ditem-text">
<p class="ditem-para" id="tx-acne">检测完成后显示分析结果。</p>
</div>
</div>
</div>
<div class="dpanel active" data-panel="0">
<img id="ditem-img1" src="assets/img/det-1.jpg" loading="lazy" class="ditem-img" alt="轮廓检测图">
<p class="ditem-para" id="ditem-para1">检测完成后显示分析结果。</p>
</div>
<div class="dpanel" data-panel="1">
<img id="ditem-img2" src="assets/img/det-2.jpg" loading="lazy" class="ditem-img" alt="皱纹检测图">
<p class="ditem-para" id="ditem-para2">检测完成后显示分析结果。</p>
</div>
</div>
<h2 class="sec-title">活力感</h2>
<div class="card card-detail">
<div class="detail-tabs">
<button class="dtab active" data-tab="0">光滑</button>
<button class="dtab" data-tab="1">肤色水润</button>
</div>
<div class="dpanel active" data-panel="0">
<img id="viz-smooth" loading="lazy" class="ditem-img" alt="光滑检测标记图" hidden>
<p class="ditem-para" id="tx-smooth">检测完成后显示分析结果。</p>
</div>
<div class="dpanel" data-panel="1">
<img id="viz-tone" loading="lazy" class="ditem-img" alt="肤色色沉图" hidden>
<p class="ditem-para" id="tx-tone">检测完成后显示分析结果。</p>
</div>
</div>
<h2 class="sec-title">稳定力</h2>
<div class="card card-detail">
<div class="detail-tabs">
<button class="dtab active" data-tab="0">敏感</button>
<button class="dtab" data-tab="1">痘痘</button>
</div>
<div class="dpanel active" data-panel="0">
<img id="viz-sens" loading="lazy" class="ditem-img" alt="敏感红区图" hidden>
<p class="ditem-para" id="tx-sens">检测完成后显示分析结果。</p>
</div>
<div class="dpanel" data-panel="1">
<img id="viz-acne" loading="lazy" class="ditem-img" alt="痘痘检测标记图" hidden>
<p class="ditem-para" id="tx-acne">检测完成后显示分析结果。</p>
</div>
</div>
@@ -248,7 +243,7 @@
<div class="prod-card">
<div class="prod-info">
<img class="prod-badge" src="assets/svg/prod-badge.svg" alt="CORE Ⅱ">
<h3 class="prod-name">以我自在有势挺括双仓精华液</h3>
<h3 class="prod-name">以我自在有势挺括<br>双仓精华液</h3>
<p class="prod-sub">参透皮下根源 28天校准皮肤状态</p>
</div>
<div class="prod-scroll" id="prod-scroll">
@@ -278,16 +273,23 @@
</div>
<!-- ═══════════ 弹窗:拍照引导 ═══════════ -->
<div class="overlay" id="p-photoguide">
<!-- 背景只保留启动页底图 + Slogan(不叠档案表单),对齐设计稿 04 -->
<div class="overlay overlay-guide" id="p-photoguide">
<div class="guide-bg"><p class="splash-slogan">Life in Full<br>为更好的生命表现</p></div>
<div class="popup">
<button class="popup-close" data-action="close-photoguide" aria-label="关闭">
<img src="assets/svg/图标-关闭.svg" alt="关闭">
</button>
<h2 class="popup-title popup-title-center">请拍摄正面及<br>左右侧面照片</h2>
<h2 class="popup-title popup-title-center">请拍摄或上传<br>正面及左右侧面照片</h2>
<button class="btn btn-primary popup-btn" data-action="start-camera">开始</button>
</div>
</div>
<!-- ═══════════ 弹层:报告检测图放大(点击任意处返回) ═══════════ -->
<div class="overlay overlay-zoom" id="p-zoom">
<img id="zoom-img" alt="检测图放大">
</div>
<!-- ═══════════ 弹层:隐私政策与服务协议(全文) ═══════════ -->
<div class="overlay overlay-policy" id="p-policy">
<div class="policy-page">
@@ -449,6 +451,8 @@
<script src="vendor/faceapi/face-api.js" defer></script>
<script src="js/head-pose.js" defer></script>
<script src="js/skin-score.js" defer></script>
<script src="vendor/mobile-select/mobile-select.iife.min.js" defer></script>
<script src="js/region-data.js" defer></script>
<script>
// 仅 ?mock=1 调试:同步注入报告页可视化 fixture(生产模式不发起该请求)
if (/[?&]mock=1/.test(location.search)) document.write('<script src="assets/mock/report-fixture.js"><\/script>');
+266 -178
View File
@@ -14,9 +14,9 @@
toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, Math.max(1800, String(msg).length * 130));
}
/* ─── 屏幕管理 ─── */
/* ─── 屏幕管理:页面内不放返回按钮,前进时 pushState,微信/浏览器返回键经 popstate 回到上一屏 ─── */
var currentScreen = 's-splash';
function showScreen(id) {
function showScreen(id, nav) { // nav: 'push' 新增历史 | 'replace' 替换当前历史 | 缺省不动历史
document.querySelectorAll('.screen').forEach(function (s) {
s.classList.toggle('active', s.id === id);
});
@@ -24,16 +24,28 @@
sc.scrollTop = 0;
currentScreen = id;
if (id === 's-report') resetReport(); // 每次进入报告页都还原初始状态
if (nav === 'push') history.pushState({ screen: id }, '');
else if (nav === 'replace') history.replaceState({ screen: id }, '');
}
// 代码里已切好屏、只需把历史栈退一格(如拍照失败回档案页):紧随的 popstate 不再切屏
var popSilentUntil = 0;
function popHistorySilently() { popSilentUntil = Date.now() + 1000; history.back(); }
window.addEventListener('popstate', function (e) {
if (Date.now() < popSilentUntil) { popSilentUntil = 0; return; }
var to = (e.state && e.state.screen) || 's-splash';
['p-privacy', 'p-photoguide', 'p-policy', 'p-zoom', 'p-history'].forEach(closeOverlay);
if (currentScreen === 's-cam') leaveCamera();
if (to === 's-cam') { to = 's-profile'; history.replaceState({ screen: to }, ''); } // 「前进」回拍照页不自动开相机
if (to !== currentScreen) showScreen(to);
});
/* ─── 报告页状态重置(Tab 回到「紧塑力」、产品横滑归零) ─── */
/* ─── 报告页状态重置(各维度卡回到第一个子 Tab、产品横滑归零) ─── */
function setDetailTab(card, idx) {
card.querySelectorAll('.dtab').forEach(function (t) { t.classList.toggle('active', t.dataset.tab === idx); });
card.querySelectorAll('.dpanel').forEach(function (p) { p.classList.toggle('active', p.dataset.panel === idx); });
}
function resetReport() {
document.querySelectorAll('.dtab').forEach(function (t, k) {
t.classList.toggle('active', k === 0);
});
document.querySelectorAll('.dpanel').forEach(function (p, k) {
p.classList.toggle('active', k === 0);
});
document.querySelectorAll('.card-detail').forEach(function (c) { setDetailTab(c, '0'); });
var ps = $('#prod-scroll');
if (ps) ps.scrollLeft = 0;
}
@@ -43,33 +55,39 @@
function closeOverlay(id) { $('#' + id).classList.remove('show'); }
// 点遮罩空白处关闭(与各自关闭按钮同行为:隐私→回启动页、拍照引导→留在档案页)
// e.target === overlay 确保只响应遮罩本身,不误伤弹窗卡片内交互
// 只响应遮罩本身(拍照引导另含底图层),不误伤弹窗卡片内交互
function bindBackdropClose(id) {
var ov = $('#' + id);
ov.addEventListener('click', function (e) {
if (e.target === ov) closeOverlay(id);
if (e.target === ov || e.target.closest('.guide-bg')) closeOverlay(id);
});
}
bindBackdropClose('p-privacy');
bindBackdropClose('p-photoguide');
bindBackdropClose('p-policy');
// ESC 关闭(仅这三层;分析中过渡不响应;协议层最顶,优先关)
// ESC 关闭(分析中过渡不响应;放大图/协议层在最上,优先关)
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape' && e.key !== 'Esc') return;
if ($('#p-policy').classList.contains('show')) closeOverlay('p-policy');
else if ($('#p-privacy').classList.contains('show')) closeOverlay('p-privacy');
else if ($('#p-photoguide').classList.contains('show')) closeOverlay('p-photoguide');
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; }
}
});
// 分析中弹层点遮罩取消:作废未决分析回调(mock 路径同样经 seq 丢弃)、关弹层、停相机、回档案页
$('#p-analyzing').addEventListener('click', function (e) {
if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片
// 退出拍照流程:作废未决快门/分析回调、掐断播报、释放摄像头
function leaveCamera() {
camSeq++;
voice.stop(true);
closeOverlay('p-analyzing');
stopCamera();
shutterLock = false; // 流程终止解锁
shutterLock = false;
}
// 分析中弹层点遮罩取消:回档案页(mock 路径同样经 seq 丢弃)
$('#p-analyzing').addEventListener('click', function (e) {
if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片
leaveCamera();
showScreen('s-profile');
popHistorySilently();
});
/* ─── 肤质轮播 ─── */
@@ -103,7 +121,13 @@
dotsBox.querySelectorAll('i').forEach(function (d, k) {
d.classList.toggle('on', k === skinIndex);
});
$('.skin-arrow.prev').disabled = skinIndex === 0;
$('.skin-arrow.next').disabled = skinIndex === SKINS.length - 1;
}
document.querySelectorAll('[data-skin-step]').forEach(function (b) {
b.addEventListener('click', function () { goSkin(skinIndex + (+b.dataset.skinStep)); });
});
goSkin(0);
// 拖拽切换(Pointer Events 统一鼠标 + 触摸)
var drag = { id: null, startX: 0, startY: 0, horizontal: false, suppress: false };
@@ -144,32 +168,48 @@
if (drag.suppress) { e.preventDefault(); e.stopPropagation(); drag.suppress = false; }
}, true);
/* ─── 地区选项(自定义下拉:原生 select 各端样式不一,改自绘弹层) ─── */
var REGIONS = ['北京', '上海', '广州', '深圳', '杭州', '成都', '重庆', '武汉', '西安', '南京', '天津', '苏州', '长沙', '郑州', '青岛', '其他'];
var regionSel = $('#f-region'); // hidden input,存选中值
var rsBox = $('#region-rs'), rsList = $('#rs-list'), rsValue = $('#rs-value');
rsList.innerHTML = REGIONS.map(function (r) { return '<li data-v="' + r + '">' + r + '</li>'; }).join('');
function rsClose() { rsList.hidden = true; rsBox.classList.remove('open'); }
$('#rs-current').addEventListener('click', function (e) {
e.stopPropagation();
var willOpen = rsList.hidden;
rsList.hidden = !willOpen;
rsBox.classList.toggle('open', willOpen);
/* ─── 档案选择控件:出生年月 / 性别 / 省份城市,统一用 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 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 makePicker(trigger, opts) {
if (typeof MobileSelect === 'undefined') return null;
return new MobileSelect(Object.assign({
trigger: trigger, triggerDisplayValue: false,
ensureBtnText: '确定', cancelBtnText: '取消'
}, opts));
}
makePicker('#pick-birth', {
title: '出生年月',
wheels: [{ data: YEARS }, { data: MONTHS }],
position: [YEARS.indexOf((NOW_Y - 30) + '年'), 0],
onChange: function (val) {
var y = parseInt(val[0], 10), m = parseInt(val[1], 10);
$('#f-birth').value = y + '-' + (m < 10 ? '0' : '') + m;
$('#f-age').value = ageFromBirth(y, m);
setPick('#pick-birth', y + '年' + m + '月');
}
});
rsList.addEventListener('click', function (e) {
var li = e.target.closest('li[data-v]');
if (!li) return;
regionSel.value = li.dataset.v;
rsValue.textContent = li.dataset.v;
rsValue.classList.remove('rs-placeholder');
rsClose();
makePicker('#pick-gender', {
title: '性别',
wheels: [{ data: ['女', '男'] }],
onChange: function (val) { $('#f-gender').value = val[0]; setPick('#pick-gender', val[0]); }
});
document.addEventListener('click', rsClose); // 点空白收起
// 年龄只收数字:inputmode=numeric 在部分安卓输入法仍可切到全键盘
$('#f-age').addEventListener('input', function () {
var v = this.value.replace(/\D/g, '').slice(0, 2);
if (v !== this.value) this.value = v;
makePicker('#pick-region', {
title: '所在地区',
wheels: [{ data: (window.REGION_DATA || []).map(function (p) {
return { id: p[0], value: p[0], childs: p[1].map(function (c) { return { id: c, value: c }; }) };
}) }],
onChange: function (val) {
var prov = val[0] && val[0].value, city = val[1] && val[1].value;
var text = !city || city === prov ? prov : prov + ' ' + city; // 直辖市/港澳台只显示一级
regionSel.value = text;
setPick('#pick-region', text);
}
});
/* ─── 实时相机(getUserMedia)+ 系统相机降级 ─── */
@@ -215,6 +255,7 @@
}
function startCamera() {
if (camStream) { stopCamera(); }
camFallback = false;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; }
camSeqCam++;
var token = camSeqCam;
@@ -249,18 +290,21 @@
}
function stopCamera() {
camSeqCam++; // 使进行中的 getUserMedia 回调失效
stopDetect(); // 检测循环/倒计时/无进展提示一并收尾
stopDetect(); // 检测循环/无进展提示一并收尾
$('#cam-status').classList.remove('show');
if (camStream) { camStream.getTracks().forEach(function (t) { t.stop(); }); camStream = null; }
camVideo.srcObject = null;
camLive = false;
$('#s-cam').classList.remove('cam-live');
$('#cam-mask').classList.remove('scanning');
}
function enableFallback() {
camFallback = true;
$('#cam-status').classList.remove('show'); // 与 stopCamera 对齐:去掉「检测引擎加载中」胶囊
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框,快门出现
toast('已切换为系统相机拍摄');
// 系统相机拍回的照片也要校验(有脸、角度对),模型照常预加载
if (!IS_MOCK && typeof faceapi !== 'undefined') loadModels().catch(function () {});
}
// 从视频帧截取照片(完整帧、摄像头原始视角,压到最长边 1280 的 JPEG)
// 抓完整帧而非取景框可见区:新 UI 轮廓外全黑,用户只关注轮廓内人脸;按可见区裁切在侧脸/偏站位时会切掉人脸(Face++ INVALID_IMAGE_FACE)
@@ -276,9 +320,10 @@
ctx.drawImage(camVideo, 0, 0, vw, vh, 0, 0, cw, ch);
return canvas.toDataURL('image/jpeg', 0.88);
}
// 文件 → 统一压成最长边 1280 的 JPEG → 推进流程(降级系统相机与相册选图共用)
// 文件 → 统一压成最长边 1280 的 JPEG → 校验合格才推进流程(降级系统相机与相册选图共用)
function readPhotoFile(f) {
if (!f) return;
var step = camStep, seq = camSeq;
var reader = new FileReader();
reader.onload = function () {
var img = new Image();
@@ -287,12 +332,50 @@
var c = document.createElement('canvas');
c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale);
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
onPhotoCaptured(c.toDataURL('image/jpeg', 0.88));
var url = c.toDataURL('image/jpeg', 0.88);
shutterLock = true; // 校验期间不接受新的拍摄/选图
showCamStatus('照片检查中…');
checkStill(url, CAM_STEPS[step].target).then(function (err) {
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.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) {
var img = new Image();
img.onload = function () {
faceapi
.detectSingleFace(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(''); });
};
img.onerror = function () { resolve('照片读取失败,请重新选择'); };
img.src = dataUrl;
});
}
function showCamStatus(text) { var s = $('#cam-status'); s.textContent = text; s.classList.add('show'); }
function hideCamStatus() { $('#cam-status').classList.remove('show'); }
// 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程
camFile.addEventListener('change', function () {
shutterLock = false; // 选完(含空选择)即解锁;推进窗口由 onPhotoCaptured 重新上锁
@@ -429,22 +512,23 @@
})();
document.addEventListener('click', voice.unlock); // 任意点击即完成解锁
/* ─── 姿态检测(face-api + HeadPose,引导式自动三连拍) ─── */
/* ─── 姿态检测(face-api + HeadPose):动态捕捉——距离/居中/角度/光线全部合格并稳住片刻即自动抓拍,不倒计时、无手动快门 ─── */
var MODEL_URL = 'vendor/faceapi/models';
var DETECT_INTERVAL = 150; // 检测帧间隔(ms)
var DETECT_INPUT_SIZE = 416; // tinyFaceDetector 输入尺寸:320 下正脸置信度仅 0.67 且框偏松,416 置信度 ~0.99 框更准(CPU 端略慢,移动端 GPU 无感)
var DETECT_SCORE = 0.3; // 置信度阈值:0.4 在噪声帧易漏检,0.3 配合距离/角度闸门足够抗误检
var MISS_FRAMES_MAX = 3; // 连续漏检 ≥3 帧(~450ms)才报「未检测到人脸」,容忍单帧抖动
var OK_HOLD_MS = 600; // 姿态 ok 需连续维持时长,达标才进倒计时
var COUNTDOWN_S = 3; // 倒计时秒数
var NO_PROGRESS_MS = 12000; // 单步超过该时长未触发倒计时→浮出手动提示
var FACE_NEAR = 0.26, FACE_FAR = 0.58; // 人脸框高/视频高:<26% 太远,>58% 太近(以取景框≈42% 屏高、416 输入实测框 0.40–0.44 标定)
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;
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 okSince = 0; // 当前连续 ok 起点(0=未在累积)
var counting = false, cdTimer = null, cdN = 0; // 倒计时
var cdDeferCancel = null; // 倒计时等播报收尾的取消句柄(afterCurrent)
var nudgeTimer = null; // 12s 无进展提示
var capturing = false; // 白屏补光抓帧中(暂停姿态判定)
var lastHint = ''; // 提示文案去重
@@ -461,6 +545,15 @@
return modelsPromise;
}
// 扫描带裁切:复制镂空轮廓到 <clipPath id="cam-clip">,随 data-step 的 CSS 显隐一起切换
(function buildScanClip() {
var NS = 'http://www.w3.org/2000/svg';
var clip = document.createElementNS(NS, 'clipPath');
clip.id = 'cam-clip';
document.querySelectorAll('#cam-cut [data-shape]').forEach(function (p) { clip.appendChild(p.cloneNode(false)); });
$('#cam-mask defs').appendChild(clip);
})();
function setMask(cls) { // '' 静态白框 | 'no-face' 呼吸动画 | 'ok' 品牌绿定格
var m = $('#cam-mask');
m.classList.toggle('no-face', cls === 'no-face');
@@ -479,76 +572,46 @@
}
function hideNudge() { $('#cam-nudge').classList.remove('show'); }
// 每步进入/回退时的检测状态机重置(蒙版/提示/倒计时/无进展计时全部归位)
// 手动模式(显示快门):系统相机降级 / 识别不可用 / mock 调试;其余为实时检测自动抓拍
function isManualMode() { return IS_MOCK || camFallback || detectDisabled; }
function syncCamMode() {
$('#s-cam').classList.toggle('cam-manual', isManualMode());
$('#cam-mask').classList.toggle('scanning', camLive && !isManualMode() && modelsOk);
}
// 每步进入/回退时的检测状态机重置(蒙版/提示/无进展计时全部归位)
function resetDetectState() {
okSince = 0;
missFrames = 0;
oppFrames = 0;
lastHint = '';
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
setHint('');
setMask(camLive && !IS_MOCK && !detectDisabled && !camFallback ? 'no-face' : '');
setMask(camLive && !isManualMode() ? 'no-face' : '');
syncCamMode();
armNudge();
}
// 单步 12s 未触发倒计时→底部浮出轻提示(不阻塞流程);进入下一步/倒计时/抓帧时重置
// 单步 12s 仍未抓拍→底部浮出调整提示(不阻塞流程);进入下一步/抓帧时重置
function armNudge() {
clearTimeout(nudgeTimer);
hideNudge();
if (IS_MOCK || detectDisabled || camFallback) return; // 手动模式无需提示
if (isManualMode()) return;
if (currentScreen !== 's-cam') return;
nudgeTimer = setTimeout(function () {
if (currentScreen === 's-cam' && !counting && !shutterLock) $('#cam-nudge').classList.add('show');
if (currentScreen === 's-cam' && !shutterLock) $('#cam-nudge').classList.add('show');
}, NO_PROGRESS_MS);
}
function startCountdown() {
counting = true; // 立即置位:等待播报收尾期间姿态被破坏,走 cancelCountdown 统一取消
clearTimeout(nudgeTimer);
hideNudge();
setMask('ok'); // 轮廓保持绿色
setHint('保持不动', true, /* silent= */true); // 倒计时前不再抢播「保持不动」(会被 321 立刻切断,听着像吞字)
// 「保持不动」等仍在播放的提示音自然播完再进倒计时,画面数字与「三、二、一」同步起播
cdDeferCancel = voice.afterCurrent(beginCountdown);
}
function beginCountdown() {
cdDeferCancel = null;
if (!counting) return; // 等待期间已被取消(姿态破坏/退出拍照页)
cdN = COUNTDOWN_S;
var c = $('#cam-count');
c.textContent = cdN;
c.classList.add('show');
voice.play(VOICE.countdown); // 「三、二、一」与数字同步起播
cdTimer = setTimeout(cdTick, 1000);
}
function cdTick() {
cdN--;
if (cdN <= 0) {
counting = false;
$('#cam-count').classList.remove('show');
doCapture(); // 倒计时结束:白屏补光抓帧(与手动快门同路径)
return;
}
$('#cam-count').textContent = cdN;
cdTimer = setTimeout(cdTick, 1000);
}
function cancelCountdown() {
counting = false;
if (cdDeferCancel) { cdDeferCancel(); cdDeferCancel = null; } // 取消尚未起播的倒计时接续
clearTimeout(cdTimer);
$('#cam-count').classList.remove('show');
}
function maybeStartDetect() {
if (detRunning || IS_MOCK || detectDisabled || !modelsOk || !camLive || currentScreen !== 's-cam') return;
detRunning = true;
syncCamMode();
detTick();
}
function stopDetect() {
detRunning = false;
clearTimeout(detTimer);
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
}
@@ -572,6 +635,25 @@
})
.catch(function () { /* 单帧异常忽略,下一帧继续 */ });
}
// 人脸框中心相对取景中线的水平偏移(占屏宽比例);按 object-fit:cover + 预览缩放换算到屏幕,镜像只影响符号
function faceOffsetX(box) {
var vw = camVideo.videoWidth, vh = camVideo.videoHeight, W = camVideo.clientWidth, H = camVideo.clientHeight;
if (!vw || !vh || !W || !H) return 0;
var s = Math.max(W / vw, H / vh) * PREVIEW_SCALE;
return ((box.x + box.width / 2) - vw / 2) * s / W;
}
// 脸部平均亮度:人脸框缩到 16×16 取均值
var lumaCanvas = null;
function faceLuma(box) {
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);
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) {
var hint = '', ok = false;
@@ -580,17 +662,19 @@
if (noFace) {
hint = '未检测到人脸';
} else if (!hasFace) {
return; // 偶发漏检帧:保持上一条提示与状态,不扰动倒计时/ok 累积
return; // 偶发漏检帧:保持上一条提示与状态,不扰动 ok 累积
} else {
// 提示优先级:无人脸 > 距离 > 角度
var ratio = d.detection.box.height / (camVideo.videoHeight || 1);
// 提示优先级:无人脸 > 距离 > 居中 > 角度 > 光线
var box = d.detection.box;
var ratio = box.height / (camVideo.videoHeight || 1);
if (ratio < FACE_NEAR) hint = '请靠近一点';
else if (ratio > FACE_FAR) hint = '请远一点';
else if (Math.abs(faceOffsetX(box)) > CENTER_TOL) hint = '请将脸部移入取景框';
else {
// 距离不合格时角度不判 ok。特征点取自原始帧(预览的 CSS 镜像不影响),侧脸步语义方向按原始视角判定:
// 标准机型恒为 false,个别前置帧本身已镜像的机型由自校准翻转 sideYawFlip;正脸步只看 |yaw|,与符号无关
// 特征点取自原始帧(预览的 CSS 镜像不影响),三步的语义方向都按原始视角判定:
// 标准机型 sideYawFlip 恒为 false,个别前置帧本身已镜像的机型由侧脸自校准翻转
var tgt = CAM_STEPS[camStep].target;
var pose = HeadPose.check(d.landmarks.positions, tgt, tgt === 'front' ? camMirrored : sideYawFlip);
var pose = HeadPose.check(d.landmarks.positions, tgt, sideYawFlip);
// 侧脸自校准:个别机型前置帧本身已镜像(语义 yaw 与实际转向持续相反)时自救——
// 需连续 30 帧(~10s)强反向才翻转一次:阈值要高,否则用户转错方向 1~2s 就会被误判为镜像机,
// 反而把错误的侧脸当合格抓走(左右图归属错乱)。翻转只动判定方向,不动预览画面
@@ -604,21 +688,22 @@
}
} else oppFrames = 0;
}
hint = pose.hint;
ok = pose.ok;
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;
}
}
window.__dbgRatio = ratio; // 自动化校准/测试用(检测框高/视频高)
}
if (counting && !ok) cancelCountdown(); // 倒计时中任一帧不 ok→立即取消,回到对应提示
setMask(counting || ok ? 'ok' : (noFace ? 'no-face' : ''));
setHint(counting ? '保持不动' : hint, counting || ok);
if (counting) return;
if (ok) {
if (!okSince) okSince = Date.now();
else if (Date.now() - okSince >= OK_HOLD_MS) startCountdown();
} else {
okSince = 0;
}
setMask(ok ? 'ok' : (noFace ? 'no-face' : ''));
// 合格时「保持不动」只显示不播报:随即抓拍,「拍摄完成」会截断它
setHint(ok ? '保持不动' : hint, ok, ok);
if (!ok) { okSince = 0; return; }
if (!okSince) okSince = Date.now();
else if (Date.now() - okSince >= OK_HOLD_MS) { okSince = 0; doCapture(); }
}
/* ─── 拍照流程(三步,顺序对齐 Figma:正脸 → 右脸出镜 → 左脸出镜) ─── */
@@ -626,9 +711,9 @@
// 左脸出镜=头向自己的右侧转(target 'left',产物作 left_image)。
// mask 为 index.html 取景轮廓编号(1=左脸出镜 耳在画面左,2=右脸出镜 耳在画面右,均按镜像预览绘制)
var CAM_STEPS = [
{ step: '1/3<br>请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front', mask: 0 },
{ step: '2/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right', mask: 2 },
{ step: '3/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left', mask: 1 }
{ step: '1/3<br>请正对屏幕', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front', mask: 0 },
{ step: '2/3<br>请右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right', mask: 2 },
{ step: '3/3<br>请左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left', mask: 1 }
];
function photosByView(ph) { // 拍摄顺序 → 送检/报告/历史统一口径 [正脸, 左脸, 右脸]
var at = {};
@@ -643,30 +728,23 @@
$('#cam-guide').innerHTML = cfg.guides
.map(function (g) { return '<span>' + g + '</span>'; })
.join('<i></i>');
// 步骤圆点:当前步高亮、完成步绿色打勾
var dots = $('#cam-dots').children;
for (var i = 0; i < dots.length; i++) {
dots[i].classList.toggle('done', i < camStep);
dots[i].classList.toggle('on', i === camStep);
}
$('#cam-mask').setAttribute('data-step', cfg.mask); // 蒙版按步骤切换轮廓
applyMirror();
resetDetectState();
voice.play(VOICE.step[camStep]); // 步骤引导语音
}
var camSeq = 0; // 流程令牌:cam-back/开始拍照时递增,使未决的快门/分析回调失效
var camSeq = 0; // 流程令牌:退出拍照/开始拍照时递增,使未决的快门/分析回调失效
var shutterLock = false; // 快门防连点锁:流程进行中直接忽略
// 照片就绪后推进流程(实时抓帧与降级选图共用):存图→步骤点变绿→300ms 过渡→下一步
// 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步
function onPhotoCaptured(dataUrl) {
shutterLock = true; // 推进窗口上锁:降级模式 change 里已解锁,这里重新锁住直到下一步/送检
shutterLock = true; // 推进窗口上锁,直到下一步/送检
photos[camStep] = dataUrl;
$('#cam-dots').children[camStep].classList.add('done');
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废(自动拍时倒计时本就等播报收尾)
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废
var seq = camSeq;
setTimeout(function () {
if (seq !== camSeq) return; // 期间已 cam-back/重新开始:放弃本次推进
if (seq !== camSeq) return; // 期间已退出/重新开始:放弃本次推进
if (camStep < CAM_STEPS.length - 1) {
camStep++;
renderCamStep(); // 内含检测状态机重置
@@ -683,7 +761,6 @@
if (!camLive) return;
shutterLock = true;
capturing = true;
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
var seq = camSeq;
@@ -696,7 +773,7 @@
fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出
capturing = false;
if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出)
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即重启倒计时
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即再抓
onPhotoCaptured(shot);
}, 300);
}, 300);
@@ -729,6 +806,7 @@
voice.play(VOICE.fail, { cut: true });
toast(errMsg);
showScreen('s-profile'); // 回档案页,允许重新提交再拍
popHistorySilently();
return;
}
var vm = null;
@@ -741,12 +819,13 @@
if (!vm) {
toast('报告生成失败,请重新拍摄');
showScreen('s-profile');
popHistorySilently();
return;
}
renderReport(vm, photos[0], photos[1], payload); // img1=正脸实拍,payload 供检测可视化取 face_maps/marks
storeReport(payload, photos, profile); // 以检测为单位存三张照片+分析结果(本地历史)
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
showScreen('s-report');
showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机
}, wait);
});
}
@@ -754,8 +833,9 @@
function shutter() {
if (shutterLock) return;
if (camLive) {
if (!isManualMode()) return; // 实时检测模式只自动抓拍,不接受手动快门
if (!camVideo.videoWidth) { toast('相机尚未就绪,请稍候'); return; }
doCapture(); // 手动兜底:任何时刻可拍当前步,同样走白屏补光,不依赖检测
doCapture(); // 识别不可用/mock:手动拍当前步,同样走白屏补光
} else if (camFallback) {
shutterLock = true; // 前置上锁防连点:change/cancel 回调里解锁
camFile.click(); // 降级:调起系统相机,回调里推进
@@ -828,11 +908,16 @@
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function setDim(scoreSel, dim, isWorst) {
var el = $(scoreSel);
// 设计稿:最低分标红,其余深色;无档位徽章
el.textContent = dim.S == null ? '-.-' : dim.S.toFixed(1);
el.classList.toggle('dim-bad', !!isWorst && dim.S != null);
// 维度卡:整数位大字 + 小数位小字;进度条按 S/10;最高分浅绿、最低分橙色并标红挂「优先关注」
function setDim(key, dim, rank) {
var box = document.querySelector('.dim[data-dim="' + key + '"]');
var el = $('#dim-' + key), has = dim.S != null;
var parts = has ? dim.S.toFixed(1).split('.') : ['-', '-'];
el.innerHTML = '<b>' + parts[0] + '</b>.' + parts[1];
el.classList.toggle('dim-bad', has && rank === 'worst');
box.classList.toggle('worst', has && rank === 'worst');
box.classList.toggle('best', has && rank === 'best');
box.querySelector('.dim-bar i').style.width = has ? Math.max(0, Math.min(100, dim.S * 10)) + '%' : '0';
}
function setCat(key, cat) {
@@ -1021,26 +1106,30 @@
for (i = 0; i < 3; i++) if (typeof ds[i] === 'number') { sum += ds[i]; n++; }
if (n) total = Math.round(sum / n * 10);
}
// 设计稿:大数字 30px/400 + 单位 15px/500(分/岁为小字后缀)
// 环形综合评分:满分 100,环长 2πr(r=52)≈326.73
$('#stat-score').innerHTML = total != null ? '<b>' + total + '</b><i>分</i>' : '<b>--</b><i>分</i>';
var ring = $('#ring-fg');
ring.style.strokeDashoffset = total != null ? 326.73 * (1 - Math.max(0, Math.min(100, total)) / 100) : 326.73;
$('#stat-age').innerHTML = vm.skinAgeDisplay != null ? '<b>' + vm.skinAgeDisplay + '</b><i>岁</i>' : '<b>--</b><i>岁</i>';
$('#stat-age-note').textContent = ''; // 肌龄小字按产品要求不展示(钳制留痕 vm.ageClampNote 仍随数据返回)
// 肌龄对比胶囊(新版设计「比实际年龄年轻2岁」);缺实际年龄或肌龄时不展示
var pill = $('#stat-age-note');
var diff = vm.skinAgeDisplay != null && vm.realAge ? vm.skinAgeDisplay - vm.realAge : null;
pill.hidden = diff == null;
pill.textContent = diff == null ? '' : diff < 0 ? '比实际年龄年轻' + (-diff) + '岁' : diff > 0 ? '比实际年龄大' + diff + '岁' : '与实际年龄相当';
// 三维度:S 值(1 位小数),最低分标红(设计稿无档位徽章)
// 三维度:按 P 排名,最低分标红 + 优先关注,最高分浅绿进度条
var dimPs = { firm: vm.dims.firm.P, vita: vm.dims.vita.P, stab: vm.dims.stab.P };
var worstDimKey = Object.keys(dimPs).reduce(function (a, b) {
if (dimPs[a] == null) return b;
if (dimPs[b] == null) return a;
return dimPs[b] < dimPs[a] ? b : a;
var ranked = Object.keys(dimPs).filter(function (k) { return dimPs[k] != null; })
.sort(function (a, b) { return dimPs[a] - dimPs[b]; });
var worstDimKey = ranked[0], bestDimKey = ranked.length > 1 ? ranked[ranked.length - 1] : null;
['firm', 'vita', 'stab'].forEach(function (k) {
setDim(k, vm.dims[k], k === worstDimKey ? 'worst' : k === bestDimKey ? 'best' : '');
});
setDim('#dim-firm', vm.dims.firm, worstDimKey === 'firm');
setDim('#dim-vita', vm.dims.vita, worstDimKey === 'vita');
setDim('#dim-stab', vm.dims.stab, worstDimKey === 'stab');
// 关注行(设计稿两行:「近期优先关注:」灰 + 维度名深色,下行为深色口号)
// 关注行(设计稿两行:「近期优先关注:」灰 + 维度名深色下划线,下行为灰色口号)
var DIM_SLOGAN = {
'紧塑力': '紧塑提拉力,轮廓上扬不松垮',
'活力感': '活力透亮感,水润澎弹有光泽',
'稳定力': '强韧稳态力,锁水保湿抵御刺激' // 设计稿原文
'紧塑力': '紧塑提拉力,轮廓上扬不松垮。',
'活力感': '活力透亮感,水润澎弹有光泽。',
'稳定力': '强韧稳态力,锁水保湿抵御刺激。' // 设计稿原文
};
$('#dims-note').innerHTML = vm.focus.name
? '<span class="dims-note-l1">近期优先关注:<b>' + esc(vm.focus.name) + '</b></span><br><span class="dims-note-l2">' + esc(DIM_SLOGAN[vm.focus.name] || '') + '</span>'
@@ -1100,7 +1189,7 @@
}
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
resetDetectState();
if (!modelsOk) $('#cam-status').classList.add('show'); // “检测引擎加载中…”
if (!modelsOk) showCamStatus('检测引擎加载中…');
loadModels().then(function () {
$('#cam-status').classList.remove('show');
maybeStartDetect();
@@ -1128,16 +1217,23 @@
openOverlay('p-policy');
});
/* ─── 报告页 Tab ─── */
$('#detail-tabs').addEventListener('click', function (e) {
var btn = e.target.closest('.dtab');
if (!btn) return;
var idx = btn.dataset.tab;
document.querySelectorAll('.dtab').forEach(function (t) { t.classList.toggle('active', t === btn); });
document.querySelectorAll('.dpanel').forEach(function (p) {
p.classList.toggle('active', p.dataset.panel === idx);
/* ─── 报告页:每张维度卡各自的子 Tab;点检测图放大,再点返回 ─── */
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' }, '');
}
});
});
$('#p-zoom').addEventListener('click', function () {
closeOverlay('p-zoom');
popHistorySilently();
});
/* ─── 动作分发 ─── */
var actions = {
@@ -1147,12 +1243,11 @@
'agree-continue': function () {
if (!agreed) { toast('请先阅读并同意隐私协议'); return; }
closeOverlay('p-privacy');
showScreen('s-profile');
showScreen('s-profile', 'push');
},
'back-splash': function () { showScreen('s-splash'); },
'submit-profile': function () {
var age = $('#f-age').value.trim();
if (!age || !/^\d{1,2}$/.test(age) || +age < 1 || +age > 99) { toast('请输入有效年龄'); return; }
var age = +$('#f-age').value;
if (!$('#f-birth').value || !(age >= 1 && age <= 99)) { toast('请选择出生年月'); return; }
if (!$('#f-gender').value) { toast('请选择性别'); return; }
if (!regionSel.value) { toast('请选择所在地区'); return; }
openOverlay('p-photoguide');
@@ -1164,16 +1259,9 @@
camStep = 0;
photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍)
shutterLock = false; // 重新进入流程,保险解锁
renderCamStep();
showScreen('s-cam');
showScreen('s-cam', 'push');
startCamera(); // beginDetect 在其成功回调里:确认相机可用才加载模型
},
'cam-back': function () {
camSeq++; // 使未决的快门/分析回调失效,避免回退被旧定时器顶回
voice.stop(true); // 回退/退出:上一步的播报不再接着念
shutterLock = false; // 中途退出也解锁
if (camStep > 0) { camStep--; renderCamStep(); } // renderCamStep 内含检测状态机重置
else { stopCamera(); showScreen('s-profile'); }
renderCamStep(); // 在 startCamera 复位降级标记之后渲染,快门显隐按本次模式
},
'shutter': shutter,
'album': function () {
@@ -1187,7 +1275,6 @@
camMirrored = camFacing === 'user';
startCamera(); // 复用启停与预热(camSeqCam 防竞态)
},
'back-profile': function () { showScreen('s-profile'); },
'history': function () { openHistory(); },
'close-history': function () { closeOverlay('p-history'); },
'clear-history': function () {
@@ -1280,7 +1367,7 @@
if (!vm) { toast('该记录已损坏'); return; }
renderReport(vm, entry.photos && entry.photos[0], null, entry.payload);
closeOverlay('p-history');
showScreen('s-report');
showScreen('s-report', currentScreen === 's-report' ? undefined : 'push');
});
/* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 photos[] 与 camStep) ─── */
@@ -1309,6 +1396,7 @@
});
/* ─── 初始化 ─── */
history.replaceState({ screen: 's-splash' }, ''); // 刷新后从启动页开始,历史栈的屏标记与之对齐
renderCamStep();
// 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口)
// 有 MOCK_FIXTURE 时走 fixture(检测可视化同步出图),否则回退 SkinScore 内置样本
+23 -12
View File
@@ -73,8 +73,8 @@
var HINT_KEEP = '保持不动';
var HINT_TURN_LEFT = '请向左转头'; // 用户向自己的左侧转
var HINT_TURN_RIGHT = '请向右转头'; // 用户向自己的右侧转
var HINT_RAISE = '请抬头';
var HINT_LOWER = '请低头';
var HINT_RAISE = '请略微抬头';
var HINT_LOWER = '请略微低头';
var HINT_FRONT = '请正对屏幕';
var RAD2DEG = 180 / Math.PI;
@@ -157,8 +157,8 @@
/**
* check(points, target, mirrored) -> { ok, hint }
* target: 'front' | 'left' | 'right'
* 'front' : |yaw|<=15 且 |pitch|<=18;不 ok 时一律 hint='请正对屏幕'
* (产品要求 2026-09-28:正脸步不提示左右转头/抬头低头,只引导回正)
* 'front' : |yaw|<=15 且 |pitch|<=18;不 ok 时按超出更多的一项给方向提示
* (UI 反馈 2026-10:动态提示「请向左转头」「请略微抬头」等,取代此前只提示回正)
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-75°,-28°]),|pitch|<=20
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [28°,75°]),|pitch|<=20
* ok=true 时 hint='保持不动';输入非法(含 estimate 返回 null 的退化特征点)时 ok=false、hint='请正对屏幕'。
@@ -181,8 +181,12 @@
if (Math.abs(sYaw) <= FRONT_YAW_MAX && Math.abs(pitch) <= FRONT_PITCH_MAX) {
return { ok: true, hint: HINT_KEEP, yaw: sYaw };
}
// 正脸步只提示回正(产品要求:不区分左右转头/抬头低头,避免正脸阶段引导混乱)
return { ok: false, hint: HINT_FRONT, yaw: sYaw };
// 按超出阈值比例更大的一项引导回正:头偏向自己左侧(yaw>0)→请向右转头;抬头过多(pitch>0)→请略微低头
var yawOver = Math.abs(sYaw) / FRONT_YAW_MAX, pitchOver = Math.abs(pitch) / FRONT_PITCH_MAX;
if (yawOver >= pitchOver) {
return { ok: false, hint: sYaw > 0 ? HINT_TURN_RIGHT : HINT_TURN_LEFT, yaw: sYaw };
}
return { ok: false, hint: pitch > 0 ? HINT_LOWER : HINT_RAISE, yaw: sYaw };
}
if (target === 'left' || target === 'right') {
@@ -293,22 +297,29 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
ok(bc.ok === true, '反向侧脸 target=left ok');
ok(HeadPose.check(back, 'right', false).ok === false, '反向侧脸 target=right 拒绝');
// 7) 侧脸做 front check → 拒绝并只引导回正(产品要求:正脸步不再提示左右转头)
// 7) 侧脸做 front check → 拒绝并引导转回(被摄者左转 → 请向右转头;反向 → 请向左转头)
var sc = HeadPose.check(makeSide(), 'front', false);
ok(sc.ok === false && sc.hint === '请正对屏幕', '侧脸 front 拒绝,hint=请正对屏幕');
ok(sc.ok === false && sc.hint === '请向右转头', '左转侧脸 front 拒绝,hint=请向右转头');
var scb = makeSide();
scb[30] = { x: 182.3, y: 140 };
scb[33] = { x: 182.3, y: 161.5 };
ok(HeadPose.check(scb, 'front', false).hint === '请向左转头', '右转侧脸 front 拒绝,hint=请向左转头');
// 8) roll:右眼(45 点)下移 10px → roll≈5.7°
var tilted = makeFrontal();
tilted[45] = { x: 250, y: 110 };
ok(Math.abs(HeadPose.estimate(tilted, false).roll - 5.71) < 0.2, 'roll≈5.71°');
// 9) 低头:鼻底上移 → pitch<0,front 拒绝并只提示回正(正脸步不再提示抬头/低头)
// 9) 低头:鼻底上移 → pitch<0,front 拒绝并提示略微抬头
var down = makeFrontal();
down[33] = { x: 200, y: 100 };
var de = HeadPose.estimate(down, false);
var dc = HeadPose.check(down, 'front', false);
ok(de.pitch < -30, '低头 pitch<0 (got ' + de.pitch.toFixed(2) + ')');
ok(dc.ok === false && dc.hint === '请正对屏幕', '低头 front 拒绝,hint=请正对屏幕');
ok(dc.ok === false && dc.hint === '请略微抬头', '低头 front 拒绝,hint=请略微抬头');
var up = makeFrontal();
up[33] = { x: 200, y: 200 };
ok(HeadPose.check(up, 'front', false).hint === '请略微低头', '抬头过多 front 拒绝,hint=请略微低头');
// 10) 非法输入:check 不抛异常,返回兜底引导
var bad = HeadPose.check(null, 'front', false);
@@ -327,7 +338,7 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
ok(HeadPose.estimate(tiny, false) === null, 'faceHeight 过小 → estimate 返回 null');
ok(HeadPose.check(tiny, 'front', false).hint === '请正对屏幕', 'faceHeight 过小 check 兜底');
// 13) 正脸阈值放宽到 |yaw|≤15°(产品要求 2026-09-28):yaw≈14.9° 应通过,yaw≈16.2° 应拒绝且只提示回正
// 13) 正脸阈值放宽到 |yaw|≤15°(产品要求 2026-09-28):yaw≈14.9° 应通过,yaw≈16.2° 应拒绝并引导转回
ok(HeadPose.config.FRONT_YAW_MAX === 15, 'config.FRONT_YAW_MAX = 15');
var slight = makeFrontal();
slight[30] = { x: 205.6, y: 140 }; // 偏移 5.6 → yaw≈14.9°(YAW_COEFF=3.8 标定)
@@ -342,7 +353,7 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
var se2 = HeadPose.estimate(slight2, false);
ok(se2.yaw > 15 && se2.yaw < 17.5, '偏头 yaw∈(15,17.5) (got ' + se2.yaw.toFixed(2) + ')');
var sc3 = HeadPose.check(slight2, 'front', false);
ok(sc3.ok === false && sc3.hint === '请正对屏幕', 'yaw≈16.2° front 拒绝,hint=请正对屏幕');
ok(sc3.ok === false && sc3.hint === '请向右转头', 'yaw≈16.2° front 拒绝,hint=请向右转头');
if (failures > 0) {
console.error('\n' + failures + ' 项自测失败');
+39
View File
@@ -0,0 +1,39 @@
/* 省份 → 城市(地级)数据。来源:china-division@2.7.0 dist/pc.json(modood/Administrative-divisions-of-China,WTFPL);
直辖市第二列只保留本市,另补港澳台与海外。 */
window.REGION_DATA = [
["北京市", ["北京市"]],
["天津市", ["天津市"]],
["河北省", ["石家庄市","唐山市","秦皇岛市","邯郸市","邢台市","保定市","张家口市","承德市","沧州市","廊坊市","衡水市"]],
["山西省", ["太原市","大同市","阳泉市","长治市","晋城市","朔州市","晋中市","运城市","忻州市","临汾市","吕梁市"]],
["内蒙古自治区", ["呼和浩特市","包头市","乌海市","赤峰市","通辽市","鄂尔多斯市","呼伦贝尔市","巴彦淖尔市","乌兰察布市","兴安盟","锡林郭勒盟","阿拉善盟"]],
["辽宁省", ["沈阳市","大连市","鞍山市","抚顺市","本溪市","丹东市","锦州市","营口市","阜新市","辽阳市","盘锦市","铁岭市","朝阳市","葫芦岛市"]],
["吉林省", ["长春市","吉林市","四平市","辽源市","通化市","白山市","松原市","白城市","延边朝鲜族自治州"]],
["黑龙江省", ["哈尔滨市","齐齐哈尔市","鸡西市","鹤岗市","双鸭山市","大庆市","伊春市","佳木斯市","七台河市","牡丹江市","黑河市","绥化市","大兴安岭地区"]],
["上海市", ["上海市"]],
["江苏省", ["南京市","无锡市","徐州市","常州市","苏州市","南通市","连云港市","淮安市","盐城市","扬州市","镇江市","泰州市","宿迁市"]],
["浙江省", ["杭州市","宁波市","温州市","嘉兴市","湖州市","绍兴市","金华市","衢州市","舟山市","台州市","丽水市"]],
["安徽省", ["合肥市","芜湖市","蚌埠市","淮南市","马鞍山市","淮北市","铜陵市","安庆市","黄山市","滁州市","阜阳市","宿州市","六安市","亳州市","池州市","宣城市"]],
["福建省", ["福州市","厦门市","莆田市","三明市","泉州市","漳州市","南平市","龙岩市","宁德市"]],
["江西省", ["南昌市","景德镇市","萍乡市","九江市","新余市","鹰潭市","赣州市","吉安市","宜春市","抚州市","上饶市"]],
["山东省", ["济南市","青岛市","淄博市","枣庄市","东营市","烟台市","潍坊市","济宁市","泰安市","威海市","日照市","临沂市","德州市","聊城市","滨州市","菏泽市"]],
["河南省", ["郑州市","开封市","洛阳市","平顶山市","安阳市","鹤壁市","新乡市","焦作市","濮阳市","许昌市","漯河市","三门峡市","南阳市","商丘市","信阳市","周口市","驻马店市","济源市"]],
["湖北省", ["武汉市","黄石市","十堰市","宜昌市","襄阳市","鄂州市","荆门市","孝感市","荆州市","黄冈市","咸宁市","随州市","恩施土家族苗族自治州","仙桃市","潜江市","天门市","神农架林区"]],
["湖南省", ["长沙市","株洲市","湘潭市","衡阳市","邵阳市","岳阳市","常德市","张家界市","益阳市","郴州市","永州市","怀化市","娄底市","湘西土家族苗族自治州"]],
["广东省", ["广州市","韶关市","深圳市","珠海市","汕头市","佛山市","江门市","湛江市","茂名市","肇庆市","惠州市","梅州市","汕尾市","河源市","阳江市","清远市","东莞市","中山市","潮州市","揭阳市","云浮市"]],
["广西壮族自治区", ["南宁市","柳州市","桂林市","梧州市","北海市","防城港市","钦州市","贵港市","玉林市","百色市","贺州市","河池市","来宾市","崇左市"]],
["海南省", ["海口市","三亚市","三沙市","儋州市","五指山市","琼海市","文昌市","万宁市","东方市","定安县","屯昌县","澄迈县","临高县","白沙黎族自治县","昌江黎族自治县","乐东黎族自治县","陵水黎族自治县","保亭黎族苗族自治县","琼中黎族苗族自治县"]],
["重庆市", ["重庆市"]],
["四川省", ["成都市","自贡市","攀枝花市","泸州市","德阳市","绵阳市","广元市","遂宁市","内江市","乐山市","南充市","眉山市","宜宾市","广安市","达州市","雅安市","巴中市","资阳市","阿坝藏族羌族自治州","甘孜藏族自治州","凉山彝族自治州"]],
["贵州省", ["贵阳市","六盘水市","遵义市","安顺市","毕节市","铜仁市","黔西南布依族苗族自治州","黔东南苗族侗族自治州","黔南布依族苗族自治州"]],
["云南省", ["昆明市","曲靖市","玉溪市","保山市","昭通市","丽江市","普洱市","临沧市","楚雄彝族自治州","红河哈尼族彝族自治州","文山壮族苗族自治州","西双版纳傣族自治州","大理白族自治州","德宏傣族景颇族自治州","怒江傈僳族自治州","迪庆藏族自治州"]],
["西藏自治区", ["拉萨市","日喀则市","昌都市","林芝市","山南市","那曲市","阿里地区"]],
["陕西省", ["西安市","铜川市","宝鸡市","咸阳市","渭南市","延安市","汉中市","榆林市","安康市","商洛市"]],
["甘肃省", ["兰州市","嘉峪关市","金昌市","白银市","天水市","武威市","张掖市","平凉市","酒泉市","庆阳市","定西市","陇南市","临夏回族自治州","甘南藏族自治州"]],
["青海省", ["西宁市","海东市","海北藏族自治州","黄南藏族自治州","海南藏族自治州","果洛藏族自治州","玉树藏族自治州","海西蒙古族藏族自治州"]],
["宁夏回族自治区", ["银川市","石嘴山市","吴忠市","固原市","中卫市"]],
["新疆维吾尔自治区", ["乌鲁木齐市","克拉玛依市","吐鲁番市","哈密市","昌吉回族自治州","博尔塔拉蒙古自治州","巴音郭楞蒙古自治州","阿克苏地区","克孜勒苏柯尔克孜自治州","喀什地区","和田地区","伊犁哈萨克自治州","塔城地区","阿勒泰地区","石河子市","阿拉尔市","图木舒克市","五家渠市","北屯市","铁门关市","双河市","可克达拉市","昆玉市","胡杨河市","新星市","白杨市"]],
["香港特别行政区", ["香港特别行政区"]],
["澳门特别行政区", ["澳门特别行政区"]],
["台湾省", ["台湾省"]],
["海外", ["海外"]]
];
+26
View File
@@ -0,0 +1,26 @@
@echo off
rem 宿主机端口转发 + 防火墙放行(需「以管理员身份运行」)
rem 镜像网络模式下通常可直接访问 http://<本机局域网IP>:8088 ,本脚本作兜底。
net session >nul 2>&1
if errorlevel 1 (
echo 请右键本文件 - 以管理员身份运行
pause
exit /b 1
)
netsh interface portproxy delete v4tov4 listenport=8088 listenaddress=0.0.0.0 >nul 2>&1
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8088 connectaddress=127.0.0.1 connectport=8088
netsh advfirewall firewall delete rule name="Juzi H5 8088" >nul 2>&1
netsh advfirewall firewall add rule name="Juzi H5 8088" dir=in action=allow protocol=TCP localport=8088 profile=any
echo.
echo === portproxy ===
netsh interface portproxy show all
echo === firewall ===
netsh advfirewall firewall show rule name="Juzi H5 8088"
echo.
echo 手机访问: http://192.168.3.19:8088/
echo 后台: http://192.168.3.19:8088/admin.html
echo mock: http://192.168.3.19:8088/?mock=1
pause
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
export HOST="${HOST:-0.0.0.0}"
export PORT="${PORT:-8088}"
if pgrep -f '[n]ode server.js' >/dev/null; then
echo "already running: $(pgrep -af '[n]ode server.js')"
exit 0
fi
nohup node server.js >> server.log 2>&1 &
echo "started pid=$! http://${HOST}:${PORT}"
+15 -8
View File
@@ -2,7 +2,7 @@
* 无头 Chromium 冒烟:mock 三连拍走到报告页,并核对语音清单是否真的被拉取、解码、起播。
*
* 依赖:全局 playwright + `npx playwright install chromium`
* 服务:node h5/server.js(默认 http://127.0.0.1:8080)
* 服务:node h5/server.js(默认 http://127.0.0.1:8080,可用 BASE_URL 覆盖)
*
* node h5/test/e2e-browser.mjs
*/
@@ -119,10 +119,17 @@ try {
await page.getByRole('button', { name: '开始检测' }).click();
await page.locator('#privacy-check').click();
await page.getByRole('button', { name: '继续' }).click();
await page.locator('#f-age').fill('28');
await page.locator('#f-gender').selectOption('女');
await page.locator('#rs-current').click();
await page.locator('#rs-list li[data-v="北京"]').click();
// 三个滚轮选择器:打开后直接点「确定」取默认项(出生年月默认 30 年前 1 月、女、北京市)
for (const sel of ['#pick-birth', '#pick-gender', '#pick-region']) {
await page.locator(sel).click();
await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
await page.waitForTimeout(400);
}
const prof = await page.evaluate(() => ({
birth: document.querySelector('#f-birth').value, age: document.querySelector('#f-age').value,
gender: document.querySelector('#f-gender').value, region: document.querySelector('#f-region').value,
}));
check('档案滚轮选择', /^\d{4}-\d{2}$/.test(prof.birth) && +prof.age > 0 && prof.gender && prof.region, JSON.stringify(prof));
await page.getByRole('button', { name: '提交' }).click();
await page.getByRole('button', { name: '开始' }).click();
@@ -131,11 +138,11 @@ try {
return v && v.videoWidth > 0 && document.querySelector('#s-cam').classList.contains('cam-live');
}, null, { timeout: 12000 });
const step1 = (await page.locator('#cam-step').innerText()).replace(/\s+/g, '');
check('进入拍照 1/3', step1.includes('1/3') && step1.includes('正脸'), step1);
check('进入拍照 1/3', step1.includes('1/3') && step1.includes('正对屏幕'), step1);
// 听完每步引导再按快门(最长 step2 4.3s):引导、「拍摄完成」、「分析中」都应完整播出
// mock 无人脸检测,快门作为调试入口保留;听完每步引导再按(最长 step2 4.3s):引导、「拍摄完成」、「分析中」都应完整播出
// 步骤顺序与轮廓对齐 Figma:正脸 → 右脸(耳在画面右,mask 2)→ 左脸(耳在画面左,mask 1)
const FIGMA_STEPS = [['正脸', '0'], ['右脸', '2'], ['左脸', '1']];
const FIGMA_STEPS = [['正对屏幕', '0'], ['右脸', '2'], ['左脸', '1']];
for (let i = 0; i < 3; i++) {
const n = i + 1;
await page.waitForFunction((num) => {
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017-present, LIHONG OU(onlyhom)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+4
View File
@@ -0,0 +1,4 @@
# mobile-select 1.4.0
来源:npm `mobile-select@1.4.0`(MIT,见同目录 `LICENSE`),取 `dist/mobile-select.iife.min.js` 与 `dist/style/mobile-select.css`,未改动。
档案页的出生年月 / 性别 / 地区滚轮由它渲染,样式覆盖写在 `css/style.css` 的 mobile-select 段。
+164
View File
@@ -0,0 +1,164 @@
/*
* mobile-select v1.4.0
* Homepage: https://github.com/onlyhom/mobile-select
* Released under the MIT License.
* (c) 2017-present
*/
.ms-mobile-select {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
opacity: 0;
visibility: hidden;
-webkit-transition: opacity 0.4s, z-index 0.4s;
transition: opacity 0.4s, z-index 0.4s;
transform: translate(0, 0);
}
.ms-mobile-select * {
margin: 0;
padding: 0;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.ms-mobile-select .ms-gray-layer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 888;
display: block;
}
.ms-mobile-select .ms-content {
width: 100%;
display: block;
position: fixed;
z-index: 889;
color: black;
-webkit-transition: all 0.4s;
transition: all 0.4s;
bottom: -350px;
left: 0;
background: white;
}
.ms-mobile-select .ms-fix-width {
width: 90%;
margin: 0 auto;
position: relative;
}
.ms-mobile-select .ms-fix-width:after {
content: ".";
display: block;
height: 0;
clear: both;
visibility: hidden;
}
.ms-mobile-select .ms-btn-bar {
border-bottom: 1px solid #dcdcdc;
font-size: 15px;
height: 45px;
position: relative;
text-align: center;
line-height: 45px;
}
.ms-mobile-select .ms-cancel,
.ms-mobile-select .ms-ensure {
height: 45px;
width: 55px;
cursor: pointer;
position: absolute;
top: 0;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
.ms-mobile-select .ms-cancel {
left: 0;
color: #666;
}
.ms-mobile-select .ms-ensure {
right: 0;
color: #1e83d3;
}
.ms-mobile-select .ms-title {
font-size: 15px;
padding: 0 15%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ms-mobile-select .ms-panel:after {
content: ".";
display: block;
height: 0;
clear: both;
visibility: hidden;
}
.ms-mobile-select .ms-wheels {
width: 100%;
height: 200px;
overflow: hidden;
}
.ms-mobile-select .ms-wheel {
position: relative;
z-index: 0;
float: left;
width: 50%;
height: 200px;
overflow: hidden;
-webkit-transition: width 0.3s ease;
transition: width 0.3s ease;
}
.ms-mobile-select .ms-select-container {
display: block;
text-align: center;
}
.ms-mobile-select .ms-select-container li {
font-size: 15px;
display: block;
height: 40px;
line-height: 40px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
.ms-mobile-select .ms-select-line {
height: 40px;
width: 100%;
position: absolute;
top: 80px;
pointer-events: none;
-webkit-box-sizing: border-box;
box-sizing: border-box;
border-top: 1px solid #dcdcdc;
border-bottom: 1px solid #dcdcdc;
}
.ms-mobile-select .ms-shadow-mask {
position: absolute;
top: 0;
width: 100%;
height: 200px;
background: linear-gradient(to bottom, #fffffff5, #ffffffcc 25%, #ffffff00 49%, #ffffff00 51%, #ffffffcc 75%, #fffffff5);
opacity: 0.9;
pointer-events: none;
}
.ms-mobile-select.ms-show {
opacity: 1;
z-index: 10000;
visibility: visible;
}
.ms-mobile-select.ms-show .ms-content {
bottom: 0;
}
.ms-default-trigger {
min-height: 1em;
color: #888;
}
/** Prevent Page Scrolling When a Modal is Open */
body.ms-show {
overflow-y: hidden;
}
File diff suppressed because one or more lines are too long