diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3168dca --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# 密钥与敏感配置 —— 绝不入库 +apikey.bash +apikey.* +.env +.env.* + +# 运行时产物 +node_modules/ +*.log +__pycache__/ diff --git a/design/设计规范.md b/design/设计规范.md index 3309c87..c68c0fc 100644 --- a/design/设计规范.md +++ b/design/设计规范.md @@ -131,7 +131,7 @@ design/ ## 8. 还原建议 1. **流程**:启动页(授权弹窗)→ 档案填写(年龄/性别/地区/肤质轮播)→ 拍照弹窗 → 正面/左/右三连拍 → 生成报告。 -2. 肤质选择是**轮播交互**(轮播标识×4 圆点 + 偏干↔偏油轴标签),插画随选项换色。 +2. 肤质选择是**轮播交互**(轮播标识×5 圆点 + 偏干↔偏油轴标签),插画随选项换色。 3. 报告页为长滚动页:总分(82 分/100)→ 六维度 → 三维度拆解 → 具体描述(配检测图)→ 护理建议 → 产品推荐 → 免责声明。 4. 位图素材均为 PNG(原始命名 .jpg 已改),报告页背景 4915×11457 建议压缩后再入包。 5. 颜色、字号直接抄本规范第 2、3 节;组件直接用 assets/svg 切图。 diff --git a/h5/README.md b/h5/README.md new file mode 100644 index 0000000..e1f0e87 --- /dev/null +++ b/h5/README.md @@ -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% 压暗遮罩保证白字可读。 diff --git a/h5/assets/favicon.svg b/h5/assets/favicon.svg new file mode 100644 index 0000000..0127094 --- /dev/null +++ b/h5/assets/favicon.svg @@ -0,0 +1,4 @@ + + + 1' + diff --git a/h5/assets/img/bg-profile.jpg b/h5/assets/img/bg-profile.jpg deleted file mode 100644 index 220d6d2..0000000 Binary files a/h5/assets/img/bg-profile.jpg and /dev/null differ diff --git a/h5/assets/img/bg-report.jpg b/h5/assets/img/bg-report.jpg deleted file mode 100644 index 69f150f..0000000 Binary files a/h5/assets/img/bg-report.jpg and /dev/null differ diff --git a/h5/assets/img/det-3.jpg b/h5/assets/img/det-3.jpg deleted file mode 100644 index 776f9c2..0000000 Binary files a/h5/assets/img/det-3.jpg and /dev/null differ diff --git a/h5/assets/img/prod-2.jpg b/h5/assets/img/prod-2.jpg deleted file mode 100644 index 4799bb1..0000000 Binary files a/h5/assets/img/prod-2.jpg and /dev/null differ diff --git a/h5/assets/img/prod-3.jpg b/h5/assets/img/prod-3.jpg deleted file mode 100644 index 1c615fb..0000000 Binary files a/h5/assets/img/prod-3.jpg and /dev/null differ diff --git a/h5/assets/img/prod-4.jpg b/h5/assets/img/prod-4.jpg deleted file mode 100644 index 496e356..0000000 Binary files a/h5/assets/img/prod-4.jpg and /dev/null differ diff --git a/h5/assets/svg/按钮-主按钮.svg b/h5/assets/svg/按钮-主按钮.svg deleted file mode 100644 index 9ad0646..0000000 --- a/h5/assets/svg/按钮-主按钮.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/h5/assets/svg/肤质-面部插画.svg b/h5/assets/svg/肤质-面部插画.svg deleted file mode 100644 index 8edab13..0000000 --- a/h5/assets/svg/肤质-面部插画.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/h5/css/style.css b/h5/css/style.css index 415e09d..880c336 100644 --- a/h5/css/style.css +++ b/h5/css/style.css @@ -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; } diff --git a/h5/index.html b/h5/index.html index d69ca25..b641580 100644 --- a/h5/index.html +++ b/h5/index.html @@ -4,7 +4,14 @@ + + + + + 1'me 在线肌肤测试 + + @@ -64,8 +71,8 @@
偏干 - 偏油 水油平衡 + 偏油
@@ -215,6 +228,17 @@
+ +
+ +
+
diff --git a/h5/js/app.js b/h5/js/app.js index 1e1bd16..a710133 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -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 = - '' + s.name + '' + + '' + s.name + '' + '' + s.name + '' + '

' + s.desc + '

'; 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 + '
' + 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
请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'] }, @@ -99,34 +346,82 @@ .join(''); } - 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('相册功能开发中'); }, diff --git a/h5/preview/01-splash.png b/h5/preview/01-splash.png index a8dc783..a845867 100644 Binary files a/h5/preview/01-splash.png and b/h5/preview/01-splash.png differ diff --git a/h5/preview/02-privacy.png b/h5/preview/02-privacy.png index 3233a8f..5839dcf 100644 Binary files a/h5/preview/02-privacy.png and b/h5/preview/02-privacy.png differ diff --git a/h5/preview/03-policy.png b/h5/preview/03-policy.png new file mode 100644 index 0000000..cad8a3a Binary files /dev/null and b/h5/preview/03-policy.png differ diff --git a/h5/preview/03-privacy-checked.png b/h5/preview/03-privacy-checked.png deleted file mode 100644 index cdd9062..0000000 Binary files a/h5/preview/03-privacy-checked.png and /dev/null differ diff --git a/h5/preview/04-profile.png b/h5/preview/04-profile.png index a57548e..03bbc7b 100644 Binary files a/h5/preview/04-profile.png and b/h5/preview/04-profile.png differ diff --git a/h5/preview/05-profile-skin1.png b/h5/preview/05-profile-skin1.png new file mode 100644 index 0000000..7e4bf5f Binary files /dev/null and b/h5/preview/05-profile-skin1.png differ diff --git a/h5/preview/05-profile-skin3.png b/h5/preview/05-profile-skin3.png deleted file mode 100644 index 0cc900d..0000000 Binary files a/h5/preview/05-profile-skin3.png and /dev/null differ diff --git a/h5/preview/06-profile-filled.png b/h5/preview/06-profile-filled.png deleted file mode 100644 index 162d029..0000000 Binary files a/h5/preview/06-profile-filled.png and /dev/null differ diff --git a/h5/preview/06-profile-skin3.png b/h5/preview/06-profile-skin3.png new file mode 100644 index 0000000..92ac1cf Binary files /dev/null and b/h5/preview/06-profile-skin3.png differ diff --git a/h5/preview/07-photoguide.png b/h5/preview/07-photoguide.png index 334b7aa..b14ebfe 100644 Binary files a/h5/preview/07-photoguide.png and b/h5/preview/07-photoguide.png differ diff --git a/h5/preview/08-cam-front.png b/h5/preview/08-cam-front.png index b4d9414..7d18c09 100644 Binary files a/h5/preview/08-cam-front.png and b/h5/preview/08-cam-front.png differ diff --git a/h5/preview/09-cam-left.png b/h5/preview/09-cam-left.png index 883d524..a490d1b 100644 Binary files a/h5/preview/09-cam-left.png and b/h5/preview/09-cam-left.png differ diff --git a/h5/preview/10-cam-right.png b/h5/preview/10-cam-right.png index 9e4d37a..8bddd76 100644 Binary files a/h5/preview/10-cam-right.png and b/h5/preview/10-cam-right.png differ diff --git a/h5/preview/11-analyzing.png b/h5/preview/11-analyzing.png deleted file mode 100644 index 2d4a456..0000000 Binary files a/h5/preview/11-analyzing.png and /dev/null differ diff --git a/h5/preview/11-report-top.png b/h5/preview/11-report-top.png new file mode 100644 index 0000000..aee2fef Binary files /dev/null and b/h5/preview/11-report-top.png differ diff --git a/h5/preview/12-report-mid.png b/h5/preview/12-report-mid.png new file mode 100644 index 0000000..cc8c840 Binary files /dev/null and b/h5/preview/12-report-mid.png differ diff --git a/h5/preview/12-report-top.png b/h5/preview/12-report-top.png deleted file mode 100644 index 7aa4ff0..0000000 Binary files a/h5/preview/12-report-top.png and /dev/null differ diff --git a/h5/preview/13-report-bottom.png b/h5/preview/13-report-bottom.png new file mode 100644 index 0000000..cc8c840 Binary files /dev/null and b/h5/preview/13-report-bottom.png differ diff --git a/h5/preview/13-report-mid.png b/h5/preview/13-report-mid.png deleted file mode 100644 index 9475d74..0000000 Binary files a/h5/preview/13-report-mid.png and /dev/null differ diff --git a/h5/preview/14-report-bottom.png b/h5/preview/14-report-bottom.png deleted file mode 100644 index 918a6dc..0000000 Binary files a/h5/preview/14-report-bottom.png and /dev/null differ diff --git a/h5/server.js b/h5/server.js new file mode 100644 index 0000000..b81f064 --- /dev/null +++ b/h5/server.js @@ -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)`); +});