Compare commits

...
21 Commits
Author SHA1 Message Date
xsl 2a7593b736 save 2026-10-10 14:23:43 +08:00
root 658b232a39 save 2026-10-09 23:22:28 +08:00
Ubuntu f7902b86c8 save 2026-10-09 22:29:02 +08:00
xsl 55f26d91af 修改整理反馈需求 2026-10-09 22:12:33 +08:00
xsl 499be22771 第一版反馈 2026-10-09 21:08:12 +08:00
Ubuntu c06d15a2d4 chore+perf: 密钥出库 + 后台详情接口裁剪 face_maps
安全:
- reference/facepp-skinanalyze/credentials.env 内含 Face++ 正式 key/secret
  且被 git 跟踪,移出 index(文件保留本地,.gitignore 已覆盖 *.env)。
  ⚠️ 密钥已存在于 git 历史,若 origin 曾被 push 即视为泄漏,
  请在旷视控制台重置 API secret,改用环境变量/apikey.bash 注入。

性能(后台详情页卡顿):
- 详情接口将 raw_json.result.face_maps 的 base64 图层(单条约 3MB)
  替换为占位说明,响应 3.2MB→163KB;新增
  GET /api/admin/records/:id/raw 供下载完整原始 JSON(走 Basic Auth)
- admin 列表/详情图片加 loading=lazy + decoding=async
2026-09-28 22:46:08 +08:00
Ubuntu 3de730a127 save 2026-09-28 15:27:31 +08:00
xsl 3d32434d6f 添加后端文档 2026-09-28 10:49:09 +08:00
xslandCursor 67b6d86fce chore: 移除 graphify 日期快照目录
历史快照与根目录图重复,加入 ignore 防止再入库。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 09:05:25 +08:00
xslandCursor ecb4bb2515 chore: 将 graphify cache 移出版本库
cache 可本地重建,加入 .gitignore 避免再次误提交。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 09:04:27 +08:00
xsl b6ba6b65c5 save 2026-09-28 09:02:32 +08:00
xslandCursor 531a7ddd14 fix: 拍照页对齐 Figma(正→右→左/镜像预览/WebView 口径定位/85%遮罩) + 语音调度重写 + 正脸照不镜像
- 拍照顺序改为正脸→右脸→左脸(对齐 Figma),左右以用户本人为准;送检前按 target 重排为 [正,左,右]
- 三步预览均镜像(Figma 侧脸轮廓按镜像绘制),后置镜头侧脸轮廓自动翻转;抓帧恒为原始视角
- 蒙版 viewBox 取 Figma 帧 y86–812(扣除微信导航栏)+ meet 缩放,文案/引导/提示按 726 高定位;框外 85% 黑,静态态白底
- 语音:流程音排队不截断、姿态提示挂起补播、倒计时等播报收尾、拍摄完成打断过时引导、出报告/退出清队列;去重仅限姿态提示
- 语音文件改名 step2-rightface / step3-leftface,同步清单、生成脚本与设计文档
- 精华卡去重复正文、推荐依据写明具体原因;年龄仅数字;toast 按字数延时;备注去内部质量码
- 新增无头浏览器冒烟测试 h5/test/e2e-browser.mjs

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 02:22:37 +08:00
Ubuntu 4e3d915b7d feat: 拍摄流程对齐 Figma(正→左→右顺序/描摹轮廓蒙版/正脸只提示回正) + 报告页按 Figma 08 重排
- 拍照顺序 正脸→左脸→右脸:步骤文案/语音文件(step2-leftface/step3-rightface)/payload 归属对齐
- 蒙版轮廓由 Figma 拍照示意位图 alpha 镂空区逐像素描摹生成(0 正脸含耳/1 左脸鼻朝左/2 右脸鼻朝右),框外纯黑
- 侧脸步预览与抓帧不镜像(左右不再迷惑),HeadPose 侧脸自校准阈值 30 帧防误翻转
- 语音:「保持不动」播完再进倒计时(afterCurrent 接续,不再拦腰切断)
- 正脸步角度不合格只提示「请正对屏幕」,阈值放宽 |yaw|≤15°/|pitch|≤18°;转头提示仅侧脸步
- 抓帧改完整帧(修 Face++ INVALID_IMAGE_FACE)
- 报告页按 Figma 08:内联白底顶栏(返回+标题)、总分双独立白卡(30px 大数字+15px 单位后缀)、
  维度短竖分隔线+最低分标红、Tab 黄条、免责左对齐、护理建议去 tag 加粗+收尾句、
  产品大标题移出白卡、COREⅡ 换 Figma 导出 SVG 字标、产品图 151×174、卡圆角 10px
- 精华卡判据措辞用户化(均分档→综合均分/最差项约束→最低分维度/年龄组档→年龄段,引擎值不动)
- 修报告页滚动 P0(去 position:relative 覆写),文档同步(语音文档/README)
2026-09-28 00:57:28 +08:00
Ubuntu 0e00f3e938 feat: 真机反馈第9轮(浮层层级/侧脸镜像自校准/提示样式/Tab设计宽/去历史/档案顶栏/静态禁缓存)
1. 城市下拉浮层 z-index 999(实测本不重叠,仅 1px 相邻;用户端旧样式系缓存所致——server.js 静态 html/css/js 改为 no-cache,此后改动即时到端)
3. 侧脸步「浮层」改设计语言:去气泡底,纯白/纯绿文字+投影,置于引导 chips 上方;侧脸拍摄不通的根因(本机镜像策略差异)加「镜像自校准」:语义 yaw 连续 5 帧强反向即自动翻转镜像约定,姿态引导方向随设备自适应
4. 语音引导的侧脸照片方向与镜像约定联动(camMirrored 统一管预览/抓拍/姿态),自校准后语音方向与实际出片一致
5. 痘痘图标记:坐标随 Face++ 返回原样叠加;未检出时纯照片+话术「未检出痤疮与皮损」(详见回复)
6. Tab 条对齐设计稿:文字 11px/选中 700,黄色指示条恢复设计实测宽度 78px
7. 关注行:维度名加粗 700,两行收紧
8. 报告页「历史」入口移除(存储逻辑保留)
9. 档案页顶部白条移除,返回键改悬浮白圆
HeadPose.check 增加 yaw 返回供自校准;e2e 适配历史入口变更,全量绿
2026-09-27 11:55:20 +08:00
Ubuntu 6c634e3a66 feat: 真机反馈 11 项(下拉层级/侧脸蒙版/报告改版/护理建议 V2.3/水分图)
1. 城市下拉浮层 z-index 提至 50,不再被肤质轮播圆点压盖
2. 倒计时音频已更新(用户重生成,解码验证 2.62s)
3. 侧脸步蒙版鼻向修正:2/3 右脸鼻朝右、3/3 左脸鼻朝左(原与设计稿 06/07 相反)
4. 无进展提示上移至 bottom:24%(引导 chips 上方,不再压快门)
5. 相册选择器「拍照」项为 iOS/安卓系统行为,web 无法移除(详见回复)
6. 报告页顶栏移除,返回/历史改悬浮
7. 肌龄小字按产品要求不再展示(钳制留痕仍随数据返回)
8. 关注行对齐设计稿:前缀灰 + 维度名深色 500
9. 三维度排版对齐设计稿:去掉档位徽章,最低分标红
10. 肤色水润图改用 water_area(接口水分图),REPORT_MAPS 与 mock fixture 同步更新
11. 报告「综合分析」「生活方式建议」通栏移除,新增「护理建议」卡(V2.3 §8:肤质定制/最差2分类/精华/生活方式 五段,§8.5.1 无内部标注;skin-score 实现 8.6/8.7 全量文案库,回归新增 §8.8 两组样例逐字断言 86/86 绿)
e2e 全量绿(含真实 API:水分图/红区合成图挂载、护理建议五段)
2026-09-27 11:15:54 +08:00
xsl ca76128ac0 更新 3, 2, 1 的声音 2026-09-27 10:38:28 +08:00
Ubuntu 7155f23213 fix: 用户测试 agent 反馈(协议标题遮挡/气泡重叠/脚注技术文案/图层可读性)
- 隐私协议正文上留返回键位,标题不再被悬浮返回键遮挡
- 姿态提示气泡从底部改到步骤文案下方(原位置压住「取下眼镜」引导文字)
- 报告脚注只展示用户可感知的降级项(下颌线/侧脸/补拍类),引擎内部技术项(上限锁/字段未返回)不再面向用户泄漏
- 肤色/敏感图改为正脸×图层正片叠底(multiply):色沉与红区落在脸上,解决直挂「悬浮面具感/白底过淡」
- e2e 全量绿
2026-09-27 02:20:33 +08:00
Ubuntu d09d29946d feat: 真机反馈 13 项修改(下拉/拍照页/语音时机/预热/报告样式/翻转镜头)
1. 城市改自定义下拉(原生 select 各端样式不一)
2. 姿态提示浮层下移,不再压住脸部轮廓
3. 倒计时 321 前不再抢播「保持不动」(被切断听着像吞字)
4. 相机预热:分辨率稳定两拍后才出实时画面+放开检测,开头 1-2s 大脸闪变消除
5. 隐私协议页去掉头部文字条,返回键改悬浮
6. 肌龄小字常显「与实际年龄相差X岁」,钳制时追加「触发(原X→Y)」(vm 增 realAge 字段)
7. 删除设计稿没有的「6个维度观察/状态动态流动」两段文字
8. 维度卡关注行改设计稿两行式:灰小字+深色口号(稳定力口号用设计稿原文,另两条待确认)
9. Tab 黄色指示条宽度跟随文字(原固定 78px)
10. 抓拍只取取景框可见区域(object-fit cover 中心裁切),照片=所见画幅;拍照页顶栏黑条删除;无进展提示上移不再压快门
11. 相册选择器「拍照」项为系统行为,代码层已是图库意图(无 capture),详见回复
12. 切换镜头实现:前后置切换+镜像/姿态参数跟随,替换 Toast 占位
13. 报告分类卡去掉得分与档位徽章(设计稿只有话术),图片改 100px 见方圆角 10
e2e 全量绿(E0-E7)
2026-09-27 01:56:04 +08:00
Ubuntu eae22d18a1 fix: 拍照引导检测链路校准——模拟摄像头闭环修复四个实测问题
用户真机症状:一直『未检测到人脸』;脸进框却提示太远;凑近又超框。用 C3 三视图生成 Y4M 模拟摄像头闭环复现并定位:

- 检测可靠性:tinyFaceDetector 320/0.4 → 416/0.3(正脸置信度 0.67→0.99,框更准);连续漏检 ≥3 帧才报未检测到人脸(单帧抖动不再刷屏)
- 距离阈值:0.35/0.8 → 0.26/0.58(以取景框≈42% 屏高、416 输入实测框 0.40–0.44 标定;原阈值下脸进框即误判太远)
- HeadPose:check() 双重镜像符号 bug(estimate 已翻转 yaw,check 又翻一次→侧脸引导方向相反);YAW_COEFF 2.2→3.8(±45° 侧脸实测只读 ±25°);PITCH_BASELINE 0.32→0.45(正脸误读 29° 被判低头)
- head-pose 自测夹具按新系数数值标定,全部通过
- e2e 新增 E7:模拟摄像头全自动三连拍(正脸→右脸→左脸,姿态引导+倒计时+抓帧,分析请求拦截不烧次数包),全量绿
2026-09-27 00:47:22 +08:00
Ubuntu f52b52a954 chore: temp/ 草稿目录移出版本库并加入 .gitignore 2026-09-27 00:05:34 +08:00
Ubuntu cc83987043 feat: 语音播报契约与播放模块 + 相册选图 + 报告历史存储/服务端落盘
- docs/语音播报内容设计文档_V1.0.md:14 条播报文案/触发时机/文件名契约;格式定 MP3(iOS Safari/微信不支持 OGG);播放层 WebAudio 单通道、4s 去重、manifest.json 清单制(文件待生成,缺失静默跳过)
- 相册:相机页「相册」按钮接系统图库(#album-file 不带 capture),与实拍同管线压 1280 推进,替换 Toast 占位
- 报告历史:检测成功按「检测」为单位存 localStorage(juzi.reports.v1,5 条上限)——三张照片+档案+评分数据+marks 坐标,不含 face_maps 图层;报告页「历史」列表可回看/删除/清空,回看时坐标叠加类图仍可用
- server.js:每次检测完整原始返回落盘 h5/data/results/(复核/真值,已 .gitignore)
- e2e:新增 E5(相册三连拍)/E6(存储与历史回看)断言,E3 增落盘校验;全量绿(含真实 API 闭环)
2026-09-27 00:05:34 +08:00
83 changed files with 40439 additions and 851 deletions
+7
View File
@@ -12,3 +12,10 @@ node_modules/
__pycache__/
aipkey.bash
h5/data/
temp/
# graphify 本地缓存与日期快照(可重建,无需入库)
graphify-out/cache/
graphify-out/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/
graphify-out/.rebuild.lock
graphify-out/..graph.tmp.*
+1
View File
@@ -0,0 +1 @@
kpx234XimQGtFPo6
+11 -8
View File
@@ -23,18 +23,18 @@
| `step2-rightface.mp3` | 请保持单人出镜,慢慢向左转头,露出右脸 | 进入 2/3 右脸出镜步 | ≈4s |
| `step3-leftface.mp3` | 请慢慢向右转头,露出左脸 | 进入 3/3 左脸出镜步 | ≈3s |
> 语义说明:2/3 步需要画面中看到「右脸」= 用户头向**左**转;3/3 步看到「左脸」= 头向**右**转。语音引导的是用户动作,与取景蒙版方向一致,不要改写成"露出左侧/右侧脸"这类歧义表述。
> 语义说明:拍摄顺序对齐 Figma,为正脸 → 右脸 → 左脸。左右一律以**用户本人**的左右为准:2/3「右脸」= 用户头向自己的**左**侧转;3/3「左脸」= 头向自己的**右**侧转。前置预览为镜像(照镜子),与取景蒙版方向一致。不要改写成"露出左侧/右侧脸"这类歧义表述。
### B. 姿态提示(实时检测不通过时播,同一提示 4 秒内不重复)
| 文件名 | 文案 | 触发时机(与屏幕提示同文本) | 时长 |
|---|---|---|---|
| `hint-noface.mp3` | 未检测到人脸,请正对镜头 | 画面无人脸 | ≈3s |
| `hint-near.mp3` | 请靠近一点 | 人脸框过高占比 <35%(太远) | ≈1.5s |
| `hint-far.mp3` | 请远一点 | 占比 >80%(太近) | ≈1.5s |
| `hint-front.mp3` | 请正对屏幕 | 正脸步角度不合格 | ≈1.5s |
| `hint-turn-left.mp3` | 请向左转头 | 需要头向左转修正角度时 | ≈1.5s |
| `hint-turn-right.mp3` | 请向右转头 | 需要头向右转修正角度时 | ≈1.5s |
| `hint-near.mp3` | 请靠近一点 | 人脸框高占画面高 <26%(太远) | ≈1.5s |
| `hint-far.mp3` | 请远一点 | 占比 >58%(太近) | ≈1.5s |
| `hint-front.mp3` | 请正对屏幕 | 正脸步角度不合格(正脸步一律只提示回正,不播转头/抬头低头) | ≈1.5s |
| `hint-turn-left.mp3` | 请向左转头 | 仅侧脸步:需要头向左转修正角度时 | ≈1.5s |
| `hint-turn-right.mp3` | 请向右转头 | 仅侧脸步:需要头向右转修正角度时 | ≈1.5s |
| `hint-hold.mp3` | 保持不动 | 姿态已合格、进入稳定确认 | ≈1.5s |
### C. 拍摄与流程
@@ -50,8 +50,11 @@
1. **清单制**:语音文件配 `assets/voice/manifest.json`(格式 `{"files":["step1-front","hint-near",...]}`,内容为已生成的文件名去 .mp3)。前端只播清单里声明的条目;清单或条目缺失时该条静默跳过——语音文件可分批补齐,每次补齐后更新清单。
2. **浏览器自动播放限制**:首次播放必须发生在用户手势链路里。前端在任意按钮点击时初始化并 resume WebAudio(点击「开始检测/继续/开始」即完成解锁),后续步骤提示均为程序触发。
3. **同条去重**:同一文件名 4 秒内不重复播(姿态提示会随检测帧反复触发)。
4. **不叠加**:新提示开播前停掉上一条未播完的(单通道),倒计时与拍摄完成音除外(允许叠在提示音上)。
3. **同条去重**:姿态提示同一文件名 4 秒内不重复播(会随检测帧反复触发);流程音是离散事件,每次都播(连拍的「拍摄完成」、取消后重来的倒计时)。
4. **不叠加、不截断**:单通道,两类播报分开处理——
- **流程音**(A 类步骤引导 + C 类倒计时/拍摄完成/分析中):排队顺序播,不互相截断(如「拍摄完成」播完再播下一步引导)。例外:`analyze-fail` 直接打断「分析中」。
- **姿态提示**(B 类):提示之间可互相打断;流程音播放期间到来的提示不截断流程音,只挂起最新一条,流程音收尾后补播;换步或提示清空时挂起作废。
- 倒计时「三、二、一」等全部在播/排队/挂起的播报自然收尾后,与画面数字同步起播(`voice.afterCurrent` 接续,期间姿态被破坏则取消倒计时)。
5. **不打断原则**:分析中/失败提示开播时,拍照页已离开,步骤与姿态提示不再触发。
6. **静音开关**:V1.0 暂不提供 UI 开关,跟随系统音量与静音键;后续如加开关,在拍照页右上角放小喇叭图标,状态存 localStorage。
@@ -0,0 +1,58 @@
# 1meAI测肤旷视UI问题反馈
## UI交互界面调整
##### 交互
1. 年龄调整为滚轮,消费者选择出生年份月份,系统自动计算年龄(下拉设计)。
![image\.png](图片和附件/image%204.png)
1. 地区做成滚动形式,省份城市形式(下拉设计)。 找现存的插件,提供选择省份和城市。
![image\.png](图片和附件/image%205.png)
1. 拍摄方式修改为**动态捕捉方式**,再加入 远一点,近一点 请向左转头,请略微抬头等的动态提示。同时,如果消费者的角度光线等必要条件合适,立刻拍摄,不再倒计时拍摄, 不要手动拍摄的按钮。
![image\.png](图片和附件/image%206.png)
1. 拍摄的时候画面缩小 15%, 使得摄像头离面部近一点也能正常拍摄。 (当前拍摄,需要手机离面部很远才符合要求)
2. 正脸,两个侧脸的拍摄,必须安装顺序完成,拍摄完成后才能拍摄下一张。
![image\.png](图片和附件/image.png)
##### **设计**
具体设计问题:
1 主页面 参考下面图片, 左边的是当前的状态,右边的是要达到效果的标准,
- Life in Full 为更好的生命表现 字号,位置,粗细 要和图片一致。
- 开始检测的按钮不要圆角,要长方形带一点倒角。
![截屏2026\-09\-28 13\.38\.51\.png](图片和附件/截屏2026-09-28%2013.38.51.png)
2 设置的页面
- 不再要返回的按钮
- “您的年龄“, “您的性别”, “您所在的地区”,需要居中,不是左对齐
- 选择年龄,性别,和地区的选择控件风格要一致
- 提交按钮 同样 要长方形带一点倒角
![截屏2026\-09\-28 13\.43\.25\.png](图片和附件/截屏2026-09-28%2013.43.25.png)
拍照页面,参考图片右边的标准
- 弹出“请拍摄正面及左右侧面照片” 这个对话框的时候, 对话框大小需要和右边的标准一致,背景要把选择年龄,性别,地区等UI元素去掉。 “开始” 按钮要长方形带一点倒角
![截屏2026\-09\-28 13\.44\.10\.png](图片和附件/截屏2026-09-28%2013.44.10.png)
报告页面
- 去掉返回按钮和 "1'me 在线肌肤测试" 的文字
- “您的肌肤状态表现”,这个部分字体有点小,需要整体优化一下
- 再往下的图片和说明部分,也是字体有点小,同样需要整体优化一下
- 点击图片需要放大,再次点击返回
![截屏2026\-09\-28 13\.56\.32\.png](图片和附件/截屏2026-09-28%2013.56.32.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+69
View File
@@ -0,0 +1,69 @@
{
"0": "face-api.js",
"1": "n",
"2": "app.js",
"3": "skin-score.js",
"4": "isTensor",
"5": ".toBatchTensor",
"6": "getEnv",
"7": "FaceLandmarks",
"8": "disposeUnusedWeightTensors",
"9": "__awaiter",
"10": "extractWeightEntryFactory",
"11": "FaceProcessor",
"12": "server.js",
"13": "files",
"14": "extractAllFacesAndComputeResults",
"15": ".runAndExtendWithFaceDetection",
"16": "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范",
"17": "extractFaces",
"18": "1'me 在线肌肤测试 H5",
"19": "head-pose.js",
"20": "build_fixtures.py",
"21": ".forwardInput",
"22": ".loadFromDisk",
"23": "facepp-skinanalyze(旷视皮肤分析专业版调用技能)",
"24": "巨子测肤 · 1'me 语音播报内容设计文档 V1.0",
"25": "extractParamsFromWeigthMap$1",
"26": "wi",
"27": "gen_voice.py",
"28": "initialize",
"29": "face-api.js 本地化依赖(h5/vendor/faceapi/)",
"30": "regression.js",
"31": "isValidNumber",
"32": "FaceMatcher",
"33": "build-fixture.py",
"34": "smoke-test.js",
"35": "DetectSingleFaceLandmarksTask",
"36": "so",
"37": "tt",
"38": "TinyYolov2",
"39": "cn",
"40": ".computeFaceDescriptor",
"41": "SsdMobilenetv1",
"42": "dr",
"43": "FaceLandmark68Net",
"44": "rescaleAndNormalize",
"45": "PredictAllAgeAndGenderWithFaceAlignmentTask",
"46": "PredictAllFaceExpressionsWithFaceAlignmentTask",
"47": "PredictSingleAgeAndGenderWithFaceAlignmentTask",
"48": "PredictSingleFaceExpressionsWithFaceAlignmentTask",
"49": "Yo",
"50": "call_skinanalyze_3view.sh",
"51": "TinyXception",
"52": "Vc",
"53": "FaceDetection",
"54": "ff",
"55": "ge",
"56": "sa",
"57": "po",
"58": ".dispose",
"59": "call_skinanalyze.sh",
"60": "report-fixture.js",
"61": ".extractWeights",
"62": "NeuralNetwork",
"63": "e2e-browser.mjs",
"64": "AgeGenderNet",
"66": "FaceExpressionNet",
"71": "PredictSingleFaceExpressionsTask"
}
+1
View File
@@ -0,0 +1 @@
{"0": "084975bb68797cf4", "1": "6c6ce24229e04595", "2": "64f07a78d61df933", "3": "8ac814cdb72a1a7d", "4": "c18a4a8fab272cc6", "5": "7f62d3cc90a92195", "6": "865611558d18274c", "7": "e7b6c52cb9523ba1", "8": "934b465b2e687d3d", "9": "31a3a505422c0528", "10": "f943c63a37873e9e", "11": "9ed244ba3453a0c1", "12": "8d1af962d46ec19f", "13": "7e3b558a587a4ebe", "14": "2681c1b3f4f3539e", "15": "8bd61518dd267641", "16": "95add6a975c0cb72", "17": "d5ee72467704e6f0", "18": "fd0841b607871dc7", "19": "0a373ad2197fd1a6", "20": "aecf2fce44ba49c5", "21": "880a74c6d255e5cc", "22": "1c67ad3d97e5c622", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "79d2843a716971c8", "26": "83b0fd3b14363e4d", "27": "eab427ce9a5219e9", "28": "eb3733e409dc7411", "29": "c608f1c6492e1cb1", "30": "f2a40b5f1b29f669", "31": "8f647d3ce08ecd2b", "32": "f6bcf56c4589316b", "33": "75f750c5b7d9f63d", "34": "f024ff7942935690", "35": "23f1d406f6c779ae", "36": "c55f872b3e16ab6f", "37": "3088ae48043ee39d", "38": "c96276581a8c98d4", "39": "932f010dc6115fb4", "40": "444ef0744645f5ac", "41": "386a184c6bd5a40e", "42": "2bfc96d4090afd84", "43": "9a25b1230b5fe649", "44": "8d5f4e992bac9095", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "47": "01f68dfc34a29d32", "48": "8c471c7baeaaca54", "49": "a54008a71fdd44fa", "50": "bbb2b57d72922b16", "51": "0985ce7473fdd021", "52": "c5a22f1501fe85b7", "53": "287d60115d9acda8", "54": "1331494302f6c136", "55": "f42d242a020b2f05", "56": "2537fe1309d3ab9a", "57": "7862e3ee5a214160", "58": "440ae90e84499b6f", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "151209871c761759", "62": "d96493a5f110bd4c", "63": "c256b43f75d7809e", "64": "512a2535c88c4284", "66": "e858d75e995c92e5", "71": "cb5ac3401283c378"}
+1
View File
@@ -0,0 +1 @@
.
+310
View File
@@ -0,0 +1,310 @@
# Graph Report - juzi (2026-09-28)
## Corpus Check
- 31 files · ~1,845,638 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 900 nodes · 2117 edges · 67 communities (46 shown, 21 thin omitted)
- Extraction: 92% EXTRACTED · 8% INFERRED · 0% AMBIGUOUS · INFERRED: 168 edges (avg confidence: 0.52)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `4e3d915b`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation)
- face-api.js
- n
- app.js
- skin-score.js
- isTensor
- .toBatchTensor
- getEnv
- FaceLandmarks
- disposeUnusedWeightTensors
- __awaiter
- extractWeightEntryFactory
- FaceProcessor
- server.js
- files
- extractAllFacesAndComputeResults
- .runAndExtendWithFaceDetection
- 巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范
- extractFaces
- 1'me 在线肌肤测试 H5
- head-pose.js
- build_fixtures.py
- .forwardInput
- .loadFromDisk
- facepp-skinanalyze(旷视皮肤分析专业版调用技能)
- 巨子测肤 · 1'me 语音播报内容设计文档 V1.0
- extractParamsFromWeigthMap$1
- wi
- gen_voice.py
- initialize
- face-api.js 本地化依赖(h5/vendor/faceapi/)
- regression.js
- isValidNumber
- FaceMatcher
- build-fixture.py
- DetectSingleFaceLandmarksTask
- so
- tt
- TinyYolov2
- cn
- .computeFaceDescriptor
- SsdMobilenetv1
- dr
- FaceLandmark68Net
- rescaleAndNormalize
- PredictAllAgeAndGenderWithFaceAlignmentTask
- PredictAllFaceExpressionsWithFaceAlignmentTask
- PredictSingleAgeAndGenderWithFaceAlignmentTask
- PredictSingleFaceExpressionsWithFaceAlignmentTask
- Yo
- call_skinanalyze_3view.sh
- TinyXception
- Vc
- FaceDetection
- ff
- ge
- sa
- po
- .dispose
- call_skinanalyze.sh
- .extractWeights
- NeuralNetwork
- e2e-browser.mjs
- AgeGenderNet
- FaceExpressionNet
- PredictSingleFaceExpressionsTask
## God Nodes (most connected - your core abstractions)
1. `__awaiter()` - 59 edges
2. `__generator()` - 59 edges
3. `n()` - 36 edges
4. `t()` - 33 edges
5. `i()` - 33 edges
6. `r()` - 32 edges
7. `C()` - 32 edges
8. `toNetInput()` - 27 edges
9. `e()` - 22 edges
10. `f()` - 20 edges
## Surprising Connections (you probably didn't know these)
- `detectFrame()` --indirect_call--> `d()` [INFERRED]
h5/js/app.js → h5/vendor/faceapi/face-api.js
- `vizCompose()` --indirect_call--> `h()` [INFERRED]
h5/js/app.js → h5/vendor/faceapi/face-api.js
- `vizCompose()` --indirect_call--> `rh()` [INFERRED]
h5/js/app.js → h5/vendor/faceapi/face-api.js
- `vizCompose()` --indirect_call--> `w()` [INFERRED]
h5/js/app.js → h5/vendor/faceapi/face-api.js
- `renderReport()` --indirect_call--> `t()` [INFERRED]
h5/js/app.js → h5/vendor/faceapi/face-api.js
## Import Cycles
- None detected.
## Communities (67 total, 21 thin omitted)
### Community 0 - "face-api.js"
Cohesion: 0.02
Nodes (38): bn(), ca(), cd(), ce(), dn(), ee(), er(), et() (+30 more)
### Community 1 - "n"
Cohesion: 0.07
Nodes (85): a(), ae(), ao(), b(), ba(), be(), bf(), bi() (+77 more)
### Community 2 - "app.js"
Cohesion: 0.06
Nodes (71): afterCurrent(), applyMirror(), armNudge(), beginCountdown(), beginDetect(), bindBackdropClose(), busyWithFlow(), cancelCountdown() (+63 more)
### Community 3 - "skin-score.js"
Cohesion: 0.12
Nodes (44): acneGrade(), buildViewModel(), calcAcne(), calcContour(), calcSens(), calcSmooth(), calcTone(), calcWrinkles() (+36 more)
### Community 4 - "isTensor"
Cohesion: 0.33
Nodes (5): extractorsFactory$3(), extractParamsFromWeigthMap$4(), isTensor(), isTensor1D(), isTensor2D()
### Community 5 - ".toBatchTensor"
Cohesion: 0.08
Nodes (30): conv(), conv$1(), convDown(), convLayer(), convLayer$1(), convNoRelu(), convWithBatchNorm(), denseBlock3() (+22 more)
### Community 6 - "getEnv"
Cohesion: 0.09
Nodes (26): awaitMediaLoaded(), bufferToImage(), createCanvas(), createCanvasFromMedia(), DetectAllFacesTask(), DrawBox(), drawDetections(), drawFaceExpressions() (+18 more)
### Community 7 - "FaceLandmarks"
Cohesion: 0.06
Nodes (16): bgrToRgbTensor(), extendWithFaceLandmarks(), FaceLandmark68NetBase(), FaceLandmarks(), FaceLandmarks5(), FaceLandmarks68(), getCenterPoint(), isDimensions() (+8 more)
### Community 8 - "disposeUnusedWeightTensors"
Cohesion: 0.22
Nodes (6): disposeUnusedWeightTensors(), extractorsFactory$9(), extractParamsFromWeigthMap(), extractParamsFromWeigthMap$6(), extractParamsFromWeigthMap$7(), extractParamsFromWeigthMapTiny()
### Community 9 - "__awaiter"
Cohesion: 0.22
Nodes (8): __awaiter(), fetchImage(), fetchJson(), fetchNetWeights(), fetchOrThrow(), __generator(), Mtcnn(), toNetInput()
### Community 10 - "extractWeightEntryFactory"
Cohesion: 0.53
Nodes (6): extractorsFactory$7(), extractWeightEntryFactory(), loadConvParamsFactory(), loadParamsFactory(), loadParamsFactory$1(), loadSeparableConvParamsFactory()
### Community 11 - "FaceProcessor"
Cohesion: 0.24
Nodes (3): extractParams$1(), FaceLandmark68TinyNet(), FaceProcessor()
### Community 12 - "server.js"
Cohesion: 0.14
Nodes (16): callFacepp(), CRED, DATA_DIR, ERR_MSG, fs, handleAnalyze(), http, isUsableB64() (+8 more)
### Community 13 - "files"
Cohesion: 0.12
Nodes (15): files, analyze-fail, analyzing, countdown-321, hint-far, hint-front, hint-hold, hint-near (+7 more)
### Community 14 - "extractAllFacesAndComputeResults"
Cohesion: 0.15
Nodes (8): extendWithAge(), extendWithFaceExpressions(), extendWithGender(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult(), PredictAllAgeAndGenderTask(), PredictAllFaceExpressionsTask(), PredictSingleAgeAndGenderTask()
### Community 15 - ".runAndExtendWithFaceDetection"
Cohesion: 0.23
Nodes (6): allFacesMtcnn(), allFacesSsdMobilenetv1(), allFacesTinyYolov2(), DetectAllFaceLandmarksTask(), detectAllFaces(), DetectSingleFaceTask()
### Community 16 - "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范"
Cohesion: 0.15
Nodes (12): 1. 基准与尺寸换算, 2. 色彩规范, 3. 字体规范, 4. 组件切图(assets/svg/), 5. 图片素材(assets/images/,共 15 张), 6. 页面清单(design/ 下有整屏 PNG 预览), 7. 目录结构, 8. 还原建议 (+4 more)
### Community 17 - "extractFaces"
Cohesion: 0.22
Nodes (6): Box(), extractFaces(), extractFaceTensors(), imageTensorToCanvas(), isTensor3D(), isTensor4D()
### Community 18 - "1'me 在线肌肤测试 H5"
Cohesion: 0.18
Nodes (10): 1'me 在线肌肤测试 H5, Windows 本机部署(提测用), 密钥配置, 已知占位清单, 技术说明, 拍照交互(引导式自动三连拍), 本地运行, 检测流程与评分渲染 (+2 more)
### Community 19 - "head-pose.js"
Cohesion: 0.33
Nodes (8): assertPoints(), check(), clamp(), dist(), estimate(), makeFrontal(), makeSide(), midpoint()
### Community 20 - "build_fixtures.py"
Cohesion: 0.40
Nodes (9): build_case1(), build_case2(), build_truth(), col(), dump(), main(), parse_value(), set_path() (+1 more)
### Community 21 - ".forwardInput"
Cohesion: 0.22
Nodes (9): boxPredictionLayer(), decodeBoxesLayer(), depthwiseConvLayer(), getCenterCoordinatesAndSizesLayer(), getStridesForLayerIdx(), mobileNetV1(), outputLayer(), pointwiseConvLayer() (+1 more)
### Community 22 - ".loadFromDisk"
Cohesion: 0.19
Nodes (5): ComposableTask(), getModelUris(), Gf(), loadWeightMap(), TinyFaceDetector()
### Community 23 - "facepp-skinanalyze(旷视皮肤分析专业版调用技能)"
Cohesion: 0.20
Nodes (9): 1. 凭据(统一管理), 2. 标准调用模板(全可选参数,每次都带), 2b. 三视图调用模板(正脸 + 双侧脸 → 下颌线), 3.1 本轮踩过的坑(务必避复), 3. 三维度 × 6 分类 P/S 计算(2026-09-26 生效,取代旧六维), 4. 产品分组公式, 5. Excel 报告铁律(必须遵守), 6. 其他 Agent 调用约定 (+1 more)
### Community 24 - "巨子测肤 · 1'me 语音播报内容设计文档 V1.0"
Cohesion: 0.22
Nodes (8): 1. 音频格式约定(已定), 2. 播报清单(14 条), 3. 播放策略(前端已实现侧约定), 4. 生成验收, A. 步骤引导(进入每个拍照步骤时播,与屏幕步骤文案一致), B. 姿态提示(实时检测不通过时播,同一提示 4 秒内不重复), C. 拍摄与流程, 巨子测肤 · 1'me 语音播报内容设计文档 V1.0
### Community 25 - "extractParamsFromWeigthMap$1"
Cohesion: 0.40
Nodes (3): extractParamsFromWeigthMap$1(), extractParamsFromWeigthMap$3(), seperateWeightMaps()
### Community 26 - "wi"
Cohesion: 0.28
Nodes (9): Ci(), Ei(), Fi(), Ii(), ki(), pe(), ri(), wi() (+1 more)
### Community 27 - "gen_voice.py"
Cohesion: 0.30
Nodes (11): ndarray, Path, gen_countdown(), main(), normalize_clip(), 倒计时单独按字生成再拼接,避免「三、二、一」整句被读成播报腔、与提示音气质漂移。 与提示音共用同一 speaker + instruct;句间留约…, 裁掉过长首尾静音,保留 ≤150ms 边沿,避免提示显迟钝。, 响度归一 −16 LUFS + 转 MP3 48k CBR mono 22050Hz。 (+3 more)
### Community 28 - "initialize"
Cohesion: 0.29
Nodes (7): createBrowserEnv(), createFileSystem(), createNodejsEnv(), initialize(), isBrowser(), monkeyPatch(), setEnv()
### Community 29 - "face-api.js 本地化依赖(h5/vendor/faceapi/)"
Cohesion: 0.29
Nodes (6): face-api.js 本地化依赖(h5/vendor/faceapi/), 为什么本地化, 文件清单与体积, 来源与版本, 用法, 许可证
### Community 30 - "regression.js"
Cohesion: 0.60
Nodes (5): diffCtx(), loadJSON(), main(), okNum(), okStr()
### Community 31 - "isValidNumber"
Cohesion: 0.50
Nodes (4): Dimensions(), isValidNumber(), isValidProbablitiy(), isWithGender()
### Community 33 - "build-fixture.py"
Cohesion: 0.60
Nodes (4): main(), 递归缩放 result 内全部坐标:{left,top,width,height} 矩形与 {x,y} 点,与原数据同为整数。, scale_coords(), to_data_url()
### Community 36 - "so"
Cohesion: 0.50
Nodes (4): Do(), io(), so(), To()
### Community 37 - "tt"
Cohesion: 0.50
Nodes (4): kn(), tt(), wo(), z()
### Community 39 - "cn"
Cohesion: 0.67
Nodes (3): cn(), en(), yn()
### Community 40 - ".computeFaceDescriptor"
Cohesion: 0.18
Nodes (4): ComputeAllFaceDescriptorsTask(), ComputeSingleFaceDescriptorTask(), extendWithFaceDescriptor(), FaceRecognitionNet()
### Community 41 - "SsdMobilenetv1"
Cohesion: 0.40
Nodes (3): extractorsFactory$5(), extractParamsFromWeigthMap$5(), SsdMobilenetv1()
### Community 42 - "dr"
Cohesion: 0.67
Nodes (3): dr(), na(), ta()
### Community 44 - "rescaleAndNormalize"
Cohesion: 0.67
Nodes (3): getSizesForScale(), normalize$1(), rescaleAndNormalize()
### Community 49 - "Yo"
Cohesion: 0.67
Nodes (3): qo(), Xo(), Yo()
### Community 58 - ".dispose"
Cohesion: 0.14
Nodes (13): computeReshapedDimensions(), extractBoundingBoxes(), iou(), isEven(), nonMaxSuppression(), nonMaxSuppression$1(), pyramidDown(), scale() (+5 more)
### Community 61 - ".extractWeights"
Cohesion: 0.08
Nodes (25): createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractConvParamsFactory(), extractFCParamsFactory() (+17 more)
### Community 63 - "e2e-browser.mjs"
Cohesion: 0.29
Nodes (4): EXPECTED, fails, pageErrors, pwRoot
## Knowledge Gaps
- **63 isolated node(s):** `step1-front`, `step2-rightface`, `step3-leftface`, `hint-noface`, `hint-near` (+58 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **21 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `val()` connect `skin-score.js` to `extractFaces`?**
_High betweenness centrality (0.083) - this node is a cross-community bridge._
- **Why does `vizCompose()` connect `app.js` to `n`?**
_High betweenness centrality (0.058) - this node is a cross-community bridge._
- **Why does `Box()` connect `extractFaces` to `face-api.js`, `.dispose`, `isValidNumber`, `FaceLandmarks`?**
_High betweenness centrality (0.058) - this node is a cross-community bridge._
- **Are the 23 inferred relationships involving `n()` (e.g. with `ae()` and `be()`) actually correct?**
_`n()` has 23 INFERRED edges - model-reasoned connections that need verification._
- **Are the 7 inferred relationships involving `t()` (e.g. with `renderReport()` and `e()`) actually correct?**
_`t()` has 7 INFERRED edges - model-reasoned connections that need verification._
- **Are the 9 inferred relationships involving `i()` (e.g. with `da()` and `f()`) actually correct?**
_`i()` has 9 INFERRED edges - model-reasoned connections that need verification._
- **What connects `step1-front`, `step2-rightface`, `step3-leftface` to the rest of the system?**
_63 weakly-connected nodes found - possible documentation gaps or missing edges._
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+812
View File
@@ -0,0 +1,812 @@
{
"design/file-tree.json": {
"mtime": 1790000113.2956002,
"ast_hash": "88c90bbcbe3229fa637280968a08a0dd",
"semantic_hash": ""
},
"design/full.json": {
"mtime": 1790000315.5846467,
"ast_hash": "2de4a512da615f6d73cd4b8651e6a419",
"semantic_hash": ""
},
"design/imgusage.json": {
"mtime": 1790000370.6153483,
"ast_hash": "00b2a8d870c5324489f326969b1b4c85",
"semantic_hash": ""
},
"design/node-4-48.json": {
"mtime": 1790000094.0240362,
"ast_hash": "efb5bc302a86c3500cca0656d4710527",
"semantic_hash": ""
},
"h5/assets/mock/build-fixture.py": {
"mtime": 1790528158.658554,
"ast_hash": "759c1402050f8d09ac713c0ec45dd7ae",
"semantic_hash": ""
},
"h5/assets/mock/report-fixture.js": {
"mtime": 1790528158.6625543,
"ast_hash": "a29ac0b23b262206470345e32041a0c8",
"semantic_hash": ""
},
"h5/assets/mock/smoke-test.js": {
"mtime": 1790438217.8753266,
"ast_hash": "4aa15834481b394b873c32f38c782bb0",
"semantic_hash": ""
},
"h5/assets/voice/manifest.json": {
"mtime": 1790531330.970398,
"ast_hash": "bd691605fe7c997dea60869a892624c3",
"semantic_hash": ""
},
"h5/js/app.js": {
"mtime": 1790531707.8223634,
"ast_hash": "7c3652449c905d9f19ca37a66089766f",
"semantic_hash": ""
},
"h5/js/head-pose.js": {
"mtime": 1790528422.8265557,
"ast_hash": "165cc2ae73d0a9526db0755ec62ffaac",
"semantic_hash": ""
},
"h5/js/skin-score.js": {
"mtime": 1790528158.6665542,
"ast_hash": "61d27784c13fc9fd5ea3b4e2f6187756",
"semantic_hash": ""
},
"h5/server.js": {
"mtime": 1790528158.6705542,
"ast_hash": "e23a541e2ce85e0815616071820b46ef",
"semantic_hash": ""
},
"h5/test/build_fixtures.py": {
"mtime": 1790438217.95594,
"ast_hash": "71c960e0089ed6e10a26789f8aee17d3",
"semantic_hash": ""
},
"h5/test/fixtures/case1.json": {
"mtime": 1790438217.95594,
"ast_hash": "879ad7b59bd68ab233e20f90b76e3a9f",
"semantic_hash": ""
},
"h5/test/fixtures/case2.json": {
"mtime": 1790438217.95594,
"ast_hash": "66c1aaebec257b6d7ba237d7d4bbe3da",
"semantic_hash": ""
},
"h5/test/fixtures/truth.json": {
"mtime": 1790438217.95594,
"ast_hash": "c85c9f5317c0603afd0a04f1e92361bc",
"semantic_hash": ""
},
"h5/test/regression.js": {
"mtime": 1790528158.6705542,
"ast_hash": "d83fc0a378d1117fc2203569d325ca24",
"semantic_hash": ""
},
"h5/vendor/faceapi/face-api.js": {
"mtime": 1790438217.9597785,
"ast_hash": "6939d12050455a60cdc7871d56e16d37",
"semantic_hash": ""
},
"h5/vendor/faceapi/models/face_landmark_68_model-weights_manifest.json": {
"mtime": 1790438217.9597785,
"ast_hash": "1d4029763003335bc6921aadeb58706a",
"semantic_hash": ""
},
"h5/vendor/faceapi/models/tiny_face_detector_model-weights_manifest.json": {
"mtime": 1790438217.9597785,
"ast_hash": "5bab50532388f5da9b4cd85b15adc11c",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/examples/call_skinanalyze.sh": {
"mtime": 1790395590.0,
"ast_hash": "14041c2f7ede800a3d661e6d6dd31568",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/examples/call_skinanalyze_3view.sh": {
"mtime": 1790407434.0,
"ast_hash": "f024f01d1869662aa896a69e5c7efd3b",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/examples/last_3view_result.json": {
"mtime": 1790438217.9636173,
"ast_hash": "8097ab0b741dae75ea7aa07e506755f0",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/examples/ref_3view_raw.json": {
"mtime": 1790438217.9904885,
"ast_hash": "bbd2255eecada059f2253e7adde0527a",
"semantic_hash": ""
},
"tools/gen_voice.py": {
"mtime": 1790531338.7343898,
"ast_hash": "00e33f0da3f6fbe8357e60d89ad27879",
"semantic_hash": ""
},
"design/\u8bbe\u8ba1\u89c4\u8303.md": {
"mtime": 1790049369.1868372,
"ast_hash": "cdddd8fbd14bcd79b66ec6c4b707d309",
"semantic_hash": ""
},
"docs/\u8bed\u97f3\u64ad\u62a5\u5185\u5bb9\u8bbe\u8ba1\u6587\u6863_V1.0.md": {
"mtime": 1790531341.690387,
"ast_hash": "93c71559618d0f53a4e02ab763a20333",
"semantic_hash": ""
},
"h5/README.md": {
"mtime": 1790531597.4743752,
"ast_hash": "26db3c846af2efc0a4bfb442985e2922",
"semantic_hash": ""
},
"h5/index.html": {
"mtime": 1790531666.4743714,
"ast_hash": "66cb0b0c306fcc9adb4dbed358651d13",
"semantic_hash": ""
},
"h5/vendor/faceapi/README.md": {
"mtime": 1790438217.95594,
"ast_hash": "dac9995fc002041506663986698a82ae",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/SKILL.md": {
"mtime": 1790407478.0,
"ast_hash": "3a0039ccbaf7e2aa0f830486bcbd5391",
"semantic_hash": ""
},
"design/01-\u542f\u52a8\u9875.png": {
"mtime": 1790000154.6901708,
"ast_hash": "d2ca3923a7c401966a73e44d438ba4e7",
"semantic_hash": ""
},
"design/02-\u542f\u52a8\u9875\u5f39\u7a97.png": {
"mtime": 1790000158.6925905,
"ast_hash": "7f19630b71855608f9679132461d55ae",
"semantic_hash": ""
},
"design/03-\u6863\u6848\u586b\u5199.png": {
"mtime": 1790000162.200167,
"ast_hash": "9807a1be1ebe6e9f1c502571cbe54d78",
"semantic_hash": ""
},
"design/04-\u62cd\u7167\u5f39\u7a97.png": {
"mtime": 1790000166.471339,
"ast_hash": "f4a252af1137ed2187898e20a380a88f",
"semantic_hash": ""
},
"design/05-\u6b63\u9762.png": {
"mtime": 1790000168.8546982,
"ast_hash": "e4b3fa148783b4362d26347fd703c71b",
"semantic_hash": ""
},
"design/06-\u5de6\u9762.png": {
"mtime": 1790000173.1124754,
"ast_hash": "e637771b374a3ddd58324e8d97e3d4a2",
"semantic_hash": ""
},
"design/07-\u53f3\u9762.png": {
"mtime": 1790000175.8311493,
"ast_hash": "1a7b7e8c334a9209101e572103fa67c2",
"semantic_hash": ""
},
"design/08-\u62a5\u544a\u9875.png": {
"mtime": 1790000180.7451975,
"ast_hash": "4ff22bccae9a45c44df7c86fe6cb981f",
"semantic_hash": ""
},
"design/09-\u80a4\u8d28\u9009\u62e9.png": {
"mtime": 1790000513.5305562,
"ast_hash": "6d1e321fde605e85357a5be744c4def5",
"semantic_hash": ""
},
"design/assets/images/\u4ea7\u54c1\u56fe-1.png": {
"mtime": 1790000430.1019387,
"ast_hash": "c6df1a56cf0e33713030e3eaa198a5e0",
"semantic_hash": ""
},
"design/assets/images/\u4ea7\u54c1\u56fe-2.png": {
"mtime": 1790000404.3295372,
"ast_hash": "cf579c739843a70967ff2c422d0c9325",
"semantic_hash": ""
},
"design/assets/images/\u4ea7\u54c1\u56fe-3.png": {
"mtime": 1790000421.2071846,
"ast_hash": "6936c50c6e4b85282ddbbda9577f8b9c",
"semantic_hash": ""
},
"design/assets/images/\u4ea7\u54c1\u56fe-4.png": {
"mtime": 1790000399.6728995,
"ast_hash": "9e23a07171a70733d27aa782381d97e6",
"semantic_hash": ""
},
"design/assets/images/\u53f3\u9762-\u62cd\u7167\u793a\u610f.png": {
"mtime": 1790000437.9018357,
"ast_hash": "b3e2b5821568a915996819c1cfc7a904",
"semantic_hash": ""
},
"design/assets/images/\u542f\u52a8\u9875-\u80cc\u666f.png": {
"mtime": 1790000413.581229,
"ast_hash": "bf422f0e71871971b2c80fe42be6b64b",
"semantic_hash": ""
},
"design/assets/images/\u542f\u52a8\u9875-\u9876\u90e8\u88c5\u9970.png": {
"mtime": 1790000426.6591818,
"ast_hash": "b8efcb504f76bd344db8d6e3033b6c3a",
"semantic_hash": ""
},
"design/assets/images/\u5de6\u9762-\u62cd\u7167\u793a\u610f.png": {
"mtime": 1790000416.8443816,
"ast_hash": "77648573f074abec346a3d37e08bcf0a",
"semantic_hash": ""
},
"design/assets/images/\u62a5\u544a\u9875-\u80cc\u666f.png": {
"mtime": 1790000396.365109,
"ast_hash": "bae154cd0079887afd54061a9f82289d",
"semantic_hash": ""
},
"design/assets/images/\u6863\u6848\u586b\u5199-\u80cc\u666f.png": {
"mtime": 1790000418.3826458,
"ast_hash": "43803b9afed439c0a76644a3aedd9c43",
"semantic_hash": ""
},
"design/assets/images/\u68c0\u6d4b\u56fe-1.png": {
"mtime": 1790000408.6502259,
"ast_hash": "974334dd6b09a2d433b1c332b078c552",
"semantic_hash": ""
},
"design/assets/images/\u68c0\u6d4b\u56fe-2.png": {
"mtime": 1790000435.4466016,
"ast_hash": "0183da8dafa710b60f5bd050be660b4f",
"semantic_hash": ""
},
"design/assets/images/\u68c0\u6d4b\u56fe-3.png": {
"mtime": 1790000432.671956,
"ast_hash": "bb835cb62b9d93edd9be15c00b08321e",
"semantic_hash": ""
},
"design/assets/images/\u68c0\u6d4b\u56fe-4.png": {
"mtime": 1790000424.6448433,
"ast_hash": "8b58cd531d67b6cec999af5f70507d93",
"semantic_hash": ""
},
"design/assets/images/\u6b63\u9762-\u62cd\u7167\u793a\u610f.png": {
"mtime": 1790000393.3901157,
"ast_hash": "16c0b62b53b41b1b0b9478b256bc4844",
"semantic_hash": ""
},
"design/assets/svg/\u54c1\u724c-1me.svg": {
"mtime": 1790000462.5893614,
"ast_hash": "9e2d6467c9652c0bf993a63a1cb4b86c",
"semantic_hash": ""
},
"design/assets/svg/\u56fe\u6807-\u4e0b\u62c9\u4e0e\u8fd4\u56de.svg": {
"mtime": 1790000457.297922,
"ast_hash": "969224abe7e91e6c791c6d568a9f520c",
"semantic_hash": ""
},
"design/assets/svg/\u56fe\u6807-\u5173\u95ed.svg": {
"mtime": 1790000455.4710763,
"ast_hash": "b5305a8fd62af5f2b572883261b6d01d",
"semantic_hash": ""
},
"design/assets/svg/\u56fe\u6807-\u76f8\u518c.svg": {
"mtime": 1790000459.09632,
"ast_hash": "b88e51bfc2badd09969127d262692196",
"semantic_hash": ""
},
"design/assets/svg/\u56fe\u6807-\u91cd\u62cd.svg": {
"mtime": 1790000460.9408302,
"ast_hash": "5851ef2c4d9d327e0d2ee5e75457c7f2",
"semantic_hash": ""
},
"design/assets/svg/\u62cd\u7167-\u5feb\u95e8.svg": {
"mtime": 1790000453.87119,
"ast_hash": "cf41a6fa1e677b1fb3e920bc8329a8bf",
"semantic_hash": ""
},
"design/assets/svg/\u6309\u94ae-\u4e3b\u6309\u94ae.svg": {
"mtime": 1790000452.1367342,
"ast_hash": "413cd4ae4a09fe4714e0ca11c12d0f23",
"semantic_hash": ""
},
"design/assets/svg/\u80a4\u8d28-\u9762\u90e8\u63d2\u753b.svg": {
"mtime": 1790000464.5260975,
"ast_hash": "d5196714a0b74dd1776c814b70fd34ca",
"semantic_hash": ""
},
"h5/assets/favicon.svg": {
"mtime": 1790049363.6837668,
"ast_hash": "c607b5042971f9c893c72c50ccabfbd2",
"semantic_hash": ""
},
"h5/assets/img/bg-splash.jpg": {
"mtime": 1790438217.8714879,
"ast_hash": "e1a17b5eca9b820d00e1f1f4ade26dd4",
"semantic_hash": ""
},
"h5/assets/img/cam-front.jpg": {
"mtime": 1790002769.368461,
"ast_hash": "c6fc84aa9669a62e93827a8afc8080c5",
"semantic_hash": ""
},
"h5/assets/img/cam-left.jpg": {
"mtime": 1790528422.8225558,
"ast_hash": "778113ed834bf0d7524f285a4796d0c6",
"semantic_hash": ""
},
"h5/assets/img/cam-right.jpg": {
"mtime": 1790528422.8225558,
"ast_hash": "0204aaa790b6ec96458fe15cafc8c09f",
"semantic_hash": ""
},
"h5/assets/img/det-1.jpg": {
"mtime": 1790002770.475919,
"ast_hash": "a40b32001b6eec4e540cf8c59ce6cfff",
"semantic_hash": ""
},
"h5/assets/img/det-2.jpg": {
"mtime": 1790002770.4835565,
"ast_hash": "32958edf9582ef0e76aae6ffbb234e88",
"semantic_hash": ""
},
"h5/assets/img/prod-1.jpg": {
"mtime": 1790002770.4491873,
"ast_hash": "f4785fb93b92b943bf82e33383bf2989",
"semantic_hash": ""
},
"h5/assets/img/skin-0-dry.png": {
"mtime": 1790002750.3837938,
"ast_hash": "d29aa6f0991f1d323c2070f8d176f29a",
"semantic_hash": ""
},
"h5/assets/img/skin-1-mixeddry.png": {
"mtime": 1790002752.342788,
"ast_hash": "bb292677c581ce480c1a46619c1a3875",
"semantic_hash": ""
},
"h5/assets/img/skin-2-neutral.png": {
"mtime": 1790002753.958099,
"ast_hash": "f355bd6e48f75ba4b3e15a8099e540f7",
"semantic_hash": ""
},
"h5/assets/img/skin-3-oily.png": {
"mtime": 1790002755.8330815,
"ast_hash": "be8f518b42e53cf97f57451c1270ac29",
"semantic_hash": ""
},
"h5/assets/img/skin-4-mixedoily.png": {
"mtime": 1790002759.3910704,
"ast_hash": "8aa99ca6593dcb550492ce483940807a",
"semantic_hash": ""
},
"h5/assets/svg/\u54c1\u724c-1me.svg": {
"mtime": 1790002759.3987067,
"ast_hash": "9e2d6467c9652c0bf993a63a1cb4b86c",
"semantic_hash": ""
},
"h5/assets/svg/\u56fe\u6807-\u4e0b\u62c9\u4e0e\u8fd4\u56de.svg": {
"mtime": 1790002759.3987067,
"ast_hash": "969224abe7e91e6c791c6d568a9f520c",
"semantic_hash": ""
},
"h5/assets/svg/\u56fe\u6807-\u5173\u95ed.svg": {
"mtime": 1790002759.3987067,
"ast_hash": "b5305a8fd62af5f2b572883261b6d01d",
"semantic_hash": ""
},
"h5/assets/svg/\u56fe\u6807-\u76f8\u518c.svg": {
"mtime": 1790002759.3987067,
"ast_hash": "b88e51bfc2badd09969127d262692196",
"semantic_hash": ""
},
"h5/assets/svg/\u56fe\u6807-\u91cd\u62cd.svg": {
"mtime": 1790002759.3987067,
"ast_hash": "5851ef2c4d9d327e0d2ee5e75457c7f2",
"semantic_hash": ""
},
"h5/assets/svg/\u62cd\u7167-\u5feb\u95e8.svg": {
"mtime": 1790002759.3987067,
"ast_hash": "cf41a6fa1e677b1fb3e920bc8329a8bf",
"semantic_hash": ""
},
"h5/preview/01-splash.png": {
"mtime": 1790050131.6772592,
"ast_hash": "c715cc3da1bad38ce7bf023746e8081e",
"semantic_hash": ""
},
"h5/preview/02-privacy.png": {
"mtime": 1790050131.6772592,
"ast_hash": "d04922d16ce5b9cdd7e7f2ac4c4b09a3",
"semantic_hash": ""
},
"h5/preview/03-policy.png": {
"mtime": 1790050131.6772592,
"ast_hash": "e8913d2d0d9aa11fb6c57f5c9bc774df",
"semantic_hash": ""
},
"h5/preview/04-profile.png": {
"mtime": 1790050131.6772592,
"ast_hash": "f0a6894d702f773ffb1bedd7b49812a2",
"semantic_hash": ""
},
"h5/preview/05-profile-skin1.png": {
"mtime": 1790050131.6772592,
"ast_hash": "1d47579cd47578c383e244e9aa6b9282",
"semantic_hash": ""
},
"h5/preview/06-profile-skin3.png": {
"mtime": 1790050131.6772592,
"ast_hash": "3974c06483739676578d320e485a7e8c",
"semantic_hash": ""
},
"h5/preview/07-photoguide.png": {
"mtime": 1790050131.6772592,
"ast_hash": "0d90b2f73832739a7f52c9a236114a60",
"semantic_hash": ""
},
"h5/preview/08-cam-front.png": {
"mtime": 1790528422.8265557,
"ast_hash": "6a91a1ad636b7dfc6894b24dbf56a28b",
"semantic_hash": ""
},
"h5/preview/09-cam-left.png": {
"mtime": 1790528422.8265557,
"ast_hash": "b5112fc38a23d5a56705a54bc4459120",
"semantic_hash": ""
},
"h5/preview/10-cam-right.png": {
"mtime": 1790528422.8265557,
"ast_hash": "336a83daf1bb236f1eba0938cffba08d",
"semantic_hash": ""
},
"h5/preview/11-report-top.png": {
"mtime": 1790050131.6772592,
"ast_hash": "c02509ee4f78ef5120a151fce6698d51",
"semantic_hash": ""
},
"h5/preview/12-report-mid.png": {
"mtime": 1790050131.6772592,
"ast_hash": "664b00f9c7123cd19aa98b9297d91552",
"semantic_hash": ""
},
"h5/preview/13-report-bottom.png": {
"mtime": 1790050131.6772592,
"ast_hash": "664b00f9c7123cd19aa98b9297d91552",
"semantic_hash": ""
},
"h5/preview/14-report-vm-viewport.png": {
"mtime": 1790438217.8791654,
"ast_hash": "180ce2cde0f2f40000cc7f8d6fe955c3",
"semantic_hash": ""
},
"h5/preview/15-report-vm-full.png": {
"mtime": 1790438217.8791654,
"ast_hash": "280932f0bc053e918c314cb745e851ae",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/01-splash.png": {
"mtime": 1790438217.8945203,
"ast_hash": "8e2627a1b5bc4bc112c89d0fc9ca0b15",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/02-privacy.png": {
"mtime": 1790438217.898359,
"ast_hash": "32bb6862f6706b46627d4ce93732fd74",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/03-profile.png": {
"mtime": 1790438217.898359,
"ast_hash": "50da58d92e1a70467cc79a370f0c0d4b",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/04-photoguide.png": {
"mtime": 1790438217.9021978,
"ast_hash": "dba989b00270fc733b2478ca4bc06bae",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/05-cam-step1.png": {
"mtime": 1790438217.9021978,
"ast_hash": "d0bf44295ec0eead4db79a8513e7cee6",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/06-mock-report-bottom.png": {
"mtime": 1790438217.9060364,
"ast_hash": "33cb9b3ab0982b2c8d9ddab060153502",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/06-mock-report-tab0.png": {
"mtime": 1790438217.9060364,
"ast_hash": "e0f8cb949cd37fe5fbec206ab9700d36",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/06-mock-report-tab1.png": {
"mtime": 1790438217.9098752,
"ast_hash": "e693fbe6914b8f56cb186534493c7b83",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/06-mock-report-tab2.png": {
"mtime": 1790438217.9098752,
"ast_hash": "f864c88ef9088ff1b130dd35372d150c",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/08-real-report-bottom.png": {
"mtime": 1790438217.9098752,
"ast_hash": "a458d0674304fb3b1312caa915c8e515",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/08-real-report-tab0.png": {
"mtime": 1790438217.913714,
"ast_hash": "923315723540ffd1e8759ffd6b29ffb8",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/08-real-report-tab1.png": {
"mtime": 1790438217.913714,
"ast_hash": "9c39170a3937002e8dc2a2a494c654f4",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/08-real-report-tab2.png": {
"mtime": 1790438217.9175527,
"ast_hash": "30a60c22a71b98c1c4ff8a2f065bae7e",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/09-policy-end.png": {
"mtime": 1790438217.9175527,
"ast_hash": "0c46468e37116cdbc4380cf226630c70",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/09-policy-mid.png": {
"mtime": 1790438217.9213915,
"ast_hash": "16ed812b52d452db5153bff5a8f3f964",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/09-policy-top.png": {
"mtime": 1790438217.9213915,
"ast_hash": "29b3c9f9083afe6810a0c75e7b3a845b",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/vp352x736-splash.png": {
"mtime": 1790438217.9290688,
"ast_hash": "cc4e8f45d3a6aca94710b7c099cb895d",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/vp375x812-splash.png": {
"mtime": 1790438217.9329076,
"ast_hash": "8e2627a1b5bc4bc112c89d0fc9ca0b15",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/vp390x844-splash.png": {
"mtime": 1790438217.940585,
"ast_hash": "9a5d05c6534f65ba6cd244b2d2fc55c1",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/vp414x896-splash.png": {
"mtime": 1790438217.9521012,
"ast_hash": "09350489e9692fc8b3b7b70c1a0de047",
"semantic_hash": ""
},
"h5/preview/verify-report.png": {
"mtime": 1790438217.9521012,
"ast_hash": "65029ee2e9ee620e2513c999ad5650cb",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/examples/images/C3_-135.jpg": {
"mtime": 1790407491.0,
"ast_hash": "65e145520cf438df78b985ddddebe6fd",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/examples/images/C3_135.jpg": {
"mtime": 1790407491.0,
"ast_hash": "af8c5642cfa8b4a4af1f48e4bb02f602",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/examples/images/C3_180.jpg": {
"mtime": 1790407491.0,
"ast_hash": "3b3ba2bbe11175c76f25ea63730106bd",
"semantic_hash": ""
},
"h5/assets/voice/analyze-fail.mp3": {
"mtime": 1790438527.0770879,
"ast_hash": "d1c9babebdf8ce4a4b84cbcc91ae75a8",
"semantic_hash": ""
},
"h5/assets/voice/analyzing.mp3": {
"mtime": 1790438526.9699075,
"ast_hash": "215590766d8155ba6a31623ad124990f",
"semantic_hash": ""
},
"h5/assets/voice/countdown-321.mp3": {
"mtime": 1790476628.9047492,
"ast_hash": "2d70857eb145ff43c48070e3b1dc68e7",
"semantic_hash": ""
},
"h5/assets/voice/hint-far.mp3": {
"mtime": 1790438526.610088,
"ast_hash": "5909ae510464763f60d0c0d22a8253e7",
"semantic_hash": ""
},
"h5/assets/voice/hint-front.mp3": {
"mtime": 1790438526.6636782,
"ast_hash": "fb3d866925dcde339e99a46a83c41598",
"semantic_hash": ""
},
"h5/assets/voice/hint-hold.mp3": {
"mtime": 1790438526.7938256,
"ast_hash": "a12c24dff61679831b24d9dfad121ad1",
"semantic_hash": ""
},
"h5/assets/voice/hint-near.mp3": {
"mtime": 1790438526.5718093,
"ast_hash": "eb3ad138d3d9bf45cefd82c8b1c2013f",
"semantic_hash": ""
},
"h5/assets/voice/hint-noface.mp3": {
"mtime": 1790438526.5105634,
"ast_hash": "c3ef6d710e1cacb9a03f00bf1a08fd45",
"semantic_hash": ""
},
"h5/assets/voice/hint-turn-left.mp3": {
"mtime": 1790438526.7057848,
"ast_hash": "03596c1fcd62f0656c0c674a67ac0649",
"semantic_hash": ""
},
"h5/assets/voice/hint-turn-right.mp3": {
"mtime": 1790438526.755547,
"ast_hash": "7a73db280cb86b3c513f044e6cceeaa2",
"semantic_hash": ""
},
"h5/assets/voice/shot-done.mp3": {
"mtime": 1790438526.897178,
"ast_hash": "09eaefdee08f5f5edbe75c58fe57f1ff",
"semantic_hash": ""
},
"h5/assets/voice/step1-front.mp3": {
"mtime": 1790438526.2005062,
"ast_hash": "8b19ad0d6dd7692bc6a10fcab5fd6ee7",
"semantic_hash": ""
},
"tools/voice_wav_tmp/analyze-fail.wav": {
"mtime": 1790438526.9737353,
"ast_hash": "65abad5af593c912e19c245c6f14483c",
"semantic_hash": ""
},
"tools/voice_wav_tmp/analyzing.wav": {
"mtime": 1790438526.897178,
"ast_hash": "08ffd1d47f4c9d83f17120a938ef1839",
"semantic_hash": ""
},
"tools/voice_wav_tmp/countdown-321.wav": {
"mtime": 1790476628.7545965,
"ast_hash": "3b8e9ffcab556fd7adf8ec393bc39a82",
"semantic_hash": ""
},
"tools/voice_wav_tmp/hint-far.wav": {
"mtime": 1790438526.5718093,
"ast_hash": "4b7b0555fc3a826bcb18d251247b6aea",
"semantic_hash": ""
},
"tools/voice_wav_tmp/hint-front.wav": {
"mtime": 1790438526.613916,
"ast_hash": "64e7c24d89c5ded6bfcd7fabc8b9f314",
"semantic_hash": ""
},
"tools/voice_wav_tmp/hint-hold.wav": {
"mtime": 1790438526.7593749,
"ast_hash": "406f91fa180fe2eb6502e26ab8d30170",
"semantic_hash": ""
},
"tools/voice_wav_tmp/hint-near.wav": {
"mtime": 1790438526.5143914,
"ast_hash": "44baaf9c5b68bdcab51893d0464c950e",
"semantic_hash": ""
},
"tools/voice_wav_tmp/hint-noface.wav": {
"mtime": 1790438526.3957274,
"ast_hash": "5289e30144bda0019352fcae944e253a",
"semantic_hash": ""
},
"tools/voice_wav_tmp/hint-turn-left.wav": {
"mtime": 1790438526.667506,
"ast_hash": "cb4b8752c0d918e0a7d0f10eeeb510a8",
"semantic_hash": ""
},
"tools/voice_wav_tmp/hint-turn-right.wav": {
"mtime": 1790438526.7096126,
"ast_hash": "61513064e4a0b0727e0e68ac5d41ed43",
"semantic_hash": ""
},
"tools/voice_wav_tmp/shot-done.wav": {
"mtime": 1790438526.866555,
"ast_hash": "b7053fef4fb194acb8646d105970fdaf",
"semantic_hash": ""
},
"tools/voice_wav_tmp/step1-front.wav": {
"mtime": 1790438526.0703588,
"ast_hash": "f528486fa595066fb8c89ba68f82987f",
"semantic_hash": ""
},
"tools/voice_wav_tmp/step2-rightface.wav": {
"mtime": 1790438526.204334,
"ast_hash": "5b4d9391087ec0f7aeaff7efdbacd39b",
"semantic_hash": ""
},
"tools/voice_wav_tmp/step3-leftface.wav": {
"mtime": 1790438526.322998,
"ast_hash": "890de5970d83b76604a1823f47faa567",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/10-history-list.png": {
"mtime": 1790476530.4511077,
"ast_hash": "98aac9734d0734ab786479ea4b46db55",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/11-auto3shot-report.png": {
"mtime": 1790476530.4548612,
"ast_hash": "010ce5731435048085fe4a2ae4edd558",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/debug-cam-live.png": {
"mtime": 1790476530.4548612,
"ast_hash": "6519b24c8da157b893ec654fec5c6457",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r6-region.png": {
"mtime": 1790476530.4586143,
"ast_hash": "641d1de74e43e17f59c93083eb449a87",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r6-report.png": {
"mtime": 1790476530.4586143,
"ast_hash": "411861be4980af6506712519d63fb5e1",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r7-policy.png": {
"mtime": 1790476530.4586143,
"ast_hash": "b18e55a76b10c77f68934c9655d323aa",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r7-tab1.png": {
"mtime": 1790476530.4586143,
"ast_hash": "731fac1d0ce5efb0d8914b18b51c2177",
"semantic_hash": ""
},
"h5/test/e2e-browser.mjs": {
"mtime": 1790531366.4343376,
"ast_hash": "3ae597baedac698f02f27c5662784348",
"semantic_hash": ""
},
"h5/assets/svg/prod-badge.svg": {
"mtime": 1790528422.8225558,
"ast_hash": "672628fa098279e7ff6af11ac46f0d40",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r8-cam-step2.png": {
"mtime": 1790528158.6665542,
"ast_hash": "426f78d3a7707a6055de143e1b7db62f",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r8-report-mid.png": {
"mtime": 1790528158.6665542,
"ast_hash": "fa33c1814681a8875c059535725cc3fb",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r9-hint.png": {
"mtime": 1790528158.6705542,
"ast_hash": "12e66e2c4dd2a2eb3868e9ebf3422823",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r9-tab.png": {
"mtime": 1790528158.6705542,
"ast_hash": "5844e3cdce417de3c7cd448cc226688b",
"semantic_hash": ""
},
"h5/assets/voice/step2-rightface.mp3": {
"mtime": 1790528422.8225558,
"ast_hash": "8c0a0adf17ae494cc10d61d16c2bde90",
"semantic_hash": ""
},
"h5/assets/voice/step3-leftface.mp3": {
"mtime": 1790528422.8225558,
"ast_hash": "9f7ec9320aad81e86fbb1677228e6483",
"semantic_hash": ""
}
}
+91 -24
View File
@@ -10,10 +10,18 @@
```
h5/
├── index.html 页面结构(启动 / 档案 / 拍照 / 报告 + 弹层 + 内联 SVG 取景蒙版)
├── server.js 后端:静态托管 + POST /api/skin-analyze(Face++ skinanalyze_pro 三视图中转)
├── css/style.css 全部样式(375px 基准)
├── admin.html 后台查看页(记录列表 / 失败与异常 / 统计概览,零依赖,见下文「后台查看」)
├── server.js 后端:静态托管 + POST /api/skin-analyze(Face++ skinanalyze_pro 三视图中转)+ /api/admin/*(记录查询/重试/统计/导出)
├── css/style.css C 端全部样式(375px 基准)
├── css/admin.css 后台查看页样式
├── js/app.js 全部交互(屏幕切换、肤质轮播、引导式三连拍、报告渲染)
├── js/skin-score.js 三维度×6分类评分引擎(纯逻辑 UMD,window.SkinScore,可 node 自测)
├── js/admin.js 后台查看页脚本(纯原生 ES5)
├── js/skin-score.js 三维度×6分类评分引擎(纯逻辑 UMD,window.SkinScore,可 node 自测;服务端评分复用同一份)
├── js/head-pose.js 头部姿态估算(纯逻辑 UMD,window.HeadPose,可 node 自测)
├── data/records.jsonl 检测记录库(一行一条 JSON,零依赖嵌入式存储,已 .gitignore)
├── data/photos/ 检测照片 data/photos/<record_id>/{front,left,right}.jpg(本地磁盘代替对象存储)
├── data/seed.js 演示种子数据(一次性:node h5/data/seed.js,覆盖重写 records.jsonl)
├── data/results/ 每次检测的 Face++ 原始返回落盘(复核用,已 .gitignore)
├── js/head-pose.js 头部姿态估算(纯逻辑 UMD,window.HeadPose,可 node 自测)
├── test/regression.js 真值回归(node h5/test/regression.js,对照 docs xlsx 两组三视图真值)
├── vendor/faceapi/ face-api.js 0.22.2 + 模型(本地化,见该目录 README)
@@ -46,27 +54,36 @@ 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 坐标),改该文件即可换样本。
## 拍照交互(引导式自动三连拍)
## 拍照交互(动态捕捉自动三连拍)
三步:1/3 正脸(target `front`)→ 2/3 右脸出镜(用户头向左转,target `right`,产物作 `right_image`)→ 3/3 左脸出镜(头向右转,target `left`,产物作 `left_image`)。
三步严格按顺序,本步拍到合格照片才进入下一步(左右一律以用户本人为准):1/3 正对屏幕(target `front`)→ 2/3 右脸出镜(用户头向自己的左侧转,target `right`,产物作 `right_image`)→ 3/3 左脸出镜(头向自己的右侧转,target `left`,产物作 `left_image`)。送检前 `photosByView` 按 target 重排为 [正, 左, 右]。页面内没有返回按钮,微信/浏览器返回键经 History API 回到档案页并释放摄像头。
- **步骤圆点**:顶部三点,当前步高亮、完成步绿色打勾。
- **取景蒙版**:内联 SVG(`#cam-mask`)按步骤切换轮廓——正脸步为正脸线框(含耳部),侧脸步为对应方向的侧脸剪影(鼻朝向与转头方向一致);框外 58% 黑色半透明压暗(SVG mask 镂空),轮廓线 2.5px 半透明白。未检测到人脸时轮廓呼吸动画(1.8s);姿态 ok 时轮廓变绿并停止动效。
- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`)。进入拍照页即开始加载模型(状态提示"检测引擎加载中…"),加载完成前快门为手动模式。检测循环 ~150ms 一帧,`detectSingleFace(video, TinyFaceDetectorOptions({inputSize:320, scoreThreshold:0.4})).withFaceLandmarks()` 后拿 68 特征点调 `HeadPose.check(pos, target, /* mirrored= */true)`;人脸框高度占比 <35% 提示"请靠近一点"、>80% 提示"请远一点"。提示一次一条,优先级:无人脸 > 距离 > 角度。
- **倒计时自动拍**:姿态 ok 连续维持 ≥600ms 进入 3s 倒计时(中央大数字,轮廓保持绿色);倒计时中任一帧不 ok 立即取消并回到对应提示。倒计时结束进入白屏补光。
- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(镜像 + 最长边 1280 JPEG),保持 ~300ms 再淡出(总 600–900ms),同步 `navigator.vibrate(30)`。
- **手动兜底**:快门任何时刻可拍当前步(同样走白屏补光),不依赖检测;face-api/模型加载失败 → toast"自动识别不可用,已切换手动拍摄",全程手动。系统相机降级模式(getUserMedia 不可用/被拒)维持原样:无检测、无补光。
- **无进展提示**:单步 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 切换;点检测图全屏放大,再点或按返回键回到报告。
## 检测流程与评分渲染
1. 三视图送检:POST `/api/skin-analyze`,请求 `{ front_image, left_image, right_image }`(base64 可带 dataURL 前缀;正脸=photos[0],左=photos[2],右=photos[1])。
1. 三视图送检:POST `/api/skin-analyze`,请求 `{ front_image, left_image, right_image, profile? }`(base64 可带 dataURL 前缀;正脸=photos[0],左=photos[2],右=photos[1];`profile` 为可选档案 `{real_age,gender,region,daily_skin_type}`,后台记录用)。
2. 服务端转发 Face++ **专业版 `skinanalyze_pro`**(三视图必须走 `image_file` 文件形式),请求 `return_side_results=jawline_info` 取左右下颌线角度,`return_maps`/`return_marks` 取叠加图与标注;响应 `{ ok:true, result, face_rectangle, side:{ left, right } }`,失败 `{ ok:false, code, message }`(message 已是用户可读文案)。`result.face_maps` 为对象,含报告页所需图层的完整 data URL(`red_area`/`brown_area`/`texture_enhanced_lines`/`texture_enhanced_pores`/`texture_enhanced_blackheads`,由 server.js 的 `REPORT_MAPS` 常量控制);`marks` 坐标(`acne`/`brown_spot`/`mole`/`closed_comedones`/`acne_pustule`/`acne_nodule`/`wrinkle_mark`/`melanin_mark`/`dark_circle_mark`)随 `result` 原样透传,与上传图片同一像素空间。
3. 评分由 `js/skin-score.js` 引擎完成,公式/锁值/话术/产品推荐以 `docs/三维度评分与话术生成需求文档_V2.2.docx` 为最高契约(V2.2 口径):
3. 评分由 `js/skin-score.js` 引擎完成,公式/锁值/话术/产品推荐以 `docs/三维度评分与话术生成需求文档_V2.3.docx` 为最高契约(V2.3 口径,含 §8.5 护理建议五段):
- 6 分类:轮廓 = min(眼袋映射分, 下颌线角度分);皱纹(12 分区);光滑(毛孔/黑头/纹理);肤色水润(色素×水润);敏感(泛红面积取 `red_spot.red_spot_area`/红区分);痘痘(分级映射×丘疹 `acne.count` 计数扣分);分组按实际年龄;
- 三维度木桶 min:紧塑力(轮廓+皱纹)、活力感(光滑+肤色水润)、稳定力(敏感+痘痘);
- S = 7 + 3×P/100;档位:良好(P≥90)/改善(80–90)/预警(<80);肤龄钳制到实际年龄 ±3 并留痕;三视图版 18 条话术;
@@ -74,10 +91,34 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
4. 前端 `SkinScore.buildViewModel(data.result, data.side, { realAge, skinType })` → viewModel 直渲报告页:
- 头部:综合评分(`vm.total`,缺失回退三维度 S 均分×10)、肤龄 `vm.skinAgeDisplay` + 钳制标记 `vm.ageClampNote`;三维度 S(1 位小数)+ 档位配色(良好绿/改善琥珀/预警红);
- 三个 Tab 面板各展示所辖两分类:分类名、S + 档位徽章、话术正文(`vm.cats.*.text`);P 值不直接展示,`P=null` 的分类显示"本次未检出有效数据";
- 报告底部:降级标注(`vm.notes` 非空才展示,如下颌线未参与评分)、综合分析(`vm.summary`)、生活方式建议(`vm.lifestyle`)、精华推荐卡(`vm.essence` + 触发判据);
- 报告底部:降级标注(`vm.notes` 中与侧脸/下颌线/补拍相关的条目,去掉接口质量码后展示)、护理建议五段(`vm.careAdvice`)、精华推荐卡(`vm.essence.name` + 命中判据;推荐正文已在护理建议第 4 段,卡内不重复);
- 近期优先关注 = 最差维度(`vm.focus`);6 分类各配检测可视化图:轮廓=正脸实拍、皱纹=正脸压暗+`texture_enhanced_lines` 皱纹增强线(线图层几乎全透明,直挂白底不可读,故与压暗沉脸合成)、光滑=正脸+色斑/痣/痤疮标记叠加、肤色=`brown_area` 色沉图、敏感=`red_area` 红区图、痘痘=正脸+闭口粉刺/痤疮标记叠加(marks 按原像素坐标叠加到正脸实拍)。
5. 真值回归:`node h5/test/regression.js` 以 `docs/皮肤检测测试结果_三视图_20260926.xlsx`(sheet「三视图检测汇总」,数据第 3/4 行=案例1/案例2)两组三视图真值对照评分引擎输出。
6. 检测失败按服务端 message 提示并退回档案页。
5. 浏览器冒烟:`node h5/test/e2e-browser.mjs`(需全局 `playwright` + `npx playwright install chromium`,服务跑在 8080)——mock 三连拍到报告页,并核对 14 条语音可拉取、流程语音完整播完不被截断。
6. 真值回归:`node h5/test/regression.js` 以 `docs/皮肤检测测试结果_三视图_20260926.xlsx`(sheet「三视图检测汇总」,数据第 3/4 行=案例1/案例2)两组三视图真值对照评分引擎输出。
7. **报告历史**:检测成功后按「检测」为单位存入 localStorage(`juzi.reports.v1`,上限 5 条,超配额逐条淘汰)——含三张照片、档案、全部评分数据与 marks 坐标,**不含 face_maps 整幅图层**(体积原因;回看时皱纹/肤色/敏感图降级为占位,光滑/痘痘标记叠加仍可用)。当前无 UI 入口(第9轮移除),数据仅作本地追溯。
8. **原始返回落盘**:服务端每次检测成功后把 Face++ 完整原始返回写入 `h5/data/results/<时间戳>-<request_id>.json`(复核/真值用,已 .gitignore;写盘失败不影响响应)。
9. 检测失败按服务端 message 提示并退回档案页。
## 后台查看(admin.html)
本地查看版后台,浏览器打开 **`http://127.0.0.1:8080/admin.html`**(无登录鉴权,仅内网/本地使用;上线前需加网关或鉴权)。请求体除三张照片外还带可选档案 `profile:{real_age,gender,region,daily_skin_type}`(前端 `js/app.js` 自动附带,年龄非法按 null),服务端据此落库并在检测成功时用 `SkinScore.buildViewModel` 现场评分(与 C 端同一份公式)。
- **数据落库**:每次有效调用(正脸照片校验通过起)生成 `record_id`,照片写 `data/photos/<record_id>/`,记录(档案/照片地址/拍摄与上传状态/检测状态与失败原因/评分/文案/精华/耗时/raw 文件名/重试关系)追加到 `data/records.jsonl`。C 端响应结构不变。
- **记录列表**:多条件筛选(时间区间/检测状态/失败原因/年龄/产品组别/维度+档位/记录ID),行点击开详情抽屉(照片大图与下载、状态、六分类与三维度评分表、文案全文、护理建议、精华与触发判据、计算备注、接口原始 JSON 折叠查看、手动重试)。
- **失败与异常**:失败原因分布条 + 失败列表(每条可一键重试:用原照片重新检测,生成新记录并挂版本链)。
- **统计概览**:今日/累计/成功率/平均耗时卡片,三维度档位分布,产品组别分布(纯 CSS 饼图),近 14 天每日检测量柱状图,按日期区间导出 CSV(UTF-8 BOM,不含 raw_json)。
- **演示数据**:`node h5/data/seed.js` 生成约 10 条演示记录(覆盖重写 records.jsonl,照片复制自 `reference/facepp-skinanalyze/examples/images/` 真实三视图)。
### Admin API(统一返回 `{ok:false,message}` 错误)
| 接口 | 说明 |
| --- | --- |
| `GET /api/admin/records` | 分页查询。参数:`from/to`(ISO 日期)、`detect_status`、`fail_code`、`age_min/age_max`、`group`、`dim`(firm/vita/stab)+`dim_level`、`q`(record_id 前缀)、`page`、`page_size`(默认 20)、`only_fail=1`。返回 `{total,page,page_size,items}`(倒序,含照片 URL,不含 raw_json 与文案全文) |
| `GET /api/admin/records/:id` | 完整记录 + `raw_json` 全文(读失败为 null)+ `photos` URL + `versions`(版本链:父记录与重试子记录) |
| `POST /api/admin/records/:id/retry` | 手动重试:原照片重新检测,生成新记录(`retry_of`=原 id),原记录 `retry_count+1`,返回新记录 |
| `GET /api/admin/photos/:id/:which` | 照片回显(`which`=front/left/right),`image/jpeg` inline;文件不存在 404 |
| `GET /api/admin/stats` | 今日/累计检测量、成功率、平均耗时、失败原因分布、三维度档位分布、产品组别分布、近 14 天每日检测量/成功数 |
| `GET /api/admin/export?from=&to=` | CSV 导出(UTF-8 BOM,Content-Disposition attachment 带日期区间文件名);禁止 raw_json 进单元格(Excel 单格 32767 字符截断) |
## 部署
@@ -87,6 +128,24 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
PORT=8080 node server.js # 建议 pm2 / systemd 常驻
```
### 线上部署(juzi.xiangsilian.com)
- Node 只绑回环(`HOST` 默认 `127.0.0.1`,公网入口仅 nginx;需局域网调试才 `HOST=0.0.0.0`),nginx 反代 + certbot HTTPS,见 `/etc/nginx/sites-available/juzi`。
- **后台 Basic Auth**:nginx 对 `admin.html`、`css/admin.css`、`js/admin.js`、`/api/admin/` 做 `auth_basic`(账密 `/etc/nginx/.htpasswd-juzi`),`/data/`(照片/原始 JSON/记录库)由 nginx 404 + server.js 静态层 403 双重封锁,照片只能走鉴权后的 `/api/admin/photos`。**上线正式版前应替换为文档要求的后台账号体系。**
- 服务常驻建议 systemd:
```ini
[Unit]
Description=juzi skin h5
After=network.target
[Service]
WorkingDirectory=/home/ubuntu/juzi/h5
ExecStart=/usr/bin/node server.js
Restart=always
[Install]
WantedBy=multi-user.target
```
### Windows 本机部署(提测用)
```bat
@@ -100,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 终结):
@@ -118,17 +177,25 @@ server {
}
```
> **HTTPS 必需**:getUserMedia 相机权限仅在安全上下文可用(localhost 除外)。正式域名必须上 HTTPS,否则前端自动降级为系统相机拍照。
> **HTTPS 必需**:手机自动拍摄要求安全上下文,正式域名必须使用 HTTPS;本机 localhost 例外。相机不可用时页面显示重试与相册入口。
## 已知占位清单
- 产品图(prod-1.jpg)为设计稿素材;检测图已替换为用户实拍。
- 相册、切换镜头、「了解更多」为开发中占位(Toast 提示)。
- **侧脸角度符号与 yaw 系数需真机校准**:`head-pose.js` 的 `YAW_COEFF`/pitch 映射与 `mirrored` 符号在不同机型/镜像策略下可能相反,上线前用真机样本回归(校准接口:调用方 `mirrored` 标志与 `HeadPose.config`)。
- 语音 14 条已由 `tools/gen_voice.py` 生成;改文案后重跑脚本并同步 `manifest.json`。
- 「了解更多」为开发中占位(Toast 提示);拍照页右下按钮为切换前后镜头(系统相机降级模式下提示在系统相机内切换)。
- 产品推荐卡(COREⅡ 双仓精华液)为设计稿固定素材,不随精华推荐档位(25+/35+/50+)变化,待产品提供三档对应素材。
- **真机复核**:头部姿态系数沿用既有校准,新增多人、屏幕坐标距离/居中与亮度闸门已通过可控浏览器输入测试。前后镜头、不同手机曝光及微信相机权限仍需用真实设备复核;自动化测试不替代硬件实拍。
## 技术说明
- 尺寸换算:设计稿 1080px 基准 ÷ 2.88 = 375px 落地基准。
- 图片性能:首屏背景 preload;首屏外 `loading="lazy"`。
- 快门防连点/双流程令牌(camSeq/camSeqCam)防竞态;拍照页 15% 压暗遮罩 + 蒙版 58% 镂空压暗保证白字可读。
- 快门防连点/双流程令牌(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 路径时使用全局安装。
+154
View File
@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>巨子测肤 · 后台查看</title>
<link rel="stylesheet" href="css/admin.css">
</head>
<body>
<header class="topbar">
<div class="brand">巨子测肤<span>后台查看</span></div>
<nav class="tabs">
<button type="button" class="tab on" data-tab="records">记录列表</button>
<button type="button" class="tab" data-tab="fails">失败与异常</button>
<button type="button" class="tab" data-tab="stats">统计概览</button>
</nav>
</header>
<main>
<!-- ─── 记录列表(文档 4.1/4.2) ─── -->
<section id="tab-records" class="panel on">
<div class="filters">
<label>创建时间 <input type="date" id="f-from"> ~ <input type="date" id="f-to"></label>
<label>检测状态
<select id="f-status">
<option value="">全部</option>
<option>待检测</option><option>检测中</option><option>成功</option><option>失败</option>
</select>
</label>
<label>失败原因
<select id="f-failcode">
<option value="">全部</option>
<option>NO_FACE_FOUND</option>
<option>INVALID_IMAGE_FACE</option>
<option>INVALID_FACE_SIZE</option>
<option>INVALID_IMAGE_SIZE</option>
<option>IMAGE_FILE_TOO_LARGE</option>
<option>IMAGE_ERROR_UNSUPPORTED_FORMAT</option>
<option>AUTHENTICATION_ERROR</option>
<option>AUTHORIZATION_ERROR</option>
<option>CONCURRENCY_LIMIT_EXCEEDED</option>
<option>INTERNAL_ERROR</option>
<option>TIMEOUT</option>
<option>NETWORK_ERROR</option>
</select>
</label>
<label>年龄 <input type="number" id="f-agemin" min="1" max="120" placeholder="最小"> ~ <input type="number" id="f-agemax" min="1" max="120" placeholder="最大"></label>
<label>产品组别
<select id="f-group">
<option value="">全部</option>
<option>25+组</option><option>35+组</option><option>50+组</option>
</select>
</label>
<label>维度
<select id="f-dim">
<option value="">不限</option>
<option value="firm">紧塑力</option>
<option value="vita">活力感</option>
<option value="stab">稳定力</option>
</select>
<select id="f-dimlevel">
<option value="">不限档位</option>
<option>预警</option><option>改善</option><option>良好</option>
</select>
</label>
<label>记录ID <input type="text" id="f-q" placeholder="精确/前缀匹配"></label>
<button type="button" class="btn primary" id="btn-search">查询</button>
<button type="button" class="btn" id="btn-reset">重置</button>
</div>
<div class="table-wrap">
<table id="tbl-records">
<thead>
<tr>
<th>照片</th><th>记录ID</th><th>年龄/性别/地区/肤质</th><th>拍摄状态</th><th>上传状态</th>
<th>检测状态</th><th>总分</th><th>三维度档位</th><th>推荐精华</th><th>耗时</th><th>创建时间</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="pager">
<button type="button" class="btn" id="btn-prev">上一页</button>
<span id="page-info">-</span>
<button type="button" class="btn" id="btn-next">下一页</button>
</div>
</section>
<!-- ─── 失败与异常(文档 4.4) ─── -->
<section id="tab-fails" class="panel">
<div id="fail-dist" class="fail-dist"></div>
<div class="table-wrap">
<table id="tbl-fails">
<thead>
<tr>
<th>记录ID</th><th>失败环节</th><th>失败码</th><th>失败信息</th><th>年龄</th><th>拍摄状态</th><th>创建时间</th><th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- ─── 统计概览(文档 4.5) ─── -->
<section id="tab-stats" class="panel">
<div class="cards" id="stat-cards"></div>
<div class="grid2">
<div class="block">
<h3>三维度档位分布</h3>
<div id="dim-dist"></div>
</div>
<div class="block">
<h3>产品组别分布</h3>
<div id="group-dist" class="pie-wrap"></div>
</div>
</div>
<div class="block">
<h3>近 14 天每日检测量</h3>
<div id="daily-chart" class="daily-chart"></div>
</div>
<div class="block">
<h3>导出 CSV</h3>
<div class="export-bar">
<label>起始日期 <input type="date" id="exp-from"></label>
<label>结束日期 <input type="date" id="exp-to"></label>
<button type="button" class="btn primary" id="btn-export">导出 CSV</button>
<span class="hint">UTF-8 BOM,Excel 直接打开;不含接口原始 JSON</span>
</div>
</div>
</section>
</main>
<!-- ─── 记录详情(抽屉) ─── -->
<div id="drawer-mask" class="drawer-mask hidden"></div>
<aside id="drawer" class="drawer hidden">
<div class="drawer-head">
<h2 id="d-title">记录详情</h2>
<div>
<button type="button" class="btn primary" id="btn-retry">重试</button>
<button type="button" class="btn" id="btn-close">关闭</button>
</div>
</div>
<div class="drawer-body" id="d-body"></div>
</aside>
<!-- 图片查看 -->
<div id="lightbox" class="lightbox hidden">
<img id="lightbox-img" alt="">
</div>
<div id="toast" class="toast hidden"></div>
<script src="js/admin.js"></script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+1 -1
View File
@@ -17,7 +17,7 @@ REF = os.path.join(ROOT, '..', '..', '..', 'reference', 'facepp-skinanalyze', 'e
TARGET_W = 1280
# 与服务端 REPORT_MAPS 白名单一致(h5/server.js)
MAP_KEYS = ['red_area', 'brown_area', 'texture_enhanced_lines', 'texture_enhanced_pores', 'texture_enhanced_blackheads']
MAP_KEYS = ['red_area', 'brown_area', 'water_area', 'texture_enhanced_lines', 'texture_enhanced_pores', 'texture_enhanced_blackheads']
def to_data_url(img, fmt):
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
<svg width="112" height="30" viewBox="0 0 112 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_299)">
<mask id="mask0_4_299" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="112" height="30">
<path d="M0 0H112V30H0V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_4_299)">
<path d="M104.35 24.9371C104.35 26.5464 103.046 27.8628 101.452 27.8628C99.8575 27.8628 98.5532 26.5464 98.5532 24.9371V5.27264C98.5532 3.66337 99.8575 2.34694 101.452 2.34694C103.046 2.34694 104.35 3.66337 104.35 5.27264V24.9371ZM108.987 24.0658V6.72785C108.987 3.59399 109.781 2.30905 111.875 2.50195V0.608173H91.0288V2.50195C93.1203 2.30905 93.9167 3.69701 93.9167 6.72785V24.0658C93.9167 27.0242 93.0786 28.1947 90.9032 27.9987V29.6016H112V27.9987C109.823 28.1947 108.987 27.093 108.987 24.0658Z" fill="#282828"/>
<path d="M17.7768 21.0466C17.6508 22.5156 17.099 30 9.11663 30C0.937227 30 -6.10352e-05 22.7475 -6.10352e-05 14.9434C-6.10352e-05 5.54099 2.73023 0.00115776 9.42472 0.00115776C17.0523 0.00115776 17.6455 6.60578 17.763 8.92787H12.6678C12.5872 7.94213 12.5205 4.41107 9.3443 4.41107C6.11099 4.41107 5.32305 8.4062 5.32305 15.0524C5.32305 20.5271 5.86532 25.6672 9.20241 25.6672C12.2728 25.6672 12.5184 22.1605 12.6444 21.0466H17.7768Z" fill="#282828"/>
<path d="M26.8478 14.6505C26.8478 21.5406 27.8825 25.6384 30.9022 25.6384C34.0805 25.6384 34.9879 21.8431 34.9879 14.6623C34.9879 7.91333 34.0231 4.40025 30.8978 4.40025C27.8178 4.40025 26.8478 8.18064 26.8478 14.6505ZM40.2697 14.7596C40.2697 25.3775 37.4101 30 30.7804 30C24.5845 30 21.5662 25.4459 21.5662 14.6998C21.5662 4.22301 25.0802 0.00019455 31.0142 0.00019455C37.2819 0.00019455 40.2697 4.67722 40.2697 14.7596Z" fill="#282828"/>
<path d="M49.569 12.9081H51.9847C55.0943 12.9081 56.3102 11.5579 56.3102 8.62231C56.3102 6.12183 55.1261 4.67202 52.4898 4.67202H49.569V12.9081ZM49.569 17.1192V29.5H44.4462V0.462158H53.2555C58.4842 0.462158 61.4487 3.21487 61.4487 8.39559C61.4487 12.5948 59.2193 14.4068 57.7187 15.0078C59.946 16.0607 61.0009 17.9711 61.0009 22.2591V23.418C61.0009 26.7183 61.1544 27.9499 61.3886 29.4998H56.4065C55.986 28.171 55.9098 25.8715 55.9098 23.371V22.3126C55.9098 18.4319 55.0277 17.1192 51.7773 17.1192H49.569Z" fill="#282828"/>
<path d="M81.5694 16.5932H72.3351V25.025H82.7026L82.0682 29.5H67.2516L67.2514 0.462158H82.0035L82.0037 5.01316H72.3351V12.0797H81.5694V16.5932Z" fill="#282828"/>
</g>
</g>
<defs>
<clipPath id="clip0_4_299">
<rect width="112" height="30" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.
+170
View File
@@ -0,0 +1,170 @@
/* 巨子测肤 · 后台查看页样式(零依赖) */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f3f5f8; color: #2b3445; font-size: 14px;
}
.hidden { display: none !important; }
/* ─── 顶栏 ─── */
.topbar {
display: flex; align-items: center; gap: 32px;
background: #fff; border-bottom: 1px solid #e4e8ef;
padding: 0 24px; height: 56px; position: sticky; top: 0; z-index: 20;
}
.brand { font-size: 17px; font-weight: 700; color: #1f2733; }
.brand span { font-weight: 400; color: #8a94a6; margin-left: 8px; font-size: 13px; }
.tabs { display: flex; gap: 4px; height: 56px; }
.tab {
border: none; background: none; cursor: pointer; padding: 0 18px;
font-size: 14px; color: #5a6578; border-bottom: 2px solid transparent;
}
.tab:hover { color: #2563eb; }
.tab.on { color: #2563eb; border-bottom-color: #2563eb; font-weight: 600; }
main { padding: 20px 24px; }
.panel { display: none; }
.panel.on { display: block; }
/* ─── 筛选栏 ─── */
.filters {
background: #fff; border: 1px solid #e4e8ef; border-radius: 8px;
padding: 12px 16px; margin-bottom: 16px;
display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center;
}
.filters label { color: #5a6578; font-size: 13px; display: inline-flex; align-items: center; gap: 6px; }
input, select {
border: 1px solid #d4dae3; border-radius: 6px; padding: 6px 8px; font-size: 13px; color: #2b3445;
background: #fff; outline: none;
}
input:focus, select:focus { border-color: #2563eb; }
input[type="number"] { width: 64px; }
#f-q { width: 170px; }
.btn {
border: 1px solid #d4dae3; background: #fff; border-radius: 6px;
padding: 6px 14px; font-size: 13px; cursor: pointer; color: #2b3445;
}
.btn:hover { border-color: #2563eb; color: #2563eb; }
.btn.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
.btn.primary:hover { background: #1d4fd7; color: #fff; }
.btn.small { padding: 3px 10px; font-size: 12px; }
/* ─── 表格 ─── */
.table-wrap { background: #fff; border: 1px solid #e4e8ef; border-radius: 8px; overflow: auto; }
table { width: 100%; border-collapse: collapse; min-width: 1080px; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #eef1f6; white-space: nowrap; }
th { background: #f8fafc; color: #5a6578; font-weight: 600; font-size: 13px; }
tbody tr { cursor: pointer; }
tbody tr:hover { background: #f5f8ff; }
tbody tr:last-child td { border-bottom: none; }
.thumb { width: 44px; height: 44px; object-fit: cover; border-radius: 4px; border: 1px solid #e4e8ef; cursor: zoom-in; display: block; }
.mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: #4a5568; }
.sub { color: #8a94a6; font-size: 12px; }
/* 档位标签:预警=红 / 改善=橙黄 / 良好=绿 */
.lv {
display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; margin: 1px 2px;
}
.lv-bad { background: #fdecec; color: #d93026; }
.lv-warn { background: #fdf3e0; color: #d4880f; }
.lv-good { background: #e6f6ec; color: #1e9e50; }
.st { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
.st-ok { background: #e6f6ec; color: #1e9e50; }
.st-fail { background: #fdecec; color: #d93026; }
.st-ing { background: #e8f0fe; color: #2563eb; }
.st-wait { background: #f0f2f5; color: #8a94a6; }
.pager { display: flex; align-items: center; gap: 12px; justify-content: flex-end; margin-top: 12px; color: #5a6578; font-size: 13px; }
/* ─── 失败分布条 ─── */
.fail-dist { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
.fail-chip {
background: #fff; border: 1px solid #e4e8ef; border-radius: 8px;
padding: 10px 16px; display: flex; flex-direction: column; gap: 2px; min-width: 150px;
}
.fail-chip .code { font-weight: 600; color: #d93026; font-size: 13px; }
.fail-chip .pct { color: #5a6578; font-size: 12px; }
.fail-chip .bar { height: 6px; background: #fdecec; border-radius: 3px; margin-top: 4px; overflow: hidden; }
.fail-chip .bar i { display: block; height: 100%; background: #d93026; }
/* ─── 统计卡片与图表 ─── */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 16px; }
.card { background: #fff; border: 1px solid #e4e8ef; border-radius: 8px; padding: 16px 18px; }
.card .num { font-size: 26px; font-weight: 700; color: #1f2733; margin-top: 4px; }
.card .lab { color: #8a94a6; font-size: 13px; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; }
@media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; } }
.block { background: #fff; border: 1px solid #e4e8ef; border-radius: 8px; padding: 16px 18px; margin-bottom: 14px; }
.block h3 { font-size: 14px; color: #1f2733; margin-bottom: 14px; }
.dim-row { margin-bottom: 12px; }
.dim-row .dim-name { font-weight: 600; margin-bottom: 4px; }
.dim-bar { display: flex; height: 18px; border-radius: 4px; overflow: hidden; gap: 2px; }
.dim-bar i { display: block; height: 100%; }
.dim-bar .b-bad { background: #d93026; }
.dim-bar .b-warn { background: #f0a020; }
.dim-bar .b-good { background: #2fa84f; }
.dim-legend { color: #8a94a6; font-size: 12px; margin-top: 4px; }
.pie-wrap { display: flex; align-items: center; gap: 24px; }
.pie { width: 140px; height: 140px; border-radius: 50%; flex: none; }
.pie-legend { font-size: 13px; color: #5a6578; }
.pie-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; }
.pie-empty { color: #8a94a6; }
.daily-chart { display: flex; align-items: flex-end; gap: 6px; height: 160px; padding-top: 8px; }
.daily-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; height: 100%; justify-content: flex-end; }
.daily-bar { width: 70%; background: #2563eb; border-radius: 3px 3px 0 0; position: relative; min-height: 2px; }
.daily-bar.ok { background: #2fa84f; position: absolute; bottom: 0; left: 15%; opacity: .55; }
.daily-val { font-size: 11px; color: #5a6578; }
.daily-date { font-size: 11px; color: #8a94a6; transform: rotate(-38deg); white-space: nowrap; }
.export-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.hint { color: #8a94a6; font-size: 12px; }
/* ─── 详情抽屉 ─── */
.drawer-mask { position: fixed; inset: 0; background: rgba(20, 28, 45, .45); z-index: 30; }
.drawer {
position: fixed; top: 0; right: 0; bottom: 0; width: min(860px, 94vw);
background: #fff; z-index: 31; box-shadow: -6px 0 24px rgba(20, 28, 45, .18);
display: flex; flex-direction: column;
}
.drawer-head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-bottom: 1px solid #e4e8ef; flex: none;
}
.drawer-head h2 { font-size: 16px; }
.drawer-head > div { display: flex; gap: 8px; }
.drawer-body { overflow: auto; padding: 18px 20px 40px; }
.d-sec { margin-bottom: 20px; }
.d-sec h3 { font-size: 14px; color: #1f2733; margin-bottom: 10px; border-left: 3px solid #2563eb; padding-left: 8px; }
.d-photos { display: flex; gap: 14px; flex-wrap: wrap; }
.d-photo { text-align: center; }
.d-photo img { width: 150px; height: 150px; object-fit: cover; border-radius: 6px; border: 1px solid #e4e8ef; cursor: zoom-in; display: block; }
.d-photo a { font-size: 12px; color: #2563eb; text-decoration: none; }
.d-kv { display: flex; flex-wrap: wrap; gap: 6px 22px; color: #4a5568; font-size: 13px; }
.d-kv b { color: #1f2733; }
.d-table { width: 100%; min-width: 0; border: 1px solid #e4e8ef; border-radius: 6px; overflow: hidden; }
.d-table th, .d-table td { white-space: normal; }
.d-copy { background: #f8fafc; border: 1px solid #e4e8ef; border-radius: 6px; padding: 10px 12px; margin-bottom: 8px; font-size: 13px; line-height: 1.7; color: #4a5568; }
.d-copy b { color: #1f2733; }
.d-notes li { color: #8a6d1a; font-size: 13px; margin: 4px 0 4px 18px; }
.d-versions a { color: #2563eb; cursor: pointer; text-decoration: none; margin-right: 14px; font-size: 13px; }
details pre {
background: #1f2733; color: #d7e1f0; border-radius: 6px; padding: 12px;
font-size: 12px; overflow: auto; max-height: 420px; white-space: pre-wrap; word-break: break-all;
}
summary { cursor: pointer; color: #2563eb; font-size: 13px; margin-bottom: 8px; }
/* ─── 图片查看 / 提示 ─── */
.lightbox {
position: fixed; inset: 0; background: rgba(10, 14, 22, .85); z-index: 40;
display: flex; align-items: center; justify-content: center; cursor: zoom-out;
}
.lightbox img { max-width: 92vw; max-height: 92vh; border-radius: 6px; }
.toast {
position: fixed; top: 70px; left: 50%; transform: translateX(-50%);
background: #1f2733; color: #fff; padding: 9px 20px; border-radius: 6px;
font-size: 13px; z-index: 50;
}
+344 -295
View File
@@ -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,196 +86,189 @@ 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; }
.cam-bg {
position: absolute; inset: 0;
background-size: cover; background-position: center;
}
/* 实时相机画面(getUserMedia 成功后显示,盖住静态示意图) */
/* 相机未就绪/降级时的静态态:白底经蒙版 85% 压暗 → 框外深灰、轮廓内白 */
.cam-bg { position: absolute; inset: 0; background: #fff; }
/* 实时画面整体缩小 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; }
/* 15% 黑遮罩,压住画面保证白色文案可读(静态示意图与实时画面共用) */
#s-cam::before { content: ""; position: absolute; inset: 0; background: rgba(0,0,0,.15); z-index: 1; }
#s-cam .nav { position: fixed; left: 0; right: 0; }
/* 纵向定位按 WebView 口径:Figma 帧 812 高扣掉顶部 86px 微信导航栏 = 726 高视口 */
.cam-step {
position: absolute; left: 0; right: 0; top: 16.5%;
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: 20%;
display: flex; justify-content: center; align-items: center; gap: 18px;
position: absolute; left: 0; right: 0; bottom: 22.4%;
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; }
/* 步骤圆点:当前步高亮、完成步绿色打勾 */
.cam-dots {
position: absolute; top: calc(var(--nav-h) + 10px); 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 镂空压暗(58%) + 轮廓线框,按 data-step 切换轮廓 */
/* 取景蒙版:SVG mask 镂空 + 轮廓线框,按 data-step 切换轮廓;框外 85% 黑 */
.cam-mask {
position: absolute; inset: 0;
width: 100%; height: 100%;
@@ -276,53 +278,47 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.cam-mask[data-step="0"] [data-shape="0"],
.cam-mask[data-step="1"] [data-shape="1"],
.cam-mask[data-step="2"] [data-shape="2"] { display: block; }
.cam-dim-rect { fill: rgba(0,0,0,.58); }
.cam-mask.flip { transform: scaleX(-1); } /* 后置镜头预览不镜像:侧脸轮廓随之翻转 */
.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: calc(var(--nav-h) + 32px); 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;
@@ -332,104 +328,172 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.cam-nudge.show { opacity: 1; transform: translateX(-50%); }
/* ═══════════ 08 报告页 ═══════════ */
.report-body { padding: 12px 38px 34px; }
#s-report { background: var(--bg); }
.report-body { padding: 0 calc(18 * var(--u)) calc(28 * var(--u)); }
.report-title {
font-size: 20px; font-weight: 700; color: var(--ink);
text-align: center; margin: 10px 0 16px;
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: 6px;
padding: 14px 15px;
margin-bottom: 9px;
background: #fff; border-radius: calc(8 * var(--u));
margin-bottom: calc(12 * var(--u));
}
/* 总分 */
.card-stats { display: flex; padding: 11px 15px; }
.stat { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; }
.stat-label { font-size: 9px; font-weight: 500; color: var(--grey); }
/* label 下短横线(34×1 居中) */
.stat-label::after {
content: ""; display: block;
width: 34px; height: 1px; background: #EFEFEF;
margin: 4px auto 0;
.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-value { font-size: 29px; font-weight: 400; color: var(--ink); letter-spacing: 1px; line-height: 34px; }
/* 维度(灰底独立文字,置于总分卡与维度卡之间) */
.dims-intro { font-size: 10px; font-weight: 500; color: #464646; line-height: 1.6; margin: 0 2px 9px; }
.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;
}
.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; display: flex; flex-direction: column; align-items: center; gap: 2px; }
.dim + .dim { border-left: 1px solid #EFEFEF; }
.dim-name { font-size: 9px; font-weight: 500; color: var(--grey); }
.dim-score { font-size: 29px; font-weight: 500; color: var(--ink); line-height: 1.1; }
.dim-lv { font-size: 9px; font-weight: 500; min-height: 11px; }
.dims-note { font-size: 9px; font-weight: 500; color: var(--grey); line-height: 1.55; margin-top: 9px; text-align: center; }
.dims-disclaimer { font-size: 7px; font-weight: 400; color: var(--grey-3); margin: 0 2px 9px; line-height: 1.5; }
/* 具体描述 */
.detail-tabs {
display: flex;
border-bottom: 1px solid #F0F0F0;
margin-bottom: 12px;
.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 {
flex: 1; padding: 8px 0 9px;
font-size: 10px; font-weight: 500; color: var(--ink);
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);
}
.dtab.active { font-weight: 700; }
.dtab.active::after {
content: ""; position: absolute;
left: 50%; transform: translateX(-50%); bottom: -1px;
width: 78px; height: 7px; border-radius: 3.5px;
background: var(--yellow);
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; }
.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: 11px; align-items: flex-start; }
.ditem + .ditem { margin-top: 13px; padding-top: 13px; border-top: 1px solid #F0F0F0; }
.ditem-img { width: 92px; height: 96px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
.ditem-text { flex: 1; }
/* 检测条目标题:竖排,位于图左(设计稿如此) */
.ditem-title {
writing-mode: vertical-rl;
letter-spacing: 4px;
font-size: 11.5px; font-weight: 500; color: var(--ink);
margin-right: 12px; 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: 10px; font-weight: 400; color: var(--para); line-height: 1.45; overflow-wrap: anywhere; }
/* 护理建议 */
.card-heading { font-size: 13px; font-weight: 700; color: #000; margin-bottom: 8px; }
.care-item { font-size: 10px; color: var(--para); line-height: 1.6; margin-bottom: 8px; overflow-wrap: anywhere; }
.care-summary { font-size: 11px; font-weight: 500; color: #000; margin-top: 10px; }
/* 产品推荐:白底圆角卡,左侧文案 + 右侧单图(图高撑卡) */
.prod-title { font-size: 21px; font-weight: 700; color: var(--ink); line-height: 1.45; text-align: center; margin: 4px 0 12px; }
.card-product { padding: 0; overflow: hidden; }
.prod-card { display: flex; align-items: stretch; }
.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: 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 { overflow: hidden; }
.prod-card { display: flex; align-items: stretch; min-height: calc(176 * var(--u)); }
.prod-info {
flex: 1; min-width: 0;
padding: 18px 12px 18px 16px;
display: flex; flex-direction: column; justify-content: center; gap: 7px;
}
.prod-badge { font-size: 8px; font-weight: 500; letter-spacing: 3px; color: #B9B9B9; }
.prod-name { font-size: 13px; font-weight: 700; color: var(--ink); line-height: 1.4; overflow-wrap: anywhere; }
.prod-sub { font-size: 7.5px; font-weight: 500; color: var(--grey-3); }
.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: 168px; height: 225px;
object-fit: cover; flex-shrink: 0;
border-radius: 0 6px 6px 0;
}
.btn-more { margin: 16px auto 0; }
.report-disclaimer {
font-size: 7px; font-weight: 300; color: var(--grey-3);
line-height: 1.7; text-align: center;
margin: 5px 2px 9px; /* 灰底独立文字,位于描述卡与护理建议卡之间 */
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: 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 {
@@ -441,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 {
@@ -479,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);
@@ -503,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: 7px; color: var(--grey-3); margin-top: 6px; }
/* ═══════════ 第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);
@@ -572,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;
@@ -594,3 +639,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.his-score b { font-size: 22px; font-weight: 500; color: var(--ink); }
.his-score span { font-size: 11px; color: var(--grey); margin-left: 2px; }
.his-del { flex: none; font-size: 11px; color: var(--grey-2); padding: 6px 4px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
+4 -1
View File
@@ -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
+135 -140
View File
@@ -12,6 +12,7 @@
<title>1'me 在线肌肤测试</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="preload" as="image" href="assets/img/bg-splash.jpg">
<link rel="stylesheet" href="vendor/mobile-select/mobile-select.css">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
@@ -32,50 +33,45 @@
<!-- ═══════════ 03 档案填写 ═══════════ -->
<section class="screen" id="s-profile">
<div class="profile-bg"></div>
<header class="nav">
<button class="nav-back" data-action="back-splash" aria-label="返回"><i class="chev left"></i></button>
<span class="nav-title">1'me在线肌肤测试</span>
</header>
<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="field-select-wrap">
<select id="f-region" class="field-input field-select">
<option value="" selected disabled>请选择</option>
</select>
<i class="chev down"></i>
</div>
<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>
@@ -88,36 +84,45 @@
<!-- ═══════════ 05-07 拍照页 ═══════════ -->
<section class="screen" id="s-cam">
<div class="cam-bg" id="cam-bg"></div>
<header class="nav nav-dark">
<button class="nav-back" data-action="cam-back" aria-label="返回"><i class="chev left"></i></button>
<span class="nav-title">1'me在线肌肤测试</span>
</header>
<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>
<!-- 取景蒙版:SVG mask 镂空压暗 + 轮廓线框;data-step 切换三种轮廓
0=正脸(含耳部) 1=右脸出镜(头向左转,镜像画面中鼻朝左) 2=左脸出镜(头向右转,鼻朝右) -->
<svg class="cam-mask" id="cam-mask" data-step="0" viewBox="0 0 375 812" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<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>
<mask id="cam-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="375" height="812">
<rect x="0" y="0" width="375" height="812" fill="#fff"/>
<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">
<path data-shape="0" d="M187.5 240 C252 240 300 285 302 365 C313 367 318 381 315 397 C312 412 305 418 299 420 C294 488 246 578 187.5 586 C129 578 81 488 76 420 C70 418 63 412 60 397 C57 381 62 367 73 365 C75 285 123 240 187.5 240 Z"/>
<path data-shape="1" transform="translate(375 0) scale(-1 1)" d="M170 245 C230 240 258 270 262 320 C263 330 262 338 259 345 C270 360 282 380 284 398 C285 405 282 411 274 413 C267 415 262 417 261 421 C267 425 268 431 263 435 C267 439 267 445 262 448 C264 454 263 460 265 466 C266 476 260 483 248 486 C220 505 185 530 158 548 C140 560 122 558 112 538 C95 500 90 430 94 370 C98 305 120 250 170 245 Z"/>
<path data-shape="2" d="M170 245 C230 240 258 270 262 320 C263 330 262 338 259 345 C270 360 282 380 284 398 C285 405 282 411 274 413 C267 415 262 417 261 421 C267 425 268 431 263 435 C267 439 267 445 262 448 C264 454 263 460 265 466 C266 476 260 483 248 486 C220 505 185 530 158 548 C140 560 122 558 112 538 C95 500 90 430 94 370 C98 305 120 250 170 245 Z"/>
<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"/>
<path data-shape="2" d="M55.8 354.9L57.9 344.8L60.0 337.7L62.0 316.6L64.1 310.1L66.2 305.0L68.3 300.8L70.4 297.0L72.4 293.6L74.5 290.5L76.6 287.6L78.7 284.9L80.8 282.4L82.8 279.9L84.9 277.7L87.0 275.5L89.1 273.5L91.2 271.5L93.2 269.7L95.3 267.9L97.4 266.1L99.5 264.4L101.5 262.8L103.6 261.3L105.7 259.8L107.8 258.5L109.9 257.2L111.9 256.0L114.0 254.7L116.1 253.6L118.2 252.4L120.3 251.3L122.3 250.3L124.4 249.3L126.5 248.4L128.6 247.5L130.7 246.6L132.7 245.8L134.8 245.1L136.9 244.3L139.0 243.6L141.1 242.9L143.1 242.2L145.2 241.5L147.3 240.9L149.4 240.3L151.5 239.7L153.5 239.2L155.6 238.7L157.7 238.2L159.8 237.8L161.9 237.4L163.9 237.0L166.0 236.7L168.1 236.3L170.2 236.0L172.3 235.7L174.3 235.4L176.4 235.2L178.5 235.0L180.6 234.8L182.6 234.6L184.7 234.5L186.8 234.4L188.9 234.3L191.0 234.3L193.0 234.3L195.1 234.3L197.2 234.3L199.3 234.3L201.4 234.4L203.4 234.5L205.5 234.6L207.6 234.8L209.7 235.0L211.8 235.2L213.8 235.4L215.9 235.7L218.0 236.0L220.1 236.3L222.2 236.7L224.2 237.1L226.3 237.5L228.4 238.0L230.5 238.5L232.6 239.0L234.6 239.6L236.7 240.2L238.8 240.8L240.9 241.6L243.0 242.4L245.0 243.1L247.1 243.9L249.2 244.8L251.3 245.7L253.4 246.7L255.4 247.8L257.5 248.9L259.6 250.1L261.7 251.3L263.7 252.7L265.8 254.0L267.9 255.4L270.0 256.9L272.1 258.5L274.1 260.3L276.2 262.1L278.3 264.1L280.4 266.1L282.5 268.2L284.5 270.4L286.6 272.8L288.7 275.3L290.8 278.1L292.9 281.0L294.9 284.2L297.0 287.6L299.1 291.2L301.2 295.2L303.3 299.8L305.3 305.0L307.4 311.3L309.5 329.6L311.6 347.5L313.7 365.1L315.7 382.1L317.8 396.1L318.9 397.7L318.9 419.8L317.8 422.9L315.7 425.8L313.7 431.4L311.6 436.8L309.5 441.2L307.4 445.3L305.3 448.8L303.3 452.0L301.2 454.8L299.1 457.3L297.0 459.4L294.9 461.3L292.9 462.9L290.8 464.1L288.7 465.1L286.6 465.8L284.5 466.4L282.5 466.9L280.4 468.5L278.3 471.2L276.2 474.9L274.1 479.5L272.1 484.9L270.0 489.9L267.9 494.5L265.8 498.5L263.7 502.3L261.7 505.7L259.6 508.7L257.5 511.5L255.4 514.1L253.4 516.4L251.3 518.6L249.2 520.8L247.1 522.8L245.0 524.7L243.0 526.5L240.9 528.3L238.8 530.0L236.7 531.8L234.6 533.5L232.6 535.2L230.5 536.8L228.4 538.4L226.3 540.0L224.2 541.5L222.2 543.0L220.1 544.5L218.0 545.9L215.9 547.3L213.8 548.7L211.8 550.1L209.7 551.5L207.6 552.8L205.5 554.1L203.4 555.3L201.4 556.6L199.3 557.8L197.2 558.9L195.1 560.0L193.0 561.2L191.0 562.3L188.9 563.4L186.8 564.5L184.7 565.5L182.6 566.5L180.6 567.4L178.5 568.3L176.4 569.2L174.3 570.0L172.3 570.8L170.2 571.6L168.1 572.3L166.0 573.0L163.9 573.7L161.9 574.3L159.8 574.9L157.7 575.4L155.6 575.9L153.5 576.3L151.5 576.7L149.4 577.0L147.3 577.3L145.2 577.5L143.1 577.7L141.1 577.7L139.0 577.7L136.9 577.7L134.8 577.5L132.7 577.2L130.7 576.8L128.6 576.3L126.5 575.6L124.4 574.7L122.3 573.7L120.3 572.5L118.2 571.2L116.1 569.6L114.0 567.9L111.9 566.0L109.9 563.8L107.8 561.6L105.7 559.0L103.6 556.3L101.5 553.4L99.5 550.4L97.4 547.0L95.3 543.7L93.2 540.1L91.2 536.5L89.1 532.8L87.0 529.1L84.9 525.3L82.8 521.4L80.8 517.5L78.7 513.4L76.6 509.2L74.5 505.0L72.4 500.5L70.4 495.8L68.3 490.7L66.2 485.3L64.1 478.7L62.0 466.5L60.0 453.6L57.9 447.0L55.8 438.2Z"/>
</g>
</mask>
</defs>
<rect class="cam-dim-rect" x="0" y="0" width="375" height="812" mask="url(#cam-cut)"/>
<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="M187.5 240 C252 240 300 285 302 365 C313 367 318 381 315 397 C312 412 305 418 299 420 C294 488 246 578 187.5 586 C129 578 81 488 76 420 C70 418 63 412 60 397 C57 381 62 367 73 365 C75 285 123 240 187.5 240 Z"/>
<path data-shape="1" transform="translate(375 0) scale(-1 1)" d="M170 245 C230 240 258 270 262 320 C263 330 262 338 259 345 C270 360 282 380 284 398 C285 405 282 411 274 413 C267 415 262 417 261 421 C267 425 268 431 263 435 C267 439 267 445 262 448 C264 454 263 460 265 466 C266 476 260 483 248 486 C220 505 185 530 158 548 C140 560 122 558 112 538 C95 500 90 430 94 370 C98 305 120 250 170 245 Z"/>
<path data-shape="2" d="M170 245 C230 240 258 270 262 320 C263 330 262 338 259 345 C270 360 282 380 284 398 C285 405 282 411 274 413 C267 415 262 417 261 421 C267 425 268 431 263 435 C267 439 267 445 262 448 C264 454 263 460 265 466 C266 476 260 483 248 486 C220 505 185 530 158 548 C140 560 122 558 112 538 C95 500 90 430 94 370 C98 305 120 250 170 245 Z"/>
<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"/>
<path data-shape="2" d="M55.8 354.9L57.9 344.8L60.0 337.7L62.0 316.6L64.1 310.1L66.2 305.0L68.3 300.8L70.4 297.0L72.4 293.6L74.5 290.5L76.6 287.6L78.7 284.9L80.8 282.4L82.8 279.9L84.9 277.7L87.0 275.5L89.1 273.5L91.2 271.5L93.2 269.7L95.3 267.9L97.4 266.1L99.5 264.4L101.5 262.8L103.6 261.3L105.7 259.8L107.8 258.5L109.9 257.2L111.9 256.0L114.0 254.7L116.1 253.6L118.2 252.4L120.3 251.3L122.3 250.3L124.4 249.3L126.5 248.4L128.6 247.5L130.7 246.6L132.7 245.8L134.8 245.1L136.9 244.3L139.0 243.6L141.1 242.9L143.1 242.2L145.2 241.5L147.3 240.9L149.4 240.3L151.5 239.7L153.5 239.2L155.6 238.7L157.7 238.2L159.8 237.8L161.9 237.4L163.9 237.0L166.0 236.7L168.1 236.3L170.2 236.0L172.3 235.7L174.3 235.4L176.4 235.2L178.5 235.0L180.6 234.8L182.6 234.6L184.7 234.5L186.8 234.4L188.9 234.3L191.0 234.3L193.0 234.3L195.1 234.3L197.2 234.3L199.3 234.3L201.4 234.4L203.4 234.5L205.5 234.6L207.6 234.8L209.7 235.0L211.8 235.2L213.8 235.4L215.9 235.7L218.0 236.0L220.1 236.3L222.2 236.7L224.2 237.1L226.3 237.5L228.4 238.0L230.5 238.5L232.6 239.0L234.6 239.6L236.7 240.2L238.8 240.8L240.9 241.6L243.0 242.4L245.0 243.1L247.1 243.9L249.2 244.8L251.3 245.7L253.4 246.7L255.4 247.8L257.5 248.9L259.6 250.1L261.7 251.3L263.7 252.7L265.8 254.0L267.9 255.4L270.0 256.9L272.1 258.5L274.1 260.3L276.2 262.1L278.3 264.1L280.4 266.1L282.5 268.2L284.5 270.4L286.6 272.8L288.7 275.3L290.8 278.1L292.9 281.0L294.9 284.2L297.0 287.6L299.1 291.2L301.2 295.2L303.3 299.8L305.3 305.0L307.4 311.3L309.5 329.6L311.6 347.5L313.7 365.1L315.7 382.1L317.8 396.1L318.9 397.7L318.9 419.8L317.8 422.9L315.7 425.8L313.7 431.4L311.6 436.8L309.5 441.2L307.4 445.3L305.3 448.8L303.3 452.0L301.2 454.8L299.1 457.3L297.0 459.4L294.9 461.3L292.9 462.9L290.8 464.1L288.7 465.1L286.6 465.8L284.5 466.4L282.5 466.9L280.4 468.5L278.3 471.2L276.2 474.9L274.1 479.5L272.1 484.9L270.0 489.9L267.9 494.5L265.8 498.5L263.7 502.3L261.7 505.7L259.6 508.7L257.5 511.5L255.4 514.1L253.4 516.4L251.3 518.6L249.2 520.8L247.1 522.8L245.0 524.7L243.0 526.5L240.9 528.3L238.8 530.0L236.7 531.8L234.6 533.5L232.6 535.2L230.5 536.8L228.4 538.4L226.3 540.0L224.2 541.5L222.2 543.0L220.1 544.5L218.0 545.9L215.9 547.3L213.8 548.7L211.8 550.1L209.7 551.5L207.6 552.8L205.5 554.1L203.4 555.3L201.4 556.6L199.3 557.8L197.2 558.9L195.1 560.0L193.0 561.2L191.0 562.3L188.9 563.4L186.8 564.5L184.7 565.5L182.6 566.5L180.6 567.4L178.5 568.3L176.4 569.2L174.3 570.0L172.3 570.8L170.2 571.6L168.1 572.3L166.0 573.0L163.9 573.7L161.9 574.3L159.8 574.9L157.7 575.4L155.6 575.9L153.5 576.3L151.5 576.7L149.4 577.0L147.3 577.3L145.2 577.5L143.1 577.7L141.1 577.7L139.0 577.7L136.9 577.7L134.8 577.5L132.7 577.2L130.7 576.8L128.6 576.3L126.5 575.6L124.4 574.7L122.3 573.7L120.3 572.5L118.2 571.2L116.1 569.6L114.0 567.9L111.9 566.0L109.9 563.8L107.8 561.6L105.7 559.0L103.6 556.3L101.5 553.4L99.5 550.4L97.4 547.0L95.3 543.7L93.2 540.1L91.2 536.5L89.1 532.8L87.0 529.1L84.9 525.3L82.8 521.4L80.8 517.5L78.7 513.4L76.6 509.2L74.5 505.0L72.4 500.5L70.4 495.8L68.3 490.7L66.2 485.3L64.1 478.7L62.0 466.5L60.0 453.6L57.9 447.0L55.8 438.2Z"/>
</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>
@@ -126,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>
@@ -138,99 +144,83 @@
<!-- ═══════════ 08 报告页 ═══════════ -->
<section class="screen" id="s-report">
<header class="nav">
<button class="nav-back" data-action="back-profile" aria-label="返回"><i class="chev left"></i></button>
<span class="nav-title">1'me在线肌肤测试</span>
<button class="nav-history" data-action="history">历史</button>
</header>
<div class="report-body">
<h1 class="report-title">您的肌肤状态表现</h1>
<div class="card card-stats">
<div class="stat">
<span class="stat-label">综合评分</span>
<span class="stat-value" id="stat-score">--分</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="stat">
<div class="sum-age">
<span class="stat-label">肌肤年龄</span>
<span class="stat-value" id="stat-age">--岁</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>
<p class="dims-intro">我们从6个维度观察了你的肌肤,当下优先关注:紧致、光泽、纹路、耐受、细腻、水润</p>
<p class="dims-disclaimer">*状态是动态流动的,和作息、压力、季节都有关系,仅代表此刻的状态参考。</p>
<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><span class="dim-lv" id="dim-firm-lv"></span></div>
<div class="dim"><span class="dim-name">活力感</span><span class="dim-score" id="dim-vita">-.-</span><span class="dim-lv" id="dim-vita-lv"></span></div>
<div class="dim"><span class="dim-name">稳定力</span><span class="dim-score" id="dim-stab">-.-</span><span class="dim-lv" id="dim-stab-lv"></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">紧塑力</button>
<button class="dtab" data-tab="1">活力感</button>
<button class="dtab" data-tab="2">稳定力</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="cat-head"><span class="cat-s" id="s-contour">-.-</span><i class="cat-badge" id="lv-contour"></i></p>
<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="cat-head"><span class="cat-s" id="s-wrinkles">-.-</span><i class="cat-badge" id="lv-wrinkles"></i></p>
<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="cat-head"><span class="cat-s" id="s-smooth">-.-</span><i class="cat-badge" id="lv-smooth"></i></p>
<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="cat-head"><span class="cat-s" id="s-tone">-.-</span><i class="cat-badge" id="lv-tone"></i></p>
<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="cat-head"><span class="cat-s" id="s-sens">-.-</span><i class="cat-badge" id="lv-sens"></i></p>
<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="cat-head"><span class="cat-s" id="s-acne">-.-</span><i class="cat-badge" id="lv-acne"></i></p>
<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>
@@ -238,30 +228,27 @@
<p class="report-disclaimer">*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。<br>*本次检测仅代表当前肌肤状态参考。</p>
<div class="card card-summary">
<h2 class="card-heading">综合分析</h2>
<p class="care-item" id="vm-summary">检测完成后显示分析结果。</p>
</div>
<div class="card card-care">
<h2 class="card-heading">生活方式建议</h2>
<p class="care-item" id="vm-lifestyle">--</p>
<h2 class="card-heading">护理建议</h2>
<div id="care-list">
<p class="care-item">检测完成后显示分析结果。</p>
</div>
<p class="care-summary">好状态,是内外同调的长期节奏。</p>
</div>
<div class="card card-essence">
<h2 class="card-heading">精华推荐</h2>
<p class="essence-name" id="essence-name">--</p>
<p class="care-item" id="essence-text">--</p>
<p class="essence-triggers" id="essence-triggers"></p>
</div>
<!-- 产品推荐大标题独立于白卡之外,直接落在灰底上(Figma 08) -->
<h2 class="prod-title">感受高光时刻<br>从日常的好状态开始</h2>
<div class="card card-product">
<h2 class="prod-title">感受高光时刻<br>从日常的好状态开始</h2>
<div class="prod-card">
<div class="prod-info">
<span class="prod-badge">CORE Ⅱ</span>
<h3 class="prod-name">以我自在有势挺括双仓精华液</h3>
<img class="prod-badge" src="assets/svg/prod-badge.svg" alt="CORE Ⅱ">
<h3 class="prod-name">以我自在有势挺括<br>双仓精华液</h3>
<p class="prod-sub">参透皮下根源 28天校准皮肤状态</p>
</div>
<div class="prod-scroll" id="prod-scroll">
@@ -291,23 +278,29 @@
</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">
<header class="policy-header">
<button class="nav-back" data-action="close-policy" aria-label="返回"><i class="chev left"></i></button>
<span class="policy-header-title">隐私政策与服务协议</span>
</header>
<button class="nav-back policy-float-back" data-action="close-policy" aria-label="返回"><i class="chev left"></i></button>
<div class="policy-body">
<h1 class="policy-title">Face⁺⁺个人信息与隐私保护政策</h1>
@@ -465,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>');
+501
View File
@@ -0,0 +1,501 @@
/* ═══════════════════════════════════════════════════════════
巨子测肤 · 后台查看页脚本(纯原生 ES5,零依赖)
页签:记录列表 / 失败与异常 / 统计概览;行点击开详情抽屉。
═══════════════════════════════════════════════════════════ */
'use strict';
/* ─── 工具 ─── */
function $(sel, root) { return (root || document).querySelector(sel); }
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
function esc(s) { // 拼 HTML 前转义(接口文案/失败信息不可信)
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function fmtTime(iso) {
if (!iso) return '-';
return iso.replace('T', ' ').slice(0, 19);
}
function fmtMs(ms) { return ms == null ? '-' : ms + 'ms'; }
function fmtScore(v) { return v == null ? '-' : v; }
var LV_CLASS = { '预警': 'lv-bad', '改善': 'lv-warn', '良好': 'lv-good' };
var DIM_NAMES = { firm: '紧塑力', vita: '活力感', stab: '稳定力' };
var CAT_ORDER = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne'];
var ST_CLASS = { '成功': 'st-ok', '失败': 'st-fail', '检测中': 'st-ing', '待检测': 'st-wait' };
function toast(msg) {
var t = $('#toast');
t.textContent = msg;
t.classList.remove('hidden');
clearTimeout(toast._timer);
toast._timer = setTimeout(function () { t.classList.add('hidden'); }, 2600);
}
function api(url, opts) {
return fetch(url, opts || {}).then(function (res) {
return res.json().then(function (data) {
if (!res.ok || data.ok === false) throw new Error(data.message || ('HTTP ' + res.status));
return data;
});
});
}
/* ─── 页签切换 ─── */
var TABS = { records: 1, fails: 1, stats: 1 };
function switchTab(name) {
var tabs = document.querySelectorAll('.tab');
for (var i = 0; i < tabs.length; i++) tabs[i].classList.toggle('on', tabs[i].dataset.tab === name);
var panels = document.querySelectorAll('.panel');
for (var j = 0; j < panels.length; j++) panels[j].classList.toggle('on', panels[j].id === 'tab-' + name);
if (name === 'records') loadRecords();
else if (name === 'fails') loadFails();
else if (name === 'stats') loadStats();
}
/* ─── 档位标签 ─── */
function lvHtml(level) {
if (!level) return '<span class="sub">-</span>';
return '<span class="lv ' + (LV_CLASS[level] || '') + '">' + esc(level) + '</span>';
}
function dimsHtml(dims) {
if (!dims) return '<span class="sub">-</span>';
var h = '';
for (var k in DIM_NAMES) {
var d = dims[k];
h += '<span class="lv ' + (d && LV_CLASS[d.level] || '') + '" title="' + DIM_NAMES[k] + '">' +
DIM_NAMES[k].slice(0, 1) + (d ? esc(d.level) : '-') + '</span>';
}
return h;
}
function profileHtml(it) {
var parts = [];
parts.push(it.real_age != null ? it.real_age + '岁' : '-');
parts.push(it.gender || '-');
parts.push(it.region || '-');
parts.push(it.daily_skin_type || '-');
return esc(parts.join(' / '));
}
/* ═══════════ 记录列表 ═══════════ */
var pageState = { page: 1, page_size: 20, total: 0 };
function collectFilters() {
var p = [];
function add(k, v) { if (v) p.push(k + '=' + encodeURIComponent(v)); }
add('from', $('#f-from').value);
add('to', $('#f-to').value);
add('detect_status', $('#f-status').value);
add('fail_code', $('#f-failcode').value);
add('age_min', $('#f-agemin').value);
add('age_max', $('#f-agemax').value);
add('group', $('#f-group').value);
add('dim', $('#f-dim').value);
add('dim_level', $('#f-dimlevel').value);
add('q', $('#f-q').value.trim());
p.push('page=' + pageState.page);
p.push('page_size=' + pageState.page_size);
return p.join('&');
}
function loadRecords() {
api('/api/admin/records?' + collectFilters()).then(function (data) {
pageState.total = data.total;
var tbody = $('#tbl-records tbody');
tbody.innerHTML = '';
if (!data.items.length) {
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;color:#8a94a6;padding:32px">暂无记录</td></tr>';
}
for (var i = 0; i < data.items.length; i++) {
(function (it) {
var tr = el('tr');
var photos = it.photos || {};
var thumb = photos.front
? '<img class="thumb" loading="lazy" decoding="async" src="' + photos.front + '" alt="正脸">'
: '<span class="sub">无</span>';
tr.innerHTML =
'<td>' + thumb + '</td>' +
'<td class="mono">' + esc(it.record_id) + (it.retry_of ? '<div class="sub">重试自 ' + esc(it.retry_of) + '</div>' : '') + '</td>' +
'<td>' + profileHtml(it) + '</td>' +
'<td>' + esc(it.shoot_status) + '</td>' +
'<td>' + esc(it.upload_status) + '</td>' +
'<td><span class="st ' + (ST_CLASS[it.detect_status] || '') + '">' + esc(it.detect_status) + '</span>' +
(it.fail_code ? '<div class="sub">' + esc(it.fail_code) + '</div>' : '') + '</td>' +
'<td><b>' + fmtScore(it.total_score) + '</b></td>' +
'<td>' + dimsHtml(it.dims) + '</td>' +
'<td>' + esc(it.essence || '-') + '</td>' +
'<td class="mono">' + fmtMs(it.cost_ms) + '</td>' +
'<td class="mono">' + fmtTime(it.created_at) + '</td>';
tr.addEventListener('click', function (e) {
if (e.target && e.target.className === 'thumb') { showLightbox(e.target.src); return; }
openDetail(it.record_id);
});
tbody.appendChild(tr);
})(data.items[i]);
}
var pages = Math.max(1, Math.ceil(data.total / data.page_size));
$('#page-info').textContent = '第 ' + data.page + ' / ' + pages + ' 页 · 共 ' + data.total + ' 条';
$('#btn-prev').disabled = data.page <= 1;
$('#btn-next').disabled = data.page >= pages;
}).catch(function (e) { toast('加载失败:' + e.message); });
}
/* ═══════════ 失败与异常 ═══════════ */
function loadFails() {
api('/api/admin/stats').then(function (st) {
var box = $('#fail-dist');
box.innerHTML = '';
if (!st.fail_dist.length) box.appendChild(el('div', 'pie-empty', '暂无失败记录'));
for (var i = 0; i < st.fail_dist.length; i++) {
(function (f) {
var chip = el('div', 'fail-chip');
chip.innerHTML = '<span class="code">' + esc(f.code) + '</span>' +
'<span class="pct">' + f.count + ' 次 · 占比 ' + f.pct + '%</span>' +
'<span class="bar"><i style="width:' + Math.min(100, f.pct) + '%"></i></span>';
box.appendChild(chip);
})(st.fail_dist[i]);
}
}).catch(function (e) { toast('加载失败:' + e.message); });
api('/api/admin/records?detect_status=失败&page_size=100').then(function (data) {
var tbody = $('#tbl-fails tbody');
tbody.innerHTML = '';
if (!data.items.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#8a94a6;padding:32px">暂无失败记录</td></tr>';
}
for (var i = 0; i < data.items.length; i++) {
(function (it) {
var tr = el('tr');
tr.innerHTML =
'<td class="mono">' + esc(it.record_id) + '</td>' +
'<td>' + esc(it.fail_stage || '-') + '</td>' +
'<td><span class="st st-fail">' + esc(it.fail_code || '-') + '</span></td>' +
'<td style="white-space:normal;max-width:340px">' + esc(it.fail_msg || '-') + '</td>' +
'<td>' + (it.real_age != null ? it.real_age + '岁' : '-') + '</td>' +
'<td>' + esc(it.shoot_status) + '</td>' +
'<td class="mono">' + fmtTime(it.created_at) + '</td>' +
'<td><button type="button" class="btn small primary">重试</button></td>';
tr.addEventListener('click', function (e) {
if (e.target && e.target.tagName === 'BUTTON') { doRetry(it.record_id); return; }
openDetail(it.record_id);
});
tbody.appendChild(tr);
})(data.items[i]);
}
}).catch(function (e) { toast('加载失败:' + e.message); });
}
function doRetry(id) {
if (!confirm('将使用原照片重新检测并生成新记录,确认重试 ' + id + ' ?')) return;
toast('重试中,请稍候(最长约 1 分钟)…');
api('/api/admin/records/' + encodeURIComponent(id) + '/retry', { method: 'POST' }).then(function (data) {
toast('重试完成,新记录 ' + data.record.record_id + '(' + data.record.detect_status + ')');
var active = $('.panel.on').id;
if (active === 'tab-records') loadRecords();
else if (active === 'tab-fails') loadFails();
}).catch(function (e) { toast('重试失败:' + e.message); });
}
/* ═══════════ 统计概览 ═══════════ */
var PIE_COLORS = ['#2563eb', '#2fa84f', '#f0a020', '#8a5cf6', '#d93026'];
function loadStats() {
api('/api/admin/stats').then(function (st) {
var cards = [
['今日检测', st.today_total],
['累计检测', st.total],
['成功率', st.success_rate + '%'],
['平均耗时', st.avg_cost_ms + 'ms']
];
var box = $('#stat-cards');
box.innerHTML = '';
for (var i = 0; i < cards.length; i++) {
var c = el('div', 'card');
c.innerHTML = '<div class="lab">' + cards[i][0] + '</div><div class="num">' + cards[i][1] + '</div>';
box.appendChild(c);
}
/* 三维度档位分布:每组三根横条 */
var dd = $('#dim-dist');
dd.innerHTML = '';
for (var d in DIM_NAMES) {
var dist = st.dim_dist[d] || {};
var max = Math.max(1, dist['预警'] || 0, dist['改善'] || 0, dist['良好'] || 0);
var row = el('div', 'dim-row');
var bars = '';
var levels = [['预警', 'b-bad'], ['改善', 'b-warn'], ['良好', 'b-good']];
for (var l = 0; l < levels.length; l++) {
var cnt = dist[levels[l][0]] || 0;
bars += '<div style="display:flex;align-items:center;gap:8px;margin:3px 0">' +
'<span style="width:34px;color:#5a6578;font-size:12px">' + levels[l][0] + '</span>' +
'<span style="flex:1;height:14px;background:#f0f2f5;border-radius:3px;overflow:hidden">' +
'<i style="display:block;height:100%;width:' + (cnt * 100 / max) + '%" class="' + levels[l][1] + '"></i></span>' +
'<span style="width:24px;text-align:right;font-size:12px;color:#4a5568">' + cnt + '</span></div>';
}
row.innerHTML = '<div class="dim-name">' + DIM_NAMES[d] + '</div>' + bars;
dd.appendChild(row);
}
/* 产品组别分布:纯 CSS conic-gradient 饼图 */
var gd = $('#group-dist');
gd.innerHTML = '';
var keys = Object.keys(st.group_dist);
if (!keys.length) {
gd.appendChild(el('div', 'pie-empty', '暂无成功记录'));
} else {
var total = 0, k;
for (k in st.group_dist) total += st.group_dist[k];
var acc = 0, segs = [];
for (var g = 0; g < keys.length; g++) {
var from = acc * 100 / total;
acc += st.group_dist[keys[g]];
var to = acc * 100 / total;
segs.push(PIE_COLORS[g % PIE_COLORS.length] + ' ' + from + '% ' + to + '%');
}
var legend = '';
for (var g2 = 0; g2 < keys.length; g2++) {
legend += '<div><i style="background:' + PIE_COLORS[g2 % PIE_COLORS.length] + '"></i>' +
esc(keys[g2]) + ':' + st.group_dist[keys[g2]] + ' 次(' + Math.round(st.group_dist[keys[g2]] * 100 / total) + '%)</div>';
}
gd.innerHTML = '<div class="pie" style="background:conic-gradient(' + segs.join(',') + ')"></div>' +
'<div class="pie-legend">' + legend + '</div>';
}
/* 近 14 天每日检测量(蓝=总量,绿=成功数) */
var dc = $('#daily-chart');
dc.innerHTML = '';
var daily = st.daily || [];
var dmax = 1;
for (var n = 0; n < daily.length; n++) dmax = Math.max(dmax, daily[n].total);
for (var m = 0; m < daily.length; m++) {
(function (day) {
var col = el('div', 'daily-col');
var h = Math.max(2, day.total * 130 / dmax);
var okH = day.total ? Math.max(1, day.success * 130 / dmax) : 0;
col.innerHTML = '<span class="daily-val">' + day.total + '</span>' +
'<div class="daily-bar" style="height:' + h + 'px"><div class="daily-bar ok" style="width:70%;left:15%;height:' + okH + 'px"></div></div>' +
'<span class="daily-date">' + day.date.slice(5) + '</span>';
col.title = day.date + ':检测 ' + day.total + ' 次,成功 ' + day.success + ' 次';
dc.appendChild(col);
})(daily[m]);
}
}).catch(function (e) { toast('加载失败:' + e.message); });
}
/* ═══════════ 记录详情(抽屉) ═══════════ */
var currentDetailId = null;
function openDetail(id) {
currentDetailId = id;
$('#d-title').textContent = '记录详情 ' + id;
$('#btn-retry').disabled = true;
$('#d-body').innerHTML = '<div class="sub">加载中…</div>';
$('#drawer').classList.remove('hidden');
$('#drawer-mask').classList.remove('hidden');
api('/api/admin/records/' + encodeURIComponent(id)).then(renderDetail)
.catch(function (e) { $('#d-body').innerHTML = '<div class="sub">加载失败:' + esc(e.message) + '</div>'; });
}
function closeDetail() {
$('#drawer').classList.add('hidden');
$('#drawer-mask').classList.add('hidden');
currentDetailId = null;
}
function dSec(title, innerHtml) {
return '<div class="d-sec"><h3>' + title + '</h3>' + innerHtml + '</div>';
}
function renderDetail(data) {
var r = data.record;
$('#btn-retry').disabled = !(data.photos && data.photos.front);
var h = '';
/* 三张照片 */
var photosHtml = '<div class="d-photos">';
var whichNames = { front: '正脸', left: '左脸', right: '右脸' };
var anyPhoto = false;
for (var w in whichNames) {
var u = data.photos[w];
if (!u) continue;
anyPhoto = true;
photosHtml += '<div class="d-photo"><img loading="lazy" decoding="async" src="' + u + '" alt="' + whichNames[w] + '" data-zoom="' + u + '">' +
'<a href="' + u + '" download="' + r.record_id + '-' + w + '.jpg">下载' + whichNames[w] + '</a></div>';
}
photosHtml += anyPhoto ? '' : '<span class="sub">无照片</span>';
photosHtml += '</div>';
h += dSec('检测照片', photosHtml);
/* 状态行 */
h += dSec('状态', '<div class="d-kv">' +
'<span>检测状态 <b>' + esc(r.detect_status) + '</b></span>' +
'<span>拍摄状态 <b>' + esc(r.shoot_status) + '</b></span>' +
'<span>上传状态 <b>' + esc(r.upload_status) + '</b></span>' +
'<span>创建时间 <b>' + fmtTime(r.created_at) + '</b></span>' +
'<span>更新时间 <b>' + fmtTime(r.updated_at) + '</b></span>' +
'<span>耗时 <b>' + fmtMs(r.cost_ms) + '</b></span>' +
(r.retry_of ? '<span>重试来源 <b class="mono">' + esc(r.retry_of) + '</b></span>' : '') +
'<span>被重试次数 <b>' + (r.retry_count || 0) + '</b></span>' +
'</div>');
/* 档案 */
h += dSec('档案信息', '<div class="d-kv">' +
'<span>年龄 <b>' + (r.real_age != null ? r.real_age + '岁' : '-') + '</b></span>' +
'<span>性别 <b>' + esc(r.gender || '-') + '</b></span>' +
'<span>地区 <b>' + esc(r.region || '-') + '</b></span>' +
'<span>日常肤质 <b>' + esc(r.daily_skin_type || '-') + '</b></span>' +
'<span>产品组别 <b>' + esc(r.group || '-') + '</b></span>' +
'</div>');
/* 失败:错误 + 版本链 */
if (r.detect_status === '失败') {
h += dSec('失败信息', '<div class="d-copy"><b>' + esc(r.fail_code || '-') + '</b>(环节:' + esc(r.fail_stage || '-') + ')<br>' + esc(r.fail_msg || '-') + '</div>');
}
/* 版本链(重试关系) */
var vHtml = '';
if (data.versions && data.versions.parent) {
vHtml += '<div>上一版本(本次为重试):<a data-id="' + esc(data.versions.parent.record_id) + '">' +
esc(data.versions.parent.record_id) + '(' + esc(data.versions.parent.detect_status) + ')</a></div>';
}
if (data.versions && data.versions.children && data.versions.children.length) {
vHtml += '<div>重试产生的记录:';
for (var c = 0; c < data.versions.children.length; c++) {
vHtml += '<a data-id="' + esc(data.versions.children[c].record_id) + '">' +
esc(data.versions.children[c].record_id) + '(' + esc(data.versions.children[c].detect_status) + ')</a>';
}
vHtml += '</div>';
}
if (vHtml) h += dSec('版本链', '<div class="d-versions">' + vHtml + '</div>');
/* 成功:评分 + 文案 */
if (r.detect_status === '成功') {
h += dSec('综合', '<div class="d-kv">' +
'<span>总分 <b>' + fmtScore(r.total_score) + '</b></span>' +
'<span>肌肤年龄(接口原始) <b>' + fmtScore(r.skin_age) + '岁</b></span>' +
'<span>六分类均分S <b>' + fmtScore(r.avg_s) + '</b></span>' +
'<span>左下颌线角 <b>' + (r.jawline_angle_l != null ? r.jawline_angle_l + '°' : '-') + '</b></span>' +
'<span>右下颌线角 <b>' + (r.jawline_angle_r != null ? r.jawline_angle_r + '°' : '-') + '</b></span>' +
'</div>');
/* 6 分类表格 */
var catRows = '';
for (var i = 0; i < CAT_ORDER.length; i++) {
var ck = CAT_ORDER[i], cat = r.cats && r.cats[ck];
var catName = (cat && cat.name) || ({ contour: '轮廓', wrinkles: '皱纹', smooth: '光滑', tone: '肤色水润', sens: '敏感', acne: '痘痘' })[ck];
catRows += '<tr><td>' + esc(catName) + '</td><td>' + fmtScore(cat && cat.P) + '</td><td>' + fmtScore(cat && cat.S) + '</td><td>' + lvHtml(cat && cat.level) + '</td></tr>';
}
h += dSec('六分类评分', '<table class="d-table"><thead><tr><th>分类</th><th>P</th><th>S</th><th>档位</th></tr></thead><tbody>' + catRows + '</tbody></table>');
/* 3 维度表格 */
var dimRows = '';
for (var dk in DIM_NAMES) {
var dim = r.dims && r.dims[dk];
dimRows += '<tr><td>' + DIM_NAMES[dk] + '</td><td>' + fmtScore(dim && dim.P) + '</td><td>' + fmtScore(dim && dim.S) + '</td><td>' + lvHtml(dim && dim.level) + '</td></tr>';
}
h += dSec('三维度评分', '<table class="d-table"><thead><tr><th>维度</th><th>P</th><th>S</th><th>档位</th></tr></thead><tbody>' + dimRows + '</tbody></table>');
/* 文案全文 */
var copyHtml = '';
if (r.copy_6) {
for (var c2 = 0; c2 < CAT_ORDER.length; c2++) {
var txt = r.copy_6[CAT_ORDER[c2]];
if (txt) copyHtml += '<div class="d-copy"><b>' + esc((r.cats[CAT_ORDER[c2]] || {}).name || '') + ':</b>' + esc(txt) + '</div>';
}
}
if (r.copy_summary) copyHtml += '<div class="d-copy"><b>综合文案:</b>' + esc(r.copy_summary) + '</div>';
if (r.lifestyle) copyHtml += '<div class="d-copy"><b>生活方式:</b>' + esc(r.lifestyle) + '</div>';
if (copyHtml) h += dSec('生成文案', copyHtml);
if (r.careAdvice && r.careAdvice.length) {
var advice = '';
for (var a = 0; a < r.careAdvice.length; a++) {
advice += '<div class="d-copy"><b>[' + esc(r.careAdvice[a].tag) + ']</b>' + esc(r.careAdvice[a].text) + '</div>';
}
h += dSec('护理建议', advice);
}
h += dSec('推荐精华', '<div class="d-copy"><b>' + esc((r.essence && r.essence.name) || '-') + '</b><br>' +
esc((r.essence && r.essence.text) || '') + '</div>' +
'<div class="d-kv"><span>触发判据 <b>' + esc(r.trigger || '-') + '</b></span></div>');
if (r.notes && r.notes.length) {
var notes = '<ul class="d-notes">';
for (var n2 = 0; n2 < r.notes.length; n2++) notes += '<li>' + esc(r.notes[n2]) + '</li>';
notes += '</ul>';
h += dSec('计算备注', notes);
}
}
/* 接口原始 JSON:details 折叠,pre 展示(face_maps 已在服务端裁剪,完整版走下载链接) */
if (data.raw_json) {
var rawDl = data.raw_file
? ' <a href="/api/admin/records/' + encodeURIComponent(r.record_id) + '/raw" download>下载完整原始 JSON</a>'
: '';
h += dSec('接口原始 JSON', '<details><summary>展开/收起(' + (data.raw_file || '') + ')' + rawDl + '</summary><pre>' +
esc(JSON.stringify(data.raw_json, null, 1)) + '</pre></details>');
} else {
h += dSec('接口原始 JSON', '<div class="sub">无(失败记录或未落盘)</div>');
}
var body = $('#d-body');
body.innerHTML = h;
/* 图片点击看大图 */
var imgs = body.querySelectorAll('img[data-zoom]');
for (var z = 0; z < imgs.length; z++) {
imgs[z].addEventListener('click', function () { showLightbox(this.getAttribute('data-zoom')); });
}
/* 版本链跳转 */
var links = body.querySelectorAll('.d-versions a');
for (var v = 0; v < links.length; v++) {
links[v].addEventListener('click', function () { openDetail(this.getAttribute('data-id')); });
}
}
/* ═══════════ 图片查看 ═══════════ */
function showLightbox(src) {
$('#lightbox-img').src = src;
$('#lightbox').classList.remove('hidden');
}
/* ═══════════ 事件绑定与初始化 ═══════════ */
function bind() {
var tabs = document.querySelectorAll('.tab');
for (var i = 0; i < tabs.length; i++) {
tabs[i].addEventListener('click', function () { switchTab(this.dataset.tab); });
}
$('#btn-search').addEventListener('click', function () { pageState.page = 1; loadRecords(); });
$('#btn-reset').addEventListener('click', function () {
var ids = ['f-from', 'f-to', 'f-status', 'f-failcode', 'f-agemin', 'f-agemax', 'f-group', 'f-dim', 'f-dimlevel', 'f-q'];
for (var i2 = 0; i2 < ids.length; i2++) $('#' + ids[i2]).value = '';
pageState.page = 1;
loadRecords();
});
$('#btn-prev').addEventListener('click', function () { if (pageState.page > 1) { pageState.page--; loadRecords(); } });
$('#btn-next').addEventListener('click', function () { pageState.page++; loadRecords(); });
$('#btn-close').addEventListener('click', closeDetail);
$('#drawer-mask').addEventListener('click', closeDetail);
$('#btn-retry').addEventListener('click', function () { if (currentDetailId) doRetry(currentDetailId); });
$('#lightbox').addEventListener('click', function () { this.classList.add('hidden'); });
$('#btn-export').addEventListener('click', function () {
var p = [];
if ($('#exp-from').value) p.push('from=' + $('#exp-from').value);
if ($('#exp-to').value) p.push('to=' + $('#exp-to').value);
var url = '/api/admin/export' + (p.length ? '?' + p.join('&') : '');
var a = el('a');
a.href = url;
a.download = '';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeDetail(); $('#lightbox').classList.add('hidden'); }
});
}
bind();
loadRecords();
+618 -256
View File
File diff suppressed because it is too large Load Diff
+62 -48
View File
@@ -52,19 +52,20 @@
CHIN: 8
};
// ---- 经验系数(⚠ 真机需校准) ----
// ---- 经验系数(2026-09-27 用 C3 三视图(+45°侧脸)模拟摄像头校准) ----
// yaw = atan((dLeft-dRight)/(dLeft+dRight)) × YAW_COEFF。
// atan 输出 ∈ (-45°,45°),取 2.2 使真实 ~45° 侧脸读数落在 40° 上下。
var YAW_COEFF = 2.2;
// pitch:鼻底相对「眉眼连线中点」的纵向偏移 / 脸高(眉 19/24 — 下巴 8),
// 校准:±45° 实拍侧脸读数 ±25°(2.2 偏小)→ 3.8 使读数落在 44° 上下;正脸 ~4°,仍 <12° 阈值
var YAW_COEFF = 3.8;
// pitch:鼻底相对「眉眼连线中点」的纵向偏移 / 脸高(眉 19/24 — 下巴 8),
// 减去中性正脸基线后线性映射为角度。
var PITCH_BASELINE = 0.32; // 正脸时 offset/faceHeight 的典型值
var PITCH_BASELINE = 0.45; // 校准:0.32 时正脸误读 ~29°(被误判低头),实测基线 0.48,留 0.03 余量
var PITCH_COEFF = 180; // 归一化偏移 → 角度 的增益
// ---- check 阈值(角度制) ----
// 正脸口径:Face++ 正脸判定 |yaw|<10°,前端留 2° 余量取 ≤12°
var FRONT_YAW_MAX = 12;
var FRONT_PITCH_MAX = 15;
// 正脸口径:Face++ 正脸判定 |yaw|<10°;产品要求(2026-09-28)正脸引导放宽一点点,
// 前端取 |yaw|≤15°、|pitch|≤18°(略超 Face++ 口径,正脸图仍可正常分析,仅 orientation 读数略偏)
var FRONT_YAW_MAX = 15;
var FRONT_PITCH_MAX = 18;
var SIDE_YAW_MIN = 28;
var SIDE_YAW_MAX = 75;
var SIDE_PITCH_MAX = 20;
@@ -72,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;
@@ -156,7 +157,8 @@
/**
* check(points, target, mirrored) -> { ok, hint }
* target: 'front' | 'left' | 'right'
* 'front' : |yaw|<=12 且 |pitch|<=15
* '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='请正对屏幕'。
@@ -166,23 +168,25 @@
try {
e = estimate(points, mirrored);
} catch (err) {
return { ok: false, hint: HINT_FRONT };
return { ok: false, hint: HINT_FRONT, yaw: null };
}
if (!e) return { ok: false, hint: HINT_FRONT }; // 退化特征点(重合/脸高过小 → NaN/Infinity)
if (!e) return { ok: false, hint: HINT_FRONT, yaw: null }; // 退化特征点(重合/脸高过小 → NaN/Infinity)
// 语义 yaw:>0 = 被摄者向自己的左侧转头,与预览是否镜像无关
var sYaw = mirrored ? -e.yaw : e.yaw;
// 语义 yaw:>0 = 被摄者向自己的左侧转头。estimate(mirrored) 内部已按镜像翻转过符号,
// 这里直接用 e.yaw(此前再翻一次是双重镜像 bug:侧脸引导方向完全相反)
var sYaw = e.yaw;
var pitch = e.pitch;
if (target === 'front') {
if (Math.abs(sYaw) <= FRONT_YAW_MAX && Math.abs(pitch) <= FRONT_PITCH_MAX) {
return { ok: true, hint: HINT_KEEP };
return { ok: true, hint: HINT_KEEP, yaw: sYaw };
}
if (Math.abs(sYaw) > FRONT_YAW_MAX) {
// 已向左转过头 → 提示向右回正,反之亦然
return { ok: false, hint: sYaw > 0 ? HINT_TURN_RIGHT : HINT_TURN_LEFT };
// 按超出阈值比例更大的一项引导回正:头偏向自己左侧(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 };
return { ok: false, hint: pitch > 0 ? HINT_LOWER : HINT_RAISE, yaw: sYaw };
}
if (target === 'left' || target === 'right') {
@@ -190,20 +194,20 @@
var want = target === 'left' ? -1 : 1;
var sy = sYaw * want; // 统一折算到正区间判断
if (sy >= SIDE_YAW_MIN && sy <= SIDE_YAW_MAX && Math.abs(pitch) <= SIDE_PITCH_MAX) {
return { ok: true, hint: HINT_KEEP };
return { ok: true, hint: HINT_KEEP, yaw: sYaw };
}
if (sy < SIDE_YAW_MIN) {
// 转得不够(含转错方向):提示朝目标方向转
return { ok: false, hint: target === 'left' ? HINT_TURN_RIGHT : HINT_TURN_LEFT };
return { ok: false, hint: target === 'left' ? HINT_TURN_RIGHT : HINT_TURN_LEFT, yaw: sYaw };
}
if (sy > SIDE_YAW_MAX) {
// 转过了:提示往回
return { ok: false, hint: target === 'left' ? HINT_TURN_LEFT : HINT_TURN_RIGHT };
return { ok: false, hint: target === 'left' ? HINT_TURN_LEFT : HINT_TURN_RIGHT, yaw: sYaw };
}
return { ok: false, hint: pitch > 0 ? HINT_LOWER : HINT_RAISE };
return { ok: false, hint: pitch > 0 ? HINT_LOWER : HINT_RAISE, yaw: sYaw };
}
return { ok: false, hint: HINT_FRONT };
return { ok: false, hint: HINT_FRONT, yaw: null };
}
return {
@@ -247,15 +251,15 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
pts[24] = { x: 230, y: 70 };
pts[8] = { x: 200, y: 240 }; // 下巴 → 脸高 240-70=170
pts[30] = { x: 200, y: 140 }; // 鼻尖居中
pts[33] = { x: 200, y: 139.4 }; // (139.4-85)/170 = 0.32 = PITCH_BASELINE
pts[33] = { x: 200, y: 161.5 }; // (161.5-85)/170 = 0.45 = PITCH_BASELINE
return pts;
}
// 把鼻点横向偏移,模拟 ~45° 侧脸(向画面右侧偏 = 被摄者向左转)
// 把鼻点横向偏移 17.7px,模拟 ~45° 侧脸(向画面右侧偏 = 被摄者向左转;偏移量按 YAW_COEFF=3.8 数值标定)
function makeSide() {
var pts = makeFrontal();
pts[30] = { x: 235, y: 140 };
pts[33] = { x: 235, y: 139.4 };
pts[30] = { x: 217.7, y: 140 };
pts[33] = { x: 217.7, y: 161.5 };
return pts;
}
@@ -279,36 +283,43 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
ok(rc.ok === true, '侧脸(被摄者左转)target=right ok');
ok(lc.ok === false && lc.hint === '请向右转头', '同侧脸 target=left 拒绝并引导向右');
// 5) mirrored=true 翻转 yaw 符号,但 check 语义不变
// 5) mirrored=true 翻转 yaw 符号,check 语义随之翻转(同一点集在镜像预览下对应相反的物理转头)
var sideM = HeadPose.estimate(makeSide(), true);
ok(Math.abs(sideM.yaw + side.yaw) < 0.001, 'mirrored=true 时 yaw 符号翻转');
ok(HeadPose.check(makeSide(), 'right', true).ok === true, 'mirrored 下 check(right) 仍 ok');
ok(HeadPose.check(makeSide(), 'left', true).ok === false, 'mirrored 下 check(left) 仍拒绝');
ok(HeadPose.check(makeSide(), 'right', true).ok === false, 'mirrored 语义翻转:check(right) 拒绝');
ok(HeadPose.check(makeSide(), 'left', true).ok === true, 'mirrored 语义翻转:check(left) 通过');
// 6) 反方向侧脸:鼻尖偏画面左 → target 'left' 通过
// 6) 反方向侧脸:鼻尖偏画面左 → target 'left' 通过
var back = makeSide();
back[30] = { x: 165, y: 140 };
back[33] = { x: 165, y: 139.4 };
back[30] = { x: 182.3, y: 140 };
back[33] = { x: 182.3, y: 161.5 };
var bc = HeadPose.check(back, 'left', false);
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,19 +338,22 @@ 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|≤12°(Face++ 口径 <10° + 2° 余量):yaw≈13.7°(旧阈值 15 内)应拒绝
ok(HeadPose.config.FRONT_YAW_MAX === 12, 'config.FRONT_YAW_MAX = 12');
// 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: 209, y: 140 };
slight[33] = { x: 209, y: 139.4 };
slight[30] = { x: 205.6, y: 140 }; // 偏移 5.6 → yaw≈14.9°(YAW_COEFF=3.8 标定)
slight[33] = { x: 205.6, y: 161.5 };
var se = HeadPose.estimate(slight, false);
ok(se.yaw > 12 && se.yaw < 15, '轻微偏头 yaw∈(12,15) (got ' + se.yaw.toFixed(2) + ')');
ok(se.yaw > 13.5 && se.yaw <= 15, '轻微偏头 yaw∈(13.5,15] (got ' + se.yaw.toFixed(2) + ')');
var sc2 = HeadPose.check(slight, 'front', false);
ok(sc2.ok === false, 'yaw≈13.7° front 拒绝(旧阈值 15 会放行)');
ok(sc2.ok === true, 'yaw≈14.9°(≤15°)front 通过(旧阈值 12 会拒绝)');
var slight2 = makeFrontal();
slight2[30] = { x: 207, y: 140 };
slight2[33] = { x: 207, y: 139.4 };
ok(HeadPose.check(slight2, 'front', false).ok === true, 'yaw≈10.7°(≤12°)front 通过');
slight2[30] = { x: 206.2, y: 140 }; // 偏移 6.2 → yaw≈16.2°
slight2[33] = { x: 206.2, y: 161.5 };
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=请向右转头');
if (failures > 0) {
console.error('\n' + failures + ' 项自测失败');
+39
View File
@@ -0,0 +1,39 @@
/* 省份 → 城市(地级)数据。来源:china-division@2.7.0 dist/pc.json(modood/Administrative-divisions-of-China,WTFPL);
直辖市第二列只保留本市,另补港澳台与海外。 */
window.REGION_DATA = [
["北京市", ["北京市"]],
["天津市", ["天津市"]],
["河北省", ["石家庄市","唐山市","秦皇岛市","邯郸市","邢台市","保定市","张家口市","承德市","沧州市","廊坊市","衡水市"]],
["山西省", ["太原市","大同市","阳泉市","长治市","晋城市","朔州市","晋中市","运城市","忻州市","临汾市","吕梁市"]],
["内蒙古自治区", ["呼和浩特市","包头市","乌海市","赤峰市","通辽市","鄂尔多斯市","呼伦贝尔市","巴彦淖尔市","乌兰察布市","兴安盟","锡林郭勒盟","阿拉善盟"]],
["辽宁省", ["沈阳市","大连市","鞍山市","抚顺市","本溪市","丹东市","锦州市","营口市","阜新市","辽阳市","盘锦市","铁岭市","朝阳市","葫芦岛市"]],
["吉林省", ["长春市","吉林市","四平市","辽源市","通化市","白山市","松原市","白城市","延边朝鲜族自治州"]],
["黑龙江省", ["哈尔滨市","齐齐哈尔市","鸡西市","鹤岗市","双鸭山市","大庆市","伊春市","佳木斯市","七台河市","牡丹江市","黑河市","绥化市","大兴安岭地区"]],
["上海市", ["上海市"]],
["江苏省", ["南京市","无锡市","徐州市","常州市","苏州市","南通市","连云港市","淮安市","盐城市","扬州市","镇江市","泰州市","宿迁市"]],
["浙江省", ["杭州市","宁波市","温州市","嘉兴市","湖州市","绍兴市","金华市","衢州市","舟山市","台州市","丽水市"]],
["安徽省", ["合肥市","芜湖市","蚌埠市","淮南市","马鞍山市","淮北市","铜陵市","安庆市","黄山市","滁州市","阜阳市","宿州市","六安市","亳州市","池州市","宣城市"]],
["福建省", ["福州市","厦门市","莆田市","三明市","泉州市","漳州市","南平市","龙岩市","宁德市"]],
["江西省", ["南昌市","景德镇市","萍乡市","九江市","新余市","鹰潭市","赣州市","吉安市","宜春市","抚州市","上饶市"]],
["山东省", ["济南市","青岛市","淄博市","枣庄市","东营市","烟台市","潍坊市","济宁市","泰安市","威海市","日照市","临沂市","德州市","聊城市","滨州市","菏泽市"]],
["河南省", ["郑州市","开封市","洛阳市","平顶山市","安阳市","鹤壁市","新乡市","焦作市","濮阳市","许昌市","漯河市","三门峡市","南阳市","商丘市","信阳市","周口市","驻马店市","济源市"]],
["湖北省", ["武汉市","黄石市","十堰市","宜昌市","襄阳市","鄂州市","荆门市","孝感市","荆州市","黄冈市","咸宁市","随州市","恩施土家族苗族自治州","仙桃市","潜江市","天门市","神农架林区"]],
["湖南省", ["长沙市","株洲市","湘潭市","衡阳市","邵阳市","岳阳市","常德市","张家界市","益阳市","郴州市","永州市","怀化市","娄底市","湘西土家族苗族自治州"]],
["广东省", ["广州市","韶关市","深圳市","珠海市","汕头市","佛山市","江门市","湛江市","茂名市","肇庆市","惠州市","梅州市","汕尾市","河源市","阳江市","清远市","东莞市","中山市","潮州市","揭阳市","云浮市"]],
["广西壮族自治区", ["南宁市","柳州市","桂林市","梧州市","北海市","防城港市","钦州市","贵港市","玉林市","百色市","贺州市","河池市","来宾市","崇左市"]],
["海南省", ["海口市","三亚市","三沙市","儋州市","五指山市","琼海市","文昌市","万宁市","东方市","定安县","屯昌县","澄迈县","临高县","白沙黎族自治县","昌江黎族自治县","乐东黎族自治县","陵水黎族自治县","保亭黎族苗族自治县","琼中黎族苗族自治县"]],
["重庆市", ["重庆市"]],
["四川省", ["成都市","自贡市","攀枝花市","泸州市","德阳市","绵阳市","广元市","遂宁市","内江市","乐山市","南充市","眉山市","宜宾市","广安市","达州市","雅安市","巴中市","资阳市","阿坝藏族羌族自治州","甘孜藏族自治州","凉山彝族自治州"]],
["贵州省", ["贵阳市","六盘水市","遵义市","安顺市","毕节市","铜仁市","黔西南布依族苗族自治州","黔东南苗族侗族自治州","黔南布依族苗族自治州"]],
["云南省", ["昆明市","曲靖市","玉溪市","保山市","昭通市","丽江市","普洱市","临沧市","楚雄彝族自治州","红河哈尼族彝族自治州","文山壮族苗族自治州","西双版纳傣族自治州","大理白族自治州","德宏傣族景颇族自治州","怒江傈僳族自治州","迪庆藏族自治州"]],
["西藏自治区", ["拉萨市","日喀则市","昌都市","林芝市","山南市","那曲市","阿里地区"]],
["陕西省", ["西安市","铜川市","宝鸡市","咸阳市","渭南市","延安市","汉中市","榆林市","安康市","商洛市"]],
["甘肃省", ["兰州市","嘉峪关市","金昌市","白银市","天水市","武威市","张掖市","平凉市","酒泉市","庆阳市","定西市","陇南市","临夏回族自治州","甘南藏族自治州"]],
["青海省", ["西宁市","海东市","海北藏族自治州","黄南藏族自治州","海南藏族自治州","果洛藏族自治州","玉树藏族自治州","海西蒙古族藏族自治州"]],
["宁夏回族自治区", ["银川市","石嘴山市","吴忠市","固原市","中卫市"]],
["新疆维吾尔自治区", ["乌鲁木齐市","克拉玛依市","吐鲁番市","哈密市","昌吉回族自治州","博尔塔拉蒙古自治州","巴音郭楞蒙古自治州","阿克苏地区","克孜勒苏柯尔克孜自治州","喀什地区","和田地区","伊犁哈萨克自治州","塔城地区","阿勒泰地区","石河子市","阿拉尔市","图木舒克市","五家渠市","北屯市","铁门关市","双河市","可克达拉市","昆玉市","胡杨河市","新星市","白杨市"]],
["香港特别行政区", ["香港特别行政区"]],
["澳门特别行政区", ["澳门特别行政区"]],
["台湾省", ["台湾省"]],
["海外", ["海外"]]
];
+69
View File
@@ -589,6 +589,57 @@
3: { name: '50+重塑精华', text: '建议搭配使用50+重塑精华:针对熟龄肌松弛与轮廓流失问题,深层重塑支撑结构,改善明显松弛与纹路,重建面部紧实与弹性。' }
};
/* ─── V2.3 §8 护理建议 ─── */
var SKIN_TYPE_TEXT = { // §8.6 五类映射【肤质定制】段全文 + 联动句
'干性肌肤': { base: '你的日常肤质为干性肌肤:全脸皮脂分泌偏少,角质层保水能力弱,易紧绷起屑。护理以高效保湿与封闭锁水为核心,洁面选用温和低泡产品,避免频繁去角质,面霜可选择含神经酰胺、角鲨烷等封闭性成分的配方。',
link: function (c) { return c.tone && c.tone.level === '预警' ? '当前已处于明显缺水状态,建议暂缓一切控油类产品' : ''; } },
'油性肌肤': { base: '你的日常肤质为油性肌肤:皮脂分泌旺盛,毛孔易被氧化油脂堵塞。护理以温和控油与毛孔疏通为核心,可选择含烟酰胺、水杨酸的清爽配方,但清洁仍需适度,避免过度去脂反而刺激代偿性出油。',
link: function (c) { return c.tone && c.tone.level === '预警' ? '需警惕外油内干:控油同时必须叠加保湿,避免屏障受损' : ''; } },
'中性肌肤': { base: '你的日常肤质为中性肌肤:水油分泌平衡,角质状态稳定,是理想的肌肤基础态。护理以维持现状为核心,精简护肤流程,做好基础清洁、保湿与防晒即可,无需叠加过多功效产品。',
link: function () { return ''; } },
'混合偏干肌肤': { base: '你的日常肤质为混合偏干肌肤:T区状态尚可,但两颊及面部外侧偏干,易出现紧绷与细纹。建议分区护理:两颊侧重保湿滋养,选用滋润型乳液/面霜,T区轻量保湿即可,避免全脸统一用厚重面霜。',
link: function (c) { return c.smooth && c.smooth.level === '预警' ? '干燥会放大纹理粗糙感,建议先将两颊水合度提上来再做角质调理' : ''; } },
'混合偏油肌肤': { base: '你的日常肤质为混合偏油肌肤:T区出油旺盛而两颊偏干,呈典型外油内干倾向。建议分区护理:T区使用控油清爽产品,两颊加强保湿;全脸避免高滋润面霜,可选用质地轻薄的保湿乳分区涂抹。',
link: function (c) { return c.tone && c.tone.level === '预警' ? '外油内干状态下切勿加大清洁力度,补水优先于控油' : ''; } }
};
function skinTypeKey(name) { // 归一:用户自选五类名;未知/缺失按中性
var keys = ['混合偏干肌肤', '混合偏油肌肤', '干性肌肤', '油性肌肤', '中性肌肤'];
for (var i = 0; i < keys.length; i++) if (name && name.indexOf(keys[i].replace('肌肤', '')) >= 0) return keys[i];
return '中性肌肤';
}
var CARE_POINTS = { // §8.7 护理要点库(6分类×3档,逐字;肤色水润=色沉+水分并列,先色沉后水分)
contour: {
'预警': '强化轮廓紧致管理,重点针对面中、苹果肌与下颌线支持带区域,配合向上提升按摩手法,促进真皮层胶原再生与微循环。',
'改善': '加强弹韧护理补充胶原支架营养,关注易显疲态区域的支撑力,日常可增加紧致精华步骤并配合向上按摩。',
'良好': '维持现有抗老护理节奏,做好光损伤防护与状态管理,巩固真皮胶原网络密度。'
},
wrinkles: {
'预警': '加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。',
'改善': '加强日常淡纹与弹润护理,维持真皮基质含水量,保持肌肤饱满平滑。',
'良好': '以抗初老预防为核心,提前建立抗氧化/紧致护理习惯,延缓第一道静态纹出现。'
},
smooth: {
'预警': '加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。',
'改善': '加强基础清洁与角质调理,关注鼻翼、下颌等易粗糙部位,提升毛孔通畅度与柔滑度。',
'良好': '维持当前温和护理节奏,保护角质层排列与微循环稳态。'
},
tone: { // 每档 = [色沉段, 水分段](并列输出,先色沉后水分)
'预警': ['强化透亮与色素代谢管理,严格广谱防晒,配合温和抗氧化成分(如维C衍生物)抑制黑色素转运。', '强化屏障锁水修复,停用控油类产品,改用含神经酰胺的封闭性面霜,每周2-3次补水面膜快速纠正缺水状态。'],
'改善': ['加强日常光泽管理与局部色素防控,做好基础防晒与抗氧化打底。', '注重水油平衡调理,选用清爽保湿乳液,避免过度清洁带走皮脂膜。'],
'良好': ['维持现有防护与护理习惯,巩固肤色均匀度与透亮感。', '维持现有补水习惯,巩固水合状态与水油平衡。']
},
sens: {
'预警': '优先屏障修复与舒缓管理,精简护肤流程,避免频繁更换方案与叠加功效成分,修复受损角质层。',
'改善': '保持温和护理节奏,注意防晒与环境防护,逐步提升皮肤耐受度。',
'良好': '维持规律护理并按季节阶段性调整,巩固皮肤耐受稳态。'
},
acne: { // 痘痘:V1.2 无原文,V2.3 本版新撰
'预警': '优先抗炎与痘肌修护,避免挤压与厚重型封闭产品,选用含水杨酸或壬二酸的清爽配方,局部点涂抗痘精华,必要时寻求皮肤科处理。',
'改善': '维持温和清洁与控油节奏,注意枕巾与手部卫生,避免叠加过多功效产品刺激皮损。',
'良好': '维持现有清洁控油习惯,保持作息与饮食规律,防止毛孔堵塞复发。'
}
};
/* ─── 主入口 ─── */
function buildViewModel(apiResult, sideResults, profile) {
var r = apiResult || {};
@@ -717,11 +768,28 @@
+ (tail ? tail + '。' : '');
}
/* 护理建议(V2.3 §8.5 五段固定顺序:【肤质】【最差分类1】【最差分类2】【精华】【生活方式】;
§8.5.1 输出纪律:段内不得出现 P/S 数值、档位名、判据、字段名等内部标注) */
var careAdvice = [];
var stDef = SKIN_TYPE_TEXT[skinTypeKey(profile.skinType)];
var linkSeg = stDef.link(cats);
careAdvice.push({ tag: '肤质', text: stDef.base + (linkSeg ? linkSeg + '。' : '') });
if (sorted && sorted.length >= 2) {
for (i = 0; i < 2; i++) {
var ck = CAT_DEFS.filter(function (d) { return d.name === sorted[i].name; })[0].key;
var lv2 = cats[ck].level || '良好', pts = CARE_POINTS[ck][lv2];
careAdvice.push({ tag: sorted[i].name, text: Array.isArray(pts) ? pts.join('') : pts });
}
}
careAdvice.push({ tag: '精华', text: essence.text });
careAdvice.push({ tag: '生活方式', text: worst ? LIFESTYLE[worst.catKey] : '' });
return {
total: pick(r, 'total_score'),
skinAgeRaw: skinAge,
skinAgeDisplay: skinAgeDisplay,
ageClampNote: ageClampNote,
realAge: realAge,
dims: dims,
cats: cats,
group: group,
@@ -731,6 +799,7 @@
focus: focus,
summary: summary,
lifestyle: worst ? LIFESTYLE[worst.catKey] : '',
careAdvice: careAdvice,
notes: notes
};
}
+26
View File
@@ -0,0 +1,26 @@
@echo off
rem 宿主机端口转发 + 防火墙放行(需「以管理员身份运行」)
rem 镜像网络模式下通常可直接访问 http://<本机局域网IP>:8088 ,本脚本作兜底。
net session >nul 2>&1
if errorlevel 1 (
echo 请右键本文件 - 以管理员身份运行
pause
exit /b 1
)
netsh interface portproxy delete v4tov4 listenport=8088 listenaddress=0.0.0.0 >nul 2>&1
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8088 connectaddress=127.0.0.1 connectport=8088
netsh advfirewall firewall delete rule name="Juzi H5 8088" >nul 2>&1
netsh advfirewall firewall add rule name="Juzi H5 8088" dir=in action=allow protocol=TCP localport=8088 profile=any
echo.
echo === portproxy ===
netsh interface portproxy show all
echo === firewall ===
netsh advfirewall firewall show rule name="Juzi H5 8088"
echo.
echo 手机访问: http://192.168.3.19:8088/
echo 后台: http://192.168.3.19:8088/admin.html
echo mock: http://192.168.3.19:8088/?mock=1
pause
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+570 -29
View File
@@ -4,6 +4,8 @@
2) POST /api/skin-analyze:三视图(正/左/右)中转 Face++ 专业版测肤
密钥只存在服务端(SKIN_API_KEY / SKIN_API_SECRET),
前端永远接触不到 api_secret。
3) 检测记录落库:data/records.jsonl(一行一条 JSON)+ data/photos/<record_id>/
原始返回仍落盘 data/results/(复核用)。后台查看页 admin.html + /api/admin/*。
启动: node server.js (默认 8080,可用 PORT 环境变量改)
═══════════════════════════════════════════════════════════ */
'use strict';
@@ -13,11 +15,19 @@ const fs = require('fs');
const path = require('path');
const PORT = Number(process.env.PORT) || 8080;
// 只绑回环:公网入口是 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';
const DATA_DIR = path.join(ROOT, 'data', 'results'); // 每次检测的原始返回落盘(复核用,已在 .gitignore)
const DATA_ROOT = path.join(ROOT, 'data');
const DATA_DIR = path.join(DATA_ROOT, 'results'); // 每次检测的原始返回落盘(复核用,已在 .gitignore)
const PHOTOS_DIR = path.join(DATA_ROOT, 'photos'); // 检测照片:data/photos/<record_id>/{front,left,right}.jpg
const RECORDS_FILE = path.join(DATA_ROOT, 'records.jsonl'); // 检测记录(一行一条 JSON)
/* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env ─── */
/* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */
const SkinScore = require('./js/skin-score.js');
/* ─── 密钥加载:环境变量优先,其次 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 };
@@ -28,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();
@@ -46,7 +62,7 @@ const MIME = {
'.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml',
'.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2'
'.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2', '.csv': 'text/csv; charset=utf-8'
};
function serveStatic(req, res, pathname) {
let p;
@@ -57,11 +73,18 @@ function serveStatic(req, res, pathname) {
return res.end('Bad Request');
}
if (p === '/') p = '/index.html';
// /data/ 下是检测照片/原始 JSON/记录库(敏感个人信息),禁止静态直出:
// 照片只能走 /api/admin/photos(由 nginx Basic Auth 保护),此处一律 403
if (p === '/data' || p.startsWith('/data/')) { res.writeHead(403); return res.end('Forbidden'); }
const file = path.normalize(path.join(ROOT, p));
if (file !== ROOT && !file.startsWith(ROOT + path.sep)) { res.writeHead(403); return res.end('Forbidden'); }
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); }
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
const ext = path.extname(file);
// html/css/js 不缓存(迭代期样式/脚本改动必须即时到端;图片/字体/音频走默认)
const headers = { 'Content-Type': MIME[ext] || 'application/octet-stream' };
if (['.html', '.css', '.js'].includes(ext)) headers['Cache-Control'] = 'no-cache';
res.writeHead(200, headers);
res.end(data);
});
}
@@ -78,6 +101,7 @@ const ERR_MSG = {
'AUTHORIZATION_ERROR': '检测服务额度或权限异常,请联系管理员',
'CONCURRENCY_LIMIT_EXCEEDED': '当前检测人数较多,请稍候重试',
'INTERNAL_ERROR': '检测服务开小差了,请稍候重试',
'TIMEOUT': '检测服务响应超时,请稍候重试',
'NETWORK_ERROR': '网络异常,请检查网络后重试'
};
@@ -101,7 +125,7 @@ function readBody(req, limitBytes) {
const sleep = ms => new Promise(r => setTimeout(r, ms));
/* ─── 报告页图层白名单:face_maps 只透传这几层,一行增删 ─── */
const REPORT_MAPS = ['red_area', 'brown_area', 'texture_enhanced_lines', 'texture_enhanced_pores', 'texture_enhanced_blackheads'];
const REPORT_MAPS = ['red_area', 'brown_area', 'water_area', 'texture_enhanced_lines', 'texture_enhanced_pores', 'texture_enhanced_blackheads'];
/* 按 magic bytes 判断图片格式并补 data: URL 前缀(实测 red_area/brown_area 为 jpeg 头、其余 png,不硬编码) */
function toDataUrl(b64) {
@@ -121,9 +145,174 @@ function isUsableB64(b64) {
return Buffer.from(b64, 'base64').length > 500;
}
/* ═══════════════════════════════════════════════════════════
检测记录存储(records.jsonl,一行一条 JSON,零依赖嵌入式存储)
字段对照后台产品文档第五章 detect_record。
═══════════════════════════════════════════════════════════ */
function nowIso() { return new Date().toISOString(); }
function newRecordId() {
const d = new Date();
const p = n => String(n).padStart(2, '0');
const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
return `R${stamp}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
}
function readAllRecords() {
try {
return fs.readFileSync(RECORDS_FILE, 'utf8').split('\n')
.map(line => { try { return JSON.parse(line); } catch (e) { return null; } })
.filter(r => r && r.record_id);
} catch (e) { return []; } // 文件不存在视为空库
}
function appendRecord(rec) {
try {
fs.mkdirSync(DATA_ROOT, { recursive: true });
fs.appendFileSync(RECORDS_FILE, JSON.stringify(rec) + '\n');
} catch (e) { console.log(`[record] 记录追加失败(忽略): ${e.message}`); }
}
function updateRecord(rec) {
try {
const all = readAllRecords();
const i = all.findIndex(r => r.record_id === rec.record_id);
if (i >= 0) all[i] = rec;
fs.writeFileSync(RECORDS_FILE, all.map(r => JSON.stringify(r)).join('\n') + '\n');
} catch (e) { console.log(`[record] 记录更新失败(忽略): ${e.message}`); }
}
function findRecord(id) {
const safe = String(id || '').replace(/[^\w-]/g, '');
return readAllRecords().find(r => r.record_id === safe) || null;
}
/* 请求体里的档案信息归一化:年龄非法(非 1–120 整数)按 null,其余空串按 null */
function normalizeProfile(raw) {
const p = (raw && typeof raw === 'object') ? raw : {};
const age = Number(p.real_age);
const str = v => (typeof v === 'string' && v.trim()) ? v.trim() : null;
return {
real_age: Number.isInteger(age) && age >= 1 && age <= 120 ? age : null,
gender: str(p.gender),
region: str(p.region),
daily_skin_type: str(p.daily_skin_type)
};
}
/* 照片写盘:data/photos/<record_id>/{front,left,right}.jpg(本地磁盘代替对象存储,库只存相对地址)。
写盘失败不影响检测流程,只打日志。返回 {front|left|right: 相对路径} */
function writePhotos(recordId, parts) {
const out = {};
try {
const dir = path.join(PHOTOS_DIR, recordId);
fs.mkdirSync(dir, { recursive: true });
for (const which of ['front', 'left', 'right']) {
const b64 = parts[which];
if (!b64) continue;
fs.writeFileSync(path.join(dir, which + '.jpg'), Buffer.from(b64, 'base64'));
out[which] = `data/photos/${recordId}/${which}.jpg`;
}
} catch (e) { console.log(`[record] 照片写盘失败(忽略): ${e.message}`); }
return out;
}
/* 从侧脸结果取下颌线角度:quality≠1 时角度无效,记 null */
function jawlineAngle(side, which) {
if (!side || typeof side !== 'object') return null;
const info = side[which + '_jawline_info'];
if (!info || info[which + '_jawline_quality'] !== 1) return null;
const a = info[which + '_jawline_angle'];
return typeof a === 'number' ? a : null;
}
/* 原始返回完整落盘(含 face_maps base64),供复核/真值比对;写盘失败不影响检测。
返回落盘文件名(相对 data/results/),失败返回 null */
function dumpRaw(fpJson) {
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
const reqId = String(fpJson.request_id || 'noreq').replace(/[^\w,-]/g, '').slice(0, 40);
const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
const fname = `${stamp}-${reqId}.json`;
fs.writeFile(path.join(DATA_DIR, fname), JSON.stringify(fpJson), () => {});
console.log(`[analyze] 原始返回已落盘: data/results/${fname}`);
return fname;
} catch (e) { console.log(`[analyze] 落盘失败(忽略): ${e.message}`); return null; }
}
/* 评分引擎入参档案:SkinScore 用 realAge/skinType 键 */
function scoreProfile(rec) {
return { realAge: rec.real_age, skinType: rec.daily_skin_type };
}
/* 检测成功:用 SkinScore.buildViewModel 计算并补全记录字段(复用同一份公式) */
function fillSuccess(rec, fpJson) {
const vm = SkinScore.buildViewModel(fpJson.result, {
left: fpJson.left_side_result || (fpJson.side_result && fpJson.side_result.left_side_result) || null,
right: fpJson.right_side_result || (fpJson.side_result && fpJson.side_result.right_side_result) || null
}, scoreProfile(rec));
rec.detect_status = '成功';
rec.fail_stage = null; rec.fail_code = null; rec.fail_msg = null;
rec.skin_age = vm.skinAgeRaw;
rec.total_score = vm.total;
rec.jawline_angle_l = jawlineAngle(fpJson.left_side_result, 'left');
rec.jawline_angle_r = jawlineAngle(fpJson.right_side_result, 'right');
rec.cats = {}; rec.copy_6 = {};
for (const k of Object.keys(vm.cats)) {
rec.cats[k] = { P: vm.cats[k].P, S: vm.cats[k].S, level: vm.cats[k].level };
rec.copy_6[k] = vm.cats[k].text;
}
rec.dims = {};
for (const k of Object.keys(vm.dims)) rec.dims[k] = { P: vm.dims[k].P, S: vm.dims[k].S, level: vm.dims[k].level };
rec.avg_s = vm.avgS;
rec.group = vm.group;
rec.copy_summary = vm.summary;
rec.lifestyle = vm.lifestyle;
rec.careAdvice = vm.careAdvice;
rec.essence = vm.essence ? { name: vm.essence.name, text: vm.essence.text } : null;
rec.essence_line = vm.essence ? vm.essence.text : null;
rec.trigger = (vm.essence && vm.essence.triggers) ? vm.essence.triggers.join(',') : null;
rec.notes = vm.notes || [];
rec.raw_file = dumpRaw(fpJson);
}
/* 检测失败:补失败字段 */
function fillFailure(rec, code) {
rec.detect_status = '失败';
rec.fail_stage = 'detect';
rec.fail_code = code;
rec.fail_msg = ERR_MSG[code] || '检测失败,请重试';
}
/* 执行一次检测:计时调用 Face++,按结果补全记录(成功/失败都落库)。返回 Face++ 原始返回 */
async function runDetect(rec, front, left, right) {
rec.detect_status = '检测中';
rec.updated_at = nowIso();
const t0 = Date.now();
const fpJson = await callFacepp(front, left, right);
rec.cost_ms = Date.now() - t0;
if (fpJson.error_message) {
const code = fpJson.error_message.split(':')[0];
console.log(`[analyze] Face++ 错误: ${code}`);
fillFailure(rec, code);
} else if (!fpJson.result) {
fillFailure(rec, 'INTERNAL_ERROR');
} else {
try {
fillSuccess(rec, fpJson);
} catch (e) { // 评分异常按检测失败落库,但不改写 C 端成功响应(原始结果仍透传)
console.log(`[analyze] 评分异常: ${e.message}`);
fillFailure(rec, 'INTERNAL_ERROR');
}
}
rec.updated_at = nowIso();
return fpJson;
}
/* 调用 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);
@@ -158,6 +347,7 @@ async function callFacepp(front, left, right) {
}
async function handleAnalyze(req, res) {
let rec = null; // 记录:正脸照片校验通过后创建(此前失败无从落库)
try {
const body = JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8'));
// 三个 image 字段先做类型检查:非字符串视为缺失(左/右图缺失时仅发正脸,接口自动降级为单视图)
@@ -173,28 +363,49 @@ async function handleAnalyze(req, res) {
return res.end(JSON.stringify({ ok: false, code: 'INVALID_IMAGE_SIZE', message: '照片数据异常,请重试' }));
}
const fpJson = await callFacepp(front, left, right);
/* ── 记录落库:生成 record_id → 写照片 → 落一条 detect_status=检测中 ── */
const profile = normalizeProfile(body.profile);
const recordId = newRecordId();
const photos = writePhotos(recordId, { front, left, right });
rec = {
record_id: recordId,
created_at: nowIso(),
updated_at: nowIso(),
real_age: profile.real_age,
gender: profile.gender,
region: profile.region,
daily_skin_type: profile.daily_skin_type,
img_front: photos.front || null,
img_left: photos.left || null,
img_right: photos.right || null,
shoot_status: (photos.left && photos.right) ? '已完成' : '降级',
upload_status: photos.front ? '成功' : '失败',
detect_status: '检测中',
fail_stage: null, fail_code: null, fail_msg: null,
skin_age: null, total_score: null,
jawline_angle_l: null, jawline_angle_r: null,
cats: null, dims: null, avg_s: null, group: null,
copy_6: null, copy_summary: null, lifestyle: null, careAdvice: null,
essence: null, essence_line: null, trigger: null, notes: null,
raw_file: null, cost_ms: null,
retry_of: null, retry_count: 0
};
appendRecord(rec);
console.log(`[record] 新检测记录 ${recordId}(${rec.shoot_status},照片${Object.keys(photos).length}/3)`);
const fpJson = await runDetect(rec, front, left, right);
updateRecord(rec);
if (fpJson.error_message) {
const code = fpJson.error_message.split(':')[0];
console.log(`[analyze] Face++ 错误: ${code}`);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, code, message: ERR_MSG[code] || '检测失败,请重试' }));
}
if (!fpJson.result) {
if (!fpJson.result) { // 与改造前一致:仅 result 缺失才走 INTERNAL_ERROR(评分异常不改写 C 端成功响应)
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, code: 'INTERNAL_ERROR', message: ERR_MSG.INTERNAL_ERROR }));
}
const result = fpJson.result;
// 原始返回完整落盘(含 face_maps base64),供复核/真值比对;写盘失败不影响检测响应
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
const reqId = String(fpJson.request_id || 'noreq').replace(/[^\w,-]/g, '').slice(0, 40);
const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
const fname = `${stamp}-${reqId}.json`;
fs.writeFile(path.join(DATA_DIR, fname), JSON.stringify(fpJson), () => {});
console.log(`[analyze] 原始返回已落盘: data/results/${fname}`);
} catch (e) { console.log(`[analyze] 落盘失败(忽略): ${e.message}`); }
// face_maps 是整幅 base64 叠加图,体积很大:只透传报告页所需图层(REPORT_MAPS),
// 值补 data: URL 前缀供前端 <img> 直接用;未选中的图层直接删除键;
// marks 坐标字段(acne/brown_spot/mole/closed_comedones/wrinkle_mark 等)本就在 result 里,原样透传
@@ -219,6 +430,11 @@ async function handleAnalyze(req, res) {
}
}));
} catch (e) {
if (rec) { // 记录已创建但流程异常:按检测失败补全,便于后台看到这条调用
fillFailure(rec, e.message === 'TOO_LARGE' ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR');
rec.updated_at = nowIso();
updateRecord(rec);
}
const tooLarge = e.message === 'TOO_LARGE';
const code = tooLarge ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR';
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -227,13 +443,338 @@ async function handleAnalyze(req, res) {
}
}
/* ═══════════════════════════════════════════════════════════
后台查看 API(本地查看版,无登录鉴权 —— 上线前需加网关/鉴权)
═══════════════════════════════════════════════════════════ */
function sendJson(res, status, obj) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(obj));
}
/* 记录里照片的在线 URL(有存才给) */
function photoUrls(rec) {
const urls = {};
if (rec.img_front) urls.front = `/api/admin/photos/${rec.record_id}/front`;
if (rec.img_left) urls.left = `/api/admin/photos/${rec.record_id}/left`;
if (rec.img_right) urls.right = `/api/admin/photos/${rec.record_id}/right`;
return urls;
}
/* 列表用的字段子集(不含 raw_json 与文案全文) */
function listItem(rec) {
return {
record_id: rec.record_id,
created_at: rec.created_at,
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
shoot_status: rec.shoot_status, upload_status: rec.upload_status, detect_status: rec.detect_status,
fail_code: rec.fail_code, fail_msg: rec.fail_msg,
total_score: rec.total_score, skin_age: rec.skin_age,
dims: rec.dims, group: rec.group,
essence: rec.essence ? rec.essence.name : null,
cost_ms: rec.cost_ms,
retry_of: rec.retry_of, retry_count: rec.retry_count || 0,
photos: photoUrls(rec)
};
}
/* 查询参数过滤。from/to 支持 YYYY-MM-DD 或完整 ISO;纯日期 to 含当天全天 */
function matchQuery(rec, q) {
if (q.from && rec.created_at < q.from) return false;
if (q.to) {
const to = q.to.length === 10 ? q.to + 'T23:59:59.999Z' : q.to;
if (rec.created_at > to) return false;
}
if (q.detect_status && rec.detect_status !== q.detect_status) return false;
if (q.fail_code && rec.fail_code !== q.fail_code) return false;
if (q.group && rec.group !== q.group) return false;
if (q.q) {
const needle = String(q.q).trim();
if (needle && !rec.record_id.startsWith(needle)) return false;
}
if (q.age_min != null) { if (rec.real_age == null || rec.real_age < q.age_min) return false; }
if (q.age_max != null) { if (rec.real_age == null || rec.real_age > q.age_max) return false; }
if (q.dim && q.dim_level) {
const d = rec.dims && rec.dims[q.dim];
if (!d || d.level !== q.dim_level) return false;
}
if (q.only_fail === '1' && rec.detect_status !== '失败') return false;
return true;
}
function handleRecordsQuery(req, res, url) {
const sp = url.searchParams;
const q = {
from: sp.get('from') || null,
to: sp.get('to') || null,
detect_status: sp.get('detect_status') || null,
fail_code: sp.get('fail_code') || null,
group: sp.get('group') || null,
dim: sp.get('dim') || null,
dim_level: sp.get('dim_level') || null,
q: sp.get('q') || null,
only_fail: sp.get('only_fail') || null,
age_min: sp.has('age_min') && sp.get('age_min') !== '' ? Number(sp.get('age_min')) : null,
age_max: sp.has('age_max') && sp.get('age_max') !== '' ? Number(sp.get('age_max')) : null
};
if ((q.age_min != null && !Number.isFinite(q.age_min)) || (q.age_max != null && !Number.isFinite(q.age_max))) {
return sendJson(res, 400, { ok: false, message: 'age_min/age_max 必须是数字' });
}
const page = Math.max(1, parseInt(sp.get('page') || '1', 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(sp.get('page_size') || '20', 10) || 20));
const filtered = readAllRecords().filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); // created_at 倒序
const total = filtered.length;
const items = filtered.slice((page - 1) * pageSize, page * pageSize).map(listItem);
sendJson(res, 200, { ok: true, total, page, page_size: pageSize, items });
}
function handleRecordDetail(req, res, id) {
const rec = findRecord(id);
if (!rec) return sendJson(res, 404, { ok: false, message: '记录不存在' });
// 原始 JSON 全文:读文件失败给 null。
// face_maps 是整幅 base64 叠加图(单文件可达数 MB),详情页直接渲染会卡死浏览器:
// 展示层把各图层值替换为占位说明,完整原文件走 /api/admin/records/:id/raw 下载
let rawJson = null;
if (rec.raw_file) {
const fname = String(rec.raw_file).replace(/[^\w.,-]/g, '');
try { rawJson = JSON.parse(fs.readFileSync(path.join(DATA_DIR, fname), 'utf8')); }
catch (e) { rawJson = null; }
}
if (rawJson && rawJson.result && rawJson.result.face_maps) {
for (const k of Object.keys(rawJson.result.face_maps)) {
const v = rawJson.result.face_maps[k];
if (typeof v === 'string' && v.length) {
rawJson.result.face_maps[k] = `<base64 图层已省略,共 ${v.length} 字符,点上方链接下载完整原文件>`;
}
}
}
// 版本链:它 retry_of 指向的(父)+ retry_of 指向它的(子)
const all = readAllRecords();
const parent = rec.retry_of ? (all.find(r => r.record_id === rec.retry_of) || null) : null;
const children = all.filter(r => r.retry_of === rec.record_id)
.map(r => ({ record_id: r.record_id, detect_status: r.detect_status, created_at: r.created_at }));
sendJson(res, 200, {
ok: true,
record: rec,
raw_json: rawJson,
photos: photoUrls(rec),
versions: {
parent: parent ? { record_id: parent.record_id, detect_status: parent.detect_status, created_at: parent.created_at } : null,
children
}
});
}
/* 完整原始 JSON 下载(face_maps base64 未裁剪,动辄数 MB,仅供复核下载,不做页面内展示) */
function handleRawDownload(req, res, id) {
const rec = findRecord(id);
if (!rec || !rec.raw_file) return sendJson(res, 404, { ok: false, message: '记录或原始 JSON 不存在' });
const fname = String(rec.raw_file).replace(/[^\w.,-]/g, '');
const file = path.join(DATA_DIR, fname);
fs.readFile(file, (err, data) => {
if (err) return sendJson(res, 404, { ok: false, message: '原始 JSON 文件不存在' });
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="${rec.record_id}-raw.json"`
});
res.end(data);
});
}
function handlePhoto(req, res, id, which) {
const safe = String(id || '').replace(/[^\w-]/g, '');
const file = path.join(PHOTOS_DIR, safe, which + '.jpg');
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); }
res.writeHead(200, {
'Content-Type': 'image/jpeg',
'Content-Disposition': `inline; filename="${safe}-${which}.jpg"`
});
res.end(data);
});
}
function handleStats(req, res) {
const all = readAllRecords();
const today = nowIso().slice(0, 10);
const finished = all.filter(r => r.detect_status === '成功' || r.detect_status === '失败');
const succeeded = finished.filter(r => r.detect_status === '成功');
const failed = finished.filter(r => r.detect_status === '失败');
const avgCost = finished.length
? Math.round(finished.reduce((s, r) => s + (r.cost_ms || 0), 0) / finished.length)
: 0;
// 失败原因分布
const failMap = {};
for (const r of failed) failMap[r.fail_code || 'UNKNOWN'] = (failMap[r.fail_code || 'UNKNOWN'] || 0) + 1;
const fail_dist = Object.keys(failMap).map(code => ({
code, count: failMap[code],
pct: failed.length ? Math.round(failMap[code] * 1000 / failed.length) / 10 : 0
})).sort((a, b) => b.count - a.count);
// 三维度档位分布(成功记录)
const dim_dist = { firm: { '预警': 0, '改善': 0, '良好': 0 }, vita: { '预警': 0, '改善': 0, '良好': 0 }, stab: { '预警': 0, '改善': 0, '良好': 0 } };
const group_dist = {};
for (const r of succeeded) {
for (const d of ['firm', 'vita', 'stab']) {
const lv = r.dims && r.dims[d] && r.dims[d].level;
if (lv && dim_dist[d][lv] != null) dim_dist[d][lv] += 1;
}
const g = r.group || '未分组';
group_dist[g] = (group_dist[g] || 0) + 1;
}
// 近 14 天每日检测量/成功数(含今天,今天在后)
const daily = [];
for (let i = 13; i >= 0; i--) {
const d = new Date(Date.now() - i * 86400000);
const day = d.toISOString().slice(0, 10);
const recs = all.filter(r => (r.created_at || '').slice(0, 10) === day);
daily.push({
date: day,
total: recs.length,
success: recs.filter(r => r.detect_status === '成功').length
});
}
sendJson(res, 200, {
ok: true,
today_total: all.filter(r => (r.created_at || '').slice(0, 10) === today).length,
total: all.length,
finished_total: finished.length,
success_total: succeeded.length,
fail_total: failed.length,
success_rate: finished.length ? Math.round(succeeded.length * 1000 / finished.length) / 10 : 0,
avg_cost_ms: avgCost,
fail_dist,
dim_dist,
group_dist,
daily
});
}
/* 手动重试(文档 4.4):读原记录照片重新检测,生成新记录,原记录 retry_count+1 */
async function handleRetry(req, res, id) {
const rec = findRecord(id);
if (!rec) return sendJson(res, 404, { ok: false, message: '记录不存在' });
const readB64 = which => {
const file = path.join(PHOTOS_DIR, rec.record_id, which + '.jpg');
try { return fs.readFileSync(file).toString('base64'); } catch (e) { return null; }
};
const front = readB64('front');
if (!front) return sendJson(res, 400, { ok: false, message: '该记录没有可用的正脸照片,无法重试' });
const nid = newRecordId();
const photos = writePhotos(nid, { front, left: readB64('left'), right: readB64('right') });
const nrec = {
record_id: nid,
created_at: nowIso(),
updated_at: nowIso(),
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
img_front: photos.front || null,
img_left: photos.left || null,
img_right: photos.right || null,
shoot_status: (photos.left && photos.right) ? '已完成' : '降级',
upload_status: photos.front ? '成功' : '失败',
detect_status: '检测中',
fail_stage: null, fail_code: null, fail_msg: null,
skin_age: null, total_score: null,
jawline_angle_l: null, jawline_angle_r: null,
cats: null, dims: null, avg_s: null, group: null,
copy_6: null, copy_summary: null, lifestyle: null, careAdvice: null,
essence: null, essence_line: null, trigger: null, notes: null,
raw_file: null, cost_ms: null,
retry_of: rec.record_id, retry_count: 0
};
appendRecord(nrec);
console.log(`[record] 手动重试 ${rec.record_id} → 新记录 ${nid}`);
await runDetect(nrec, front, nrec.img_left ? readB64('left') : null, nrec.img_right ? readB64('right') : null);
updateRecord(nrec);
rec.retry_count = (rec.retry_count || 0) + 1;
rec.updated_at = nowIso();
updateRecord(rec);
sendJson(res, 200, { ok: true, record: listItem(nrec) });
}
/* CSV 单元格转义;raw_json 等大字段严禁进单元格(Excel 单格 32767 字符截断) */
function csvCell(v) {
if (v == null) return '';
const s = String(v);
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
function handleExport(req, res, url) {
const q = {
from: url.searchParams.get('from') || null,
to: url.searchParams.get('to') || null
};
const filtered = readAllRecords().filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
const dimP = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].P : null;
const dimS = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].S : null;
const dimLv = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].level : null;
const header = ['记录ID', '创建时间', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
'失败环节', '失败码', '失败信息', '总分', '肌肤年龄',
'紧塑力P', '紧塑力S', '紧塑力档', '活力感P', '活力感S', '活力感档', '稳定力P', '稳定力S', '稳定力档',
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms'];
const lines = [header.join(',')];
for (const r of filtered) {
lines.push([
r.record_id, r.created_at, r.real_age, r.gender, r.region, r.daily_skin_type,
r.shoot_status, r.upload_status, r.detect_status,
r.fail_stage, r.fail_code, r.fail_msg, r.total_score, r.skin_age,
dimP(r, 'firm'), dimS(r, 'firm'), dimLv(r, 'firm'),
dimP(r, 'vita'), dimS(r, 'vita'), dimLv(r, 'vita'),
dimP(r, 'stab'), dimS(r, 'stab'), dimLv(r, 'stab'),
r.avg_s, r.group, r.essence ? r.essence.name : null, r.trigger, r.cost_ms
].map(csvCell).join(','));
}
const fromTag = (q.from || 'all').replace(/[^\d-]/g, '');
const toTag = (q.to || 'all').replace(/[^\d-]/g, '');
const fname = `skin-records-${fromTag}_${toTag}.csv`;
res.writeHead(200, {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${fname}"`
});
res.end('' + lines.join('\r\n')); // UTF-8 BOM:Excel 直接打开不乱码
}
async function handleAdmin(req, res, pathname, url) {
try {
const retryM = pathname.match(/^\/api\/admin\/records\/([\w-]+)\/retry$/);
if (req.method === 'POST' && retryM) return await handleRetry(req, res, retryM[1]);
const rawM = pathname.match(/^\/api\/admin\/records\/([\w-]+)\/raw$/);
if (req.method === 'GET' && rawM) return handleRawDownload(req, res, rawM[1]);
const detailM = pathname.match(/^\/api\/admin\/records\/([\w-]+)$/);
if (req.method === 'GET' && detailM) return handleRecordDetail(req, res, detailM[1]);
const photoM = pathname.match(/^\/api\/admin\/photos\/([\w-]+)\/(front|left|right)$/);
if (req.method === 'GET' && photoM) return handlePhoto(req, res, photoM[1], photoM[2]);
if (req.method === 'GET' && pathname === '/api/admin/records') return handleRecordsQuery(req, res, url);
if (req.method === 'GET' && pathname === '/api/admin/stats') return handleStats(req, res);
if (req.method === 'GET' && pathname === '/api/admin/export') return handleExport(req, res, url);
return sendJson(res, 404, { ok: false, message: '接口不存在' });
} catch (e) {
console.log(`[admin] ${e.message}`);
return sendJson(res, 500, { ok: false, message: '服务内部错误' });
}
}
http.createServer((req, res) => {
if (req.method === 'POST' && req.url.split('?')[0] === '/api/skin-analyze') return handleAnalyze(req, res);
if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') {
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' });
return res.end(JSON.stringify({ ok: true }));
}
return serveStatic(req, res, req.url.split('?')[0]);
}).listen(PORT, '0.0.0.0', () => {
console.log(`[server] 巨子测肤 H5 已启动: http://127.0.0.1:${PORT} (静态 + /api/skin-analyze)`);
if (pathname.startsWith('/api/admin/')) {
return handleAdmin(req, res, pathname, new URL(req.url, 'http://localhost'));
}
return serveStatic(req, res, pathname);
}).listen(PORT, HOST, () => {
console.log(`[server] 巨子测肤 H5 已启动: http://${HOST}:${PORT} (静态 + /api/skin-analyze + /api/admin/*)`);
console.log(`[server] 后台查看页: http://${HOST}:${PORT}/admin.html`);
});
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
export HOST="${HOST:-0.0.0.0}"
export PORT="${PORT:-8088}"
if pgrep -f '[n]ode server.js' >/dev/null; then
echo "already running: $(pgrep -af '[n]ode server.js')"
exit 0
fi
nohup node server.js >> server.log 2>&1 &
echo "started pid=$! http://${HOST}:${PORT}"
+195
View File
@@ -0,0 +1,195 @@
/**
* 无头 Chromium 冒烟:mock 三连拍走到报告页,并核对语音清单是否真的被拉取、解码、起播。
*
* 依赖:全局 playwright + `npx playwright install chromium`
* 服务: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 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 = [
'step1-front', 'step2-rightface', 'step3-leftface',
'hint-noface', 'hint-near', 'hint-far', 'hint-front',
'hint-turn-left', 'hint-turn-right', 'hint-hold',
'countdown-321', 'shot-done', 'analyzing', 'analyze-fail',
];
const fails = [];
function check(name, ok, detail) {
const mark = ok ? 'PASS' : 'FAIL';
console.log(`${mark} ${name}${detail ? ' ' + detail : ''}`);
if (!ok) fails.push(name + (detail ? ': ' + detail : ''));
}
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'],
});
// 语音解锁后整批预加载,请求顺序不代表播放顺序:按 ArrayBuffer → AudioBuffer 反查条目名,记录真实起播与截断
await context.addInitScript(() => {
window.__voice = [];
const abId = new WeakMap(), bufId = new WeakMap();
const origFetch = window.fetch;
window.fetch = function (url, opts) {
const m = String(url).match(/voice\/([a-z0-9-]+)\.mp3/);
return origFetch.call(this, url, opts).then((res) => {
if (!m) return res;
const origAb = res.arrayBuffer.bind(res);
res.arrayBuffer = () => origAb().then((ab) => { abId.set(ab, m[1]); return ab; });
return res;
});
};
const Orig = window.AudioContext || window.webkitAudioContext;
function WrappedAudioContext(...args) {
const ctx = new Orig(...args);
const origDecode = ctx.decodeAudioData.bind(ctx);
ctx.decodeAudioData = function (ab, ok, err) {
const id = abId.get(ab);
return origDecode(ab, (buf) => { bufId.set(buf, id); if (ok) ok(buf); }, err);
};
const origCreate = ctx.createBufferSource.bind(ctx);
ctx.createBufferSource = function () {
const src = origCreate();
const origStart = src.start.bind(src), origStop = src.stop.bind(src);
let rec = null;
src.start = function (...a) {
rec = { id: bufId.get(src.buffer) || '?', t: Date.now(), dur: Math.round(src.buffer.duration * 1000), cutAt: null };
window.__voice.push(rec);
return origStart(...a);
};
src.stop = function (...a) {
if (rec && rec.cutAt == null) rec.cutAt = Date.now() - rec.t;
return origStop(...a);
};
return src;
};
return ctx;
}
WrappedAudioContext.prototype = Orig.prototype;
window.AudioContext = WrappedAudioContext;
window.webkitAudioContext = WrappedAudioContext;
});
const page = await context.newPage();
const pageErrors = [];
page.on('pageerror', (err) => pageErrors.push(String(err)));
try {
// 1) 14 条语音文件与清单都在,且 HTTP 200
const manifest = await (await page.request.get(BASE + '/assets/voice/manifest.json')).json();
const listed = manifest.files || [];
check('清单含设计文档 14 条', EXPECTED.every((id) => listed.includes(id)) && listed.length === EXPECTED.length,
'listed=' + listed.join(','));
for (const id of EXPECTED) {
const res = await page.request.get(BASE + '/assets/voice/' + id + '.mp3');
const len = (await res.body()).length;
const ctype = res.headers()['content-type'] || '';
check('音频 ' + id, res.ok() && len > 1000 && /audio|mpeg|octet/.test(ctype),
`status=${res.status()} bytes=${len} type=${ctype}`);
}
// 2) mock 直达报告页:评分与精华卡有内容
await page.goto(BASE + '/?mock=1&screen=report', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => {
const b = document.querySelector('#stat-score b');
return b && b.textContent && b.textContent !== '--';
}, null, { timeout: 8000 });
const score = await page.locator('#stat-score b').innerText();
const essence = await page.locator('#essence-name').innerText();
const reportOn = await page.locator('#s-report.active').count();
check('直达报告页', reportOn === 1 && /^\d/.test(score) && essence && essence !== '--',
`score=${score} essence=${essence}`);
// 3) 完整 mock 流程:启动 → 档案 → 三连拍 → 报告,并记录语音起播
await page.goto(BASE + '/?mock=1', { waitUntil: 'networkidle' });
await page.getByRole('button', { name: '开始检测' }).click();
await page.locator('#privacy-check').click();
await page.getByRole('button', { name: '继续' }).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();
await page.waitForFunction(() => {
const v = document.querySelector('#cam-video');
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);
// mock 无人脸检测,快门作为调试入口保留;听完每步引导再按(最长 step2 4.3s):引导、「拍摄完成」、「分析中」都应完整播出
// 步骤顺序与轮廓对齐 Figma:正脸 → 右脸(耳在画面右,mask 2)→ 左脸(耳在画面左,mask 1)
const FIGMA_STEPS = [['正对屏幕', '0'], ['右脸', '2'], ['左脸', '1']];
for (let i = 0; i < 3; i++) {
const n = i + 1;
await page.waitForFunction((num) => {
const t = document.querySelector('#cam-step');
return t && t.textContent.includes(num + '/3');
}, n, { timeout: 8000 });
const st = await page.evaluate(() => ({ text: document.querySelector('#cam-step').textContent, mask: document.querySelector('#cam-mask').getAttribute('data-step') }));
check(`步骤 ${n}/3 文案与轮廓对齐 Figma`, st.text.includes(FIGMA_STEPS[i][0]) && st.mask === FIGMA_STEPS[i][1], `${st.text} mask=${st.mask}`);
await page.waitForFunction((id) => (window.__voice || []).some((v) => v.id === id), `step${n}-${['front', 'rightface', 'leftface'][i]}`, { timeout: 8000 });
await page.waitForTimeout(4600);
await page.locator('[data-action="shutter"]').click();
}
await page.waitForFunction(() => document.querySelector('#s-report').classList.contains('active'), null, { timeout: 15000 });
const score2 = await page.locator('#stat-score b').innerText();
const age = await page.locator('#stat-age b').innerText();
check('三连拍进入报告', /^\d/.test(score2), `score=${score2} skinAge=${age}`);
await page.waitForTimeout(3200); // 等「分析中」播完
const played = await page.evaluate(() => window.__voice || []);
const ids = played.map((v) => v.id);
console.log('VOICE 起播: ' + played.map((v) => `${v.id}(${v.dur}ms${v.cutAt != null ? ' 截断@' + v.cutAt : ''})`).join(' → '));
for (const id of ['step1-front', 'step2-rightface', 'step3-leftface', 'shot-done', 'analyzing']) {
check('流程播报 ' + id, ids.includes(id));
}
check('步骤引导顺序', ids.indexOf('step1-front') < ids.indexOf('step2-rightface')
&& ids.indexOf('step2-rightface') < ids.indexOf('step3-leftface'));
check('拍摄完成每步各一次', ids.filter((id) => id === 'shot-done').length === 3,
'count=' + ids.filter((id) => id === 'shot-done').length);
const cut = played.filter((v) => v.cutAt != null && v.cutAt < v.dur - 80);
check('流程语音无截断', cut.length === 0, cut.map((v) => `${v.id}@${v.cutAt}/${v.dur}`).join(','));
check('页面无脚本异常', pageErrors.length === 0, pageErrors.join(' | '));
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: path.join(outputDir, 'juzi-e2e-fail.png'), fullPage: true }); } catch (e) {}
} finally {
await browser.close();
}
console.log(fails.length ? `\n${fails.length} 项失败` : '\n全部通过');
process.exit(fails.length ? 1 : 0);
+255
View File
@@ -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();
}
+23
View File
@@ -21,6 +21,25 @@ var SkinScore = require(path.join(__dirname, '..', 'js', 'skin-score.js'));
引擎与两组真值全部一致;今后出现引擎/真值不一致时在此登记,未登记的新差异 → 退出码 1 ─── */
var KNOWN_DIFFS = {};
/* ─── V2.3 §8.8 护理建议成品真值(五段,逐字) ─── */
var V23_SKIN = { case1: '混合偏干肌肤', case2: '干性肌肤' };
var V23_EXPECT = {
case1: [
{ tag: '肤质', text: '你的日常肤质为混合偏干肌肤:T区状态尚可,但两颊及面部外侧偏干,易出现紧绷与细纹。建议分区护理:两颊侧重保湿滋养,选用滋润型乳液/面霜,T区轻量保湿即可,避免全脸统一用厚重面霜。干燥会放大纹理粗糙感,建议先将两颊水合度提上来再做角质调理。' },
{ tag: '皱纹', text: '加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。' },
{ tag: '光滑', text: '加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。' },
{ tag: '精华', text: '建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。' },
{ tag: '生活方式', text: '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳' }
],
case2: [
{ tag: '肤质', text: '你的日常肤质为干性肌肤:全脸皮脂分泌偏少,角质层保水能力弱,易紧绷起屑。护理以高效保湿与封闭锁水为核心,洁面选用温和低泡产品,避免频繁去角质,面霜可选择含神经酰胺、角鲨烷等封闭性成分的配方。' },
{ tag: '皱纹', text: '加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。' },
{ tag: '轮廓', text: '强化轮廓紧致管理,重点针对面中、苹果肌与下颌线支持带区域,配合向上提升按摩手法,促进真皮层胶原再生与微循环。' },
{ tag: '精华', text: '建议搭配使用50+重塑精华:针对熟龄肌松弛与轮廓流失问题,深层重塑支撑结构,改善明显松弛与纹路,重建面部紧实与弹性。' },
{ tag: '生活方式', text: '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳' }
]
};
/* ─── 断言计数与输出 ─── */
var TOL = 0.05 + 1e-6; // 浮点容差 0.05(1e-6 仅抵二进制表示误差,如 98.65 vs 98.7)
var pass = 0, known = 0, fail = 0;
@@ -105,6 +124,10 @@ function main() {
okNum(tag + '.AV 六分类S均分', vm.avgS, tr.avgS);
okStr(tag + '.AW 综合文案', vm.summary, tr.summary, tag + '.AW');
okStr(tag + '.AX 生活方式', vm.lifestyle, tr.lifestyle);
// V2.3 §8 护理建议五段(真值:§8.8 样例;daily_skin_type 按样例取,与 summary 口径的肤质参数不同)
var vm23 = SkinScore.buildViewModel(fixture.result, fixture.side, { realAge: tr.profile.realAge, skinType: V23_SKIN[tag] });
okStr(tag + '.BA 护理建议·五段成品', JSON.stringify(vm23.careAdvice), JSON.stringify(V23_EXPECT[tag]));
}
// 登记项清理提示:已知差异键本轮全部命中为差异;若某键实际通过则提示移除登记
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017-present, LIHONG OU(onlyhom)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+6
View File
@@ -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 行高、底部安全区和页面滚动时点击项与显示项一致。拖动与联动逻辑保持原版。
+164
View File
@@ -0,0 +1,164 @@
/*
* mobile-select v1.4.0
* Homepage: https://github.com/onlyhom/mobile-select
* Released under the MIT License.
* (c) 2017-present
*/
.ms-mobile-select {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
opacity: 0;
visibility: hidden;
-webkit-transition: opacity 0.4s, z-index 0.4s;
transition: opacity 0.4s, z-index 0.4s;
transform: translate(0, 0);
}
.ms-mobile-select * {
margin: 0;
padding: 0;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.ms-mobile-select .ms-gray-layer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 888;
display: block;
}
.ms-mobile-select .ms-content {
width: 100%;
display: block;
position: fixed;
z-index: 889;
color: black;
-webkit-transition: all 0.4s;
transition: all 0.4s;
bottom: -350px;
left: 0;
background: white;
}
.ms-mobile-select .ms-fix-width {
width: 90%;
margin: 0 auto;
position: relative;
}
.ms-mobile-select .ms-fix-width:after {
content: ".";
display: block;
height: 0;
clear: both;
visibility: hidden;
}
.ms-mobile-select .ms-btn-bar {
border-bottom: 1px solid #dcdcdc;
font-size: 15px;
height: 45px;
position: relative;
text-align: center;
line-height: 45px;
}
.ms-mobile-select .ms-cancel,
.ms-mobile-select .ms-ensure {
height: 45px;
width: 55px;
cursor: pointer;
position: absolute;
top: 0;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
.ms-mobile-select .ms-cancel {
left: 0;
color: #666;
}
.ms-mobile-select .ms-ensure {
right: 0;
color: #1e83d3;
}
.ms-mobile-select .ms-title {
font-size: 15px;
padding: 0 15%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ms-mobile-select .ms-panel:after {
content: ".";
display: block;
height: 0;
clear: both;
visibility: hidden;
}
.ms-mobile-select .ms-wheels {
width: 100%;
height: 200px;
overflow: hidden;
}
.ms-mobile-select .ms-wheel {
position: relative;
z-index: 0;
float: left;
width: 50%;
height: 200px;
overflow: hidden;
-webkit-transition: width 0.3s ease;
transition: width 0.3s ease;
}
.ms-mobile-select .ms-select-container {
display: block;
text-align: center;
}
.ms-mobile-select .ms-select-container li {
font-size: 15px;
display: block;
height: 40px;
line-height: 40px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
.ms-mobile-select .ms-select-line {
height: 40px;
width: 100%;
position: absolute;
top: 80px;
pointer-events: none;
-webkit-box-sizing: border-box;
box-sizing: border-box;
border-top: 1px solid #dcdcdc;
border-bottom: 1px solid #dcdcdc;
}
.ms-mobile-select .ms-shadow-mask {
position: absolute;
top: 0;
width: 100%;
height: 200px;
background: linear-gradient(to bottom, #fffffff5, #ffffffcc 25%, #ffffff00 49%, #ffffff00 51%, #ffffffcc 75%, #fffffff5);
opacity: 0.9;
pointer-events: none;
}
.ms-mobile-select.ms-show {
opacity: 1;
z-index: 10000;
visibility: visible;
}
.ms-mobile-select.ms-show .ms-content {
bottom: 0;
}
.ms-default-trigger {
min-height: 1em;
color: #888;
}
/** Prevent Page Scrolling When a Modal is Open */
body.ms-show {
overflow-y: hidden;
}
File diff suppressed because one or more lines are too long
@@ -1,16 +0,0 @@
# ~/.openclaw/credentials.env — 统一凭据存放(勿提交 git、勿发到聊天)
# 用法: set -a; source ~/.openclaw/credentials.env; set +a
# ---- 旷视 Face++(巨子测肤 skinanalyze_pro 测试接口)----
# 试用key(cAa5...)已弃用: 旷视规则试用key不能调专业版。当前为正式key(EcZ...)
FACEPP_API_KEY=EcZiqYhA8eQSiRgXVem8mijgzPfSC2qp
FACEPP_API_SECRET=p62QRWv9tQitZ1tvXmEBV8AgmaS00lO8
# ---- FaceStyle 专业版调用参数 ----
FACEPP_SKINANALYZE_URL=https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_pro
# ---- 飞书 / OCR(合同盖章核验项目)----
# 实际值在 ~/Desktop/合同盖章核验项目/service/.env(未入库)
# ---- 智谱 / 火山引擎 ----
# 已存于 ~/.zshrc 及 openclaw.json providers 配置
+94 -32
View File
@@ -112,7 +112,69 @@ def to_mp3(wav_path: Path, mp3_path: Path) -> None:
)
def normalize_clip(wav: np.ndarray, sr: int) -> np.ndarray:
wav = trim_edges(np.asarray(wav, dtype=np.float32).reshape(-1), sr)
peak = float(np.max(np.abs(wav)))
if peak < 0.05:
raise RuntimeError(f"peak too low ({peak:.4f})")
wav = wav / peak * PEAK_NORM
dur = len(wav) / sr
if dur < 0.35:
wav = np.concatenate([wav, np.zeros(int((0.35 - dur) * sr), dtype=np.float32)])
return wav
def write_mp3(name: str, wav: np.ndarray, sr: int) -> None:
wav_path = STAGING / f"{name}.wav"
mp3_path = OUT_DIR / f"{name}.mp3"
sf.write(str(wav_path), wav, sr)
to_mp3(wav_path, mp3_path)
dur = len(wav) / sr
print(f"[ok] {name}.mp3 {dur:.2f}s {mp3_path.stat().st_size} bytes", flush=True)
def gen_countdown(model) -> tuple[np.ndarray, int]:
"""倒计时单独按字生成再拼接,避免「三、二、一」整句被读成播报腔、与提示音气质漂移。
与提示音共用同一 speaker + instruct;句间留约 0.35s,总长约 2.5s。
"""
digits = ["三", "二", "一"]
# 同批带上一条短提示作音色锚点,丢弃锚点音频
texts = ["保持不动", *digits]
wavs, sr = model.generate_custom_voice(
text=texts,
language=["Chinese"] * len(texts),
speaker=[SPEAKER] * len(texts),
instruct=[INSTRUCT] * len(texts),
max_new_tokens=2048,
)
gap = np.zeros(int(0.35 * sr), dtype=np.float32)
parts: list[np.ndarray] = []
for i, digit in enumerate(digits):
clip = normalize_clip(wavs[i + 1], sr)
parts.append(clip)
if i < len(digits) - 1:
parts.append(gap)
print(f" countdown digit {digit}: {len(clip) / sr:.2f}s", flush=True)
return np.concatenate(parts), sr
def main() -> int:
import argparse
ap = argparse.ArgumentParser()
ap.add_argument(
"--only",
default="",
help="逗号分隔的 id,只生成这些;空则全量",
)
args = ap.parse_args()
only = {x.strip() for x in args.only.split(",") if x.strip()}
todo = [(n, t) for n, t in LINES if not only or n in only]
if only and not todo:
print(f"unknown --only ids: {sorted(only)}", flush=True)
return 1
OUT_DIR.mkdir(parents=True, exist_ok=True)
STAGING.mkdir(parents=True, exist_ok=True)
@@ -123,46 +185,46 @@ def main() -> int:
dtype=torch.bfloat16,
)
names = [n for n, _ in LINES]
texts = [t for _, t in LINES]
print(f"generating {len(names)} lines with speaker={SPEAKER} ...", flush=True)
wavs, sr = model.generate_custom_voice(
text=texts,
language=["Chinese"] * len(names),
speaker=[SPEAKER] * len(names),
instruct=[INSTRUCT] * len(names),
max_new_tokens=2048,
)
# 倒计时走专用拼接路径,其余整句生成
normal = [(n, t) for n, t in todo if n != "countdown-321"]
do_countdown = any(n == "countdown-321" for n, _ in todo)
manifest_ids: list[str] = []
for name, wav in zip(names, wavs):
wav = np.asarray(wav, dtype=np.float32).reshape(-1)
wav = trim_edges(wav, sr)
if normal:
names = [n for n, _ in normal]
texts = [t for _, t in normal]
print(f"generating {len(names)} lines with speaker={SPEAKER} ...", flush=True)
wavs, sr = model.generate_custom_voice(
text=texts,
language=["Chinese"] * len(names),
speaker=[SPEAKER] * len(names),
instruct=[INSTRUCT] * len(names),
max_new_tokens=2048,
)
for name, wav in zip(names, wavs):
try:
write_mp3(name, normalize_clip(wav, sr), sr)
except RuntimeError as e:
print(f"[FAIL] {name}: {e}", flush=True)
return 1
if do_countdown:
print(f"generating countdown-321 (digit stitch, speaker={SPEAKER}) ...", flush=True)
wav, sr = gen_countdown(model)
# 整段再峰值归一一次,保证与提示音响度一致
peak = float(np.max(np.abs(wav)))
if peak < 0.05:
print(f"[FAIL] {name}: peak too low ({peak:.4f})", flush=True)
return 1
wav = wav / peak * PEAK_NORM
dur = len(wav) / sr
if dur < 0.35:
pad = np.zeros(int((0.35 - dur) * sr), dtype=np.float32)
wav = np.concatenate([wav, pad])
dur = len(wav) / sr
write_mp3("countdown-321", wav, sr)
wav_path = STAGING / f"{name}.wav"
mp3_path = OUT_DIR / f"{name}.mp3"
sf.write(str(wav_path), wav, sr)
to_mp3(wav_path, mp3_path)
size = mp3_path.stat().st_size
print(f"[ok] {name}.mp3 {dur:.2f}s {size} bytes", flush=True)
manifest_ids.append(name)
manifest = {"files": manifest_ids}
# 清单始终覆盖文档全量已落地文件(缺文件则跳过写入名)
existing = [
n for n, _ in LINES if (OUT_DIR / f"{n}.mp3").is_file()
]
manifest = {"files": existing}
(OUT_DIR / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"wrote {OUT_DIR / 'manifest.json'} ({len(manifest_ids)} files)", flush=True)
print(f"wrote {OUT_DIR / 'manifest.json'} ({len(existing)} files)", flush=True)
return 0
Binary file not shown.