feat: 接入 Face++ 真实测肤 + 三轮多角色迭代修复

Face++ 集成(高阶版 skinanalyze_advanced):
- h5/server.js:零依赖 Node 后端,静态托管 + /api/skin-analyze 中转,
  api_secret 只存服务端(从 apikey.bash/env 读取,.gitignore 已排除)
- 实时相机:getUserMedia 自拍取景,不可用时降级系统相机;
  相机令牌防授权回调泄漏摄像头;离开拍照页即释放
- 检测流:三连拍仅送检正面照(压至 1280px JPEG),错误码→友好提示
- 结果映射:肌肤年龄=skin_age 真实值;紧塑力/活力感/稳定力由真实
  维度合成;具体描述按检出项动态生成;检测图用用户实拍
- ?mock=1 内置样本供离线/自动化验证;README 补充部署与 HTTPS 说明

三轮迭代修复(QA+美术并行审计→开发统一修复,共 ~28 项):
- 功能:P1 鼠标拖拽轮播(Pointer Events+setPointerCapture)、
  快门/返回竞态(camSeq 令牌)、报告页状态重置、弹窗 ESC/遮罩关闭
- 视觉:还原度 80%→94%,档案页布局/弹窗比例/报告页产品卡等
  结构性偏差修正,字号/间距逐项实测核销
- 工程化:首屏 701KB→386KB(懒加载+preload+清理 212KB 无用资源)、
  favicon/SEO meta/隐私协议占位层/README

Review 修复:server.js 路径穿越前缀绕过(ROOT+sep 校验)、
getUserMedia 迟到回调释放相机(camSeqCam 令牌)
This commit is contained in:
xsl
2026-09-22 21:06:33 +08:00
parent e4e85d2805
commit 5957ef04fc
36 changed files with 712 additions and 126 deletions
+10
View File
@@ -0,0 +1,10 @@
# 密钥与敏感配置 —— 绝不入库
apikey.bash
apikey.*
.env
.env.*
# 运行时产物
node_modules/
*.log
__pycache__/
+1 -1
View File
@@ -131,7 +131,7 @@ design/
## 8. 还原建议
1. **流程**:启动页(授权弹窗)→ 档案填写(年龄/性别/地区/肤质轮播)→ 拍照弹窗 → 正面/左/右三连拍 → 生成报告。
2. 肤质选择是**轮播交互**(轮播标识×4 圆点 + 偏干↔偏油轴标签),插画随选项换色。
2. 肤质选择是**轮播交互**(轮播标识×5 圆点 + 偏干↔偏油轴标签),插画随选项换色。
3. 报告页为长滚动页:总分(82 分/100)→ 六维度 → 三维度拆解 → 具体描述(配检测图)→ 护理建议 → 产品推荐 → 免责声明。
4. 位图素材均为 PNG(原始命名 .jpg 已改),报告页背景 4915×11457 建议压缩后再入包。
5. 颜色、字号直接抄本规范第 2、3 节;组件直接用 assets/svg 切图。
+95
View File
@@ -0,0 +1,95 @@
# 1'me 在线肌肤测试 H5
拍摄正面与左右侧面照片,调用 **Face++ 皮肤分析(高阶版)** 真实检测,即时生成肌肤状态分析与护理建议的移动端 H5。
- 前端:原生 HTML/CSS/JS,零依赖、零构建
- 后端:`server.js`(Node ≥ 18,零依赖)静态托管 + Face++ API 中转,**api_secret 只存在服务端**
## 目录结构
```
h5/
├── index.html 页面结构(启动 / 档案 / 拍照 / 报告 + 弹层)
├── server.js 后端:静态托管 + POST /api/skin-analyze(Face++ 中转)
├── css/style.css 全部样式(375px 基准)
├── js/app.js 全部交互(屏幕切换、肤质轮播、实时相机、结果映射)
├── assets/
│ ├── favicon.svg 站点图标
│ ├── img/ 背景图、肤质插画、拍照底图、报告检测/产品图
│ └── svg/ 品牌 Logo 与控件图标
└── preview/ 开发期整页截图(仅存档,部署时可整目录剔除)
../apikey.bash Face++ 密钥(export SKIN_API_KEY=... / SKIN_API_SECRET=...,已 .gitignore)
```
## 密钥配置
二选一:
1. 环境变量:`export SKIN_API_KEY=xxx SKIN_API_SECRET=xxx` 后启动 server.js
2. 密钥文件:项目根目录放 `apikey.bash`(内容为 `export SKIN_API_KEY=...` 等,server.js 会自动解析)
**密钥文件已被 .gitignore 排除,严禁提交到版本库。**
## 本地运行
```bash
cd h5
node server.js # 默认 8080,可用 PORT=xxx 修改
# 打开 http://127.0.0.1:8080
```
调试:`http://127.0.0.1:8080/?mock=1` 使用内置样本数据跳过真实检测(离线/自动化验证用)。
## 检测流程与数据映射
1. 三连拍(正面/右脸/左脸):getUserMedia 实时取景,不支持/被拒时自动降级调起系统相机
2. **仅正面照**送检(Face++ 高阶版只接受正脸,yaw>45° 会报错)
3. 照片压缩到最长边 1280 JPEG 后 POST `/api/skin-analyze`,服务端转发 Face++ `skinanalyze_advanced`
4. 映射层(`mapSkinResult`):设计稿的 紧塑力/活力感/稳定力/综合评分 由真实维度合成——
- 紧塑力 ← 法令纹(+严重度)、眼袋(+严重度)
- 活力感 ← 黑眼圈、眼部细纹、鱼尾纹、抬头纹、眉间纹
- 稳定力 ← 敏感泛红(面积/强度)、痘痘、闭口、毛孔、黑头
- 肌肤年龄 = Face++ `skin_age` 真实值;综合评分 = 三维加权(0.35/0.33/0.32)×10
- 具体描述文案按检出项动态生成;报告第一/二张检测图使用用户实拍照片
5. 检测失败(无人脸/距离远/网络等)按错误码给出友好提示并退回档案页
## 部署
需要 Node ≥ 18 运行 `server.js`(负责静态与 API 中转),**不能用纯静态托管**(密钥不能进前端)。
```bash
PORT=8080 node server.js # 建议 pm2 / systemd 常驻
```
nginx 反代示例(静态与 API 都交给 server.js,nginx 只做 HTTPS 终结):
```nginx
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
client_max_body_size 12m; # 与 server.js 请求上限一致
}
}
```
> **HTTPS 必需**:getUserMedia 相机权限仅在安全上下文可用(localhost 除外)。正式域名必须上 HTTPS,否则前端自动降级为系统相机拍照。
## 已知占位清单
- 报告页「活力感 / 稳定力」两个 Tab 为占位文案("以实际检测报告为准")。
- 「相关隐私协议」点击弹出的是占位说明层,协议全文待法务提供后接入。
- 产品图(prod-1.jpg)为设计稿素材;检测图已替换为用户实拍。
- 相册、切换镜头、「了解更多」为开发中占位(Toast 提示)。
- 护理建议为设计稿静态文案,未按检测结果个性化。
## 技术说明
- 尺寸换算:设计稿 1080px 基准 ÷ 2.88 = 375px 落地基准。
- 图片性能:首屏背景 preload;首屏外 `loading="lazy"`。
- 快门防连点/流程令牌(camSeq)防竞态;拍照页 15% 压暗遮罩保证白字可读。
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#F5F078"/>
<text x="32" y="45" font-family="Arial,'PingFang SC',sans-serif" font-size="36" font-weight="700" fill="#282828" text-anchor="middle">1'</text>
</svg>

After

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.5 KiB

-28
View File
@@ -1,28 +0,0 @@
<svg width="315" height="315" viewBox="0 0 315 315" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_12_57)">
<mask id="mask0_12_57" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="315" height="315">
<path d="M0 0H315V315H0V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_12_57)">
<path d="M312.367 157.469C312.305 189.488 302.368 220.946 283.825 247.074C265.762 272.529 239.944 292.292 210.61 302.978C180.09 314.093 146.239 315.313 115 306.415C84.9198 297.848 57.8632 280.029 37.9533 255.929C17.7205 231.438 5.39449 200.803 3.05669 169.123C0.692574 137.082 8.32902 104.729 25.0042 77.2481C41.0985 50.7214 65.1451 29.3505 93.3724 16.4679C122.926 2.97823 156.656 -0.757879 188.478 5.71411C219.257 11.9755 247.704 27.7536 269.414 50.4336C291.195 73.1873 305.689 102.429 310.54 133.553C311.772 141.464 312.351 149.465 312.367 157.469C312.37 159.162 315.003 159.166 314.999 157.469C314.936 124.828 304.771 92.64 285.765 66.066C267.35 40.3184 241.317 20.4164 211.584 9.52218C180.578 -1.83889 146.055 -3.05502 114.3 5.98258C83.7246 14.685 56.1063 32.8287 35.9086 57.3688C15.4389 82.2371 2.96367 113.088 0.469678 145.207C-2.07522 177.969 5.77534 211.192 22.8805 239.265C39.2925 266.202 63.8182 287.987 92.5738 300.984C122.683 314.59 156.805 318.352 189.178 311.761C220.37 305.41 249.274 289.347 271.276 266.366C293.487 243.161 308.344 213.263 313.171 181.489C314.378 173.536 314.984 165.511 314.999 157.469C315.003 155.775 312.37 155.772 312.367 157.469Z" fill="#282828"/>
<path d="M183.045 248.883C166.948 256.364 148.047 256.364 131.949 248.883C131.112 248.493 129.904 248.937 129.968 250.02C130.564 260.214 129.976 273.383 121.88 280.773C119.047 283.357 115.516 285.365 112.29 287.432C109.284 289.359 106.232 291.212 103.132 292.985C100.415 294.54 97.6703 296.07 94.8516 297.433C94.146 297.772 93.9424 298.995 94.5848 299.499C97.3842 301.698 100.675 303.34 103.892 304.814C112.931 308.958 122.647 311.532 132.449 313.069C147.341 315.401 162.693 315.61 177.65 313.754C188.216 312.441 198.717 310.065 208.554 305.93C212.647 304.211 216.892 302.261 220.41 299.499C221.052 298.995 220.848 297.772 220.145 297.433C217.324 296.072 214.579 294.54 211.862 292.985C208.763 291.212 205.711 289.359 202.704 287.432C199.478 285.365 195.949 283.357 193.114 280.773C185.02 273.384 184.43 260.214 185.027 250.02C185.125 248.327 182.492 248.334 182.394 250.02C181.894 258.565 182.447 267.727 186.168 275.569C188.395 280.262 191.809 283.278 196.026 286.163C200.965 289.542 206.048 292.718 211.255 295.67C213.739 297.075 216.245 298.463 218.814 299.706C218.727 299.016 218.637 298.327 218.549 297.639C219.062 297.237 218.381 297.756 218.207 297.876C217.651 298.255 217.089 298.627 216.515 298.981C214.353 300.317 212.083 301.482 209.774 302.542C201.051 306.539 191.608 308.975 182.157 310.481C167.634 312.794 152.612 312.943 138.017 311.202C127.661 309.967 117.303 307.622 107.653 303.607C104.721 302.386 101.846 300.994 99.1165 299.367C98.3811 298.928 97.6527 298.476 96.9489 297.988C96.7822 297.872 95.8994 297.21 96.4452 297.639C96.3575 298.327 96.268 299.016 96.1802 299.706C103.259 296.286 110.042 292.116 116.581 287.773C121.034 284.814 125.248 282.018 127.944 277.273C130.243 273.225 131.458 268.629 132.107 264.047C132.767 259.4 132.876 254.7 132.6 250.02C131.94 250.397 131.28 250.776 130.621 251.155C147.645 259.068 167.349 259.066 184.374 251.155C185.91 250.443 184.574 248.172 183.045 248.883Z" fill="#282828"/>
<path d="M157.497 57.1367C169.967 57.1209 182.976 60.5411 194.247 65.6548C195.048 66.0163 195.89 65.7338 196.181 64.8687C198.314 58.5353 199.365 51.6439 198.405 44.9929C197.036 35.5096 190.921 28.2023 182.2 24.417C175.483 21.4987 168.051 20.4984 160.783 20.265C152.787 20.0105 144.492 20.5212 136.827 22.978C127.376 26.0087 119.557 32.5193 117.135 42.4133C115.327 49.8013 116.351 57.7667 118.706 64.9178C118.992 65.7847 119.845 66.0655 120.64 65.704C131.939 60.5587 144.989 57.1191 157.497 57.1367C159.191 57.1384 159.194 54.5061 157.497 54.5044C144.506 54.4868 131.041 58.0896 119.31 63.4314C119.956 63.6929 120.6 63.9544 121.244 64.2176C119.368 58.5213 118.369 52.3739 119.08 46.3846C120.128 37.5382 125.533 30.6872 133.593 27.016C139.974 24.1099 147.179 23.1553 154.126 22.9078C161.867 22.6323 169.841 23.0781 177.281 25.3875C186.183 28.1532 193.412 34.1619 195.474 43.5118C196.978 50.3348 195.848 57.621 193.643 64.1685C194.288 63.9052 194.932 63.6437 195.576 63.3823C183.873 58.072 170.452 54.4868 157.497 54.5044C155.804 54.5061 155.8 57.1384 157.497 57.1367Z" fill="#282828"/>
<path d="M236.677 155.235C240.547 127.759 237.388 97.6435 216.455 77.4449C203.688 65.1257 186.459 58.1904 169.094 55.5616C162.667 54.5894 156.571 54.2823 150.132 55.0036C133.716 56.8409 117.318 62.3723 104.181 72.5716C95.3479 79.4297 88.3047 88.3848 83.7924 98.6245C77.8829 112.026 76.2103 126.876 76.9457 141.405C77.1791 146.036 77.6706 150.644 78.3165 155.235C78.6236 154.694 78.9307 154.156 79.2361 153.615C74.0814 155.267 69.892 159.859 68.2404 164.918C66.4239 170.485 67.4489 176.618 69.3567 182.013C70.68 185.751 73.3162 188.988 75.8418 192.015C77.7302 194.277 79.7837 196.41 82.1004 198.235C84.0749 199.791 86.4812 201.501 89.0577 201.829L87.7887 200.862C90.109 207.627 93.6227 214.132 97.433 220.153C105.25 232.511 115.707 243.619 128.851 250.321C144.677 258.392 163.869 259.417 180.421 252.894C194.699 247.268 206.232 236.582 214.923 224.126C218.168 219.474 221.041 214.559 223.521 209.457C224.362 207.73 225.155 205.979 225.898 204.207C226.278 203.294 226.896 202.234 227.066 201.251C227.089 201.121 227.163 200.988 227.207 200.862C226.784 201.185 226.361 201.506 225.938 201.829C227.983 201.569 229.925 200.386 231.574 199.224C233.68 197.74 235.58 195.981 237.328 194.095C239.97 191.243 242.964 187.855 244.724 184.373C249.103 175.707 249.24 163.479 241.32 156.853C239.694 155.495 237.794 154.268 235.758 153.615C234.141 153.098 233.448 155.639 235.057 156.155C238.859 157.371 242.65 161.184 244.091 165.252C248.177 176.788 240.077 189.164 231.351 196.122C230.045 197.161 228.602 198.159 227.051 198.8C226.768 198.917 225.195 199.289 225.938 199.196C225.318 199.275 224.876 199.556 224.667 200.162C222.459 206.598 219.088 212.774 215.485 218.512C207.96 230.5 197.916 241.241 185.248 247.826C169.78 255.865 150.964 256.705 134.808 250.17C121.12 244.632 110.031 234.046 101.789 221.992C97.2013 215.285 92.9768 207.886 90.3266 200.162C90.116 199.549 89.6719 199.274 89.0577 199.196C89.8053 199.291 88.3942 198.975 88.1345 198.875C86.9831 198.429 85.9037 197.754 84.8928 197.054C80.1102 193.73 76.0138 188.699 73.0161 183.769C70.1903 179.124 69.1215 172.055 70.4483 166.766C71.2662 163.511 73.1161 160.636 75.7435 158.545C77.0124 157.534 78.6903 156.553 79.9364 156.155C80.6788 155.916 80.9596 155.267 80.8561 154.535C77.0651 127.627 80.3331 98.0699 101.15 78.5944C113.575 66.9718 130.148 60.5577 146.8 58.0711C152.734 57.1849 158.416 56.9462 164.364 57.5815C180.028 59.2521 195.764 64.515 208.425 74.0352C217.041 80.5124 223.914 89.0657 228.432 98.8474C234.461 111.905 236.103 126.581 235.445 140.822C235.233 145.411 234.78 149.986 234.139 154.535C233.906 156.191 236.442 156.907 236.677 155.235Z" fill="#282828"/>
<path d="M79.8462 140.228C90.0486 138.74 102.459 133.496 106.912 123.578C108.883 119.187 108.949 114.408 107.893 109.768C107.352 110.309 106.813 110.848 106.273 111.388C111.287 112.773 116.526 113.541 121.688 114.08C132.115 115.166 142.972 114.923 153.074 111.897C159.233 110.053 164.972 107.096 169.835 102.872C169.103 102.677 168.37 102.484 167.636 102.291C169.442 107.78 172.633 113.008 176.091 117.588C183.659 127.612 194.093 135.142 206.206 138.645C215.847 141.432 225.962 141.579 235.848 140.228C237.519 140 236.808 137.462 235.148 137.69C219.666 139.805 203.698 137.49 190.751 128.333C182.927 122.802 176.638 115.142 172.347 106.601C171.471 104.855 170.764 103.382 170.174 101.591C169.853 100.612 168.737 100.348 167.975 101.01C153.072 113.952 130.319 113.598 112.012 109.993C110.323 109.661 108.632 109.309 106.973 108.851C105.973 108.573 105.134 109.505 105.353 110.469C107.077 118.03 104.693 124.936 98.6222 129.753C94.08 133.358 88.4391 135.539 82.8562 136.922C81.6329 137.225 80.3938 137.508 79.1459 137.69C77.4768 137.932 78.1858 140.47 79.8462 140.228Z" fill="#282828"/>
<path d="M108.977 155.731C111.306 153.764 115.22 151.734 118.541 150.711C126.242 148.34 133.77 150.267 139.658 155.731C140.899 156.882 142.765 155.026 141.519 153.869C135.176 147.982 126.965 145.623 118.558 147.962C114.473 149.1 110.373 151.119 107.115 153.869C105.82 154.964 107.689 156.818 108.977 155.731Z" fill="#282828"/>
<path d="M175.336 155.731C177.665 153.764 181.579 151.734 184.9 150.711C192.601 148.34 200.129 150.267 206.017 155.731C207.258 156.882 209.124 155.026 207.878 153.869C201.535 147.982 193.324 145.623 184.918 147.962C180.832 149.1 176.732 151.119 173.474 153.869C172.179 154.964 174.048 156.818 175.336 155.731Z" fill="#282828"/>
<path d="M206.017 167.688C203.688 169.655 199.774 171.686 196.453 172.709C188.752 175.08 181.224 173.153 175.336 167.688C174.095 166.537 172.23 168.394 173.476 169.55C179.819 175.438 188.029 177.796 196.436 175.457C200.522 174.32 204.622 172.3 207.879 169.55C209.174 168.455 207.303 166.602 206.017 167.688Z" fill="#282828"/>
<path d="M139.658 167.688C137.329 169.655 133.415 171.686 130.095 172.709C122.393 175.08 114.865 173.153 108.977 167.688C107.736 166.537 105.871 168.394 107.117 169.55C113.46 175.438 121.67 177.796 130.077 175.457C134.163 174.32 138.263 172.3 141.52 169.55C142.815 168.455 140.945 166.602 139.658 167.688Z" fill="#282828"/>
<path d="M142.43 222.358C151.675 226.799 163.44 227.122 172.565 222.358C174.067 221.573 172.738 219.301 171.236 220.085C162.964 224.404 151.874 223.983 143.758 220.085C142.238 219.355 140.901 221.624 142.43 222.358Z" fill="#282828"/>
<path d="M136.65 199.279C136.65 207.176 130.248 213.579 122.348 213.579C114.45 213.579 108.046 207.176 108.046 199.279C108.046 191.382 114.45 184.978 122.348 184.978C130.248 184.978 136.65 191.382 136.65 199.279Z" fill="#EAE6E1"/>
<path d="M208.341 199.279C208.341 207.176 201.938 213.579 194.039 213.579C186.141 213.579 179.736 207.176 179.736 199.279C179.736 191.382 186.141 184.978 194.039 184.978C201.938 184.978 208.341 191.382 208.341 199.279Z" fill="#EAE6E1"/>
<path d="M128.631 131.455H186.363V140.549C186.363 140.549 161.337 140.631 161.337 159.296V205.707H153.657V159.296C153.657 159.296 155.391 140.549 128.631 140.549V131.455Z" fill="#EAE6E1"/>
<path d="M174.406 240.887C174.406 244.444 166.836 247.329 157.497 247.329C148.158 247.329 140.588 244.444 140.588 240.887C140.588 237.33 148.158 234.445 157.497 234.445C166.836 234.445 174.406 237.33 174.406 240.887Z" fill="#EAE6E1"/>
</g>
</g>
<defs>
<clipPath id="clip0_12_57">
<rect width="314.985" height="314.985" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

+106 -50
View File
@@ -85,7 +85,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
text-shadow: 0 1px 8px rgba(0,0,0,.18);
}
.splash-bottom {
position: absolute; left: 0; right: 0; bottom: 7%;
position: absolute; left: 0; right: 0; bottom: 11%;
display: flex; flex-direction: column; align-items: center; gap: 18px;
padding-bottom: env(safe-area-inset-bottom);
}
@@ -105,7 +105,14 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.nav-dark { background: rgba(20,20,20,.55); }
.nav-dark .nav-title { color: #fff; }
.nav-dark .chev { border-color: #fff; }
.nav-back { width: 38px; height: 38px; display: flex; align-items: center; justify-content: center; }
.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;
@@ -125,9 +132,10 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.profile-body {
position: relative; z-index: 1;
min-height: 100%;
padding: calc(var(--nav-h) + 18px) 38px 40px;
display: flex; flex-direction: column; justify-content: flex-end;
gap: 13px;
/* 设计:标题在导航下 23px;按钮随内容流(卡底→按钮间距按设计节奏约 22px) */
padding: calc(var(--nav-h) + 23px) 64px clamp(48px, 8vh, 121px);
display: flex; flex-direction: column; justify-content: flex-start;
gap: 16px;
}
.page-title {
color: #fff; font-size: 21px; font-weight: 700;
@@ -138,7 +146,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
color: #fff; font-size: 14px; font-weight: 400;
margin-bottom: 6px; letter-spacing: .5px;
}
.form-row.two { display: flex; gap: 8px; }
.form-row.two { display: flex; gap: 22px; }
.form-row.two .field { flex: 1; }
.field-input {
display: block; width: 100%; height: 38px;
@@ -153,37 +161,38 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
/* 肤质卡片 */
.skin-card {
background: #fff; border-radius: 10px;
padding: 12px 10px 14px;
background: #fff; border-radius: 6px;
padding: 24px 10px 26px;
}
.skin-axis {
display: flex; justify-content: center; gap: 14px;
margin-bottom: 4px;
display: flex; justify-content: center; gap: 8px;
margin: 0 0 14px;
}
.axis-item {
display: inline-flex; align-items: center; gap: 4px;
font-size: 9px; color: var(--ink);
}
.chip { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
.skin-carousel { overflow: hidden; }
.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: 2px; }
.skin-name { font-size: 13px; font-weight: 500; color: #000; margin-top: 12px; }
.skin-desc {
font-size: 10px; color: var(--ink); font-weight: 400;
line-height: 1.5; text-align: center;
margin-top: 5px; padding: 0 12px;
margin-top: 5px; padding: 0 26px;
}
.skin-dots { display: flex; justify-content: center; gap: 7px; margin-top: 10px; }
.skin-dots { display: flex; justify-content: center; gap: 7px; margin-top: 16px; }
.skin-dots i {
width: 6px; height: 6px; border-radius: 50%;
width: 8px; height: 8px; border-radius: 50%;
background: #D6D6D6; transition: background .25s;
}
.skin-dots i.on { background: var(--ink); }
.skin-dots i.on { background: #000; }
/* 按钮随内容流:卡底→按钮间距 22px(= flex gap 16 + 此处 6),两侧 auto 居中 */
.btn-submit { margin: 6px auto 0; }
/* ═══════════ 05-07 拍照页 ═══════════ */
@@ -192,9 +201,21 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
position: absolute; inset: 0;
background-size: cover; background-position: center;
}
/* 实时相机画面(getUserMedia 成功后显示,盖住静态示意图) */
#cam-video {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
transform: scaleX(-1); /* 自拍镜面预览 */
display: none; z-index: 0;
}
#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; }
.cam-step {
position: absolute; left: 0; right: 0; top: 15%;
position: absolute; left: 0; right: 0; top: 16.5%;
text-align: center;
color: #fff; font-size: 21px; font-weight: 500;
line-height: 1.5; letter-spacing: 1px;
@@ -202,17 +223,17 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
z-index: 2;
}
.cam-guide {
position: absolute; left: 0; right: 0; bottom: 17%;
position: absolute; left: 0; right: 0; bottom: 20%;
display: flex; justify-content: center; align-items: center; gap: 18px;
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 rgba(255,255,255,.5); }
.cam-guide i { width: 0; height: 15px; border-left: 1px solid #fff; }
.cam-controls {
position: absolute; left: 0; right: 0; bottom: 6%;
position: absolute; left: 0; right: 0; bottom: 5.2%;
display: flex; align-items: center; justify-content: space-between;
padding: 0 55px;
padding-bottom: calc(10px + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
z-index: 2;
}
.cam-side img { width: 31px; height: 31px; }
@@ -220,7 +241,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.cam-shutter:active { transform: scale(.92); }
.cam-flash {
position: absolute; inset: 0; background: #fff;
opacity: 0; pointer-events: none; z-index: 9;
opacity: 0; pointer-events: none; z-index: 30; /* 盖住导航(z 20) */
transition: opacity .18s ease;
}
.cam-flash.on { opacity: .92; }
@@ -229,28 +250,35 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.report-body { padding: 12px 38px 34px; }
.report-title {
font-size: 20px; font-weight: 700; color: var(--ink);
text-align: center; margin: 8px 0 12px;
text-align: center; margin: 10px 0 16px;
letter-spacing: .5px;
}
.card {
background: #fff; border-radius: 10px;
background: #fff; border-radius: 6px;
padding: 14px 15px;
margin-bottom: 9px;
}
/* 总分 */
.card-stats { display: flex; }
.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); }
.stat-value { font-size: 29px; font-weight: 400; color: var(--ink); letter-spacing: 1px; }
/* 维度 */
.dims-intro { font-size: 10px; font-weight: 500; color: #464646; line-height: 1.6; margin-bottom: 10px; }
/* label 下短横线(34×1 居中) */
.stat-label::after {
content: ""; display: block;
width: 34px; height: 1px; background: #EFEFEF;
margin: 4px auto 0;
}
.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; }
.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); }
.dim-bad { color: var(--bad); }
.dims-note { font-size: 9px; font-weight: 500; color: var(--grey); line-height: 1.55; margin-top: 9px; }
.dims-disclaimer { font-size: 7px; font-weight: 400; color: var(--grey-3); margin-top: 8px; line-height: 1.5; }
.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;
@@ -266,47 +294,60 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.dtab.active::after {
content: ""; position: absolute;
left: 50%; transform: translateX(-50%); bottom: -1px;
width: 46px; height: 4px; border-radius: 2px;
width: 78px; height: 7px; border-radius: 3.5px;
background: var(--yellow);
}
.dpanel { display: none; }
.dpanel.active { display: block; animation: fade .25s ease; }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.ditem { display: flex; gap: 11px; }
.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 { font-size: 11.5px; font-weight: 500; color: var(--ink); margin-bottom: 4px; }
.ditem-para { font-size: 10px; font-weight: 400; color: var(--para); line-height: 1.55; }
/* 检测条目标题:竖排,位于图左(设计稿如此) */
.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-para { font-size: 10px; font-weight: 400; color: var(--para); line-height: 1.45; overflow-wrap: anywhere; }
.dempty {
padding: 26px 0; text-align: center;
font-size: 10px; color: var(--grey-2);
}
/* 护理建议 */
.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; }
.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; }
.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 {
display: flex; gap: 9px;
overflow-x: auto;
margin: 0 -6px; padding: 0 6px 4px;
flex-shrink: 0; margin: 0; padding: 0;
display: flex; overflow: hidden;
scrollbar-width: none;
}
.prod-scroll::-webkit-scrollbar { display: none; }
.prod-scroll img {
width: 130px; height: 162px;
object-fit: cover; border-radius: 6px;
flex-shrink: 0;
width: 168px; height: 225px;
object-fit: cover; flex-shrink: 0;
border-radius: 0 6px 6px 0;
}
.prod-name { font-size: 13px; font-weight: 700; color: var(--ink); margin: 11px 0 3px; }
.prod-sub { font-size: 7.5px; font-weight: 500; color: var(--grey-3); }
.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-top: 14px;
margin: 5px 2px 9px; /* 灰底独立文字,位于描述卡与护理建议卡之间 */
}
/* ═══════════ 弹窗 ═══════════ */
@@ -314,7 +355,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
position: absolute; inset: 0; z-index: 50;
display: none;
align-items: center; justify-content: center;
background: rgba(0,0,0,.45);
background: rgba(0,0,0,.5);
}
.overlay.show { display: flex; }
.overlay .popup { animation: popIn .3s cubic-bezier(.34,1.4,.64,1) both; }
@@ -322,8 +363,8 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.popup {
position: relative;
width: 307px;
background: #fff; border-radius: 14px;
padding: 34px 26px 26px;
background: #fff; border-radius: 10px;
padding: 34px 26px 33px;
text-align: left;
}
.popup-close {
@@ -338,11 +379,18 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
margin-bottom: 15px;
}
.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: 24px; cursor: pointer; }
.privacy-row { display: flex; align-items: center; gap: 8px; margin-bottom: 40px; cursor: pointer; }
.checkbox {
width: 11px; height: 11px; border-radius: 50%;
border: 1px solid #9B9B9B; background: #fff;
border: 1px solid #282828; background: #fff;
flex-shrink: 0; position: relative;
}
.checkbox.checked::after {
@@ -381,3 +429,11 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
z-index: 99; white-space: nowrap;
}
.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; }
+48 -24
View File
@@ -4,7 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="format-detection" content="telephone=no">
<meta name="description" content="1'me 在线肌肤测试:拍摄正面与侧面照片,即时获取六维肌肤状态分析与个性化护理建议。">
<meta name="theme-color" content="#101823">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta property="og:title" content="1'me 在线肌肤测试">
<meta property="og:description" content="拍摄正面与侧面照片,即时获取六维肌肤状态分析与个性化护理建议。">
<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="css/style.css">
</head>
<body>
@@ -64,8 +71,8 @@
<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:#F5F078"></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>
</div>
<div class="skin-carousel" id="skin-carousel">
<div class="skin-track" id="skin-track"></div>
@@ -87,6 +94,8 @@
</header>
<p class="cam-step" id="cam-step">1/3<br>请单人正脸出镜</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>
<div class="cam-controls">
<button class="cam-side" data-action="album" aria-label="相册">
<img src="assets/svg/图标-相册.svg" alt="相册">
@@ -113,23 +122,24 @@
<div class="card card-stats">
<div class="stat">
<span class="stat-label">综合评分</span>
<span class="stat-value">82分</span>
<span class="stat-value" id="stat-score">--分</span>
</div>
<div class="stat">
<span class="stat-label">肌肤年龄</span>
<span class="stat-value">28岁</span>
<span class="stat-value" id="stat-age">--岁</span>
</div>
</div>
<p class="dims-intro">我们从6个维度观察了你的肌肤,当下优先关注:紧致、光泽、纹路、耐受、细腻、水润</p>
<p class="dims-disclaimer">*状态是动态流动的,和作息、压力、季节都有关系,仅代表此刻的状态参考。</p>
<div class="card card-dims">
<p class="dims-intro">我们从6个维度观察了你的肌肤,当下优先关注:紧致、光泽、纹路、耐受、细腻、水润</p>
<div class="dims-row">
<div class="dim"><span class="dim-name">紧塑力</span><span class="dim-score">7.2</span></div>
<div class="dim"><span class="dim-name">活力感</span><span class="dim-score">8.4</span></div>
<div class="dim"><span class="dim-name">稳定力</span><span class="dim-score dim-bad">5.1</span></div>
<div class="dim"><span class="dim-name">紧塑力</span><span class="dim-score" id="dim-firm">-.-</span></div>
<div class="dim"><span class="dim-name">活力感</span><span class="dim-score" id="dim-vita">-.-</span></div>
<div class="dim"><span class="dim-name">稳定力</span><span class="dim-score" id="dim-stab">-.-</span></div>
</div>
<p class="dims-note">近期优先关注:稳定力<br>强韧稳态力,锁水保湿抵御刺激</p>
<p class="dims-disclaimer">*状态是动态流动的,和作息、压力、季节都有关系,仅代表此刻的状态参考。</p>
<p class="dims-note" id="dims-note">近期优先关注:<br>---</p>
</div>
<div class="card card-detail">
@@ -141,17 +151,17 @@
<div class="detail-panels">
<div class="dpanel active" data-panel="0">
<div class="ditem">
<img src="assets/img/det-1.jpg" class="ditem-img" alt="紧致检测图">
<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">
<h3 class="ditem-title">紧致</h3>
<p class="ditem-para">面部轮廓饱满舒展,下颌线清晰立体,中面部容积充足,肌肤回弹速度快;这种紧致状态,通常和内在胶原合成充足、整体作息节奏平稳直接相关。</p>
<p class="ditem-para" id="ditem-para1">检测完成后显示分析结果。</p>
</div>
</div>
<div class="ditem">
<img src="assets/img/det-2.jpg" class="ditem-img" alt="纹路检测图">
<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">
<h3 class="ditem-title">纹路</h3>
<p class="ditem-para">面部各区域平整舒展,没有明显的纹路沟壑,只有做夸张表情时会出现极浅的动态纹路;纹路的多少,和内在胶原支撑力、日常面部肌肉活动习惯都有关系。</p>
<p class="ditem-para" id="ditem-para2">检测完成后显示分析结果。</p>
</div>
</div>
</div>
@@ -164,6 +174,8 @@
</div>
</div>
<p class="report-disclaimer">*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。<br>*本次检测仅代表当前肌肤状态参考。</p>
<div class="card card-care">
<h2 class="card-heading">护理建议</h2>
<p class="care-item">1、面部支撑力明显下降,软组织有下移趋势。护肤时请避免用力向下拉扯皮肤,涂抹护肤品时配合由下向上的提拉手法,重点照顾下颌线与苹果肌位置。</p>
@@ -173,18 +185,19 @@
<div class="card card-product">
<h2 class="prod-title">感受高光时刻<br>从日常的好状态开始</h2>
<div class="prod-scroll" id="prod-scroll">
<img src="assets/img/prod-1.jpg" alt="产品图">
<img src="assets/img/prod-2.jpg" alt="产品图">
<img src="assets/img/prod-3.jpg" alt="产品图">
<img src="assets/img/prod-4.jpg" alt="产品图">
<div class="prod-card">
<div class="prod-info">
<span class="prod-badge">CORE Ⅱ</span>
<h3 class="prod-name">以我自在有势挺括双仓精华液</h3>
<p class="prod-sub">参透皮下根源 28天校准皮肤状态</p>
</div>
<div class="prod-scroll" id="prod-scroll">
<img src="assets/img/prod-1.jpg" loading="lazy" alt="以我自在有势挺括双仓精华液">
</div>
</div>
<h3 class="prod-name">以我自在有势挺括双仓精华液</h3>
<p class="prod-sub">参透皮下根源 28天校准皮肤状态</p>
</div>
<button class="btn btn-primary btn-more" data-action="more">了解更多</button>
<p class="report-disclaimer">*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。<br>*本次检测仅代表当前肌肤状态参考。</p>
</div>
</section>
@@ -198,7 +211,7 @@
<p class="popup-body">我们将采集您的面部影像,为您生成专属肌肤分析与状态管理方案。所有数据严格保密,仅用于本次检测分析。</p>
<label class="privacy-row" id="privacy-row">
<span class="checkbox" id="privacy-check"></span>
<span class="privacy-text">我已阅读并同意相关隐私协议</span>
<span class="privacy-text">我已阅读并同意<a class="privacy-link" id="privacy-policy-link">相关隐私协议</a></span>
</label>
<button class="btn btn-primary popup-btn" data-action="agree-continue">继续</button>
</div>
@@ -215,6 +228,17 @@
</div>
</div>
<!-- ═══════════ 弹窗:隐私协议(占位) ═══════════ -->
<div class="overlay" id="p-policy">
<div class="popup popup-policy">
<button class="popup-close" data-action="close-policy" aria-label="关闭">
<img src="assets/svg/图标-关闭.svg" alt="关闭">
</button>
<h2 class="popup-title popup-title-policy">1'me 肌肤状态评估隐私协议</h2>
<p class="popup-body">(协议全文待法务提供后接入,当前为占位)</p>
</div>
</div>
<!-- ═══════════ 分析中过渡 ═══════════ -->
<div class="overlay overlay-dark" id="p-analyzing">
<div class="analyzing">
+320 -19
View File
@@ -22,12 +22,44 @@
var sc = document.getElementById(id);
sc.scrollTop = 0;
currentScreen = id;
if (id === 's-report') resetReport(); // 每次进入报告页都还原初始状态
}
/* ─── 报告页状态重置(Tab 回到「紧塑力」、产品横滑归零) ─── */
function resetReport() {
document.querySelectorAll('.dtab').forEach(function (t, k) {
t.classList.toggle('active', k === 0);
});
document.querySelectorAll('.dpanel').forEach(function (p, k) {
p.classList.toggle('active', k === 0);
});
var ps = $('#prod-scroll');
if (ps) ps.scrollLeft = 0;
}
/* ─── 弹窗管理 ─── */
function openOverlay(id) { $('#' + id).classList.add('show'); }
function closeOverlay(id) { $('#' + id).classList.remove('show'); }
// 点遮罩空白处关闭(与各自关闭按钮同行为:隐私→回启动页、拍照引导→留在档案页)
// e.target === overlay 确保只响应遮罩本身,不误伤弹窗卡片内交互
function bindBackdropClose(id) {
var ov = $('#' + id);
ov.addEventListener('click', function (e) {
if (e.target === ov) closeOverlay(id);
});
}
bindBackdropClose('p-privacy');
bindBackdropClose('p-photoguide');
bindBackdropClose('p-policy');
// ESC 关闭(仅这三层;分析中过渡不响应;协议层最顶,优先关)
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape' && e.key !== 'Esc') return;
if ($('#p-policy').classList.contains('show')) closeOverlay('p-policy');
else if ($('#p-privacy').classList.contains('show')) closeOverlay('p-privacy');
else if ($('#p-photoguide').classList.contains('show')) closeOverlay('p-photoguide');
});
/* ─── 肤质轮播 ─── */
var SKINS = [
{ img: 'assets/img/skin-0-dry.png', name: '干性肌肤', desc: '全脸干燥,容易紧绷、缺乏光泽' },
@@ -43,7 +75,7 @@
var item = document.createElement('div');
item.className = 'skin-item';
item.innerHTML =
'<img src="' + s.img + '" alt="' + s.name + '">' +
'<img src="' + s.img + '"' + (i === 0 ? '' : ' loading="lazy"') + ' alt="' + s.name + '">' +
'<span class="skin-name">' + s.name + '</span>' +
'<p class="skin-desc">' + s.desc + '</p>';
track.appendChild(item);
@@ -61,17 +93,44 @@
});
}
// 触摸滑动
var touchX = null;
$('#skin-carousel').addEventListener('touchstart', function (e) {
touchX = e.touches[0].clientX;
}, { passive: true });
$('#skin-carousel').addEventListener('touchend', function (e) {
if (touchX === null) return;
var dx = e.changedTouches[0].clientX - touchX;
if (Math.abs(dx) > 30) goSkin(skinIndex + (dx < 0 ? 1 : -1));
touchX = null;
}, { passive: true });
// 拖拽切换(Pointer Events 统一鼠标 + 触摸)
var drag = { id: null, startX: 0, startY: 0, horizontal: false, suppress: false };
var carousel = $('#skin-carousel');
carousel.addEventListener('pointerdown', function (e) {
if (e.cancelable) e.preventDefault(); // 阻止原生图片拖拽/文字选中引发 pointercancel(click 仍会正常触发)
try { carousel.setPointerCapture(e.pointerId); } catch (err) { /* 非活动指针时忽略,退化为普通监听 */ }
// 捕获指针:斜拖出卡片、卡外松手也能收到 pointerup
drag.id = e.pointerId;
drag.startX = e.clientX;
drag.startY = e.clientY;
drag.horizontal = false;
drag.suppress = false;
});
carousel.addEventListener('pointermove', function (e) {
if (e.pointerId !== drag.id) return;
var dx = e.clientX - drag.startX, dy = e.clientY - drag.startY;
// 横向位移明显大于纵向时才判定为拖拽,避免吞掉纵向滚动
if (!drag.horizontal && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) drag.horizontal = true;
if (drag.horizontal && e.cancelable) e.preventDefault();
});
carousel.addEventListener('pointerup', function (e) {
if (e.pointerId !== drag.id) return;
var dx = e.clientX - drag.startX;
if (drag.horizontal) {
if (Math.abs(dx) > 30) goSkin(skinIndex + (dx < 0 ? 1 : -1));
drag.suppress = true; // 拖拽后吞掉紧随的 click,防止点击穿透
}
drag.id = null;
drag.horizontal = false;
});
carousel.addEventListener('pointercancel', function (e) {
if (e.pointerId !== drag.id) return; // 其它指针的 cancel 不打断当前拖拽
drag.id = null; // 拖拽被系统取消(滚轮/新指针等):干净结束,不残留状态
drag.horizontal = false;
});
carousel.addEventListener('click', function (e) {
if (drag.suppress) { e.preventDefault(); e.stopPropagation(); drag.suppress = false; }
}, true);
/* ─── 地区选项 ─── */
var REGIONS = ['北京', '上海', '广州', '深圳', '杭州', '成都', '重庆', '武汉', '西安', '南京', '天津', '苏州', '长沙', '郑州', '青岛', '其他'];
@@ -82,6 +141,194 @@
regionSel.appendChild(o);
});
/* ─── 实时相机(getUserMedia)+ 系统相机降级 ─── */
var camVideo = $('#cam-video'), camFile = $('#cam-file');
var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头
var photos = []; // 三连拍的照片(dataURL),photos[0] 为正面照,送检用
function startCamera() {
photos = [];
if (camStream) { stopCamera(); }
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; }
camSeqCam++;
var token = camSeqCam;
navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 1280 } },
audio: false
}).then(function (stream) {
if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放
camStream = stream;
camVideo.srcObject = stream;
camLive = true;
$('#s-cam').classList.add('cam-live');
}).catch(function () {
if (token !== camSeqCam) return;
enableFallback();
});
}
function stopCamera() {
camSeqCam++; // 使进行中的 getUserMedia 回调失效
if (camStream) { camStream.getTracks().forEach(function (t) { t.stop(); }); camStream = null; }
camVideo.srcObject = null;
camLive = false;
$('#s-cam').classList.remove('cam-live');
}
function enableFallback() {
camFallback = true;
toast('已切换为系统相机拍摄');
}
// 从视频帧截取照片(镜像绘制,与预览一致;压到最长边 1280 的 JPEG)
function captureFromVideo() {
var vw = camVideo.videoWidth, vh = camVideo.videoHeight;
if (!vw || !vh) return null;
var scale = Math.min(1, 1280 / Math.max(vw, vh));
var cw = Math.round(vw * scale), ch = Math.round(vh * scale);
var canvas = document.createElement('canvas');
canvas.width = cw; canvas.height = ch;
var ctx = canvas.getContext('2d');
ctx.translate(cw, 0); ctx.scale(-1, 1); // 镜像
ctx.drawImage(camVideo, 0, 0, cw, ch);
return canvas.toDataURL('image/jpeg', 0.88);
}
// 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程
camFile.addEventListener('change', function () {
var f = camFile.files && camFile.files[0];
camFile.value = '';
if (!f) return;
var reader = new FileReader();
reader.onload = function () {
// 统一压成最长边 1280 的 JPEG
var img = new Image();
img.onload = function () {
var scale = Math.min(1, 1280 / Math.max(img.width, img.height));
var c = document.createElement('canvas');
c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale);
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
onPhotoCaptured(c.toDataURL('image/jpeg', 0.88));
};
img.src = reader.result;
};
reader.readAsDataURL(f);
});
/* ─── Face++ 结果 → 设计稿报告数据(映射层) ─── */
function num(node, key) {
if (node == null) return 0;
if (key != null) return Number(node[key] && (node[key].value != null ? node[key].value : node[key])) || 0;
return Number(node.value != null ? node.value : node) || 0;
}
function one(x) { return Math.round(x * 10) / 10; }
function clamp(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); }
function mapSkinResult(r) {
var fw = num(r.forehead_wrinkle, 'value') === 1;
var cf = num(r.crows_feet, 'value') === 1;
var ef = num(r.eye_finelines, 'value') === 1;
var gw = num(r.glabella_wrinkle, 'value') === 1;
var nf = num(r.nasolabial_fold, 'value') === 1, nfSev = num(r.nasolabial_fold_severity, 'value');
var ep = num(r.eye_pouch, 'value') === 1, epSev = num(r.eye_pouch_severity, 'value');
var dc = num(r.dark_circle, 'value');
var bh = num(r.blackhead, 'value');
var poreCount = ['pores_forehead', 'pores_left_cheek', 'pores_right_cheek', 'pores_jaw']
.filter(function (k) { return num(r[k], 'value') === 1; }).length;
var acneN = (r.acne && r.acne.rectangle) ? r.acne.rectangle.length : 0;
var ccN = (r.closed_comedones && r.closed_comedones.rectangle) ? r.closed_comedones.rectangle.length : 0;
var spotN = (r.skin_spot && r.skin_spot.rectangle) ? r.skin_spot.rectangle.length : 0;
var sens = r.sensitivity || {};
var sensArea = Number(sens.sensitivity_area) || 0;
var sensInt = Number(sens.sensitivity_intensity) || 0;
var age = num(r.skin_age, 'value');
// 紧塑力:法令纹 + 眼袋(松弛下垂类)
var firm = 10 - (nf ? [0.9, 1.5, 2.2][clamp(nfSev, 0, 2)] : 0) - (ep ? [0.7, 1.2, 1.8][clamp(epSev, 0, 2)] : 0);
// 活力感:黑眼圈 + 眼部细纹/鱼尾纹/抬头纹/眉间纹(倦怠神采类)
var vita = 10 - (dc > 0 ? (dc === 2 ? 1.3 : 1.0) : 0) - (ef ? 0.6 : 0) - (cf ? 0.5 : 0) - (fw ? 0.4 : 0) - (gw ? 0.3 : 0);
// 稳定力:敏感泛红 + 痘痘/闭口/毛孔/黑头(屏障稳定类)
var stab = 10 - (sensArea * 2.5 + sensInt / 100 * 2.0) - Math.min(acneN, 5) * 0.5 - Math.min(ccN, 5) * 0.35 - poreCount * 0.4 - bh * 0.3;
firm = one(clamp(firm, 4, 9.8)); vita = one(clamp(vita, 4, 9.8)); stab = one(clamp(stab, 3.5, 9.8));
var total = Math.round(clamp((firm * 0.35 + vita * 0.33 + stab * 0.32) * 10, 40, 98));
var dims = [
{ name: '紧塑力', score: firm, tip: '轮廓支撑与紧致度,护肤配合由下向上的提拉手法' },
{ name: '活力感', score: vita, tip: '神采与倦怠感,规律作息改善眼周循环' },
{ name: '稳定力', score: stab, tip: '强韧稳态力,锁水保湿抵御刺激' }
].sort(function (a, b) { return a.score - b.score; });
// 具体描述两行文案
var sevName = ['轻度', '中度', '重度'];
var para1;
if (!nf && !ep) {
para1 = '面部轮廓饱满舒展,下颌线清晰立体,中面部容积充足,肌肤回弹速度快;这种紧致状态,通常和内在胶原合成充足、整体作息节奏平稳直接相关。';
} else {
var parts = [];
if (nf) parts.push('观察到' + sevName[clamp(nfSev, 0, 2)] + '法令纹痕迹,面部支撑有松弛趋势');
if (ep) parts.push('眼部有' + sevName[clamp(epSev, 0, 2)] + '眼袋');
para1 = parts.join(',') + '。护肤时请避免用力向下拉扯皮肤,涂抹护肤品时配合由下向上的提拉手法,重点照顾下颌线与苹果肌位置。';
}
var para2;
var wrinkles = [];
if (fw) wrinkles.push('抬头纹'); if (cf) wrinkles.push('鱼尾纹');
if (ef) wrinkles.push('眼部细纹'); if (gw) wrinkles.push('眉间纹'); if (nf) wrinkles.push('法令纹');
if (wrinkles.length === 0) {
para2 = '面部各区域平整舒展,没有明显的纹路沟壑,只有做夸张表情时会出现极浅的动态纹路;纹路的多少,和内在胶原支撑力、日常面部肌肉活动习惯都有关系。';
} else {
para2 = '当前观察到' + wrinkles.join('、') + '的痕迹;纹路的多少,和内在胶原支撑力、日常面部肌肉活动习惯都有关系,日常注意表情管理与防晒,有助于减缓纹路加深。';
}
var found = [];
if (spotN > 0) found.push('斑点' + spotN + '处');
if (acneN > 0) found.push('痘痘' + acneN + '处');
if (ccN > 0) found.push('闭口' + ccN + '处');
if (found.length) para2 += '(本次检测到' + found.join(',') + ')';
return {
total: total, age: age,
firm: firm, vita: vita, stab: stab,
focusName: dims[0].name, focusTip: dims[0].tip,
para1: para1, para2: para2,
lowest: dims[0].score
};
}
function renderReport(m, frontPhoto, sidePhoto) {
$('#stat-score').textContent = m.total + '分';
$('#stat-age').textContent = m.age + '岁';
var setDim = function (id, score) {
var el = $(id);
el.textContent = score.toFixed(1);
el.classList.toggle('dim-bad', score < 6);
};
setDim('#dim-firm', m.firm); setDim('#dim-vita', m.vita); setDim('#dim-stab', m.stab);
$('#dims-note').innerHTML = '近期优先关注:' + m.focusName + '<br>' + m.focusTip;
$('#ditem-para1').textContent = m.para1;
$('#ditem-para2').textContent = m.para2;
if (frontPhoto) $('#ditem-img1').src = frontPhoto;
if (sidePhoto) $('#ditem-img2').src = sidePhoto;
}
/* ─── 分析请求(mock=1 用内置样本,便于离线/自动化验证) ─── */
var MOCK_RESULT = {"skin_color":{"value":2,"confidence":0.65},"skin_age":{"value":42},"left_eyelids":{"value":0,"confidence":0.99},"right_eyelids":{"value":0,"confidence":0.99},"eye_pouch":{"value":0,"confidence":0.75},"dark_circle":{"value":1,"confidence":0.63},"forehead_wrinkle":{"value":0,"confidence":0.76},"crows_feet":{"value":1,"confidence":0.76},"eye_finelines":{"value":1,"confidence":0.86},"glabella_wrinkle":{"value":0,"confidence":0.76},"nasolabial_fold":{"value":1,"confidence":0.80},"nasolabial_fold_severity":{"value":1,"confidence":0.30},"skin_type":{"skin_type":3},"pores_forehead":{"value":0,"confidence":0.82},"pores_left_cheek":{"value":0,"confidence":0.76},"pores_right_cheek":{"value":0,"confidence":0.95},"pores_jaw":{"value":0,"confidence":0.98},"blackhead":{"value":0,"confidence":0.99},"acne":{"rectangle":[],"confidence":[]},"closed_comedones":{"rectangle":[],"confidence":[]},"mole":{"rectangle":[],"confidence":[]},"skin_spot":{"rectangle":[{"left":356,"top":494,"width":11,"height":13}],"confidence":[0.50]},"sensitivity":{"sensitivity_area":0.26,"sensitivity_intensity":65.36}};
function requestAnalyze(imageDataUrl, seq, done) {
if (/[?&]mock=1/.test(location.search)) {
setTimeout(function () { done(null, MOCK_RESULT); }, 900);
return;
}
fetch('/api/skin-analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_base64: imageDataUrl })
}).then(function (res) { return res.json(); })
.then(function (data) {
if (seq !== camSeq) return; // 流程已被重置,丢弃过期响应
if (!data.ok) done(data.message || '检测失败,请重试');
else done(null, data.result);
})
.catch(function () {
if (seq !== camSeq) return;
done('网络异常,请检查网络后重试');
});
}
/* ─── 拍照流程 ─── */
var CAM_STEPS = [
{ bg: 'assets/img/cam-front.jpg', step: '1/3<br>请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'] },
@@ -99,34 +346,82 @@
.join('<i></i>');
}
function shutter() {
var camSeq = 0; // 流程令牌:cam-back/开始拍照时递增,使未决的快门/分析回调失效
var shutterLock = false; // 快门防连点锁:流程进行中直接忽略
// 照片就绪后推进流程(实时截帧与降级选图共用)
function onPhotoCaptured(dataUrl) {
if (shutterLock && !camFallback) return;
shutterLock = true;
photos[camStep] = dataUrl;
var flash = $('#cam-flash');
flash.classList.add('on');
setTimeout(function () { flash.classList.remove('on'); }, 220);
var seq = camSeq;
setTimeout(function () {
if (seq !== camSeq) return; // 期间已 cam-back/重新开始:放弃本次推进
if (camStep < CAM_STEPS.length - 1) {
camStep++;
renderCamStep();
shutterLock = false; // 进入下一步即解锁
} else {
camStep = 0;
// 三张拍完:送检正面照
renderCamStep();
camStep = 0;
openOverlay('p-analyzing');
setTimeout(function () {
closeOverlay('p-analyzing');
showScreen('s-report');
}, 1600);
var t0 = Date.now();
requestAnalyze(photos[0], seq, function (errMsg, result) {
if (seq !== camSeq) return; // 流程已被重置:丢弃
var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁
setTimeout(function () {
if (seq !== camSeq) return;
closeOverlay('p-analyzing');
stopCamera();
if (errMsg) {
toast(errMsg);
showScreen('s-profile'); // 回档案页,允许重新提交再拍
shutterLock = false;
return;
}
renderReport(mapSkinResult(result), photos[0], photos[1]);
showScreen('s-report');
shutterLock = false; // 流程结束进入报告后解锁
}, wait);
});
}
}, 260);
}
function shutter() {
if (shutterLock) return;
if (camLive) {
var shot = captureFromVideo();
if (!shot) { toast('相机尚未就绪,请稍候'); return; }
onPhotoCaptured(shot);
} else if (camFallback) {
camFile.click(); // 降级:调起系统相机,回调里推进
} else {
// 相机还在启动中
toast('相机启动中,请稍候');
}
}
/* ─── 隐私勾选 ─── */
var agreed = false;
$('#privacy-row').addEventListener('click', function (e) {
if (e.target.closest('#privacy-policy-link')) return; // 点协议链接:只开弹层,不切换勾选
e.preventDefault();
agreed = !agreed;
$('#privacy-check').classList.toggle('checked', agreed);
});
/* ─── 隐私协议占位弹层(QA P3-2) ─── */
$('#privacy-policy-link').addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation(); // 阻止冒泡到勾选行,避免误切换
openOverlay('p-policy');
});
/* ─── 报告页 Tab ─── */
$('#detail-tabs').addEventListener('click', function (e) {
var btn = e.target.closest('.dtab');
@@ -142,6 +437,7 @@
var actions = {
'open-privacy': function () { openOverlay('p-privacy'); },
'close-privacy': function () { closeOverlay('p-privacy'); },
'close-policy': function () { closeOverlay('p-policy'); },
'agree-continue': function () {
if (!agreed) { toast('请先阅读并同意隐私协议'); return; }
closeOverlay('p-privacy');
@@ -158,13 +454,18 @@
'close-photoguide': function () { closeOverlay('p-photoguide'); },
'start-camera': function () {
closeOverlay('p-photoguide');
camSeq++; // 使旧的快门/分析回调失效
camStep = 0;
shutterLock = false; // 重新进入流程,保险解锁
renderCamStep();
showScreen('s-cam');
startCamera();
},
'cam-back': function () {
camSeq++; // 使未决的快门/分析回调失效,避免回退被旧定时器顶回
shutterLock = false; // 中途退出也解锁
if (camStep > 0) { camStep--; renderCamStep(); }
else { showScreen('s-profile'); }
else { stopCamera(); showScreen('s-profile'); }
},
'shutter': shutter,
'album': function () { toast('相册功能开发中'); },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

+128
View File
@@ -0,0 +1,128 @@
/* ═══════════════════════════════════════════════════════════
巨子测肤 · 1'me — 后端服务(零依赖,Node ≥ 18)
1) 静态托管 h5/ 目录
2) POST /api/skin-analyze:图片中转 Face++ 高阶版测肤
密钥只存在服务端(SKIN_API_KEY / SKIN_API_SECRET),
前端永远接触不到 api_secret。
启动: node server.js (默认 8080,可用 PORT 环境变量改)
═══════════════════════════════════════════════════════════ */
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = Number(process.env.PORT) || 8080;
const ROOT = __dirname;
const FACEPP_URL = 'https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_advanced';
/* ─── 密钥加载:环境变量优先,其次解析项目根的 apikey.bash ─── */
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 };
}
try {
const txt = fs.readFileSync(path.join(ROOT, '..', 'apikey.bash'), 'utf8');
const key = (txt.match(/SKIN_API_KEY\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
const secret = (txt.match(/SKIN_API_SECRET\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
if (key && secret) return { key, secret };
} catch (e) { /* 文件不存在则走下面的报错 */ }
console.error('[启动失败] 找不到密钥:请设置 SKIN_API_KEY / SKIN_API_SECRET 环境变量,');
console.error(' 或在项目根目录放置 apikey.bash(内容为 export SKIN_API_KEY=... 等)');
process.exit(1);
}
const CRED = loadCredentials();
/* ─── 静态文件 ─── */
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'
};
function serveStatic(req, res, pathname) {
let p = decodeURIComponent(pathname);
if (p === '/') p = '/index.html';
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' });
res.end(data);
});
}
/* ─── Face++ 错误 → 用户可读提示 ─── */
const ERR_MSG = {
'NO_FACE_FOUND': '未检测到人脸,请正对镜头、露出完整五官后重试',
'INVALID_IMAGE_FACE': '照片中人脸不完整或检测到多张人脸,请单人正脸出镜',
'INVALID_FACE_SIZE': '距离太远啦,请靠近镜头一些',
'INVALID_IMAGE_SIZE': '照片尺寸不符合要求,请重试',
'IMAGE_FILE_TOO_LARGE': '照片过大,请重试',
'IMAGE_ERROR_UNSUPPORTED_FORMAT': '照片格式不支持,请重试',
'AUTHENTICATION_ERROR': '检测服务配置有误,请联系管理员',
'AUTHORIZATION_ERROR': '检测服务额度或权限异常,请联系管理员',
'CONCURRENCY_LIMIT_EXCEEDED': '当前检测人数较多,请稍候重试',
'INTERNAL_ERROR': '检测服务开小差了,请稍候重试'
};
function readBody(req, limitBytes) {
return new Promise((resolve, reject) => {
let size = 0; const chunks = [];
req.on('data', c => {
size += c.length;
if (size > limitBytes) { reject(new Error('TOO_LARGE')); req.destroy(); return; }
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
async function handleAnalyze(req, res) {
try {
const body = JSON.parse((await readBody(req, 12 * 1024 * 1024)).toString('utf8'));
const b64 = (body.image_base64 || '').replace(/^data:image\/\w+;base64,/, '');
if (!b64) { res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify({ ok: false, code: 'NO_IMAGE', message: '缺少图片' })); }
const form = new FormData();
form.append('api_key', CRED.key);
form.append('api_secret', CRED.secret);
form.append('image_base64', b64);
form.append('face_quality_control', '0');
form.append('return_rect_confidence', '1');
form.append('return_maps', 'red_area');
const fpRes = await fetch(FACEPP_URL, { method: 'POST', body: form, signal: AbortSignal.timeout(30000) });
const fpJson = await fpRes.json().catch(() => ({}));
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) {
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 }));
}
// 只回传前端需要的字段,不透传密钥相关内容
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: true, result: fpJson.result, face_rectangle: fpJson.face_rectangle || null }));
} catch (e) {
const code = e.message === 'TOO_LARGE' ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR';
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, code, message: code === 'NETWORK_ERROR' ? '网络异常,请检查网络后重试' : ERR_MSG[code] }));
}
}
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') {
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)`);
});