Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a7593b736 | ||
|
|
658b232a39 | ||
|
|
f7902b86c8 | ||
|
|
55f26d91af | ||
|
|
499be22771 |
@@ -0,0 +1 @@
|
||||
kpx234XimQGtFPo6
|
||||
@@ -0,0 +1,58 @@
|
||||
# 1meAI测肤旷视UI问题反馈
|
||||
|
||||
## UI交互界面调整
|
||||
|
||||
##### 交互
|
||||
|
||||
1. 年龄调整为滚轮,消费者选择出生年份月份,系统自动计算年龄(下拉设计)。
|
||||
|
||||

|
||||
|
||||
1. 地区做成滚动形式,省份城市形式(下拉设计)。 找现存的插件,提供选择省份和城市。
|
||||
|
||||

|
||||
|
||||
1. 拍摄方式修改为**动态捕捉方式**,再加入 远一点,近一点 请向左转头,请略微抬头等的动态提示。同时,如果消费者的角度光线等必要条件合适,立刻拍摄,不再倒计时拍摄, 不要手动拍摄的按钮。
|
||||
|
||||

|
||||
|
||||
1. 拍摄的时候画面缩小 15%, 使得摄像头离面部近一点也能正常拍摄。 (当前拍摄,需要手机离面部很远才符合要求)
|
||||
2. 正脸,两个侧脸的拍摄,必须安装顺序完成,拍摄完成后才能拍摄下一张。
|
||||
|
||||

|
||||
|
||||
##### **设计**
|
||||
|
||||
具体设计问题:
|
||||
|
||||
1 主页面 参考下面图片, 左边的是当前的状态,右边的是要达到效果的标准,
|
||||
|
||||
- Life in Full 为更好的生命表现 字号,位置,粗细 要和图片一致。
|
||||
- 开始检测的按钮不要圆角,要长方形带一点倒角。
|
||||
|
||||

|
||||
|
||||
2 设置的页面
|
||||
|
||||
- 不再要返回的按钮
|
||||
- “您的年龄“, “您的性别”, “您所在的地区”,需要居中,不是左对齐
|
||||
- 选择年龄,性别,和地区的选择控件风格要一致
|
||||
- 提交按钮 同样 要长方形带一点倒角
|
||||
|
||||

|
||||
|
||||
拍照页面,参考图片右边的标准
|
||||
|
||||
- 弹出“请拍摄正面及左右侧面照片” 这个对话框的时候, 对话框大小需要和右边的标准一致,背景要把选择年龄,性别,地区等UI元素去掉。 “开始” 按钮要长方形带一点倒角
|
||||
|
||||

|
||||
|
||||
报告页面
|
||||
|
||||
- 去掉返回按钮和 "1'me 在线肌肤测试" 的文字
|
||||
- “您的肌肤状态表现”,这个部分字体有点小,需要整体优化一下
|
||||
- 再往下的图片和说明部分,也是字体有点小,同样需要整体优化一下
|
||||
- 点击图片需要放大,再次点击返回
|
||||
|
||||

|
||||
|
||||
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 527 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 622 KiB |
|
After Width: | Height: | Size: 723 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -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)` 显示。距离判定使用 object-fit:cover 和缩放后的屏幕占比(人脸高度占屏高 <26% 提示靠近、>58% 提示远一点),避免横竖视频比例不同导致距离误判。
|
||||
- **动态提示**(一次一条,优先级:无人脸 > 距离 > 居中 > 角度 > 光线):「未检测到人脸」「请靠近一点 / 请远一点」「请将脸部移入取景框」(人脸中心偏离中线 >18% 屏宽)、角度类「请向左转头 / 请向右转头 / 请略微抬头 / 请略微低头」(正脸阈值 |yaw|≤15°、|pitch|≤18°,超出时按超出比例更大的一项给方向;侧脸 yaw 28°–75°、|pitch|≤20°)、光线类「请到光线充足处 / 光线过强,请避开强光」(脸部平均亮度 <65 或 >235)。
|
||||
- **合格即拍**:单人、距离、横纵居中、姿态和光线连续合格 450ms 后立即抓取当前帧,再闪白 180ms 给出反馈。无倒计时;任何丢帧或检测异常都会清空合格时长,防止误拍。
|
||||
- **实时检测**:本地 face-api 模型,约每 150ms 用 detectAllFaces + landmarks 判断单人及姿态。连续漏检 3 帧才更换文案,但每次漏检均中断可拍摄状态。按原始帧判断真实左右,持续转错方向也不会自动交换左右照片。切换镜头及退出页面作废旧的检测回调。
|
||||
- **镜像策略**:前置镜头预览镜像(照镜子习惯);后置预览不镜像,侧脸轮廓随之翻转(`.cam-mask.flip`)。**抓帧一律不镜像**,三张照片都是摄像头原始视角。
|
||||
- **抓拍反馈**:先抓取已校验的当前帧(原始视角、最长边 1280 JPEG),再显示 180ms 闪白和短振动;不在延迟后抓取可能已经偏离姿态的画面。
|
||||
- **正式页面无手动快门**:相机权限或模型加载失败时显示重试按钮;仅 `?mock=1` 调试模式保留手动快门。拍摄进度明确显示正脸→右脸→左脸,完成三张后只送检一次。
|
||||
- **相册选图**:按当前步骤选择照片,压缩后等待模型加载并校验单人、左右朝向和光线;不合格或检查失败均停留本步,不再跳过校验。读取、校验和抓拍期间锁定操作,离开页面使未完成读取失效。
|
||||
- **语音播报**:步骤引导/姿态提示/拍摄完成/分析中/失败,文案与文件名契约见 `../docs/语音播报内容设计文档_V1.0.md`。新增的提示(居中、抬头/低头、光线)暂无音频,清单缺失的条目静默跳过,只显示文字。
|
||||
- **无进展提示**:单步 12s 仍未拍到,底部浮出「识别不到?请正对镜头,并到光线充足处」(不阻塞流程)。
|
||||
- **资源管理**:离开拍照页停止检测循环、清全部定时器、释放摄像头;`camSeq`(流程)/`camSeqCam`(相机)双令牌防竞态;face-api 模型只加载一次。
|
||||
|
||||
## 档案页与报告页
|
||||
|
||||
- **档案页**:出生年月、性别、省市用统一 mobile-select 滚轮,提供占位提示;按出生月份计算并显示年龄,限制选择结果为 1–99 岁,提交时重新计算以适应跨月。省市联动数据见 `js/region-data.js`;插件行点击已适配 44px 行高与安全区,见 vendor README。
|
||||
- **报告页**:无返回按钮与页头标题;总分环形图 + 肌肤年龄与「比实际年龄年轻/大 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。
|
||||
局域网提测需设置 HOST=0.0.0.0 并允许对应防火墙端口。手机通过局域网 HTTP 访问时不能使用 getUserMedia,需使用 HTTPS 才能测试动态自动拍照;相册选图入口仍可用且逐步校验。
|
||||
|
||||
nginx 反代示例(静态与 API 都交给 server.js,nginx 只做 HTTPS 终结):
|
||||
|
||||
@@ -172,7 +177,7 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
> **HTTPS 必需**:getUserMedia 相机权限仅在安全上下文可用(localhost 除外)。正式域名必须上 HTTPS,否则前端自动降级为系统相机拍照。
|
||||
> **HTTPS 必需**:手机自动拍摄要求安全上下文,正式域名必须使用 HTTPS;本机 localhost 例外。相机不可用时页面显示重试与相册入口。
|
||||
|
||||
## 已知占位清单
|
||||
|
||||
@@ -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`)。
|
||||
- **真机复核**:头部姿态系数沿用既有校准,新增多人、屏幕坐标距离/居中与亮度闸门已通过可控浏览器输入测试。前后镜头、不同手机曝光及微信相机权限仍需用真实设备复核;自动化测试不替代硬件实拍。
|
||||
|
||||
## 技术说明
|
||||
|
||||
@@ -188,3 +193,9 @@ server {
|
||||
- 图片性能:首屏背景 preload;首屏外 `loading="lazy"`。
|
||||
- 快门防连点/双流程令牌(camSeq/camSeqCam)防竞态;拍照页取景框外纯黑(对齐 Figma),白字直接落在黑色上。
|
||||
- face-api.js 0.22.2 与模型全部本地化(`vendor/faceapi/`,约 1.4MB),运行时零外部请求,微信 webview / iOS Safari 弱网与内网部署可用;许可证(MIT / Apache-2.0)见 `vendor/faceapi/README.md`。
|
||||
|
||||
## UI 反馈验收
|
||||
|
||||
`node h5/test/e2e-feedback.mjs` 验证出生年月、省市联动、自动三连拍的质量闸门与顺序、模型重试、相册校验、图片放大及 320/375/430px 布局。测试拦截分析请求,不调用 Face++。
|
||||
|
||||
两个浏览器测试都支持 `BASE_URL`(例如 http://127.0.0.1:8088)、`PLAYWRIGHT_MODULE`(本机 Playwright 的 index.mjs 绝对路径)和 `E2E_OUTPUT_DIR`(截图目录),Windows 与 Linux 均可执行。未指定 Playwright 路径时使用全局安装。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* ═══════════════════════════════════════════
|
||||
巨子测肤 · 1'me 在线肌肤测试 — H5 样式
|
||||
设计稿 1080px 基准 → 此处 375px(÷2.88)
|
||||
设计稿 1080px 基准 → 375px(÷2.88);尺寸写成 N × --u,不同宽度手机等比缩放、视觉一致
|
||||
主色 #F5F078 · 文字 #282828 · 底 #EFEFEF
|
||||
═══════════════════════════════════════════ */
|
||||
:root {
|
||||
@@ -13,28 +13,29 @@
|
||||
--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;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { height: 100%; }
|
||||
html { overflow: hidden; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
color: var(--ink);
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
overflow: clip; /* 滚轮在屏外准备时不能把整个页面程序化滚走 */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
max-width: 480px; /* 桌面预览时居中显示 */
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
position: fixed; inset: 0; width: 100%;
|
||||
}
|
||||
img { display: block; }
|
||||
button { font-family: inherit; border: 0; background: none; cursor: pointer; }
|
||||
button { font-family: inherit; border: 0; background: none; cursor: pointer; color: inherit; }
|
||||
|
||||
/* ─── 屏幕容器 ─── */
|
||||
.screen {
|
||||
@@ -47,15 +48,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 +69,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 +86,188 @@ 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(14 * var(--u)); }
|
||||
.form-row.two .field { flex: 1; min-width: 0; }
|
||||
.form-row.two .field:first-child { flex: 1.25; }
|
||||
|
||||
/* 肤质卡片 */
|
||||
/* 选择控件(出生年月/性别/地区同一样式) */
|
||||
.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-value.placeholder { color: #777; }
|
||||
.profile-age { color: #eee; font-size: max(12px, calc(12 * var(--u))); text-align: center; margin: calc(-9 * var(--u)) 0 calc(18 * var(--u)); }
|
||||
.pick:focus-visible, .btn:focus-visible, .ditem-img:focus-visible, .zoom-close:focus-visible { outline: 3px solid #D3BF29; outline-offset: 3px; }
|
||||
.pick .chev { flex: none; width: calc(8 * var(--u)); height: calc(8 * var(--u)); margin-top: calc(-4 * var(--u)); border-width: 0 1.5px 1.5px 0; }
|
||||
.pick:active { background: #F7F7F7; }
|
||||
|
||||
/* 肤质卡片(设计稿 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; }
|
||||
.cam-controls button:disabled { opacity: .35; pointer-events: none; }
|
||||
.cam-retry { position: absolute; bottom: 14%; left: 50%; transform: translateX(-50%); z-index: 3; padding: 10px 18px; border: 1px solid #fff; border-radius: 5px; color: #fff; background: #282828; font-size: 14px; white-space: nowrap; }
|
||||
.cam-retry[hidden] { display: none; }
|
||||
.cam-progress { position: absolute; top: 21%; left: 0; right: 0; display: flex; justify-content: center; gap: 22px; z-index: 2; color: #bbb; font-size: max(12px, calc(12 * var(--u))); }
|
||||
.cam-progress span::before { content: ''; display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: #777; vertical-align: middle; }
|
||||
.cam-progress .current { color: var(--yellow); }
|
||||
.cam-progress .current::before { background: var(--yellow); }
|
||||
.cam-progress .done { color: #70E28A; }
|
||||
.cam-progress .done::before { background: #70E28A; }
|
||||
/* 白屏补光:瞬间点亮(.on 无过渡),移除后淡出 */
|
||||
.cam-fill {
|
||||
position: absolute; inset: 0; background: #fff;
|
||||
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 +279,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 +327,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: max(13px, calc(14 * var(--u))); color: var(--ink); }
|
||||
.stat-value { color: var(--ink); line-height: 1; white-space: nowrap; }
|
||||
.stat-value b { font-weight: 500; letter-spacing: calc(.5 * var(--u)); }
|
||||
.stat-value i { font-style: normal; font-weight: 500; }
|
||||
|
||||
/* 总分卡:环形综合评分 | 肌肤年龄 */
|
||||
.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: max(12px, calc(13 * var(--u))); color: var(--para); }
|
||||
.sum-age {
|
||||
flex: 1; min-width: 0;
|
||||
display: flex; flex-direction: column; align-items: flex-start;
|
||||
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: max(11px, calc(11 * var(--u))); color: var(--grey-2);
|
||||
background: #F2F2F2; border-radius: calc(9 * var(--u));
|
||||
padding: calc(2 * var(--u)) calc(10 * var(--u));
|
||||
white-space: nowrap;
|
||||
}
|
||||
.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: max(13px, calc(14 * var(--u))); color: var(--ink); }
|
||||
.dim-score { margin-top: calc(4 * var(--u)); font-size: calc(30 * var(--u)); font-weight: 500; color: var(--ink); line-height: 1.1; }
|
||||
.dim-score b { font-size: calc(42 * var(--u)); font-weight: 500; }
|
||||
.dim-score.dim-bad { color: var(--bad); }
|
||||
.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: max(14px, calc(14 * var(--u))); color: var(--para); }
|
||||
.dims-note-l1 b { font-weight: 500; color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.dims-note-l2 { margin-top: calc(6 * var(--u)); font-size: max(13px, calc(13 * var(--u))); color: var(--grey-2); line-height: 1.6; }
|
||||
|
||||
/* 维度区块:大标题 + 卡内子 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(20 * 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: 100%; height: auto; max-height: calc(360 * var(--u));
|
||||
margin: 0 auto calc(14 * var(--u));
|
||||
object-fit: contain; 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: max(14px, calc(15 * var(--u))); color: var(--para); line-height: 1.8;
|
||||
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: max(14px, calc(15 * var(--u))); color: var(--para); line-height: 1.8; margin-bottom: calc(14 * var(--u)); overflow-wrap: anywhere; }
|
||||
.care-summary { font-size: calc(13.5 * var(--u)); font-weight: 500; color: #000; margin-top: calc(4 * var(--u)); }
|
||||
.essence-name { font-size: calc(16 * var(--u)); font-weight: 700; color: var(--ink); }
|
||||
.essence-triggers { font-size: max(12px, calc(12 * var(--u))); color: var(--grey-3); margin-top: calc(8 * var(--u)); line-height: 1.7; }
|
||||
|
||||
/* 产品推荐:大标题落在灰底;白卡左文案右图(各占一半) */
|
||||
.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 +505,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 +537,35 @@ 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; }
|
||||
.overlay-zoom img { max-height: calc(100% - 110px); }
|
||||
.zoom-close { position: absolute; top: max(16px, env(safe-area-inset-top)); right: 14px; width: 44px; height: 44px; font-size: 34px; color: #fff; border-radius: 50%; background: rgba(255,255,255,.12); }
|
||||
.zoom-tip { position: absolute; bottom: max(24px, env(safe-area-inset-bottom)); color: #eee; font-size: 14px; }
|
||||
@keyframes zoomIn { from { opacity: 0; transform: scale(.88); } to { opacity: 1; transform: none; } }
|
||||
|
||||
/* 分析中 */
|
||||
.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 +579,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 +621,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;
|
||||
@@ -621,76 +640,6 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
|
||||
.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;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
/* #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; }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
@echo off
|
||||
rem 巨子测肤 H5 本机部署启动(提测用)。密钥由 server.js 自动加载。
|
||||
set PORT=8088
|
||||
cd /d %~dp0
|
||||
set HOST=127.0.0.1
|
||||
rem 无密钥时允许页面预览和 ?mock=1 演示,配置密钥后自动启用真实检测。
|
||||
set LOCAL_PREVIEW=1
|
||||
cd /d "%~dp0"
|
||||
node server.js >> "%~dp0server.log" 2>&1
|
||||
|
||||
@@ -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,45 @@
|
||||
<!-- ═══════════ 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" for="pick-birth">您的出生年月</label>
|
||||
<button type="button" class="pick" id="pick-birth" aria-haspopup="dialog"><span class="pick-value placeholder">请选择年月</span><i class="chev down"></i></button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">您的性别</label>
|
||||
<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>
|
||||
<label class="field-label" for="pick-gender">您的性别</label>
|
||||
<button type="button" class="pick" id="pick-gender" aria-haspopup="dialog"><span class="pick-value placeholder">请选择性别</span><i class="chev down"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="profile-age" id="profile-age" aria-live="polite">根据出生年月自动计算年龄</p>
|
||||
<div class="field">
|
||||
<label class="field-label">您的所在地区</label>
|
||||
<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">
|
||||
<label class="field-label" for="pick-region">您所在的地区</label>
|
||||
<button type="button" class="pick" id="pick-region" aria-haspopup="dialog"><span class="pick-value placeholder">请选择省份和城市</span><i class="chev down"></i></button>
|
||||
</div>
|
||||
<input type="hidden" id="f-birth">
|
||||
<input type="hidden" id="f-age">
|
||||
<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 +84,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 +106,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"/>
|
||||
@@ -113,9 +117,12 @@
|
||||
</g>
|
||||
</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-hint" id="cam-hint" role="status" aria-live="polite"></p>
|
||||
<div class="cam-progress" aria-label="拍摄进度">
|
||||
<span data-capture-step="0">正脸</span><span data-capture-step="1">右脸</span><span data-capture-step="2">左脸</span>
|
||||
</div>
|
||||
<button type="button" class="cam-retry" data-action="retry-camera" hidden>重新开启自动识别</button>
|
||||
<p class="cam-nudge" id="cam-nudge">识别不到?请正对镜头,并到光线充足处</p>
|
||||
<div class="cam-guide" id="cam-guide"></div>
|
||||
<video id="cam-video" autoplay playsinline muted></video>
|
||||
<input type="file" id="cam-file" accept="image/*" capture="user" hidden>
|
||||
@@ -124,6 +131,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 +145,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 +248,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 +278,25 @@
|
||||
</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" role="dialog" aria-modal="true" aria-label="检测图片预览">
|
||||
<button type="button" class="zoom-close" aria-label="关闭图片预览">×</button>
|
||||
<img id="zoom-img" alt="检测图放大">
|
||||
<p class="zoom-tip">点击图片或空白处返回报告</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ 弹层:隐私政策与服务协议(全文) ═══════════ -->
|
||||
<div class="overlay overlay-policy" id="p-policy">
|
||||
<div class="policy-page">
|
||||
@@ -449,6 +458,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>');
|
||||
|
||||
@@ -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,42 @@
|
||||
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')) {
|
||||
if (order[i] === 'p-zoom') closeZoom(); else 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 +124,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 +171,61 @@
|
||||
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_Y = new Date().getFullYear();
|
||||
var YEARS = [];
|
||||
for (var yy = NOW_Y - 100; yy <= NOW_Y - 1; yy++) {
|
||||
var months = [];
|
||||
for (var mm = 1; mm <= 12; mm++) {
|
||||
var selectableAge = ageFromBirth(yy, mm);
|
||||
if (selectableAge >= 1 && selectableAge <= 99) months.push({ id: mm, value: mm + '月' });
|
||||
}
|
||||
if (months.length) YEARS.push({ id: yy, value: yy + '年', childs: months });
|
||||
}
|
||||
var regionSel = $('#f-region');
|
||||
function setPick(sel, text) { var el = $(sel + ' .pick-value'); el.textContent = text; el.classList.remove('placeholder'); }
|
||||
function ageFromBirth(y, m) { var now = new Date(); return now.getFullYear() - y - (now.getMonth() + 1 < m ? 1 : 0); }
|
||||
function refreshAge() {
|
||||
var birth = $('#f-birth').value.split('-');
|
||||
if (birth.length !== 2) return;
|
||||
var age = ageFromBirth(+birth[0], +birth[1]);
|
||||
$('#f-age').value = age;
|
||||
$('#profile-age').textContent = '年龄:' + age + '岁(按出生月份计算)';
|
||||
}
|
||||
function makePicker(trigger, opts) {
|
||||
if (typeof MobileSelect === 'undefined') return null;
|
||||
return new MobileSelect(Object.assign({
|
||||
trigger: trigger, triggerDisplayValue: false,
|
||||
ensureBtnText: '确定', cancelBtnText: '取消'
|
||||
}, opts));
|
||||
}
|
||||
makePicker('#pick-birth', {
|
||||
title: '出生年月',
|
||||
wheels: [{ data: YEARS }],
|
||||
position: [YEARS.map(function (y) { return y.id; }).indexOf(NOW_Y - 30), 0],
|
||||
onChange: function (val) {
|
||||
var y = +val[0].id, m = +val[1].id;
|
||||
$('#f-birth').value = y + '-' + (m < 10 ? '0' : '') + m;
|
||||
refreshAge();
|
||||
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)+ 系统相机降级 ─── */
|
||||
@@ -181,9 +237,6 @@
|
||||
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
|
||||
var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换)
|
||||
var camMirrored = true; // 预览镜像:前置 true(照镜子习惯,Figma 侧脸轮廓即按镜像画面绘制),后置 false
|
||||
var sideYawFlip = false; // 侧脸步语义 yaw 反向标志:个别机型前置帧本身已镜像时由自校准翻转
|
||||
var mirrorCalibrated = false; // 侧脸自校准:本轮是否已翻转过一次(防振荡,一次校准管整个会话)
|
||||
var oppFrames = 0; // 侧脸步语义 yaw 连续反向帧计数
|
||||
// 预览是否镜像(仅预览;抓帧恒为原始视角)。后置不镜像时侧脸轮廓需水平翻转,才与画面中的脸朝向一致
|
||||
function effMirror() { return camMirrored; }
|
||||
function applyMirror() {
|
||||
@@ -215,6 +268,7 @@
|
||||
}
|
||||
function startCamera() {
|
||||
if (camStream) { stopCamera(); }
|
||||
camFallback = false;
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; }
|
||||
camSeqCam++;
|
||||
var token = camSeqCam;
|
||||
@@ -249,18 +303,26 @@
|
||||
}
|
||||
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');
|
||||
$('#cam-fill').classList.remove('on');
|
||||
if (capturing) shutterLock = false;
|
||||
capturing = false;
|
||||
okSince = 0;
|
||||
}
|
||||
function enableFallback() {
|
||||
camFallback = true;
|
||||
$('#cam-status').classList.remove('show'); // 与 stopCamera 对齐:去掉「检测引擎加载中」胶囊
|
||||
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框
|
||||
toast('已切换为系统相机拍摄');
|
||||
resetDetectState();
|
||||
setHint('请允许相机权限后重试', false, true);
|
||||
toast('相机暂不可用,请重试或按顺序选择照片');
|
||||
// 系统相机拍回的照片也要校验(有脸、角度对),模型照常预加载
|
||||
if (!IS_MOCK && typeof faceapi !== 'undefined') loadModels().catch(function () {});
|
||||
}
|
||||
// 从视频帧截取照片(完整帧、摄像头原始视角,压到最长边 1280 的 JPEG)
|
||||
// 抓完整帧而非取景框可见区:新 UI 轮廓外全黑,用户只关注轮廓内人脸;按可见区裁切在侧脸/偏站位时会切掉人脸(Face++ INVALID_IMAGE_FACE)
|
||||
@@ -276,23 +338,66 @@
|
||||
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;
|
||||
if (currentScreen !== 's-cam' || capturing) return;
|
||||
shutterLock = true;
|
||||
okSince = 0;
|
||||
var active = function () { return seq === camSeq && step === camStep && currentScreen === 's-cam'; };
|
||||
function fail(message) { if (!active()) return; shutterLock = false; hideCamStatus(); toast(message); }
|
||||
var reader = new FileReader();
|
||||
reader.onerror = function () { fail('照片读取失败,请重新选择'); };
|
||||
reader.onload = function () {
|
||||
if (!active()) return;
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
if (!active()) return;
|
||||
var scale = Math.min(1, 1280 / Math.max(img.width, img.height));
|
||||
var c = document.createElement('canvas');
|
||||
c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale);
|
||||
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) {
|
||||
if (!active()) return;
|
||||
hideCamStatus();
|
||||
shutterLock = false;
|
||||
if (err) { toast(err); voice.play(VOICE.fail, { cut: true }); return; } // 不合格:停在本步重拍
|
||||
onPhotoCaptured(url);
|
||||
});
|
||||
};
|
||||
img.onerror = function () { fail('照片读取失败,请重新选择'); };
|
||||
img.src = reader.result;
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
}
|
||||
// 相册也须等模型就绪后校验单人、正确侧脸和光线,失败停留在当前步骤。
|
||||
function checkStill(dataUrl, target) {
|
||||
if (IS_MOCK) return Promise.resolve('');
|
||||
if (typeof faceapi === 'undefined') return Promise.resolve('识别服务未加载,请刷新后重试');
|
||||
return loadModels().then(function () { return new Promise(function (resolve) {
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
faceapi
|
||||
.detectAllFaces(img, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
|
||||
.withFaceLandmarks()
|
||||
.then(function (faces) {
|
||||
if (!faces.length) { resolve('照片中未识别到人脸,请重新选择'); return; }
|
||||
if (faces.length !== 1) { resolve('请使用只有一人的照片'); return; }
|
||||
var d = faces[0], pose = HeadPose.check(d.landmarks.positions, target, false);
|
||||
if (!pose.ok) { resolve('请按顺序选择' + (target === 'front' ? '正脸' : target === 'right' ? '右侧脸' : '左侧脸') + '照片,' + pose.hint); return; }
|
||||
resolve(lightingHint(faceLuma(d.detection.box, img)));
|
||||
}, function () { resolve('照片检查失败,请重新选择'); });
|
||||
};
|
||||
img.onerror = function () { resolve('照片读取失败,请重新选择'); };
|
||||
img.src = dataUrl;
|
||||
}); }).catch(function () { return '识别服务加载失败,请重试'; });
|
||||
}
|
||||
function showCamStatus(text) { var s = $('#cam-status'); s.textContent = text; s.classList.add('show'); }
|
||||
function hideCamStatus() { $('#cam-status').classList.remove('show'); }
|
||||
// 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程
|
||||
camFile.addEventListener('change', function () {
|
||||
shutterLock = false; // 选完(含空选择)即解锁;推进窗口由 onPhotoCaptured 重新上锁
|
||||
@@ -429,22 +534,24 @@
|
||||
})();
|
||||
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%
|
||||
// 以屏幕显示的人脸大小判断距离,预览缩小 15% 后允许用户更靠近镜头。
|
||||
var FACE_NEAR = 0.26, FACE_FAR = 0.58; // 按缩放后的屏幕占比判断,不受横竖视频分辨率影响
|
||||
var CENTER_TOL = 0.18; // 人脸中心偏离取景中线超过屏宽 18% → 提示移入取景框
|
||||
var LUMA_DARK = 65, LUMA_BRIGHT = 235; // 脸部平均亮度(0–255):过暗/过曝提示调整光线
|
||||
|
||||
var modelsPromise = null, modelsOk = false, detectDisabled = false; // detectDisabled:face-api 缺失→全程手动;模型加载失败仅当次禁用,下次进拍照页重试
|
||||
var detRunning = false, detTimer = null; // 检测循环
|
||||
var detEpoch = 0; // 启停/切换镜头时作废上一轮未完成的异步检测
|
||||
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
|
||||
var counting = false, cdTimer = null, cdN = 0; // 倒计时
|
||||
var cdDeferCancel = null; // 倒计时等播报收尾的取消句柄(afterCurrent)
|
||||
var nudgeTimer = null; // 12s 无进展提示
|
||||
var capturing = false; // 白屏补光抓帧中(暂停姿态判定)
|
||||
var lastHint = ''; // 提示文案去重
|
||||
@@ -461,6 +568,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,146 +595,148 @@
|
||||
}
|
||||
function hideNudge() { $('#cam-nudge').classList.remove('show'); }
|
||||
|
||||
// 每步进入/回退时的检测状态机重置(蒙版/提示/倒计时/无进展计时全部归位)
|
||||
// 只有 mock 调试显示快门,正式页面在异常时提供重试入口。
|
||||
function isManualMode() { return IS_MOCK; } // 手动快门只用于 mock 调试,正式流程始终自动捕捉
|
||||
function syncCamMode() {
|
||||
$('#s-cam').classList.toggle('cam-manual', isManualMode());
|
||||
$('#cam-mask').classList.toggle('scanning', camLive && !isManualMode() && modelsOk && !detectDisabled);
|
||||
$('[data-action="retry-camera"]').hidden = IS_MOCK || (!camFallback && !detectDisabled);
|
||||
}
|
||||
|
||||
// 每步进入/回退时的检测状态机重置(蒙版/提示/无进展计时全部归位)
|
||||
function resetDetectState() {
|
||||
okSince = 0;
|
||||
missFrames = 0;
|
||||
oppFrames = 0;
|
||||
lastHint = '';
|
||||
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() || camFallback || detectDisabled) 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;
|
||||
detTick();
|
||||
syncCamMode();
|
||||
detTick(++detEpoch);
|
||||
}
|
||||
function stopDetect() {
|
||||
detRunning = false;
|
||||
detEpoch++;
|
||||
okSince = 0;
|
||||
clearTimeout(detTimer);
|
||||
cancelCountdown();
|
||||
clearTimeout(nudgeTimer);
|
||||
hideNudge();
|
||||
}
|
||||
// 递归 setTimeout 而非 setInterval:检测是异步的,避免帧与帧堆叠
|
||||
function detTick() {
|
||||
if (!detRunning) return;
|
||||
function detTick(epoch) {
|
||||
if (!detRunning || epoch !== detEpoch) return;
|
||||
detTimer = setTimeout(function () {
|
||||
if (!detRunning) return;
|
||||
detectFrame().then(detTick, detTick);
|
||||
if (!detRunning || epoch !== detEpoch) return;
|
||||
detectFrame(epoch).then(function () { detTick(epoch); }, function () { detTick(epoch); });
|
||||
}, DETECT_INTERVAL);
|
||||
}
|
||||
function detectFrame() {
|
||||
function detectFrame(epoch) {
|
||||
if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态
|
||||
var step = camStep, seq = camSeq; // 快照:检测是异步的,帧落地时步骤/流程可能已变
|
||||
return faceapi
|
||||
.detectSingleFace(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
|
||||
.detectAllFaces(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
|
||||
.withFaceLandmarks()
|
||||
.then(function (d) {
|
||||
if (step !== camStep || seq !== camSeq) return; // 跨步骤陈帧/流程已重置:丢弃
|
||||
if (detRunning && !shutterLock && !capturing) onDetect(d);
|
||||
.then(function (faces) {
|
||||
if (step !== camStep || seq !== camSeq || epoch !== detEpoch) return;
|
||||
if (detRunning && !shutterLock && !capturing) onDetect(faces);
|
||||
})
|
||||
.catch(function () { /* 单帧异常忽略,下一帧继续 */ });
|
||||
.catch(function () { if (epoch === detEpoch) okSince = 0; });
|
||||
}
|
||||
// 人脸框中心相对取景中线的水平偏移(占屏宽比例);按 object-fit:cover + 预览缩放换算到屏幕,镜像只影响符号
|
||||
function faceOffsetX(box) {
|
||||
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;
|
||||
}
|
||||
function displayedFaceRatio(box) {
|
||||
var vw = camVideo.videoWidth, vh = camVideo.videoHeight, W = camVideo.clientWidth, H = camVideo.clientHeight;
|
||||
if (!vw || !vh || !W || !H) return 0;
|
||||
return box.height * Math.max(W / vw, H / vh) * PREVIEW_SCALE / H;
|
||||
}
|
||||
function faceOffsetY(box) {
|
||||
var vw = camVideo.videoWidth, vh = camVideo.videoHeight, W = camVideo.clientWidth, H = camVideo.clientHeight;
|
||||
if (!vw || !vh || !W || !H) return 1;
|
||||
var s = Math.max(W / vw, H / vh) * PREVIEW_SCALE;
|
||||
var maskScale = Math.min(W / 375, H / 726);
|
||||
var targetY = (H - 726 * maskScale) / 2 + 320 * maskScale;
|
||||
return (H / 2 + (box.y + box.height / 2 - vh / 2) * s - targetY) / H;
|
||||
}
|
||||
function lightingHint(luma) {
|
||||
if (luma == null) return '画面读取失败,请重试';
|
||||
if (luma < LUMA_DARK) return '请到光线充足处';
|
||||
if (luma > LUMA_BRIGHT) return '光线过强,请避开强光';
|
||||
return '';
|
||||
}
|
||||
// 脸部平均亮度:人脸框缩到 16×16 取均值
|
||||
var lumaCanvas = null;
|
||||
function faceLuma(box, source) {
|
||||
try {
|
||||
if (!lumaCanvas) { lumaCanvas = document.createElement('canvas'); lumaCanvas.width = lumaCanvas.height = 16; }
|
||||
var c = lumaCanvas.getContext('2d', { willReadFrequently: true });
|
||||
c.drawImage(source || camVideo, box.x, box.y, box.width, box.height, 0, 0, 16, 16);
|
||||
var px = c.getImageData(0, 0, 16, 16).data, sum = 0;
|
||||
for (var i = 0; i < px.length; i += 4) sum += 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
|
||||
return sum / (px.length / 4);
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
var missFrames = 0; // 连续漏检帧计数:单帧抖动不报「未检测到人脸」
|
||||
function onDetect(d) {
|
||||
function onDetect(faces) {
|
||||
if (faces.length > 1) {
|
||||
okSince = 0; setMask(''); setHint('请保持单人出镜', false); return;
|
||||
}
|
||||
var d = faces[0];
|
||||
var hint = '', ok = false;
|
||||
missFrames = d ? 0 : missFrames + 1;
|
||||
var hasFace = !!d, noFace = !hasFace && missFrames >= MISS_FRAMES_MAX;
|
||||
if (noFace) {
|
||||
hint = '未检测到人脸';
|
||||
} else if (!hasFace) {
|
||||
return; // 偶发漏检帧:保持上一条提示与状态,不扰动倒计时/ok 累积
|
||||
okSince = 0; // 文案抗抖,但任何漏检都中断连续合格时长
|
||||
return;
|
||||
} else {
|
||||
// 提示优先级:无人脸 > 距离 > 角度
|
||||
var ratio = d.detection.box.height / (camVideo.videoHeight || 1);
|
||||
// 提示优先级:无人脸 > 距离 > 居中 > 角度 > 光线
|
||||
var box = d.detection.box;
|
||||
var ratio = displayedFaceRatio(box);
|
||||
if (ratio < FACE_NEAR) hint = '请靠近一点';
|
||||
else if (ratio > FACE_FAR) hint = '请远一点';
|
||||
else if (Math.abs(faceOffsetX(box)) > CENTER_TOL || Math.abs(faceOffsetY(box)) > 0.16) hint = '请将脸部移入取景框';
|
||||
else {
|
||||
// 距离不合格时角度不判 ok。特征点取自原始帧(预览的 CSS 镜像不影响),侧脸步语义方向按原始视角判定:
|
||||
// 标准机型恒为 false,个别前置帧本身已镜像的机型由自校准翻转 sideYawFlip;正脸步只看 |yaw|,与符号无关
|
||||
// 按摄像头原始帧判断真实左右;持续转错方向也不能自动交换左右照片。
|
||||
var tgt = CAM_STEPS[camStep].target;
|
||||
var pose = HeadPose.check(d.landmarks.positions, tgt, tgt === 'front' ? camMirrored : sideYawFlip);
|
||||
// 侧脸自校准:个别机型前置帧本身已镜像(语义 yaw 与实际转向持续相反)时自救——
|
||||
// 需连续 30 帧(~10s)强反向才翻转一次:阈值要高,否则用户转错方向 1~2s 就会被误判为镜像机,
|
||||
// 反而把错误的侧脸当合格抓走(左右图归属错乱)。翻转只动判定方向,不动预览画面
|
||||
if (tgt !== 'front' && pose.yaw != null && !mirrorCalibrated) {
|
||||
var wantSign = tgt === 'right' ? 1 : -1;
|
||||
if (pose.yaw * wantSign < -12) {
|
||||
if (++oppFrames >= 30) {
|
||||
sideYawFlip = !sideYawFlip;
|
||||
mirrorCalibrated = true;
|
||||
oppFrames = 0;
|
||||
}
|
||||
} else oppFrames = 0;
|
||||
var pose = HeadPose.check(d.landmarks.positions, tgt, false);
|
||||
if (!pose.ok) hint = pose.hint;
|
||||
else {
|
||||
hint = lightingHint(faceLuma(box));
|
||||
ok = !hint;
|
||||
}
|
||||
hint = pose.hint;
|
||||
ok = pose.ok;
|
||||
}
|
||||
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 +744,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 +761,29 @@
|
||||
$('#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); // 蒙版按步骤切换轮廓
|
||||
document.querySelectorAll('[data-capture-step]').forEach(function (el, i) {
|
||||
el.classList.toggle('done', i < camStep);
|
||||
el.classList.toggle('current', i === camStep);
|
||||
if (i === camStep) el.setAttribute('aria-current', 'step'); else el.removeAttribute('aria-current');
|
||||
});
|
||||
applyMirror();
|
||||
resetDetectState();
|
||||
voice.play(VOICE.step[camStep]); // 步骤引导语音
|
||||
}
|
||||
|
||||
var camSeq = 0; // 流程令牌:cam-back/开始拍照时递增,使未决的快门/分析回调失效
|
||||
var camSeq = 0; // 流程令牌:退出拍照/开始拍照时递增,使未决的快门/分析回调失效
|
||||
var shutterLock = false; // 快门防连点锁:流程进行中直接忽略
|
||||
|
||||
// 照片就绪后推进流程(实时抓帧与降级选图共用):存图→步骤点变绿→300ms 过渡→下一步
|
||||
// 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步
|
||||
function onPhotoCaptured(dataUrl) {
|
||||
shutterLock = true; // 推进窗口上锁:降级模式 change 里已解锁,这里重新锁住直到下一步/送检
|
||||
if (currentScreen !== 's-cam' || !dataUrl || photos.length !== camStep) return;
|
||||
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(); // 内含检测状态机重置
|
||||
@@ -677,29 +794,26 @@
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 白屏补光抓拍:全屏纯白瞬间点亮→约300ms 后抓全分辨率帧→保持300ms 再淡出(总 600–900ms)
|
||||
// 合格即抓取当前帧,随后闪白反馈;不在延时期间抓取已偏离角度的照片。
|
||||
function doCapture() {
|
||||
if (shutterLock) return;
|
||||
if (!camLive) return;
|
||||
var shot = captureFromVideo();
|
||||
if (!shot) { okSince = 0; return; }
|
||||
shutterLock = true;
|
||||
capturing = true;
|
||||
cancelCountdown();
|
||||
clearTimeout(nudgeTimer);
|
||||
hideNudge();
|
||||
var seq = camSeq;
|
||||
var seq = camSeq, camera = camSeqCam;
|
||||
var fill = $('#cam-fill');
|
||||
fill.classList.add('on'); // .on 无过渡,瞬间点亮
|
||||
if (navigator.vibrate) navigator.vibrate(30);
|
||||
setTimeout(function () {
|
||||
var shot = captureFromVideo();
|
||||
setTimeout(function () {
|
||||
fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出
|
||||
capturing = false;
|
||||
if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出)
|
||||
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即重启倒计时
|
||||
onPhotoCaptured(shot);
|
||||
}, 300);
|
||||
}, 300);
|
||||
if (seq !== camSeq || camera !== camSeqCam) return;
|
||||
fill.classList.remove('on');
|
||||
capturing = false;
|
||||
onPhotoCaptured(shot);
|
||||
}, 180);
|
||||
}
|
||||
|
||||
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
|
||||
@@ -729,6 +843,7 @@
|
||||
voice.play(VOICE.fail, { cut: true });
|
||||
toast(errMsg);
|
||||
showScreen('s-profile'); // 回档案页,允许重新提交再拍
|
||||
popHistorySilently();
|
||||
return;
|
||||
}
|
||||
var vm = null;
|
||||
@@ -741,21 +856,24 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function shutter() {
|
||||
if (!IS_MOCK) return;
|
||||
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 +946,16 @@
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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 +1144,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>'
|
||||
@@ -1089,26 +1216,29 @@
|
||||
: '';
|
||||
}
|
||||
|
||||
/* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */
|
||||
/* ─── 相机可用后加载检测模型,完成前等待,失败可重试 ─── */
|
||||
function beginDetect() {
|
||||
var camera = camSeqCam;
|
||||
if (IS_MOCK || camFallback) { resetDetectState(); return; }
|
||||
if (typeof faceapi === 'undefined') {
|
||||
detectDisabled = true; // face-api 本身缺失:全程手动,不再重试
|
||||
detectDisabled = true;
|
||||
resetDetectState();
|
||||
toast('自动识别不可用,已切换手动拍摄');
|
||||
toast('自动识别暂不可用,请重试');
|
||||
return;
|
||||
}
|
||||
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
|
||||
resetDetectState();
|
||||
if (!modelsOk) $('#cam-status').classList.add('show'); // “检测引擎加载中…”
|
||||
if (!modelsOk) showCamStatus('检测引擎加载中…');
|
||||
loadModels().then(function () {
|
||||
if (camera !== camSeqCam || currentScreen !== 's-cam') return;
|
||||
$('#cam-status').classList.remove('show');
|
||||
maybeStartDetect();
|
||||
}).catch(function () {
|
||||
if (camera !== camSeqCam || currentScreen !== 's-cam') return;
|
||||
$('#cam-status').classList.remove('show');
|
||||
detectDisabled = true; // 仅禁用当次;下次进拍照页 beginDetect 复位后重试
|
||||
resetDetectState();
|
||||
if (currentScreen === 's-cam') toast('自动识别不可用,已切换手动拍摄');
|
||||
if (currentScreen === 's-cam') toast('自动识别暂不可用,请重试');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1128,16 +1258,41 @@
|
||||
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;点检测图放大,再点返回 ─── */
|
||||
var zoomSource = null;
|
||||
function openZoom(img) {
|
||||
if (!img || !img.src || img.hidden) return;
|
||||
zoomSource = img;
|
||||
$('#zoom-img').src = img.src;
|
||||
$('#zoom-img').alt = img.alt;
|
||||
openOverlay('p-zoom');
|
||||
history.pushState({ screen: currentScreen, overlay: 'p-zoom' }, '');
|
||||
$('.zoom-close').focus();
|
||||
}
|
||||
function closeZoom() {
|
||||
closeOverlay('p-zoom');
|
||||
if (zoomSource) zoomSource.focus({ preventScroll: true });
|
||||
if (history.state && history.state.overlay === 'p-zoom') popHistorySilently();
|
||||
}
|
||||
document.querySelectorAll('.ditem-img').forEach(function (img) {
|
||||
img.tabIndex = 0;
|
||||
img.setAttribute('role', 'button');
|
||||
img.setAttribute('aria-label', img.alt + ',点击放大');
|
||||
img.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openZoom(img); }
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.card-detail').forEach(function (card) {
|
||||
card.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.dtab');
|
||||
if (btn) { setDetailTab(card, btn.dataset.tab); return; }
|
||||
var img = e.target.closest('.ditem-img');
|
||||
if (img) openZoom(img);
|
||||
});
|
||||
});
|
||||
$('#p-zoom').addEventListener('click', function () {
|
||||
closeZoom();
|
||||
});
|
||||
|
||||
/* ─── 动作分发 ─── */
|
||||
var actions = {
|
||||
@@ -1147,12 +1302,12 @@
|
||||
'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; }
|
||||
refreshAge();
|
||||
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,30 +1319,29 @@
|
||||
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 () {
|
||||
if (shutterLock || currentScreen !== 's-cam') return;
|
||||
okSince = 0;
|
||||
shutterLock = true; // 与降级快门同策略:前置上锁,change/cancel 解锁
|
||||
albumFile.click();
|
||||
},
|
||||
'flip': function () { // 切换前后镜头
|
||||
if (shutterLock || currentScreen !== 's-cam') return;
|
||||
if (camFallback) { toast('系统相机模式下请在系统相机内切换'); return; }
|
||||
if (!camStream) { toast('相机启动中,请稍候'); return; }
|
||||
camFacing = camFacing === 'user' ? 'environment' : 'user';
|
||||
camMirrored = camFacing === 'user';
|
||||
startCamera(); // 复用启停与预热(camSeqCam 防竞态)
|
||||
},
|
||||
'back-profile': function () { showScreen('s-profile'); },
|
||||
'retry-camera': function () {
|
||||
if (shutterLock || currentScreen !== 's-cam') return;
|
||||
startCamera();
|
||||
},
|
||||
'history': function () { openHistory(); },
|
||||
'close-history': function () { closeOverlay('p-history'); },
|
||||
'clear-history': function () {
|
||||
@@ -1280,7 +1434,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 +1463,7 @@
|
||||
});
|
||||
|
||||
/* ─── 初始化 ─── */
|
||||
history.replaceState({ screen: 's-splash' }, ''); // 刷新后从启动页开始,历史栈的屏标记与之对齐
|
||||
renderCamStep();
|
||||
// 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口)
|
||||
// 有 MOCK_FIXTURE 时走 fixture(检测可视化同步出图),否则回退 SkinScore 内置样本
|
||||
|
||||
@@ -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 + ' 项自测失败');
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/* 省份 → 城市(地级)数据。来源:china-division@2.7.0 dist/pc.json(modood/Administrative-divisions-of-China,WTFPL);
|
||||
直辖市第二列只保留本市,另补港澳台与海外。 */
|
||||
window.REGION_DATA = [
|
||||
["北京市", ["北京市"]],
|
||||
["天津市", ["天津市"]],
|
||||
["河北省", ["石家庄市","唐山市","秦皇岛市","邯郸市","邢台市","保定市","张家口市","承德市","沧州市","廊坊市","衡水市"]],
|
||||
["山西省", ["太原市","大同市","阳泉市","长治市","晋城市","朔州市","晋中市","运城市","忻州市","临汾市","吕梁市"]],
|
||||
["内蒙古自治区", ["呼和浩特市","包头市","乌海市","赤峰市","通辽市","鄂尔多斯市","呼伦贝尔市","巴彦淖尔市","乌兰察布市","兴安盟","锡林郭勒盟","阿拉善盟"]],
|
||||
["辽宁省", ["沈阳市","大连市","鞍山市","抚顺市","本溪市","丹东市","锦州市","营口市","阜新市","辽阳市","盘锦市","铁岭市","朝阳市","葫芦岛市"]],
|
||||
["吉林省", ["长春市","吉林市","四平市","辽源市","通化市","白山市","松原市","白城市","延边朝鲜族自治州"]],
|
||||
["黑龙江省", ["哈尔滨市","齐齐哈尔市","鸡西市","鹤岗市","双鸭山市","大庆市","伊春市","佳木斯市","七台河市","牡丹江市","黑河市","绥化市","大兴安岭地区"]],
|
||||
["上海市", ["上海市"]],
|
||||
["江苏省", ["南京市","无锡市","徐州市","常州市","苏州市","南通市","连云港市","淮安市","盐城市","扬州市","镇江市","泰州市","宿迁市"]],
|
||||
["浙江省", ["杭州市","宁波市","温州市","嘉兴市","湖州市","绍兴市","金华市","衢州市","舟山市","台州市","丽水市"]],
|
||||
["安徽省", ["合肥市","芜湖市","蚌埠市","淮南市","马鞍山市","淮北市","铜陵市","安庆市","黄山市","滁州市","阜阳市","宿州市","六安市","亳州市","池州市","宣城市"]],
|
||||
["福建省", ["福州市","厦门市","莆田市","三明市","泉州市","漳州市","南平市","龙岩市","宁德市"]],
|
||||
["江西省", ["南昌市","景德镇市","萍乡市","九江市","新余市","鹰潭市","赣州市","吉安市","宜春市","抚州市","上饶市"]],
|
||||
["山东省", ["济南市","青岛市","淄博市","枣庄市","东营市","烟台市","潍坊市","济宁市","泰安市","威海市","日照市","临沂市","德州市","聊城市","滨州市","菏泽市"]],
|
||||
["河南省", ["郑州市","开封市","洛阳市","平顶山市","安阳市","鹤壁市","新乡市","焦作市","濮阳市","许昌市","漯河市","三门峡市","南阳市","商丘市","信阳市","周口市","驻马店市","济源市"]],
|
||||
["湖北省", ["武汉市","黄石市","十堰市","宜昌市","襄阳市","鄂州市","荆门市","孝感市","荆州市","黄冈市","咸宁市","随州市","恩施土家族苗族自治州","仙桃市","潜江市","天门市","神农架林区"]],
|
||||
["湖南省", ["长沙市","株洲市","湘潭市","衡阳市","邵阳市","岳阳市","常德市","张家界市","益阳市","郴州市","永州市","怀化市","娄底市","湘西土家族苗族自治州"]],
|
||||
["广东省", ["广州市","韶关市","深圳市","珠海市","汕头市","佛山市","江门市","湛江市","茂名市","肇庆市","惠州市","梅州市","汕尾市","河源市","阳江市","清远市","东莞市","中山市","潮州市","揭阳市","云浮市"]],
|
||||
["广西壮族自治区", ["南宁市","柳州市","桂林市","梧州市","北海市","防城港市","钦州市","贵港市","玉林市","百色市","贺州市","河池市","来宾市","崇左市"]],
|
||||
["海南省", ["海口市","三亚市","三沙市","儋州市","五指山市","琼海市","文昌市","万宁市","东方市","定安县","屯昌县","澄迈县","临高县","白沙黎族自治县","昌江黎族自治县","乐东黎族自治县","陵水黎族自治县","保亭黎族苗族自治县","琼中黎族苗族自治县"]],
|
||||
["重庆市", ["重庆市"]],
|
||||
["四川省", ["成都市","自贡市","攀枝花市","泸州市","德阳市","绵阳市","广元市","遂宁市","内江市","乐山市","南充市","眉山市","宜宾市","广安市","达州市","雅安市","巴中市","资阳市","阿坝藏族羌族自治州","甘孜藏族自治州","凉山彝族自治州"]],
|
||||
["贵州省", ["贵阳市","六盘水市","遵义市","安顺市","毕节市","铜仁市","黔西南布依族苗族自治州","黔东南苗族侗族自治州","黔南布依族苗族自治州"]],
|
||||
["云南省", ["昆明市","曲靖市","玉溪市","保山市","昭通市","丽江市","普洱市","临沧市","楚雄彝族自治州","红河哈尼族彝族自治州","文山壮族苗族自治州","西双版纳傣族自治州","大理白族自治州","德宏傣族景颇族自治州","怒江傈僳族自治州","迪庆藏族自治州"]],
|
||||
["西藏自治区", ["拉萨市","日喀则市","昌都市","林芝市","山南市","那曲市","阿里地区"]],
|
||||
["陕西省", ["西安市","铜川市","宝鸡市","咸阳市","渭南市","延安市","汉中市","榆林市","安康市","商洛市"]],
|
||||
["甘肃省", ["兰州市","嘉峪关市","金昌市","白银市","天水市","武威市","张掖市","平凉市","酒泉市","庆阳市","定西市","陇南市","临夏回族自治州","甘南藏族自治州"]],
|
||||
["青海省", ["西宁市","海东市","海北藏族自治州","黄南藏族自治州","海南藏族自治州","果洛藏族自治州","玉树藏族自治州","海西蒙古族藏族自治州"]],
|
||||
["宁夏回族自治区", ["银川市","石嘴山市","吴忠市","固原市","中卫市"]],
|
||||
["新疆维吾尔自治区", ["乌鲁木齐市","克拉玛依市","吐鲁番市","哈密市","昌吉回族自治州","博尔塔拉蒙古自治州","巴音郭楞蒙古自治州","阿克苏地区","克孜勒苏柯尔克孜自治州","喀什地区","和田地区","伊犁哈萨克自治州","塔城地区","阿勒泰地区","石河子市","阿拉尔市","图木舒克市","五家渠市","北屯市","铁门关市","双河市","可克达拉市","昆玉市","胡杨河市","新星市","白杨市"]],
|
||||
["香港特别行政区", ["香港特别行政区"]],
|
||||
["澳门特别行政区", ["澳门特别行政区"]],
|
||||
["台湾省", ["台湾省"]],
|
||||
["海外", ["海外"]]
|
||||
];
|
||||
@@ -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
|
||||
@@ -15,7 +15,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = Number(process.env.PORT) || 8080;
|
||||
// 只绑回环:公网入口是 nginx(juzi.xiangsilian.com),8080 不再对公网裸奔;需局域网调试时 HOST=0.0.0.0
|
||||
// 只绑回环:公网入口是 nginx(1me.xajuzi.com/state/),8080 不再对公网裸奔;需局域网调试时 HOST=0.0.0.0
|
||||
const HOST = process.env.HOST || '127.0.0.1';
|
||||
const ROOT = __dirname;
|
||||
const FACEPP_URL = 'https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_pro';
|
||||
@@ -27,7 +27,7 @@ const RECORDS_FILE = path.join(DATA_ROOT, 'records.jsonl'); // 检测记录(一
|
||||
/* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */
|
||||
const SkinScore = require('./js/skin-score.js');
|
||||
|
||||
/* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env ─── */
|
||||
/* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env / key.env ─── */
|
||||
function loadCredentials() {
|
||||
if (process.env.SKIN_API_KEY && process.env.SKIN_API_SECRET) {
|
||||
return { key: process.env.SKIN_API_KEY, secret: process.env.SKIN_API_SECRET };
|
||||
@@ -38,15 +38,21 @@ function loadCredentials() {
|
||||
const secret = (txt.match(/SKIN_API_SECRET\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
|
||||
if (key && secret) return { key, secret };
|
||||
} catch (e) { /* 文件不存在则继续下一级兜底 */ }
|
||||
try {
|
||||
const txt = fs.readFileSync(path.join(ROOT, '..', 'reference', 'facepp-skinanalyze', 'credentials.env'), 'utf8');
|
||||
const key = (txt.match(/FACEPP_API_KEY\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
|
||||
const secret = (txt.match(/FACEPP_API_SECRET\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
|
||||
if (key && secret) return { key, secret };
|
||||
} catch (e) { /* 文件不存在则走下面的报错 */ }
|
||||
for (const credentialFile of ['credentials.env', 'key.env']) {
|
||||
try {
|
||||
const txt = fs.readFileSync(path.join(ROOT, '..', 'reference', 'facepp-skinanalyze', credentialFile), 'utf8');
|
||||
const key = (txt.match(/FACEPP_API_KEY\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
|
||||
const secret = (txt.match(/FACEPP_API_SECRET\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
|
||||
if (key && secret) return { key, secret };
|
||||
} catch (e) { /* 文件不存在则尝试下一个配置文件 */ }
|
||||
}
|
||||
if (process.env.LOCAL_PREVIEW === '1') {
|
||||
console.warn('[本机预览] 未配置 Face++ 密钥,真实检测不可用;演示入口: /?mock=1');
|
||||
return null;
|
||||
}
|
||||
console.error('[启动失败] 找不到密钥:请设置 SKIN_API_KEY / SKIN_API_SECRET 环境变量,');
|
||||
console.error(' 或在项目根目录放置 apikey.bash(内容为 export SKIN_API_KEY=... 等),');
|
||||
console.error(' 或在 reference/facepp-skinanalyze/credentials.env 放置 FACEPP_API_KEY=... 等');
|
||||
console.error(' 或在 reference/facepp-skinanalyze/credentials.env 或 key.env 放置 FACEPP_API_KEY=... 等');
|
||||
process.exit(1);
|
||||
}
|
||||
const CRED = loadCredentials();
|
||||
@@ -306,6 +312,7 @@ async function runDetect(rec, front, left, right) {
|
||||
/* 调用 Face++ 专业版三视图测肤;
|
||||
* 接口超时/网络错误(fetch 抛错)、HTTP 5xx、CONCURRENCY_LIMIT_EXCEEDED → 等 4s 重试 1 次(V2.0 第七章) */
|
||||
async function callFacepp(front, left, right) {
|
||||
if (!CRED) return { error_message: 'NO_API_CREDENTIALS' };
|
||||
const form = new FormData();
|
||||
form.append('api_key', CRED.key);
|
||||
form.append('api_secret', CRED.secret);
|
||||
@@ -754,6 +761,10 @@ async function handleAdmin(req, res, pathname, url) {
|
||||
|
||||
http.createServer((req, res) => {
|
||||
const pathname = req.url.split('?')[0];
|
||||
if (!CRED && req.method === 'POST' && (pathname === '/api/skin-analyze' || /^\/api\/admin\/records\/[\w-]+\/retry$/.test(pathname))) {
|
||||
req.resume();
|
||||
return sendJson(res, 503, { ok: false, code: 'NO_API_CREDENTIALS', message: '本机尚未配置 Face++ 密钥,请先配置密钥或使用演示模式(?mock=1)' });
|
||||
}
|
||||
if (req.method === 'POST' && pathname === '/api/skin-analyze') return handleAnalyze(req, res);
|
||||
if (req.method === 'GET' && pathname === '/api/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
|
||||
@@ -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}"
|
||||
@@ -2,15 +2,20 @@
|
||||
* 无头 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
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const pwRoot = execSync('npm root -g', { encoding: 'utf8' }).trim();
|
||||
const { chromium } = await import(pathToFileURL(pwRoot + '/playwright/index.mjs').href);
|
||||
const pwModule = process.env.PLAYWRIGHT_MODULE || path.join(execSync('npm root -g', { encoding: 'utf8' }).trim(), 'playwright/index.mjs');
|
||||
const { chromium } = await import(pathToFileURL(pwModule).href);
|
||||
const outputDir = process.env.E2E_OUTPUT_DIR || os.tmpdir();
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080';
|
||||
const EXPECTED = [
|
||||
@@ -119,10 +124,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 +143,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) => {
|
||||
@@ -169,12 +181,12 @@ try {
|
||||
check('流程语音无截断', cut.length === 0, cut.map((v) => `${v.id}@${v.cutAt}/${v.dur}`).join(','));
|
||||
check('页面无脚本异常', pageErrors.length === 0, pageErrors.join(' | '));
|
||||
|
||||
await page.screenshot({ path: '/tmp/juzi-e2e-report.png', fullPage: true });
|
||||
console.log('截图 /tmp/juzi-e2e-report.png');
|
||||
await page.screenshot({ path: path.join(outputDir, 'juzi-e2e-report.png'), fullPage: true });
|
||||
console.log('截图 ' + path.join(outputDir, 'juzi-e2e-report.png'));
|
||||
} catch (err) {
|
||||
console.error('RUN ERROR', err);
|
||||
fails.push(String(err && err.message || err));
|
||||
try { await page.screenshot({ path: '/tmp/juzi-e2e-fail.png', fullPage: true }); } catch (e) {}
|
||||
try { await page.screenshot({ path: path.join(outputDir, 'juzi-e2e-fail.png'), fullPage: true }); } catch (e) {}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/** UI 反馈验收:真实交互 + 可控检测输入;拦截分析请求,不调用 Face++。 */
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const modulePath = process.env.PLAYWRIGHT_MODULE || path.join(execSync('npm root -g', { encoding: 'utf8' }).trim(), 'playwright/index.mjs');
|
||||
const { chromium } = await import(pathToFileURL(modulePath).href);
|
||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080';
|
||||
const OUT = process.env.E2E_OUTPUT_DIR || path.join(os.tmpdir(), 'juzi-feedback');
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true, args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
|
||||
const context = await browser.newContext({ viewport: { width: 375, height: 812 }, permissions: ['camera'], reducedMotion: 'reduce' });
|
||||
let page;
|
||||
const errors = [], requests = [];
|
||||
const pass = name => console.log('PASS ' + name);
|
||||
|
||||
// 只替换检测输入和亮度采样;实际 HeadPose、质量判断、相机抓帧、步骤锁与送检仍运行产品代码。
|
||||
const detector = `
|
||||
window.scene = { pose: 'front', luma: 130, count: 1, ratio: .45, offX: 0, offY: 0, modelFail: false, pattern: false, calls: 0 };
|
||||
var origPixels = CanvasRenderingContext2D.prototype.getImageData;
|
||||
CanvasRenderingContext2D.prototype.getImageData = function(...args) {
|
||||
if (this.canvas.width !== 16 || this.canvas.height !== 16) return origPixels.apply(this,args);
|
||||
var a = new Uint8ClampedArray(16*16*4); a.fill(window.scene.luma); return { data: a };
|
||||
};
|
||||
window.faceapi = {
|
||||
nets: { tinyFaceDetector: { loadFromUri: () => scene.modelFail ? Promise.reject(new Error('offline')) : Promise.resolve() }, faceLandmark68Net: { loadFromUri: () => Promise.resolve() } },
|
||||
TinyFaceDetectorOptions: function() {},
|
||||
detectAllFaces: source => ({ withFaceLandmarks: () => {
|
||||
var s = {...scene}; scene.calls++;
|
||||
var w = source.videoWidth || source.naturalWidth, h = source.videoHeight || source.naturalHeight;
|
||||
var pts = Array.from({length:68},()=>({x:200,y:150}));
|
||||
pts[36]={x:150,y:100}; pts[45]={x:250,y:100}; pts[19]={x:170,y:70}; pts[24]={x:230,y:70}; pts[8]={x:200,y:240};
|
||||
pts[30]={x: s.pose==='right'?217.7:s.pose==='left'?182.3:200,y:140};
|
||||
pts[33]={x:200,y: s.pose==='down'?100:s.pose==='up'?200:161.5};
|
||||
var box={x:w*(.5+s.offX)-h*s.ratio/2,y:h*(.5+s.offY-s.ratio/2),width:h*s.ratio,height:h*s.ratio};
|
||||
var count=s.pattern && scene.calls%3===0 ? 0 : s.count;
|
||||
var result=Array.from({length:count},()=>({detection:{box},landmarks:{positions:pts}}));
|
||||
return new Promise(resolve=>setTimeout(()=>resolve(result),s.delay||0));
|
||||
} })
|
||||
};`;
|
||||
await context.route('**/vendor/faceapi/face-api.js', route => route.fulfill({ contentType: 'application/javascript', body: detector }));
|
||||
const fixture = JSON.parse(fs.readFileSync('h5/test/fixtures/case1.json', 'utf8'));
|
||||
await context.route('**/api/skin-analyze', async route => {
|
||||
requests.push(route.request().postDataJSON());
|
||||
await route.fulfill({ json: { ok: true, result: fixture.result || fixture, side: {} } });
|
||||
});
|
||||
|
||||
async function chooseDefaults() {
|
||||
for (const id of ['birth', 'gender', 'region']) {
|
||||
await page.locator('#pick-' + id).click();
|
||||
await page.waitForTimeout(450);
|
||||
await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
|
||||
await page.waitForTimeout(450); // 插件内部关闭动画锁
|
||||
}
|
||||
}
|
||||
async function profile() {
|
||||
await page.goto(BASE + '/');
|
||||
await page.getByRole('button', { name: '开始检测', exact: true }).click();
|
||||
await page.locator('#privacy-check').click();
|
||||
await page.getByRole('button', { name: '继续', exact: true }).click();
|
||||
}
|
||||
async function start() {
|
||||
await chooseDefaults();
|
||||
await page.getByRole('button', { name: '提交', exact: true }).click();
|
||||
await page.getByRole('button', { name: '开始', exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelector('#cam-video').videoWidth > 0 && document.querySelector('#s-cam').classList.contains('cam-live'));
|
||||
}
|
||||
async function scene(values) { await page.evaluate(v => Object.assign(window.scene, v), values); }
|
||||
async function hint(text) { await page.waitForFunction(t => document.querySelector('#cam-hint').textContent === t, text); }
|
||||
async function step(n) { await page.waitForFunction(n => document.querySelector('#cam-step').textContent.startsWith(n + '/3'), n); }
|
||||
async function shot(name) { await page.screenshot({ path: path.join(OUT, name + '.png') }); }
|
||||
|
||||
try {
|
||||
page = await context.newPage();
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
await page.goto(BASE + '/');
|
||||
await shot('01-splash');
|
||||
await profile();
|
||||
assert.equal(await page.locator('.pick-value.placeholder').count(), 3);
|
||||
await chooseDefaults();
|
||||
const data = await page.evaluate(() => ({ birth: document.querySelector('#f-birth').value, age: +document.querySelector('#f-age').value, region: document.querySelector('#f-region').value }));
|
||||
assert.equal(data.age, 30);
|
||||
assert.equal(data.region, '北京市');
|
||||
assert.match(await page.locator('#profile-age').innerText(), /30岁/);
|
||||
assert.equal(await page.evaluate(() => document.body.getBoundingClientRect().top), 0);
|
||||
assert.ok(await page.locator('.page-title').evaluate(el => el.getBoundingClientRect().top >= 0));
|
||||
await shot('02-profile');
|
||||
pass('出生年月自动计算年龄、控件占位和省市默认值');
|
||||
|
||||
await page.locator('#pick-birth').click();
|
||||
await page.waitForTimeout(450);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const wheel = await page.locator('.ms-mobile-select.ms-show .ms-wheel').nth(1).boundingBox();
|
||||
await page.mouse.move(wheel.x + wheel.width / 2, wheel.y + wheel.height - 22);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(wheel.x + wheel.width / 2, wheel.y + 22, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
|
||||
await page.waitForTimeout(450);
|
||||
const december = await page.evaluate(() => ({ birth: document.querySelector('#f-birth').value, age: +document.querySelector('#f-age').value, month: new Date().getMonth() + 1 }));
|
||||
assert.ok(december.birth.endsWith('-12'));
|
||||
assert.equal(december.age, december.month < 12 ? 29 : 30);
|
||||
assert.ok(await page.locator('#pick-birth .pick-value').evaluate(el => el.scrollWidth <= el.clientWidth));
|
||||
await shot('02-profile');
|
||||
pass('滚动选择12月、未过生日月份减一岁,完整年月不被截断');
|
||||
|
||||
// 省份滚轮点击相邻可见项,市级选项必须跟随更新。
|
||||
await page.locator('#pick-region').click();
|
||||
await page.waitForTimeout(450);
|
||||
await page.locator('.ms-mobile-select.ms-show .ms-select-container').first().getByText('河北省', { exact: true }).click();
|
||||
await page.waitForTimeout(350);
|
||||
assert.ok((await page.locator('.ms-mobile-select.ms-show .ms-select-container').nth(1).innerText()).includes('石家庄市'));
|
||||
await shot('03-region');
|
||||
await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
|
||||
await page.waitForTimeout(450);
|
||||
assert.match(await page.locator('#pick-region').innerText(), /河北省.*石家庄市/);
|
||||
pass('省市联动滚轮实际选择');
|
||||
|
||||
await page.getByRole('button', { name: '提交', exact: true }).click();
|
||||
await page.locator('#p-photoguide .popup').waitFor({ state: 'visible' });
|
||||
await page.waitForTimeout(150);
|
||||
await shot('04-guide');
|
||||
assert.equal(await page.locator('#p-photoguide .profile-body').count(), 0);
|
||||
await scene({ luma: 20 });
|
||||
await page.getByRole('button', { name: '开始', exact: true }).click();
|
||||
await hint('请到光线充足处');
|
||||
assert.equal(await page.locator('.cam-shutter').isVisible(), false);
|
||||
await scene({ luma: 250 }); await hint('光线过强,请避开强光');
|
||||
await scene({ luma: 130, ratio: .2 }); await hint('请靠近一点');
|
||||
await scene({ ratio: .8 }); await hint('请远一点');
|
||||
await scene({ ratio: .45, offY: .45 }); await hint('请将脸部移入取景框');
|
||||
await scene({ offY: 0, count: 2 }); await hint('请保持单人出镜');
|
||||
await scene({ count: 1, pose: 'down' }); await hint('请略微抬头');
|
||||
await scene({ pose: 'up' }); await hint('请略微低头');
|
||||
await scene({ pose: 'front', pattern: true });
|
||||
await page.waitForTimeout(2200);
|
||||
assert.match(await page.locator('#cam-step').innerText(), /^1\/3/);
|
||||
assert.equal(requests.length, 0);
|
||||
pass('光线、距离、居中、多人、抬低头与丢帧均阻止误拍;正式页面无手动快门');
|
||||
|
||||
await scene({ pattern: false });
|
||||
await step(2);
|
||||
assert.equal(await page.locator('.cam-progress .done').count(), 1);
|
||||
await scene({ pose: 'left' }); await hint('请向左转头');
|
||||
// 持续错误侧脸超过旧版 30 帧自校准阈值,仍不允许交换左右。
|
||||
const calls = await page.evaluate(() => scene.calls);
|
||||
await page.waitForFunction(n => scene.calls > n + 32, calls);
|
||||
assert.match(await page.locator('#cam-step').innerText(), /^2\/3/);
|
||||
await shot('05-camera');
|
||||
await scene({ pose: 'right' }); await step(3);
|
||||
assert.equal(await page.locator('.cam-progress .done').count(), 2);
|
||||
await scene({ pose: 'left' });
|
||||
await page.locator('#s-report.active').waitFor();
|
||||
assert.equal(requests.length, 1);
|
||||
assert.ok(requests[0].front_image && requests[0].left_image && requests[0].right_image);
|
||||
pass('条件合格自动抓拍,正→右→左严格顺序,三张齐备后只送检一次');
|
||||
|
||||
await page.goto(BASE + '/?mock=1&screen=report');
|
||||
await page.waitForFunction(() => document.querySelector('#ditem-img1').naturalWidth > 0);
|
||||
await shot('06-report');
|
||||
await page.locator('.card-detail').first().scrollIntoViewIfNeeded();
|
||||
await shot('07-report-detail');
|
||||
await page.locator('#ditem-img1').click();
|
||||
await page.locator('#p-zoom.show').waitFor();
|
||||
await shot('08-zoom');
|
||||
await page.locator('#zoom-img').click();
|
||||
await page.waitForFunction(() => !history.state?.overlay);
|
||||
assert.equal(await page.locator('#p-zoom').isVisible(), false);
|
||||
await page.locator('#ditem-img1').press('Enter');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => !history.state?.overlay);
|
||||
assert.equal(await page.locator('#p-zoom').isVisible(), false);
|
||||
pass('图片点击/键盘放大,再点或 Escape 返回,历史栈正确');
|
||||
|
||||
for (const width of [320, 375, 430]) {
|
||||
await page.setViewportSize({ width, height: 812 });
|
||||
assert.ok(await page.evaluate(() => document.querySelector('#s-report').scrollWidth <= document.body.clientWidth));
|
||||
assert.ok(await page.locator('.ditem-para').first().evaluate(el => parseFloat(getComputedStyle(el).fontSize) >= 14));
|
||||
}
|
||||
pass('320/375/430px 报告无横向溢出,正文不小于14px');
|
||||
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await profile();
|
||||
await scene({ modelFail: true });
|
||||
await start();
|
||||
await page.locator('.cam-retry:visible').waitFor();
|
||||
assert.equal(await page.locator('.cam-shutter').isVisible(), false);
|
||||
await shot('09-retry');
|
||||
await scene({ modelFail: false, luma: 20 });
|
||||
await page.locator('.cam-retry').click();
|
||||
await hint('请到光线充足处');
|
||||
assert.equal(await page.locator('.cam-retry').isVisible(), false);
|
||||
pass('模型失败可重试,恢复后继续当前步且不会降级手动快门');
|
||||
|
||||
// 相册错误侧脸/光线不合格必须留在同一步;失败后仍可以再次选择。
|
||||
await scene({ pose: 'left', luma: 130 });
|
||||
const photo = path.resolve('reference/facepp-skinanalyze/examples/images/C3_180.jpg');
|
||||
await page.locator('#album-file').setInputFiles(photo);
|
||||
await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('请按顺序选择正脸'));
|
||||
assert.match(await page.locator('#cam-step').innerText(), /^1\/3/);
|
||||
await scene({ pose: 'front', luma: 20 });
|
||||
await page.locator('#album-file').setInputFiles(photo);
|
||||
await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('光线充足'));
|
||||
assert.match(await page.locator('#cam-step').innerText(), /^1\/3/);
|
||||
pass('相册照片同样校验视角和光线,不合格不推进');
|
||||
|
||||
await page.close();
|
||||
page = await context.newPage();
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
await page.addInitScript(() => {
|
||||
const original = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
||||
let first = true;
|
||||
navigator.mediaDevices.getUserMedia = options => {
|
||||
if (first) { first = false; return Promise.reject(new DOMException('Denied', 'NotAllowedError')); }
|
||||
return original(options);
|
||||
};
|
||||
});
|
||||
await profile();
|
||||
await chooseDefaults();
|
||||
await scene({ luma: 20 });
|
||||
await page.getByRole('button', { name: '提交', exact: true }).click();
|
||||
await page.getByRole('button', { name: '开始', exact: true }).click();
|
||||
await page.locator('.cam-retry:visible').waitFor();
|
||||
assert.equal(await page.locator('.cam-shutter').isVisible(), false);
|
||||
await page.locator('.cam-retry').click();
|
||||
await hint('请到光线充足处');
|
||||
pass('相机拒绝授权后重试恢复,仍停留正脸步骤');
|
||||
|
||||
const native = await browser.newPage(); // 新 context 使用仓库实际 face-api 和模型
|
||||
await native.goto(BASE + '/?mock=1&screen=report');
|
||||
await native.locator('#ditem-img1').scrollIntoViewIfNeeded(); // lazy 图片须进入视口才会加载
|
||||
const faceCount = await native.evaluate(async () => {
|
||||
await Promise.all([faceapi.nets.tinyFaceDetector.loadFromUri('vendor/faceapi/models'), faceapi.nets.faceLandmark68Net.loadFromUri('vendor/faceapi/models')]);
|
||||
const img = document.querySelector('#ditem-img1');
|
||||
await img.decode();
|
||||
const result = await faceapi.detectAllFaces(img, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .3 })).withFaceLandmarks();
|
||||
return result.filter(d => d.landmarks.positions.length === 68).length;
|
||||
});
|
||||
assert.ok(faceCount > 0);
|
||||
await native.close();
|
||||
pass('实际本地模型可加载并返回人脸与68个特征点');
|
||||
assert.deepEqual(errors, []);
|
||||
pass('页面无脚本异常');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (page) await shot('FAIL');
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
# mobile-select 1.4.0
|
||||
|
||||
来源:npm `mobile-select@1.4.0`(MIT,见同目录 `LICENSE`),取 `dist/mobile-select.iife.min.js` 与 `dist/style/mobile-select.css`。
|
||||
档案页的出生年月 / 性别 / 地区滚轮由它渲染,样式覆盖写在 `css/style.css` 的 mobile-select 段。
|
||||
|
||||
本地补丁:点击滚轮行时,使用当前滚轮底部位置与实际 `optionHeight` 计算选中项,替代原版的 `window.innerHeight` 和固定 `40px`;保证 44px 行高、底部安全区和页面滚动时点击项与显示项一致。拖动与联动逻辑保持原版。
|
||||
@@ -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;
|
||||
}
|
||||