Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abb1c4c1de | ||
|
|
d7ae748e08 | ||
|
|
3193a4b683 | ||
|
|
d2931a41e4 | ||
|
|
85790ef985 | ||
|
|
d68f841f0e |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 405 KiB After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 428 KiB |
@@ -0,0 +1,67 @@
|
||||
# 客户反馈修改(2026-10-12)
|
||||
|
||||
依据:`feedback/傅艳NEW与项思炼的会话 2026年10月12日/傅艳NEW与项思炼的会话 2026年10月12日.md` 及其 6 张截图。本文仅处理本次客户反馈,不修改评分公式、肤龄显示规则或产品推荐规则。
|
||||
|
||||
## 逐项处理
|
||||
|
||||
| 客户反馈 | 处理 |
|
||||
| --- | --- |
|
||||
| 报告 Tab 同时支持滑动、点击 | 三组维度卡分别支持横向手势及点击;纵向滚动不切换,边界不循环。保留键盘方向键、Home/End 操作。 |
|
||||
| 检测图脸部放大,同时保留点开放大 | 根据接口原始人脸矩形统一裁切照片、增强图和标记的展示区域,保留额头、下巴余量;七张检测图均处理,点开放大保留。无人脸矩形或矩形无效时展示原图。上传原图、检测输入、存档照片不裁切。 |
|
||||
| 皱纹分区详细说明 | 展示 12 个真实分区的 9 项原始指标,缺失显示「未获取」,0 保留为 0。抬头纹和额头纹在现有数据中同属 `forehead_wrinkle_info`,合并为「抬头纹(额头纹)」,不虚构第 13 区。左右以本人为准。 |
|
||||
| 平滑度图有框,但文字说无毛孔、黑头 | 原图错误使用色斑、痣、痤疮框。改为真实的毛孔、黑头增强图,并说明颜色及其不代表严重程度。缺图不绘制其他问题框。总体评分优秀但局部计数大于 0 时明确列出计数,不再写「无明显」。评分与档位本身不变。 |
|
||||
| 黄色按钮色号不一致 | 最新 PSD 按钮矢量填色与导出像素均为 `#F5F078`,当前按钮也使用该色;正常按钮没有额外透明度/滤镜。傅艳已确认暂按 PSD 的 `#F5F078`,保持现有色值。屏幕色彩模式可能影响观感,不能据截图猜测新色。 |
|
||||
| 产品名固定断句 | 三款副标题第一行分别为「以我本然有光蓄活」「以我自在有势挺括」「以我从容有态构塑」,第二行统一「双仓精华液」,独立块且不自动换行。 |
|
||||
| 链接卡顿、拍摄提示不符 | 卡顿已由上一轮首屏延迟加载与静态缓存优化处理,本次复测无回退。拍摄提示按最新 PSD 改为「为使测试结果更准确,请关闭美颜/滤镜、并确保光线充足均匀。」 |
|
||||
| 字号不随手机宽度缩放 | 原来按 `--u` 缩放的字号改为 375 宽设计基准的固定 CSS px;布局间距和图片仍适应屏宽。既有文字自动放大控制保留。不承诺不同屏幕的物理毫米大小或系统强制辅助缩放完全相同。 |
|
||||
|
||||
## 皱纹数据说明
|
||||
|
||||
12 区:抬头纹(额头纹)、左右法令纹、左右鱼尾纹、左右眼部细纹、左右嘴角纹、眉间纹、左右脸颊纹。
|
||||
|
||||
9 项:皱纹分、严重等级、深纹条数、浅纹条数、标准化长度、标准化深度、像素密度、面积比例、深纹比例。
|
||||
|
||||
数值来自接口原始字段,最多显示四位小数;比例保留接口原值,不擅自转成百分比或补单位。分区深浅纹条数仅在对应指标缺失时使用接口分区计数回退,其余缺失不推算。不对原始数据及评分模型作任何写入。
|
||||
|
||||
新生成报告由后端保存新版文案快照,后台与前端读取同一份内容。已有历史报告继续保留当时的文案和推荐快照,不批量重算、不修改历史记录;展示图片可依据已保存原图与矩形使用新的裁切方式。验收新增皱纹话术请生成新报告,不以旧快照作为新版话术验证依据。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
node h5/test/customer-feedback.js
|
||||
H5_ARTIFACT_DIR=/tmp/juzi-feedback-customer-20261012 node h5/test/customer-feedback.mjs
|
||||
node h5/test/recommendation.js
|
||||
node h5/test/regression.js
|
||||
node h5/test/accounts.js
|
||||
node h5/test/report-persistence.mjs
|
||||
node h5/test/ui-20261009.mjs
|
||||
node h5/test/login-performance.mjs
|
||||
node h5/test/wechat-browser.mjs
|
||||
node h5/test/analyze-timeout.js
|
||||
node h5/test/browser-failures.mjs
|
||||
node h5/test/e2e-browser.mjs
|
||||
```
|
||||
|
||||
专项验证包括真实浏览器触摸事件、三组独立切换、纵滚不误触、横滑不误开放大图、点开放大仍正常、七图裁切及边界容错、12 区真实指标和缺失值、部分增强图缺失、三款产品在 320/375/390/414/480 五种宽度下固定两行且无溢出。专项截图见上述临时目录。
|
||||
|
||||
登录性能复测:150 ms 网络延迟、约 2 Mbps 下载、CPU 四倍降速,冷登录可操作约 1636 ms、静态资源编码后 262618 字节;登录仍不加载人脸引擎、模型或完整字体。该结果是开发机模拟观测,不保证所有手机耗时。微信回归使用模拟授权上游,实际 HTTPS Cookie 和跨设备身份隔离均验证,不代表重新完成一次真人微信授权。
|
||||
|
||||
上述测试全部通过,包含 126 个产品推荐案例、86 条评分真值断言;UI 回归同时刷新了 `design/ui-20261009/verification/` 内的验收截图。已执行 `graphify update .` 更新代码关系图。
|
||||
|
||||
测试使用样例、隔离数据目录或只读演示页,不调用收费 Face++,不修改生产账号或历史报告。确认本地没有近 180 秒进行中的检测后,已重启本地 H5 服务以加载后端新版话术,健康检查正常。黄色按钮色值已确认,无其他待确认项。
|
||||
|
||||
提交仅包含代码、修改说明、代码关系图及验收截图,不包含客户聊天原文和附件。公网部署沿用既有 Git bundle 快进同步方式,不推送远程仓库,不迁移或覆盖 `h5/data`,不修改微信凭据、nginx 或后台访问保护。
|
||||
|
||||
## 公网部署与复测
|
||||
|
||||
2026-10-12 已将功能提交 `d7ae748` 从本机同步至 `ubuntu@1me.xajuzi.com:/home/ubuntu/juzi`,从 `3193a4b` 快进更新。确认近 180 秒无进行中的检测后重启 `juzi-h5.service`;Node 服务与 nginx 均正常,健康检查成功,服务无自动重启。服务器上重新运行专项纯函数、126 个推荐案例、账号测试全部通过。
|
||||
|
||||
通过 `https://1me.xajuzi.com/state/` 完成以下复测:
|
||||
|
||||
- 公网浏览器专项测试全部通过:真实触摸切换、纵向滚动不误切、点开放大、七张人脸裁切图、12 区皱纹文案、三款产品在五种屏宽下固定两行、增强图缺失提示及无脚本异常。截图目录 `/tmp/juzi-online-feedback-d7ae748`。使用只读样例页,不发起收费检测。
|
||||
- HTML、应用脚本、报告文案脚本和两份样式的公网内容与本地逐字节一致,gzip、ETag/304 校验通过。功能版本的全部跟踪文件哈希清单在本地和服务器均为 `a45146c325004eecf5448d394d85ccc507e0e8f60798637829917a42b7c3a519`。
|
||||
- 微信配置仍启用,匿名会话无用户,私有会话接口保持 `no-store`。匿名报告、后台页面和后台接口返回 401;数据、源码和测试目录返回 404。没有发起新的真人微信授权,也未改动账号身份。
|
||||
- 部署前后检测记录均为 105 条,近 180 秒进行中的检测均为 0;不创建生产测试账号、报告,不清理历史数据。
|
||||
- 公网冷登录编码资源为 262640 字节,未提前加载字体、人脸引擎或模型。并发专项测试期间一次可操作耗时 5341 ms;专项完成后,三个全新浏览器上下文分别为 384、1185、299 ms,HTML 首字节分别为 245、737、220 ms。存在网络波动,不将模拟或单次观测作为手机耗时保证。
|
||||
|
||||
本记录随后以文档提交同步到服务器,不需要再次重启应用。远程仓库未推送,仍由傅艳自行 push。
|
||||
@@ -1,12 +1,14 @@
|
||||
# 微信登录接入与公网部署说明
|
||||
|
||||
更新:2026-10-11。当前环境为 Windows 宿主机 + 内网 WSL。
|
||||
更新:2026-10-12。当前环境为 Windows 宿主机 + 内网 WSL。
|
||||
|
||||
线上状态补充:`https://1me.xajuzi.com/state/` 已部署并配置真实微信凭据,服务器检查记录见 `线上微信登录部署记录-2026-10-11.md`。下文的内网缺配置说明针对本机开发环境。真实微信授权尚待客户端验证。
|
||||
线上状态补充:`https://1me.xajuzi.com/state/` 已部署并配置真实微信凭据,服务器检查记录见 `线上微信登录部署记录-2026-10-11.md`。2026-10-12 用户已确认手机微信登录成功,服务端亦记录成功回调。下文的内网缺配置说明针对本机开发环境。
|
||||
|
||||
## 结论与当前阻碍
|
||||
2026-10-12 白名单复测已通过,基础凭据有效。最新修正和真机验证步骤见[微信登录联调记录](微信登录联调-2026-10-12.md)。已有本机用户可从「我的报告」→「切换登录方式」进入微信登录;微信会话失效不会自动恢复为本机账号,两个账号的报告不自动合并。
|
||||
|
||||
已完成微信网页授权代码,但**没有完成真实微信账号联调**。当前未配置 `WECHAT_APP_ID`、`WECHAT_APP_SECRET`、`PUBLIC_BASE_URL`,也没有可验证的公众号网页授权域名配置。内网 `http://192.168.3.25:8080/` 不能作为本次正式授权部署地址。因此本地启用「本机浏览器登录」,不会模拟微信登录成功。
|
||||
## 本机开发环境限制(不代表线上状态)
|
||||
|
||||
本机未配置生产用 `WECHAT_APP_ID`、`WECHAT_APP_SECRET`、`PUBLIC_BASE_URL`。内网 `http://192.168.3.25:8080/` 不能作为本次正式授权部署地址。因此本地启用「本机浏览器登录」,不会模拟微信登录成功。线上真实登录已通过,详见联调记录。
|
||||
|
||||
官方网页授权适用于具有相应权限的认证服务号等账号;需要在公众号后台配置网页授权域名。普通小程序 AppID、开放平台网站应用 AppID 不能直接替代本方案的服务号 AppID。本实现采用 `snsapi_base` 获取 OpenID 做账号关联,不请求头像、昵称。
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 微信登录联调:2026-10-12
|
||||
|
||||
站点:`https://1me.xajuzi.com/state/`;服务器:`ubuntu@1me.xajuzi.com`。
|
||||
|
||||
## 后续真机确认与体验优化
|
||||
|
||||
- 2026-10-12 用户已在手机微信内通过「切换登录方式」完成真实微信登录;生产服务 10:40:31 记录 `login_success`。下文“待真机验证”是此前预检时点的记录,不再代表当前登录状态。跨手机读取仍只有隔离测试覆盖,未声称用户已做跨手机实测。
|
||||
- 新增「登录成功」弹框,说明当前使用微信或本机浏览器;首页右上角、我的报告顶部持续展示登录方式,并可点击切换。恢复有效会话不重复弹框;错误回调、丢失 Cookie 不显示成功。
|
||||
- 登录加载慢的实测原因、优化及验证命令见[登录反馈与加载优化](登录反馈与加载优化-2026-10-12.md)。
|
||||
|
||||
## 白名单与凭据
|
||||
|
||||
- 用户再次确认已将服务器出口 IP `49.233.222.47` 加入公众号 IP 白名单。
|
||||
- 从生产服务正在运行的进程环境读取凭据,在服务器调用微信 `cgi-bin/stable_token`,使用 `force_refresh:false`,未强制刷新已有令牌。
|
||||
- 返回有效 `access_token`,未返回 `40164`;复测剩余有效时间为 7168 秒。已确认该配置的 AppID 与 `wx645d9a7573f1fe9a` 一致,AppID/AppSecret 校验通过。
|
||||
- 密钥继续留在 root 私有文件 `/etc/juzi/wechat.env`(600);服务监听 `127.0.0.1:8080`,站点地址包含 `/state/`。没有将密钥或令牌写入代码、日志、文档或前端。
|
||||
- 基础接口令牌成功不等于真实用户网页授权成功;后者仍要验证公众号网页授权权限、网页授权域名以及真实微信客户端回调。
|
||||
|
||||
## 本轮修正
|
||||
|
||||
1. 「我的报告」增加「切换登录方式」,复用现有登录页。已有本机用户可以进入微信登录;重新勾选协议后才发起授权。只切换所选账号,不自动合并、迁移或删除报告;返回可继续原会话。
|
||||
2. 本地只记录最近一次有效登录的方式(`juzi.auth.kind.v1`),不存 OpenID 或微信令牌。微信会话失效后,不能用遗留本机随机凭据自动登录另一个账号;需显式重新选择登录方式。同一微信重新登录仍对应同一服务端账号。
|
||||
3. 微信返回错误或返回后 Cookie 未保留时,保持登录页并说明原因,不允许用本机自动恢复掩盖失败。处理后移除 URL 的一次性登录结果参数,避免刷新后反复显示旧错误。
|
||||
4. 服务端拒绝微信响应中的无效 OpenID、异常 HTTP 状态等错误响应;网络失败、平台失败、state 校验失败均不签发会话。
|
||||
5. 增加 `[auth/wechat]` 运行事件:`callback_rejected`、`exchange_failed`、`login_success`。仅记录错误种类、微信数值错误码、HTTP 状态和是否新账号;不记录 code/state、Cookie、OpenID、密钥或令牌。
|
||||
|
||||
## 自动验证
|
||||
|
||||
```bash
|
||||
node h5/test/accounts.js
|
||||
node h5/test/wechat-browser.mjs
|
||||
node h5/test/report-persistence.mjs
|
||||
node h5/test/recommendation.js
|
||||
node h5/test/analyze-timeout.js
|
||||
H5_BASE_URL=https://1me.xajuzi.com/state/ node h5/test/wechat-production.mjs
|
||||
```
|
||||
|
||||
- 账号测试覆盖协议、同源校验、state 绑定/一次性使用、跨账号隔离、账号持久化;新增无效 code、空响应、非字符串 OpenID、HTTP 500 和网络错误均拒绝登录的验证。
|
||||
- 真实 HTTPS + `/state/` 浏览器测试仅模拟微信上游,覆盖本机→微信→本机显式切换、原报告保留、同一微信跨两个独立浏览器读取同一报告、另一微信无法读取、Cookie 丢失不串号、失败提示不被本机自动恢复掩盖。
|
||||
- 报告保存与回看、前后台一致性、126 个推荐用例及检测超时回归通过;不调用收费 Face++。
|
||||
- 公网预检脚本只使用刻意无效的 code,验证授权 URL/绑定 Cookie、真实微信拒绝无效换码、state 防重放、同源与协议校验、匿名报告拒绝及后台保护;不创建微信身份,不表示真人授权成功。
|
||||
- 部署后公网预检复测通过,新版 HTML/脚本已实际提供;本地和线上代码校验一致,服务与 nginx 均正常。生产日志中的 `40029` 来自上述刻意无效 code 的负向测试,并非一次真人登录失败;尚未收到本轮真人授权成功的回调。
|
||||
|
||||
## 真实微信验收与排查
|
||||
|
||||
目前尚不能将模拟授权和基础凭据通过视为真人登录已完成。服务器与无头浏览器不能替用户产生真实微信授权 code,也不在生产环境伪造微信身份。
|
||||
|
||||
1. 手机微信内打开 `https://1me.xajuzi.com/state/`,不要选择「在浏览器打开」。
|
||||
2. 若已有本机登录,进入「我的报告」→「切换登录方式」;勾选协议,点击「微信登录」。
|
||||
3. 登录后进入「我的报告」,身份提示应为「同一微信账号可跨手机查看」。首次使用微信身份时列表可能为空:本机账号历史报告不会自动合并。
|
||||
4. 同一微信重新登录或换另一台手机查看,账号和已保存微信报告应一致;无需为了验证登录再次调用收费检测。
|
||||
5. 如微信自身页面提示域名或 scope 权限错误,请核对对应 AppID 公众号的「网页授权域名」是否为 `1me.xajuzi.com`、是否有网页授权权限;这些设置不能由域名校验文件存在或基础接口令牌通过来推断。
|
||||
|
||||
服务端排查命令:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u juzi-h5.service --since '10 minutes ago' --no-pager | grep -F '[auth/wechat]'
|
||||
```
|
||||
|
||||
只需反馈页面是否进入首页/报告列表,或错误页面截图。不要传授权 code、Cookie、OpenID、AppSecret 或接口令牌。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 登录反馈与加载优化(2026-10-12)
|
||||
|
||||
## 体验变化
|
||||
|
||||
- 显式本机登录、微信授权成功且会话确认后,显示「登录成功」弹框,说明登录方式和报告访问范围。
|
||||
- 首页右上角和「我的报告」顶部显示「微信已登录」或「本机浏览器已登录」;标识可点击切换。依据服务端会话的 `user.kind`,不根据浏览器是否处于微信来猜测。
|
||||
- 刷新后恢复有效会话不重复弹框。授权失败、Cookie 丢失不会显示成功;不合并账号、不删除缓存身份或历史报告。
|
||||
- 弹框支持确认按钮、Esc、键盘焦点约束,关闭后恢复页面交互。
|
||||
|
||||
## 慢的原因与处理
|
||||
|
||||
优化前,在开发机浏览器对公网页面的一次无缓存测量:HTML 首字节约 244 ms,登录约 5510 ms 可操作;同步排在应用前的 face-api.js 853487 字节、下载约 4319 ms。两份完整字体分别 4858624 和 5034212 字节,下载约 14.5/15.6 秒。额外还预加载了已不使用的旧首页背景(约 590 KB)。认证状态接口独立 curl 测量约 431 ms,不能将本次首屏等待主要归因为微信接口;手机网络、DNS/TLS 和真实授权往返仍会影响实际时间。
|
||||
|
||||
处理:
|
||||
|
||||
1. 人脸引擎改为进入拍照、实际可用相机或系统相机降级时才请求;加载失败可重试,20 秒超时后保留手动兜底。模型/自动抓拍算法不变。
|
||||
2. 登录、首页、报告列表使用系统字体;进入档案/拍照/报告时再注册原始 MiSans 字体。字体原文件保持不变。官方许可不允许改编或二次开发,因此没有对子集化作授权推断。[官方许可](https://hyperos.mi.com/font/zh/download/)。
|
||||
3. 保留 PSD 原导出和 JPEG,新增 WebP 分发背景(同分辨率),移除旧背景预加载,优先加载登录背景。生成方式:`python tools/prepare_h5_assets.py`。
|
||||
4. Node 静态资源增加 gzip 和 ETag/304,继续 `Cache-Control: no-cache` 及时校验新版本;仅压缩静态公共文本,不缓存/压缩账号、报告等私有 API 响应。未改 nginx 后台保护配置。
|
||||
5. 微信登录去掉多余的配置查询往返,直接请求受协议及同源校验保护的授权接口;禁用环境仍由服务端明确拒绝。仅登录鉴权请求增加 12 秒超时,失败后不清除身份凭据;不缩短含照片报告的下载等待时间。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
H5_ARTIFACT_DIR=/tmp/juzi-login-20261012 node h5/test/login-performance.mjs
|
||||
node h5/test/wechat-browser.mjs
|
||||
node h5/test/accounts.js
|
||||
node h5/test/report-persistence.mjs
|
||||
node h5/test/recommendation.js
|
||||
node h5/test/analyze-timeout.js
|
||||
H5_ARTIFACT_DIR=/tmp/juzi-login-ui-20261012 node h5/test/ui-20261009.mjs
|
||||
node h5/test/browser-failures.mjs
|
||||
node h5/test/e2e-browser.mjs
|
||||
```
|
||||
|
||||
隔离性能测试:150 ms 网络延迟、约 2 Mbps 下载、CPU 四倍降速,冷登录可操作约 1598 ms,静态资源编码后共 259547 字节;登录、首页不请求完整字体、人脸引擎或模型。另验证 gzip 解码一致、304、拒绝 `gzip;q=0`、私有 API `no-store`、超时保留身份、320/375/390/480 宽度标识不遮挡文案。拍照时引擎失败重试后,真实模型能返回 68 点人脸特征。
|
||||
|
||||
以上是可重复的开发机模拟结果,不等于每部手机的耗时保证,也不能直接与未限速的公网测量作同条件性能比例比较。测试不调用收费 Face++。
|
||||
|
||||
## 公网部署后复测
|
||||
|
||||
代码 `d2931a4` 已在本机和生产同步,生产 `juzi-h5` 与 nginx 均正常。部署前确认近 180 秒无进行中的检测;仅更新跟踪代码,未迁移、清理或覆盖 `h5/data`,没有改变微信凭据或域名设置。
|
||||
|
||||
同一开发机、无浏览器缓存的公网复测:HTML 首字节约 222 ms,登录可操作约 311 ms,资源编码后共 259678 字节;未请求完整字体或人脸引擎。随后刷新页面,资源网络传输约 7523 字节(其余走校验缓存)。这是一次观测值,不能保证所有手机达到该时间。
|
||||
|
||||
线上 gzip 与 ETag/304 已验证;健康、登录配置接口正常,匿名报告与后台均拒绝访问,私有数据目录不可公开读取。复测只读,不创建生产测试账号、报告,也不发起新的微信授权或收费检测。
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
时间:2026-10-11(北京时间)。服务器:`ubuntu@1me.xajuzi.com`,页面:`https://1me.xajuzi.com/state/`。
|
||||
|
||||
2026-10-12 最新状态:白名单已生效,生产服务器成功获取微信基础接口令牌,AppID/AppSecret 校验通过;新增账号切换、会话失效防串号和回调诊断,详见[本轮联调记录](微信登录联调-2026-10-12.md)。下文 `40164` 是历史结果,真实微信客户端授权仍待验证。
|
||||
|
||||
## 后续更新:已配置真实凭据
|
||||
|
||||
用户提供凭据后,已将其写入 `/etc/juzi/wechat.env` 并重启服务。该文件仅 root 可读写(600),目录为 700;临时凭据文件已移除,未写入代码库或前端。
|
||||
|
||||
@@ -3,25 +3,25 @@
|
||||
"1": "线上闭环测试(不含真实微信授权)",
|
||||
"2": "app.js",
|
||||
"3": "skin-score.js",
|
||||
"4": "i",
|
||||
"5": "n",
|
||||
"6": ".runAndExtendWithFaceDetections",
|
||||
"7": "Box",
|
||||
"4": "k",
|
||||
"5": "f",
|
||||
"6": ".runAndExtendWithFaceDetection",
|
||||
"7": ".run",
|
||||
"8": "admin.js",
|
||||
"9": "__awaiter",
|
||||
"10": "AgeGenderNet",
|
||||
"10": "SsdMobilenetv1",
|
||||
"11": "lib/accounts.js",
|
||||
"12": "server.js",
|
||||
"13": "files",
|
||||
"14": "FaceLandmarks68",
|
||||
"15": ".runAndExtendWithFaceDetection",
|
||||
"14": "AgeGenderNet",
|
||||
"15": "extractFaceTensors",
|
||||
"16": "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范",
|
||||
"17": "extractFaces",
|
||||
"18": "1'me 在线肌肤测试 H5",
|
||||
"19": "head-pose.js",
|
||||
"20": "build_fixtures.py",
|
||||
"21": "disposeUnusedWeightTensors",
|
||||
"22": "ComputeAllFaceDescriptorsTask",
|
||||
"22": ".loadFromDisk",
|
||||
"23": "facepp-skinanalyze(旷视皮肤分析专业版调用技能)",
|
||||
"24": "巨子测肤 · 1'me 语音播报内容设计文档 V1.0",
|
||||
"25": "mobile-select.iife.min.js",
|
||||
@@ -34,15 +34,15 @@
|
||||
"32": "FaceMatcher",
|
||||
"33": "build-fixture.py",
|
||||
"34": "smoke-test.js",
|
||||
"35": "DetectSingleFaceLandmarksTask",
|
||||
"35": "customer-feedback.js",
|
||||
"36": "ao",
|
||||
"37": "UI 外发版 10.09 图片资源",
|
||||
"38": "FaceLandmark68Net",
|
||||
"39": "UI交互界面调整",
|
||||
"40": ".predictAgeAndGender",
|
||||
"41": "test/accounts.js",
|
||||
"41": "NeuralNetwork",
|
||||
"42": "export_psd_assets.py",
|
||||
"43": ".loadFromDisk",
|
||||
"43": "normalize",
|
||||
"44": "start-local.sh",
|
||||
"45": "PredictAllAgeAndGenderWithFaceAlignmentTask",
|
||||
"46": "PredictAllFaceExpressionsWithFaceAlignmentTask",
|
||||
@@ -54,32 +54,48 @@
|
||||
"52": "region-data.js",
|
||||
"53": "护理建议(全量文本,按需求摘抄)",
|
||||
"54": "UI 对齐 10.09 设计稿:调研与实施计划",
|
||||
"55": "extractFaceTensors",
|
||||
"55": "i",
|
||||
"56": "report-ui.js",
|
||||
"57": "regression.js",
|
||||
"58": ".dispose",
|
||||
"59": "call_skinanalyze.sh",
|
||||
"60": "report-fixture.js",
|
||||
"61": "NeuralNetwork",
|
||||
"61": ".extractWeights",
|
||||
"62": "提交前 Review(2026-10-11)",
|
||||
"63": "e2e-browser.mjs",
|
||||
"64": "account-client.js",
|
||||
"65": "k",
|
||||
"66": "wechat-browser.mjs",
|
||||
"65": "微信登录接入与公网部署说明",
|
||||
"66": "TinyYolov2Base",
|
||||
"67": "ui-20261009.mjs",
|
||||
"68": "analyze-timeout.js",
|
||||
"69": "misans/README.md",
|
||||
"70": ".draw",
|
||||
"71": "production-loop.mjs",
|
||||
"71": "initialize",
|
||||
"72": "build_report_copy.py",
|
||||
"73": "camera-geometry.js",
|
||||
"74": "report-copy.js",
|
||||
"75": "prepare_h5_assets.py",
|
||||
"76": "TinyYolov2",
|
||||
"77": "drawFaceExpressions",
|
||||
"78": ".toString",
|
||||
"79": "isValidNumber",
|
||||
"76": ".detect",
|
||||
"77": ".runAndExtendWithFaceDetections",
|
||||
"78": "n",
|
||||
"79": "登录反馈与加载优化(2026-10-12)",
|
||||
"80": "线上微信登录部署记录",
|
||||
"81": "ComputeSingleFaceDescriptorTask",
|
||||
"82": "PredictAllFaceExpressionsTask"
|
||||
"81": "wechat-production.mjs",
|
||||
"82": "test/accounts.js",
|
||||
"83": "10.09 新版 H5 实施与验收",
|
||||
"84": "微信登录联调:2026-10-12",
|
||||
"85": "FaceLandmarks",
|
||||
"86": "isValidNumber",
|
||||
"87": "产品推荐规则:肤龄基准与边界调整",
|
||||
"88": "gn",
|
||||
"89": "傅艳NEW与项思炼的会话 2026年10月12日.md",
|
||||
"90": "customer-feedback.mjs",
|
||||
"91": "FaceDetection",
|
||||
"92": "客户反馈修改(2026-10-12)",
|
||||
"93": "TinyFaceFeatureExtractor",
|
||||
"94": "DetectSingleFaceLandmarksTask",
|
||||
"95": "PredictAllFaceExpressionsTask",
|
||||
"97": "PredictSingleAgeAndGenderWithFaceAlignmentTask",
|
||||
"98": "PredictSingleFaceExpressionsWithFaceAlignmentTask",
|
||||
"99": "PredictSingleFaceExpressionsTask"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"0": "73743c18a4a7fca9", "1": "cb6ea50bd4e4a5ca", "2": "9cf893b45c314643", "3": "1f737ca8edb978f2", "4": "55697fad20675b78", "5": "be544c1e1f2ddb64", "6": "46204fb2e4d50c53", "7": "fcfa9f1898866dc8", "8": "1ecf47531e1d507b", "9": "2e6b02a61343f092", "10": "563202faef7b3421", "11": "edc9f95405704875", "12": "d3e946c5907aa577", "13": "7e3b558a587a4ebe", "14": "15606ce5d8eec23a", "15": "5a9014c4a7f87699", "16": "95add6a975c0cb72", "17": "2405e1edb003a83e", "18": "300b45cf926eb704", "19": "0a373ad2197fd1a6", "20": "aecf2fce44ba49c5", "21": "d3cd661c24264482", "22": "3dd68cce72af5314", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "46e8db3e6e3f37ea", "26": "6b6c40a418e18aed", "27": "eab427ce9a5219e9", "28": "00f6d1f8841af9c4", "29": "c608f1c6492e1cb1", "30": "73c13793fca09f56", "31": "fbbcfc059751cc6a", "32": "f6bcf56c4589316b", "33": "75f750c5b7d9f63d", "34": "f024ff7942935690", "35": "23f1d406f6c779ae", "36": "8e5e97153c881130", "37": "cb8f26249485324a", "38": "e2d12f8289f83819", "39": "fdd95375b4a5ddb3", "40": "a2b13160223b5a98", "41": "4bc370be8afd7db1", "42": "ffd960136fde00ad", "43": "00dab8704da757dc", "44": "852c1bb2446edd9c", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "47": "e594ecaa8e7d1538", "48": "e042d7fe7351bcb9", "49": "1f976711cda158dc", "50": "bbb2b57d72922b16", "51": "eb6a182204867275", "52": "2ec3c8c088568ba9", "53": "68c45056378341a7", "54": "c58d41e8e580fdc6", "55": "0d376c2f52a725e0", "56": "91d7ab49459e0b92", "57": "f2a40b5f1b29f669", "58": "675690fb61ef5bc7", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "18ffa2c1feddfd10", "62": "1366ae24c7e5897d", "63": "c256b43f75d7809e", "64": "86101547c05f96b9", "65": "b7874aef5ca3f2ee", "66": "2b44d0efd8eb25c5", "67": "3e126d8deaf6e200", "68": "6b91f3679541de79", "69": "e511846eeba03076", "70": "bf75bfc99d4f9e34", "71": "4195478d5c528371", "72": "c84f196a7bd35861", "73": "2b887bf601ccfb9f", "74": "ae2a154311e1f53a", "75": "69fdd23278518884", "76": "29790cf710874dd0", "77": "a0bc88eb14b70170", "78": "42c9bc548e269a96", "79": "1c27e5f448dc4f9c", "80": "059d6ba8ca7ec3a0", "81": "7ac475ea9bc280f8", "82": "e800d832c255899a"}
|
||||
{"0": "04a103a1199997a0", "1": "cb6ea50bd4e4a5ca", "2": "b663df3416204401", "3": "1f737ca8edb978f2", "4": "377a4f12232bbcdc", "5": "f074a081a584c171", "6": "5a9014c4a7f87699", "7": "6cda159cdef28f96", "8": "1ecf47531e1d507b", "9": "a16154b22fd8e2b3", "10": "d0c91dec45eac5ad", "11": "ae1e487145595eb9", "12": "53594b653ad48743", "13": "7e3b558a587a4ebe", "14": "d0f5c6e307b85cb1", "15": "0e3ea8dc60f6e10f", "16": "95add6a975c0cb72", "17": "0d185c03b2040000", "18": "38bb0b185ceed116", "19": "0a373ad2197fd1a6", "20": "aecf2fce44ba49c5", "21": "5d94cf57d5d1a6fb", "22": "be2f6d3dcfc9aabd", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "46e8db3e6e3f37ea", "26": "83b0fd3b14363e4d", "27": "eab427ce9a5219e9", "28": "b992b3cc0a0fa9be", "29": "c608f1c6492e1cb1", "30": "73c13793fca09f56", "31": "fbbcfc059751cc6a", "32": "f6bcf56c4589316b", "33": "75f750c5b7d9f63d", "34": "f024ff7942935690", "35": "c4fa292c3dfb0d0e", "36": "a3859fc25c864a81", "37": "cb8f26249485324a", "38": "9a25b1230b5fe649", "39": "fdd95375b4a5ddb3", "40": "a2b13160223b5a98", "41": "d96493a5f110bd4c", "42": "ffd960136fde00ad", "43": "57726d26805c136b", "44": "852c1bb2446edd9c", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "47": "e594ecaa8e7d1538", "48": "e042d7fe7351bcb9", "49": "1f976711cda158dc", "50": "bbb2b57d72922b16", "51": "eb6a182204867275", "52": "2ec3c8c088568ba9", "53": "68c45056378341a7", "54": "c58d41e8e580fdc6", "55": "da0f3cd0568e4331", "56": "8c9a3fc5edff7e44", "57": "f2a40b5f1b29f669", "58": "7471c1ff7f7420f0", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "8394d2bc925a408a", "62": "1366ae24c7e5897d", "63": "c256b43f75d7809e", "64": "ce0e68dab611f022", "65": "74ae017de0ffd36b", "66": "6b96cb56e55af88c", "67": "3e126d8deaf6e200", "68": "6b91f3679541de79", "69": "e511846eeba03076", "70": "bf75bfc99d4f9e34", "71": "eb3733e409dc7411", "72": "c84f196a7bd35861", "73": "2b887bf601ccfb9f", "74": "ae2a154311e1f53a", "75": "69fdd23278518884", "76": "c7c04904801db5b4", "77": "429cd62f9137881c", "78": "1ce63692525fa555", "79": "c6cfdfe181232ecc", "80": "315325cde1352e98", "81": "37c559741825be74", "82": "4bc370be8afd7db1", "83": "1284897282eb47a3", "84": "d8bb296741d3d05c", "85": "4c7d02c634649745", "86": "1c27e5f448dc4f9c", "87": "e5fa7717989eda80", "88": "a3e6d368559999ec", "89": "9e3363b80eeceeea", "90": "dd1c246e19a2d032", "91": "287d60115d9acda8", "92": "9509aca4f2e8a012", "93": "3befc14a32607829", "94": "23f1d406f6c779ae", "95": "e800d832c255899a", "97": "01f68dfc34a29d32", "98": "8c471c7baeaaca54", "99": "cb5ac3401283c378"}
|
||||
@@ -1,16 +1,16 @@
|
||||
# Graph Report - juzi (2026-10-12)
|
||||
|
||||
## Corpus Check
|
||||
- 66 files · ~4,184,136 words
|
||||
- 74 files · ~4,387,581 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 1188 nodes · 2566 edges · 83 communities (66 shown, 17 thin omitted)
|
||||
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 179 edges (avg confidence: 0.53)
|
||||
- 1255 nodes · 2651 edges · 99 communities (78 shown, 21 thin omitted)
|
||||
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 181 edges (avg confidence: 0.53)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Graph Freshness
|
||||
- Built from commit: `7518dc23`
|
||||
- Built from commit: `3193a4b6`
|
||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||
- Run `graphify update .` after code changes (no API cost).
|
||||
|
||||
@@ -19,25 +19,25 @@
|
||||
- 线上闭环测试(不含真实微信授权)
|
||||
- app.js
|
||||
- skin-score.js
|
||||
- i
|
||||
- n
|
||||
- .runAndExtendWithFaceDetections
|
||||
- Box
|
||||
- k
|
||||
- f
|
||||
- .runAndExtendWithFaceDetection
|
||||
- .run
|
||||
- admin.js
|
||||
- __awaiter
|
||||
- AgeGenderNet
|
||||
- SsdMobilenetv1
|
||||
- lib/accounts.js
|
||||
- server.js
|
||||
- files
|
||||
- FaceLandmarks68
|
||||
- .runAndExtendWithFaceDetection
|
||||
- AgeGenderNet
|
||||
- extractFaceTensors
|
||||
- 巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范
|
||||
- extractFaces
|
||||
- 1'me 在线肌肤测试 H5
|
||||
- head-pose.js
|
||||
- build_fixtures.py
|
||||
- disposeUnusedWeightTensors
|
||||
- ComputeAllFaceDescriptorsTask
|
||||
- .loadFromDisk
|
||||
- facepp-skinanalyze(旷视皮肤分析专业版调用技能)
|
||||
- 巨子测肤 · 1'me 语音播报内容设计文档 V1.0
|
||||
- mobile-select.iife.min.js
|
||||
@@ -49,15 +49,15 @@
|
||||
- browser-failures.mjs
|
||||
- FaceMatcher
|
||||
- build-fixture.py
|
||||
- DetectSingleFaceLandmarksTask
|
||||
- customer-feedback.js
|
||||
- ao
|
||||
- UI 外发版 10.09 图片资源
|
||||
- FaceLandmark68Net
|
||||
- UI交互界面调整
|
||||
- .predictAgeAndGender
|
||||
- test/accounts.js
|
||||
- NeuralNetwork
|
||||
- export_psd_assets.py
|
||||
- .loadFromDisk
|
||||
- normalize
|
||||
- start-local.sh
|
||||
- PredictAllAgeAndGenderWithFaceAlignmentTask
|
||||
- PredictAllFaceExpressionsWithFaceAlignmentTask
|
||||
@@ -67,30 +67,45 @@
|
||||
- call_skinanalyze_3view.sh
|
||||
- 护理建议(全量文本,按需求摘抄)
|
||||
- UI 对齐 10.09 设计稿:调研与实施计划
|
||||
- extractFaceTensors
|
||||
- i
|
||||
- report-ui.js
|
||||
- regression.js
|
||||
- .dispose
|
||||
- call_skinanalyze.sh
|
||||
- NeuralNetwork
|
||||
- .extractWeights
|
||||
- 提交前 Review(2026-10-11)
|
||||
- e2e-browser.mjs
|
||||
- account-client.js
|
||||
- k
|
||||
- wechat-browser.mjs
|
||||
- 微信登录接入与公网部署说明
|
||||
- TinyYolov2Base
|
||||
- ui-20261009.mjs
|
||||
- analyze-timeout.js
|
||||
- misans/README.md
|
||||
- .draw
|
||||
- production-loop.mjs
|
||||
- initialize
|
||||
- build_report_copy.py
|
||||
- TinyYolov2
|
||||
- drawFaceExpressions
|
||||
- .toString
|
||||
- isValidNumber
|
||||
- .detect
|
||||
- .runAndExtendWithFaceDetections
|
||||
- n
|
||||
- 登录反馈与加载优化(2026-10-12)
|
||||
- 线上微信登录部署记录
|
||||
- ComputeSingleFaceDescriptorTask
|
||||
- wechat-production.mjs
|
||||
- test/accounts.js
|
||||
- 10.09 新版 H5 实施与验收
|
||||
- 微信登录联调:2026-10-12
|
||||
- FaceLandmarks
|
||||
- isValidNumber
|
||||
- 产品推荐规则:肤龄基准与边界调整
|
||||
- gn
|
||||
- 傅艳NEW与项思炼的会话 2026年10月12日.md
|
||||
- FaceDetection
|
||||
- 客户反馈修改(2026-10-12)
|
||||
- TinyFaceFeatureExtractor
|
||||
- DetectSingleFaceLandmarksTask
|
||||
- PredictAllFaceExpressionsTask
|
||||
- PredictSingleAgeAndGenderWithFaceAlignmentTask
|
||||
- PredictSingleFaceExpressionsWithFaceAlignmentTask
|
||||
- PredictSingleFaceExpressionsTask
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `__awaiter()` - 59 edges
|
||||
@@ -113,85 +128,89 @@
|
||||
h5/js/app.js → h5/vendor/faceapi/face-api.js
|
||||
- `vizCompose()` --indirect_call--> `rh()` [INFERRED]
|
||||
h5/js/app.js → h5/vendor/faceapi/face-api.js
|
||||
- `vizCompose()` --indirect_call--> `w()` [INFERRED]
|
||||
- `vizCompose()` --indirect_call--> `scale()` [INFERRED]
|
||||
h5/js/app.js → h5/vendor/faceapi/face-api.js
|
||||
|
||||
## Import Cycles
|
||||
- None detected.
|
||||
|
||||
## Communities (83 total, 17 thin omitted)
|
||||
## Communities (99 total, 21 thin omitted)
|
||||
|
||||
### Community 0 - "face-api.js"
|
||||
Cohesion: 0.02
|
||||
Nodes (35): cn(), computeReshapedDimensions(), createBrowserEnv(), createFileSystem(), createNodejsEnv(), dr(), en(), FaceDetection() (+27 more)
|
||||
Cohesion: 0.03
|
||||
Nodes (20): cn(), dr(), en(), ff(), ge(), ia(), na(), nt() (+12 more)
|
||||
|
||||
### Community 1 - "线上闭环测试(不含真实微信授权)"
|
||||
Cohesion: 0.25
|
||||
Nodes (7): 发现并修复, 可复现命令, 已通过的检查, 线上闭环测试(不含真实微信授权), 结论, 话术及仓库同步, 验证边界
|
||||
|
||||
### Community 2 - "app.js"
|
||||
Cohesion: 0.06
|
||||
Nodes (74): applyMirror(), armNudge(), beginDetect(), bindBackdropClose(), busyWithFlow(), captureFromVideo(), checkStill(), closeOverlay() (+66 more)
|
||||
Cohesion: 0.05
|
||||
Nodes (80): applyMirror(), armNudge(), beginDetect(), bindBackdropClose(), busyWithFlow(), captureFromVideo(), checkStill(), closeLoginSuccess() (+72 more)
|
||||
|
||||
### Community 3 - "skin-score.js"
|
||||
Cohesion: 0.12
|
||||
Nodes (45): acneGrade(), buildViewModel(), calcAcne(), calcContour(), calcSens(), calcSmooth(), calcTone(), calcWrinkles() (+37 more)
|
||||
|
||||
### Community 4 - "i"
|
||||
Cohesion: 0.09
|
||||
Nodes (42): a(), bf(), cf(), d(), da(), dn(), DrawBox(), drawContour() (+34 more)
|
||||
### Community 4 - "k"
|
||||
Cohesion: 0.13
|
||||
Nodes (19): b(), ba(), ca(), cl(), da(), fa(), k(), ll() (+11 more)
|
||||
|
||||
### Community 5 - "n"
|
||||
Cohesion: 0.11
|
||||
Nodes (37): ae(), b(), be(), bi(), ce(), de(), e(), Ef() (+29 more)
|
||||
### Community 5 - "f"
|
||||
Cohesion: 0.21
|
||||
Nodes (23): a(), cf(), d(), drawContour(), dt(), ea(), f(), g() (+15 more)
|
||||
|
||||
### Community 7 - "Box"
|
||||
Cohesion: 0.10
|
||||
Nodes (11): Box(), extendWithFaceDescriptor(), extendWithFaceDetection(), extendWithFaceLandmarks(), FaceLandmarks(), FaceRecognitionNet(), isWithFaceDetection(), isWithFaceLandmarks() (+3 more)
|
||||
### Community 6 - ".runAndExtendWithFaceDetection"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): allFacesMtcnn(), allFacesSsdMobilenetv1(), allFacesTinyYolov2(), detectAllFaces(), DetectSingleFaceTask()
|
||||
|
||||
### Community 7 - ".run"
|
||||
Cohesion: 0.22
|
||||
Nodes (3): ComputeAllFaceDescriptorsTask(), ComputeSingleFaceDescriptorTask(), extendWithFaceDescriptor()
|
||||
|
||||
### Community 8 - "admin.js"
|
||||
Cohesion: 0.21
|
||||
Nodes (28): ageText(), api(), bind(), closeDetail(), collectFilters(), dimsHtml(), doRetry(), dSec() (+20 more)
|
||||
|
||||
### Community 9 - "__awaiter"
|
||||
Cohesion: 0.14
|
||||
Cohesion: 0.15
|
||||
Nodes (13): __awaiter(), extendWithFaceExpressions(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult(), fetchImage(), fetchJson(), fetchNetWeights(), fetchOrThrow() (+5 more)
|
||||
|
||||
### Community 10 - "AgeGenderNet"
|
||||
Cohesion: 0.05
|
||||
Nodes (31): AgeGenderNet(), conv(), conv$1(), convDown(), convLayer(), convLayer$1(), convNoRelu(), convWithBatchNorm() (+23 more)
|
||||
### Community 10 - "SsdMobilenetv1"
|
||||
Cohesion: 0.17
|
||||
Nodes (10): boxPredictionLayer(), decodeBoxesLayer(), depthwiseConvLayer(), getCenterCoordinatesAndSizesLayer(), getStridesForLayerIdx(), mobileNetV1(), outputLayer(), pointwiseConvLayer() (+2 more)
|
||||
|
||||
### Community 11 - "lib/accounts.js"
|
||||
Cohesion: 0.17
|
||||
Nodes (19): atomic(), body(), cookie(), cookies(), crypto, fs, json(), login() (+11 more)
|
||||
Cohesion: 0.08
|
||||
Nodes (31): atomic(), body(), cookie(), cookies(), crypto, fs, json(), login() (+23 more)
|
||||
|
||||
### Community 12 - "server.js"
|
||||
Cohesion: 0.08
|
||||
Nodes (49): accounts, appendRecord(), callFacepp(), CRED, csvCell(), DATA_DIR, displaySkinAge(), dumpRaw() (+41 more)
|
||||
Nodes (51): accounts, appendRecord(), callFacepp(), CRED, crypto, csvCell(), DATA_DIR, displaySkinAge() (+43 more)
|
||||
|
||||
### Community 13 - "files"
|
||||
Cohesion: 0.12
|
||||
Nodes (15): files, analyze-fail, analyzing, countdown-321, hint-far, hint-front, hint-hold, hint-near (+7 more)
|
||||
|
||||
### Community 14 - "FaceLandmarks68"
|
||||
Cohesion: 0.19
|
||||
Nodes (3): FaceLandmarks5(), FaceLandmarks68(), getCenterPoint()
|
||||
### Community 14 - "AgeGenderNet"
|
||||
Cohesion: 0.12
|
||||
Nodes (5): AgeGenderNet(), extractParams$1(), FaceExpressionNet(), FaceLandmark68TinyNet(), FaceProcessor()
|
||||
|
||||
### Community 15 - ".runAndExtendWithFaceDetection"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): allFacesMtcnn(), allFacesSsdMobilenetv1(), allFacesTinyYolov2(), detectAllFaces(), DetectSingleFaceTask()
|
||||
### Community 15 - "extractFaceTensors"
|
||||
Cohesion: 0.25
|
||||
Nodes (3): DetectAllFaceLandmarksTask(), extractFaceTensors(), isTensor3D()
|
||||
|
||||
### Community 16 - "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范"
|
||||
Cohesion: 0.15
|
||||
Nodes (12): 1. 基准与尺寸换算, 2. 色彩规范, 3. 字体规范, 4. 组件切图(assets/svg/), 5. 图片素材(assets/images/,共 15 张), 6. 页面清单(design/ 下有整屏 PNG 预览), 7. 目录结构, 8. 还原建议 (+4 more)
|
||||
|
||||
### Community 17 - "extractFaces"
|
||||
Cohesion: 0.19
|
||||
Nodes (19): awaitMediaLoaded(), bufferToImage(), createCanvas(), createCanvasFromMedia(), extractFaces(), extractImagePatches(), getContext2dOrThrow(), getEnv() (+11 more)
|
||||
Cohesion: 0.14
|
||||
Nodes (19): awaitMediaLoaded(), Box(), bufferToImage(), createCanvas(), createCanvasFromMedia(), extractFaces(), extractImagePatches(), getContext2dOrThrow() (+11 more)
|
||||
|
||||
### Community 18 - "1'me 在线肌肤测试 H5"
|
||||
Cohesion: 0.05
|
||||
Nodes (33): 10.09 新版 H5 实施与验收, 按确认项实现, 文档未定义部分的实施口径, 素材再生成, 验证命令, 产品推荐规则:肤龄基准与边界调整, 决策顺序, 后台与前端一致性 (+25 more)
|
||||
Cohesion: 0.13
|
||||
Nodes (15): 10.09 更新, 1'me 在线肌肤测试 H5, Admin API(统一返回 `{ok:false,message}` 错误), Windows 本机部署(提测用), 后台查看(admin.html), 密钥配置, 已知占位清单, 技术说明 (+7 more)
|
||||
|
||||
### Community 19 - "head-pose.js"
|
||||
Cohesion: 0.33
|
||||
@@ -202,8 +221,12 @@ Cohesion: 0.40
|
||||
Nodes (9): build_case1(), build_case2(), build_truth(), col(), dump(), main(), parse_value(), set_path() (+1 more)
|
||||
|
||||
### Community 21 - "disposeUnusedWeightTensors"
|
||||
Cohesion: 0.06
|
||||
Nodes (33): boxPredictionLayer(), decodeBoxesLayer(), depthwiseConvLayer(), disposeUnusedWeightTensors(), extractorsFactory$3(), extractorsFactory$5(), extractorsFactory$7(), extractorsFactory$9() (+25 more)
|
||||
Cohesion: 0.09
|
||||
Nodes (23): disposeUnusedWeightTensors(), extractorsFactory$3(), extractorsFactory$5(), extractorsFactory$7(), extractorsFactory$9(), extractParamsFromWeigthMap(), extractParamsFromWeigthMap$1(), extractParamsFromWeigthMap$2() (+15 more)
|
||||
|
||||
### Community 22 - ".loadFromDisk"
|
||||
Cohesion: 0.20
|
||||
Nodes (5): ComposableTask(), getModelUris(), Gf(), Kf(), TinyFaceDetector()
|
||||
|
||||
### Community 23 - "facepp-skinanalyze(旷视皮肤分析专业版调用技能)"
|
||||
Cohesion: 0.20
|
||||
@@ -218,16 +241,16 @@ Cohesion: 0.20
|
||||
Nodes (16): _arrayLikeToArray(), _arrayWithHoles(), _arrayWithoutHoles(), _classCallCheck(), _createClass(), _defineProperties(), h(), _iterableToArray() (+8 more)
|
||||
|
||||
### Community 26 - "wi"
|
||||
Cohesion: 0.20
|
||||
Nodes (12): Ci(), Ei(), Fi(), Ii(), ki(), pe(), Qt(), ri() (+4 more)
|
||||
Cohesion: 0.28
|
||||
Nodes (9): Ci(), Ei(), Fi(), Ii(), ki(), pe(), ri(), wi() (+1 more)
|
||||
|
||||
### Community 27 - "gen_voice.py"
|
||||
Cohesion: 0.30
|
||||
Nodes (11): ndarray, Path, gen_countdown(), main(), normalize_clip(), 倒计时单独按字生成再拼接,避免「三、二、一」整句被读成播报腔、与提示音气质漂移。 与提示音共用同一 speaker + instruct;句间留约…, 裁掉过长首尾静音,保留 ≤150ms 边沿,避免提示显迟钝。, 响度归一 −16 LUFS + 转 MP3 48k CBR mono 22050Hz。 (+3 more)
|
||||
|
||||
### Community 28 - "C"
|
||||
Cohesion: 0.08
|
||||
Nodes (33): bn(), Bo(), C(), cd(), cl(), co(), eo(), fn() (+25 more)
|
||||
Cohesion: 0.14
|
||||
Nodes (22): bf(), Bo(), C(), cd(), co(), eo(), fo(), Ft() (+14 more)
|
||||
|
||||
### Community 29 - "face-api.js 本地化依赖(h5/vendor/faceapi/)"
|
||||
Cohesion: 0.29
|
||||
@@ -241,18 +264,18 @@ Nodes (5): assets, document_size, layers, pages, source
|
||||
Cohesion: 0.60
|
||||
Nodes (4): main(), 递归缩放 result 内全部坐标:{left,top,width,height} 矩形与 {x,y} 点,与原数据同为整数。, scale_coords(), to_data_url()
|
||||
|
||||
### Community 35 - "customer-feedback.js"
|
||||
Cohesion: 0.11
|
||||
Nodes (17): alternate, assert, crop, details, excellent, fs, model, modelBefore (+9 more)
|
||||
|
||||
### Community 36 - "ao"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): ao(), Do(), io(), oo(), so(), To()
|
||||
Cohesion: 0.40
|
||||
Nodes (5): ao(), Do(), io(), so(), To()
|
||||
|
||||
### Community 37 - "UI 外发版 10.09 图片资源"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): UI 外发版 10.09 图片资源, 前端素材对应, 文件位置, 重新导出
|
||||
|
||||
### Community 38 - "FaceLandmark68Net"
|
||||
Cohesion: 0.17
|
||||
Nodes (3): FaceLandmark68Net(), PredictSingleAgeAndGenderWithFaceAlignmentTask(), PredictSingleFaceExpressionsWithFaceAlignmentTask()
|
||||
|
||||
### Community 39 - "UI交互界面调整"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): 1meAI测肤旷视UI问题反馈, UI交互界面调整, 交互, **设计**
|
||||
@@ -261,17 +284,13 @@ Nodes (4): 1meAI测肤旷视UI问题反馈, UI交互界面调整, 交互, **设
|
||||
Cohesion: 0.28
|
||||
Nodes (4): extendWithAge(), extendWithGender(), PredictAllAgeAndGenderTask(), PredictSingleAgeAndGenderTask()
|
||||
|
||||
### Community 41 - "test/accounts.js"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): assert, create, fs, http, os, path, ReportUI, SkinScore (+1 more)
|
||||
|
||||
### Community 42 - "export_psd_assets.py"
|
||||
Cohesion: 0.70
|
||||
Nodes (4): bounds(), layer_at(), main(), save_image()
|
||||
|
||||
### Community 43 - ".loadFromDisk"
|
||||
Cohesion: 0.29
|
||||
Nodes (3): ComposableTask(), getModelUris(), TinyFaceDetector()
|
||||
### Community 43 - "normalize"
|
||||
Cohesion: 0.13
|
||||
Nodes (15): conv(), conv$1(), convDown(), convLayer$1(), convNoRelu(), denseBlock3(), denseBlock4(), depthwiseSeparableConv() (+7 more)
|
||||
|
||||
### Community 44 - "start-local.sh"
|
||||
Cohesion: 0.50
|
||||
@@ -293,25 +312,25 @@ Nodes (19): 5\.1 轮廓(紧塑力), 5\.2 皱纹(紧塑力), 5\.3 肤色
|
||||
Cohesion: 0.11
|
||||
Nodes (17): 1. 结论和依据, 2. 统一比较口径, 3. 逐页差异和调整内容, 4. 报告数据如何接到新 UI, 5. 登录与“我的报告”的真实能力缺口, 6. 原稿中需要整理的内容, 7. 实施顺序与交付物, 8. 验收定义 (+9 more)
|
||||
|
||||
### Community 55 - "extractFaceTensors"
|
||||
Cohesion: 0.29
|
||||
Nodes (3): DetectAllFaceLandmarksTask(), extractFaceTensors(), isTensor3D()
|
||||
### Community 55 - "i"
|
||||
Cohesion: 0.12
|
||||
Nodes (19): bn(), dn(), ee(), Ef(), et(), fn(), i(), je() (+11 more)
|
||||
|
||||
### Community 56 - "report-ui.js"
|
||||
Cohesion: 0.90
|
||||
Nodes (4): build(), count(), fill(), numeric()
|
||||
Cohesion: 0.50
|
||||
Nodes (8): build(), count(), fill(), metricText(), numeric(), scalar(), wrinkleDetails(), wrinkleText()
|
||||
|
||||
### Community 57 - "regression.js"
|
||||
Cohesion: 0.60
|
||||
Nodes (5): diffCtx(), loadJSON(), main(), okNum(), okStr()
|
||||
|
||||
### Community 58 - ".dispose"
|
||||
Cohesion: 0.16
|
||||
Nodes (12): extractBoundingBoxes(), getSizesForScale(), iou(), nonMaxSuppression(), nonMaxSuppression$1(), normalize$1(), rescaleAndNormalize(), scale() (+4 more)
|
||||
Cohesion: 0.18
|
||||
Nodes (12): extractBoundingBoxes(), FaceRecognitionNet(), getSizesForScale(), nonMaxSuppression(), normalize$1(), pyramidDown(), rescaleAndNormalize(), scale() (+4 more)
|
||||
|
||||
### Community 61 - "NeuralNetwork"
|
||||
Cohesion: 0.06
|
||||
Nodes (28): createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractConvParamsFactory(), extractFCParamsFactory() (+20 more)
|
||||
### Community 61 - ".extractWeights"
|
||||
Cohesion: 0.09
|
||||
Nodes (25): createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractConvParamsFactory(), extractFCParamsFactory() (+17 more)
|
||||
|
||||
### Community 62 - "提交前 Review(2026-10-11)"
|
||||
Cohesion: 0.40
|
||||
@@ -322,55 +341,95 @@ Cohesion: 0.29
|
||||
Nodes (4): EXPECTED, fails, pageErrors, pwRoot
|
||||
|
||||
### Community 64 - "account-client.js"
|
||||
Cohesion: 0.90
|
||||
Nodes (4): browser(), init(), read(), request()
|
||||
Cohesion: 0.80
|
||||
Nodes (5): browser(), init(), read(), rememberKind(), request()
|
||||
|
||||
### Community 65 - "k"
|
||||
Cohesion: 0.13
|
||||
Nodes (16): ba(), ca(), fa(), gn(), k(), kn(), pa(), tt() (+8 more)
|
||||
### Community 65 - "微信登录接入与公网部署说明"
|
||||
Cohesion: 0.22
|
||||
Nodes (7): 公网服务器配置, 公网真机验收清单, 反向代理与后台保护, 已完成的链路, 微信登录接入与公网部署说明, 数据、迁移与备份, 本机开发环境限制(不代表线上状态)
|
||||
|
||||
### Community 66 - "wechat-browser.mjs"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): createAccounts, h5, require, root
|
||||
### Community 66 - "TinyYolov2Base"
|
||||
Cohesion: 0.18
|
||||
Nodes (13): convLayer(), convWithBatchNorm(), depthwiseSeparableConv$1(), fullyConnectedLayer(), leaky(), ONet(), PNet(), prelu() (+5 more)
|
||||
|
||||
### Community 68 - "analyze-timeout.js"
|
||||
Cohesion: 0.20
|
||||
Nodes (7): assert, end, fs, path, source, start, vm
|
||||
|
||||
### Community 71 - "production-loop.mjs"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): artifacts, errors, samples
|
||||
### Community 71 - "initialize"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): createBrowserEnv(), createFileSystem(), createNodejsEnv(), initialize(), isBrowser(), monkeyPatch(), setEnv()
|
||||
|
||||
### Community 77 - "drawFaceExpressions"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): drawFaceExpressions(), FaceExpressions(), isWithFaceExpressions()
|
||||
### Community 76 - ".detect"
|
||||
Cohesion: 0.18
|
||||
Nodes (6): computeReshapedDimensions(), iou(), isEven(), NetInput(), nonMaxSuppression$1(), TinyYolov2()
|
||||
|
||||
### Community 78 - ".toString"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): FaceMatch(), la(), pr()
|
||||
### Community 77 - ".runAndExtendWithFaceDetections"
|
||||
Cohesion: 0.15
|
||||
Nodes (11): DetectAllFacesTask(), DrawBox(), drawDetections(), drawFaceExpressions(), DrawFaceLandmarks(), extendWithFaceDetection(), FaceExpressions(), isWithFaceDetection() (+3 more)
|
||||
|
||||
### Community 79 - "isValidNumber"
|
||||
### Community 78 - "n"
|
||||
Cohesion: 0.11
|
||||
Nodes (36): ae(), be(), bi(), ce(), de(), e(), er(), FaceMatch() (+28 more)
|
||||
|
||||
### Community 79 - "登录反馈与加载优化(2026-10-12)"
|
||||
Cohesion: 0.25
|
||||
Nodes (5): 体验变化, 公网部署后复测, 慢的原因与处理, 登录反馈与加载优化(2026-10-12), 验证
|
||||
|
||||
### Community 80 - "线上微信登录部署记录"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): 初次部署时的凭据查找记录(现已由用户提供), 初次部署的线上检查(历史结果), 后续更新:已配置真实凭据, 已执行, 用户确认加入白名单后的复测, 线上微信登录部署记录
|
||||
|
||||
### Community 81 - "wechat-production.mjs"
|
||||
Cohesion: 0.60
|
||||
Nodes (4): api(), base, callback(), start()
|
||||
|
||||
### Community 82 - "test/accounts.js"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): assert, create, fs, http, os, path, ReportUI, SkinScore (+1 more)
|
||||
|
||||
### Community 83 - "10.09 新版 H5 实施与验收"
|
||||
Cohesion: 0.29
|
||||
Nodes (5): 10.09 新版 H5 实施与验收, 按确认项实现, 文档未定义部分的实施口径, 素材再生成, 验证命令
|
||||
|
||||
### Community 84 - "微信登录联调:2026-10-12"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): 后续真机确认与体验优化, 微信登录联调:2026-10-12, 本轮修正, 白名单与凭据, 真实微信验收与排查, 自动验证
|
||||
|
||||
### Community 85 - "FaceLandmarks"
|
||||
Cohesion: 0.07
|
||||
Nodes (15): extendWithFaceLandmarks(), FaceLandmark68NetBase(), FaceLandmarks(), FaceLandmarks5(), FaceLandmarks68(), getCenterPoint(), isDimensions(), minBbox() (+7 more)
|
||||
|
||||
### Community 86 - "isValidNumber"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): bgrToRgbTensor(), Dimensions(), isValidNumber(), isValidProbablitiy(), isWithGender()
|
||||
|
||||
### Community 80 - "线上微信登录部署记录"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): 初次部署时的凭据查找记录(现已由用户提供), 初次部署的线上检查(历史结果), 后续更新:已配置真实凭据, 已执行, 用户确认加入白名单后的复测, 线上微信登录部署记录
|
||||
### Community 87 - "产品推荐规则:肤龄基准与边界调整"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): 产品推荐规则:肤龄基准与边界调整, 决策顺序, 后台与前端一致性, 实施与验证范围, 已明确的要求, 已确认的三个条件
|
||||
|
||||
### Community 88 - "gn"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): gn(), kn(), tt(), wo(), z()
|
||||
|
||||
### Community 92 - "客户反馈修改(2026-10-12)"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): 客户反馈修改(2026-10-12), 皱纹数据说明, 逐项处理, 验证
|
||||
|
||||
## Knowledge Gaps
|
||||
- **183 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+178 more)
|
||||
- **220 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+215 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **17 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
- **21 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `k()` connect `k` to `face-api.js`, `app.js`, `i`, `n`, `lib/accounts.js`?**
|
||||
_High betweenness centrality (0.061) - this node is a cross-community bridge._
|
||||
- **Why does `r()` connect `n` to `face-api.js`, `i`, `AgeGenderNet`, `server.js`, `.toString`, `wi`, `C`?**
|
||||
- **Why does `r()` connect `n` to `face-api.js`, `k`, `f`, `normalize`, `server.js`, `i`, `C`?**
|
||||
_High betweenness centrality (0.050) - this node is a cross-community bridge._
|
||||
- **Why does `k()` connect `k` to `face-api.js`, `app.js`, `f`, `lib/accounts.js`, `n`, `gn`?**
|
||||
_High betweenness centrality (0.050) - this node is a cross-community bridge._
|
||||
- **Why does `renderReport()` connect `app.js` to `k`, `n`?**
|
||||
_High betweenness centrality (0.047) - this node is a cross-community bridge._
|
||||
_High betweenness centrality (0.038) - this node is a cross-community bridge._
|
||||
- **Are the 24 inferred relationships involving `n()` (e.g. with `newRecordId()` and `ae()`) actually correct?**
|
||||
_`n()` has 24 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 27 inferred relationships involving `r()` (e.g. with `handleExport()` and `handleRecordsQuery()`) actually correct?**
|
||||
@@ -378,4 +437,4 @@ _Questions this graph is uniquely positioned to answer:_
|
||||
- **Are the 7 inferred relationships involving `t()` (e.g. with `renderReport()` and `e()`) actually correct?**
|
||||
_`t()` has 7 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **What connects `source`, `document_size`, `pages` to the rest of the system?**
|
||||
_183 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
_220 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
@@ -40,8 +40,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/js/app.js": {
|
||||
"mtime": 1791735440.264282,
|
||||
"ast_hash": "bd18e424efb5c8d037b64d67f869b198",
|
||||
"mtime": 1791780010.867014,
|
||||
"ast_hash": "da06fb14a0750f9aeaac3cf1cdc474df",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/js/head-pose.js": {
|
||||
@@ -55,8 +55,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/server.js": {
|
||||
"mtime": 1791735707.6717124,
|
||||
"ast_hash": "6bdce03487aaaf9e3ab62f6b39f7a418",
|
||||
"mtime": 1791773587.8349628,
|
||||
"ast_hash": "4895534b06f0c2d29ada96d9b52b4693",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/build_fixtures.py": {
|
||||
@@ -140,8 +140,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/index.html": {
|
||||
"mtime": 1791735440.2604818,
|
||||
"ast_hash": "444fca45ad900710607cb15b852c0305",
|
||||
"mtime": 1791779513.7549675,
|
||||
"ast_hash": "b0d1eaae730873cfb06dab32e997fa29",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/vendor/faceapi/README.md": {
|
||||
@@ -770,8 +770,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/e2e-browser.mjs": {
|
||||
"mtime": 1791646391.2915454,
|
||||
"ast_hash": "dc47c78ddf5de136d59d73fb19d8da81",
|
||||
"mtime": 1791773638.010959,
|
||||
"ast_hash": "9327216df7c2e80f2615cb7f800b7f1a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/svg/prod-badge.svg": {
|
||||
@@ -1230,12 +1230,12 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/js/account-client.js": {
|
||||
"mtime": 1791646011.3632386,
|
||||
"ast_hash": "1aad127f864693ee873d59b177386b33",
|
||||
"mtime": 1791774062.6429472,
|
||||
"ast_hash": "3197b952f29f9e45176d4300082adaae",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/js/camera-geometry.js": {
|
||||
"mtime": 1791646239.2012978,
|
||||
"mtime": 1791773589.894928,
|
||||
"ast_hash": "08048bc5e491ca47275afe067bedbc1c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
@@ -1245,28 +1245,28 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/js/report-ui.js": {
|
||||
"mtime": 1791735369.923658,
|
||||
"ast_hash": "b05b0a2181711ce6f05bbe6c16179170",
|
||||
"mtime": 1791779513.7549675,
|
||||
"ast_hash": "100c3438318d0f7a8048c74ef3455252",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/lib/accounts.js": {
|
||||
"mtime": 1791735369.9274576,
|
||||
"ast_hash": "1a0677e439938aa2e9e4decf38fad568",
|
||||
"mtime": 1791770434.3399014,
|
||||
"ast_hash": "af809199fbaae6318fb71fe602ddc102",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/accounts.js": {
|
||||
"mtime": 1791735546.0452847,
|
||||
"ast_hash": "aa05cb7d924b1ddbe8834faeab24588f",
|
||||
"mtime": 1791770576.0104883,
|
||||
"ast_hash": "d927f5d1486b56505ba4da49ec6a769c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/report-persistence.mjs": {
|
||||
"mtime": 1791735750.0753446,
|
||||
"ast_hash": "2933add99f8983a590608ba203182096",
|
||||
"mtime": 1791773638.010959,
|
||||
"ast_hash": "9876c659bb7c3513f871d576f0024e72",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/ui-20261009.mjs": {
|
||||
"mtime": 1791730674.7561882,
|
||||
"ast_hash": "70fb3ba9b32a4c2a9845dbf027bedabb",
|
||||
"mtime": 1791773972.891023,
|
||||
"ast_hash": "3a13f77513f40e5fc04aaeff06a906e8",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"tools/build_report_copy.py": {
|
||||
@@ -1275,8 +1275,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"tools/prepare_h5_assets.py": {
|
||||
"mtime": 1791646238.76662,
|
||||
"ast_hash": "6567fff1561b8cc43ae956b97adffb7e",
|
||||
"mtime": 1791773528.470933,
|
||||
"ast_hash": "60087af4505ea0fae11a6f3449c7e21e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/10.09\u65b0\u7248H5\u5b9e\u65bd\u4e0e\u9a8c\u6536.md": {
|
||||
@@ -1290,8 +1290,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/\u5fae\u4fe1\u767b\u5f55\u63a5\u5165\u4e0e\u516c\u7f51\u90e8\u7f72\u8bf4\u660e.md": {
|
||||
"mtime": 1791730674.7561882,
|
||||
"ast_hash": "6e8f9f9a8b941790adccde55be9b125c",
|
||||
"mtime": 1791774038.1869662,
|
||||
"ast_hash": "61af7cde98a3299fcc4619e4776cce2a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/fonts/misans/README.md": {
|
||||
@@ -1440,62 +1440,62 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"design/ui-20261009/verification/01-login.png": {
|
||||
"mtime": 1791710678.3993611,
|
||||
"ast_hash": "546575bd6c19464adfd8033f1416e41f",
|
||||
"mtime": 1791780103.439073,
|
||||
"ast_hash": "32f142e3c11d1c7768477e37b06e2209",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"design/ui-20261009/verification/02-home.png": {
|
||||
"mtime": 1791710681.0034657,
|
||||
"ast_hash": "48cb27f9eb1d5f01f3ca00097b31d5b8",
|
||||
"mtime": 1791780106.015078,
|
||||
"ast_hash": "52b5c983e23e89638f6f13e4b805bc33",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"design/ui-20261009/verification/03-history-empty.png": {
|
||||
"mtime": 1791710681.5034268,
|
||||
"ast_hash": "cf7cc9fdf15a8fe2c2276ffbf01e9356",
|
||||
"mtime": 1791780106.5190792,
|
||||
"ast_hash": "6d94cf3a87064fe79bb8a3cb7e227228",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"design/ui-20261009/verification/04-privacy.png": {
|
||||
"mtime": 1791710682.4802737,
|
||||
"ast_hash": "349e9841ce4f114a2816c52fea2301c3",
|
||||
"mtime": 1791780107.4590812,
|
||||
"ast_hash": "7765fe3123d067a5211190f7a08e90c5",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"design/ui-20261009/verification/05-profile.png": {
|
||||
"mtime": 1791710683.0148475,
|
||||
"ast_hash": "2e624c0cb91f96fe84e41878cf9852b5",
|
||||
"mtime": 1791780108.0350823,
|
||||
"ast_hash": "0a74071e471e65a16cf5ed22f27b7803",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"design/ui-20261009/verification/06-report-top.png": {
|
||||
"mtime": 1791710685.010846,
|
||||
"ast_hash": "ad6cbf32ddbb4ef628f8b8bf6f970af9",
|
||||
"mtime": 1791780109.9670866,
|
||||
"ast_hash": "4f47a34364b02e9ef6189d7ac0ef6660",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"design/ui-20261009/verification/07-report-full.png": {
|
||||
"mtime": 1791710685.1146839,
|
||||
"ast_hash": "8775ce75d26200ab9341f662b8aaf40d",
|
||||
"mtime": 1791780110.071087,
|
||||
"ast_hash": "0be8c9110241f7305598415d6c861f5c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/img/ui-20261009/bg-history-h5.jpg": {
|
||||
"mtime": 1791646239.0227692,
|
||||
"mtime": 1791773589.4149413,
|
||||
"ast_hash": "1dca9bdb3e19bdf837fc317e8dc3d65a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/img/ui-20261009/bg-login-h5.jpg": {
|
||||
"mtime": 1791646238.9645536,
|
||||
"mtime": 1791773588.8749564,
|
||||
"ast_hash": "bbc72a85580326d228e92c0efd0293aa",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/img/ui-20261009/bg-profile-h5.jpg": {
|
||||
"mtime": 1791646238.9994829,
|
||||
"mtime": 1791773589.1589484,
|
||||
"ast_hash": "bbc72a85580326d228e92c0efd0293aa",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/img/ui-20261009/bg-report-h5.jpg": {
|
||||
"mtime": 1791646239.1003904,
|
||||
"mtime": 1791773589.5629373,
|
||||
"ast_hash": "b49c61b68a714da65fdb706060e1a4f0",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/img/ui-20261009/bg-splash-h5.jpg": {
|
||||
"mtime": 1791646238.933505,
|
||||
"mtime": 1791773588.2109632,
|
||||
"ast_hash": "0ab40c14387e86508639e733fea6d168",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
@@ -1515,23 +1515,23 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"design/ui-20261009/verification/08-camera-front-layout.png": {
|
||||
"mtime": 1791710683.453275,
|
||||
"mtime": 1791780108.4710832,
|
||||
"ast_hash": "b9d04eed0983f1f49fed6c663070ecc1",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"design/ui-20261009/verification/08-camera-left-layout.png": {
|
||||
"mtime": 1791710683.5186543,
|
||||
"mtime": 1791780108.5350835,
|
||||
"ast_hash": "2cb973828c1d18bfbcb5c0cb8ad06d41",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"design/ui-20261009/verification/08-camera-right-layout.png": {
|
||||
"mtime": 1791710683.4840417,
|
||||
"mtime": 1791780108.5030832,
|
||||
"ast_hash": "4b299e86432feb2697dd739dc77a825d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/wechat-browser.mjs": {
|
||||
"mtime": 1791710791.703553,
|
||||
"ast_hash": "7d44f0e7fee4a30c6654db57de57923e",
|
||||
"mtime": 1791773972.891023,
|
||||
"ast_hash": "c27ca11dea3026358188f80affee2d6b",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/2026-10-11\u63d0\u4ea4\u524dReview.md": {
|
||||
@@ -1545,18 +1545,18 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/production-loop.mjs": {
|
||||
"mtime": 1791730674.763797,
|
||||
"ast_hash": "0fa6706f54f034124287d29adc894f71",
|
||||
"mtime": 1791773638.010959,
|
||||
"ast_hash": "0d0d46ec3ecfce9e767e7f31050befe2",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/\u7ebf\u4e0a\u5fae\u4fe1\u767b\u5f55\u90e8\u7f72\u8bb0\u5f55-2026-10-11.md": {
|
||||
"mtime": 1791730745.132436,
|
||||
"ast_hash": "056f5b8e0ee8920285d9cf8b1764f3f5",
|
||||
"mtime": 1791770706.5669558,
|
||||
"ast_hash": "7a475de28d6a21509d18bf30a3b29080",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/browser-failures.mjs": {
|
||||
"mtime": 1791730674.763797,
|
||||
"ast_hash": "b5eaa9d78296f5c690566ed91a39d467",
|
||||
"mtime": 1791773638.010959,
|
||||
"ast_hash": "8cb2774864d3d59bd5a418ad61819129",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/\u7ebf\u4e0a\u95ed\u73af\u6d4b\u8bd5-2026-10-11.md": {
|
||||
@@ -1573,5 +1573,105 @@
|
||||
"mtime": 1791735707.6717124,
|
||||
"ast_hash": "69c1278c6c8eea5866982940e20e421c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/\u5fae\u4fe1\u767b\u5f55\u8054\u8c03-2026-10-12.md": {
|
||||
"mtime": 1791774038.1869662,
|
||||
"ast_hash": "14292d8da8570ff0f8af2aac08d895b8",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/wechat-production.mjs": {
|
||||
"mtime": 1791770851.3389251,
|
||||
"ast_hash": "ba457695243430e271cfd7927ef22541",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/login-performance.mjs": {
|
||||
"mtime": 1791773812.003017,
|
||||
"ast_hash": "1ba88edfa7b259fc319de7f845925582",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/img/ui-20261009/bg-history-h5.webp": {
|
||||
"mtime": 1791773589.4829395,
|
||||
"ast_hash": "9a886c304206c61fb50c4d8752bd8e7a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/img/ui-20261009/bg-login-h5.webp": {
|
||||
"mtime": 1791773589.1229496,
|
||||
"ast_hash": "bbd96b66102dd7af0f9f5b34b0c72cca",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/img/ui-20261009/bg-profile-h5.webp": {
|
||||
"mtime": 1791773589.3989418,
|
||||
"ast_hash": "bbd96b66102dd7af0f9f5b34b0c72cca",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/img/ui-20261009/bg-report-h5.webp": {
|
||||
"mtime": 1791773589.8069305,
|
||||
"ast_hash": "c7cbea632696ddb7703faef098f10f1a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/img/ui-20261009/bg-splash-h5.webp": {
|
||||
"mtime": 1791773588.8429573,
|
||||
"ast_hash": "9ba24f79e71ab8793eefd4e3271faa68",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/\u767b\u5f55\u53cd\u9988\u4e0e\u52a0\u8f7d\u4f18\u5316-2026-10-12.md": {
|
||||
"mtime": 1791774227.194959,
|
||||
"ast_hash": "e925c1ba7a57fdcb2321229b3eb32b66",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/customer-feedback.js": {
|
||||
"mtime": 1791779743.1310055,
|
||||
"ast_hash": "a01d5f9ec4424a2f21f8239451f576f9",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/customer-feedback.mjs": {
|
||||
"mtime": 1791780010.8710139,
|
||||
"ast_hash": "a8332f17ef693582bc81bff83950fedb",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u5085\u8273NEW\u4e0e\u9879\u601d\u70bc\u7684\u4f1a\u8bdd 2026\u5e7410\u670812\u65e5/\u5085\u8273NEW\u4e0e\u9879\u601d\u70bc\u7684\u4f1a\u8bdd 2026\u5e7410\u670812\u65e5.md": {
|
||||
"mtime": 1791778827.0,
|
||||
"ast_hash": "09b009c9cdb63cf90710b1b45fc6cee0",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u5085\u8273NEW\u4e0e\u9879\u601d\u70bc\u7684\u4f1a\u8bdd 2026\u5e7410\u670812\u65e5/\u56fe\u7247\u548c\u9644\u4ef6/exported_img_v3_0216d_40a9e343-5d09-4760-aa80-6d13cc9ac8eg.png": {
|
||||
"mtime": 1791778827.0,
|
||||
"ast_hash": "7a58ab24cec7edaaac8667f37f4e8e5a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u5085\u8273NEW\u4e0e\u9879\u601d\u70bc\u7684\u4f1a\u8bdd 2026\u5e7410\u670812\u65e5/\u56fe\u7247\u548c\u9644\u4ef6/exported_img_v3_0216d_718c2f13-c978-4e07-bf72-21b24236bd6g.png": {
|
||||
"mtime": 1791778827.0,
|
||||
"ast_hash": "96c0f8befaa850e8cb6eab3c54c4ad04",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u5085\u8273NEW\u4e0e\u9879\u601d\u70bc\u7684\u4f1a\u8bdd 2026\u5e7410\u670812\u65e5/\u56fe\u7247\u548c\u9644\u4ef6/exported_img_v3_0216d_92abffb2-da3a-42d4-bd42-6d1b6a864e3g.png": {
|
||||
"mtime": 1791778827.0,
|
||||
"ast_hash": "9c902ad750767d96b98b20c713c36c14",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u5085\u8273NEW\u4e0e\u9879\u601d\u70bc\u7684\u4f1a\u8bdd 2026\u5e7410\u670812\u65e5/\u56fe\u7247\u548c\u9644\u4ef6/exported_img_v3_0216d_d5d64a81-acad-4db9-970c-2dd1398b532g.png": {
|
||||
"mtime": 1791778827.0,
|
||||
"ast_hash": "12c940d1d339ff8a7a5bcb4e9292f1ff",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u5085\u8273NEW\u4e0e\u9879\u601d\u70bc\u7684\u4f1a\u8bdd 2026\u5e7410\u670812\u65e5/\u56fe\u7247\u548c\u9644\u4ef6/exported_img_v3_0216d_ee1903ba-96ef-4778-9891-027588093c7g.jpeg": {
|
||||
"mtime": 1791778827.0,
|
||||
"ast_hash": "12251b6acdea7aaba393c3544832572e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u5085\u8273NEW\u4e0e\u9879\u601d\u70bc\u7684\u4f1a\u8bdd 2026\u5e7410\u670812\u65e5/\u56fe\u7247\u548c\u9644\u4ef6/exported_img_v3_0216d_eec8b07b-be07-4d37-9a9f-4057c5afc56g.png": {
|
||||
"mtime": 1791778827.0,
|
||||
"ast_hash": "c7a7e7a16688e354c0010a278fcf9124",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/\u5ba2\u6237\u53cd\u9988\u4fee\u6539-2026-10-12.md": {
|
||||
"mtime": 1791780101.1150675,
|
||||
"ast_hash": "4b0cf0511f31cb1c1ea310fe72b3b808",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"design/ui-20261009/verification/02-login-success.png": {
|
||||
"mtime": 1791780103.7670736,
|
||||
"ast_hash": "6e06a46e74f50f9f9a4ee94bb34bc3de",
|
||||
"semantic_hash": ""
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 439 KiB |
@@ -0,0 +1,3 @@
|
||||
/* Original MiSans files, unchanged; never needed for login/session restoration. */
|
||||
@font-face { font-family: MiSans; src: url(../assets/fonts/misans/MiSans-Regular.woff2) format('woff2'); font-weight: 100 500; font-display: swap; }
|
||||
@font-face { font-family: MiSans; src: url(../assets/fonts/misans/MiSans-Semibold.woff2) format('woff2'); font-weight: 600 900; font-display: swap; }
|
||||
@@ -1,6 +1,6 @@
|
||||
/* ═══════════════════════════════════════════
|
||||
巨子测肤 · 1'me 在线肌肤测试 — H5 样式
|
||||
设计稿 1080px 基准 → 375px(÷2.88);尺寸写成 N × --u,不同宽度手机等比缩放、视觉一致
|
||||
设计稿 1080px 基准 → 375px(÷2.88);布局尺寸随 --u 缩放,字号使用固定 CSS px
|
||||
主色 #F5F078 · 文字 #282828 · 底 #EFEFEF
|
||||
═══════════════════════════════════════════ */
|
||||
:root {
|
||||
@@ -56,7 +56,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
background: var(--yellow);
|
||||
border-radius: calc(5 * var(--u));
|
||||
color: var(--ink);
|
||||
font-size: calc(14.5 * var(--u)); font-weight: 500; letter-spacing: calc(.5 * var(--u));
|
||||
font-size: 14.5px; font-weight: 500; letter-spacing: calc(.5 * var(--u));
|
||||
text-align: center;
|
||||
transition: transform .12s ease;
|
||||
}
|
||||
@@ -88,7 +88,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
.splash-slogan {
|
||||
position: absolute; left: 0; right: 0; top: 10.7%;
|
||||
text-align: center;
|
||||
color: #fff; font-size: calc(23 * var(--u)); font-weight: 500;
|
||||
color: #fff; font-size: 23px; font-weight: 500;
|
||||
line-height: 1.38; letter-spacing: calc(.6 * var(--u));
|
||||
text-shadow: 0 1px 8px rgba(0,0,0,.15);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
}
|
||||
.brand-row { display: flex; align-items: center; gap: calc(7 * var(--u)); }
|
||||
.brand-logo { width: calc(46 * var(--u)); height: auto; filter: brightness(0) invert(1); }
|
||||
.brand-text { color: #fff; font-size: calc(21.5 * var(--u)); font-weight: 500; letter-spacing: calc(1 * var(--u)); }
|
||||
.brand-text { color: #fff; font-size: 21.5px; font-weight: 500; letter-spacing: calc(1 * var(--u)); }
|
||||
|
||||
/* ═══════════ 03 档案填写 ═══════════ */
|
||||
#s-profile { background: #0e0f18; }
|
||||
@@ -116,14 +116,14 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
padding: calc(34 * var(--u)) calc(64 * var(--u)) calc(40 * var(--u));
|
||||
}
|
||||
.page-title {
|
||||
color: #fff; font-size: calc(21.5 * var(--u)); font-weight: 700;
|
||||
color: #fff; font-size: 21.5px; font-weight: 700;
|
||||
letter-spacing: calc(1 * var(--u)); text-align: center;
|
||||
margin-bottom: calc(22 * var(--u));
|
||||
}
|
||||
.field { margin-bottom: calc(19 * var(--u)); }
|
||||
.field-label {
|
||||
display: block; text-align: center;
|
||||
color: #fff; font-size: calc(14.5 * var(--u)); font-weight: 400;
|
||||
color: #fff; font-size: 14.5px; font-weight: 400;
|
||||
margin-bottom: calc(9 * var(--u)); letter-spacing: calc(.5 * var(--u));
|
||||
}
|
||||
.form-row.two { display: flex; gap: calc(23 * var(--u)); }
|
||||
@@ -135,7 +135,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
width: 100%; height: calc(37 * var(--u));
|
||||
padding: 0 calc(13 * var(--u)) 0 calc(12 * var(--u));
|
||||
background: #fff; border-radius: calc(5 * var(--u));
|
||||
font-size: calc(13.5 * var(--u)); color: var(--ink); text-align: left;
|
||||
font-size: 13.5px; color: var(--ink); text-align: left;
|
||||
}
|
||||
.pick-value { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pick .chev { flex: none; width: calc(8 * var(--u)); height: calc(8 * var(--u)); margin-top: calc(-4 * var(--u)); border-width: 0 1.5px 1.5px 0; }
|
||||
@@ -152,7 +152,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
}
|
||||
.axis-item {
|
||||
display: inline-flex; align-items: center; gap: calc(5 * var(--u));
|
||||
font-size: calc(9 * var(--u)); color: var(--ink);
|
||||
font-size: 9px; color: var(--ink);
|
||||
}
|
||||
.chip { width: calc(8 * var(--u)); height: calc(8 * var(--u)); border-radius: 50%; display: inline-block; }
|
||||
.skin-stage { position: relative; padding: 0 calc(28 * var(--u)); }
|
||||
@@ -172,9 +172,9 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
}
|
||||
.skin-item img { width: calc(109 * var(--u)); height: calc(109 * var(--u)); }
|
||||
.skin-name { font-size: calc(12.5 * var(--u)); font-weight: 700; color: #000; margin-top: calc(16 * var(--u)); }
|
||||
.skin-name { font-size: 12.5px; font-weight: 700; color: #000; margin-top: calc(16 * var(--u)); }
|
||||
.skin-desc {
|
||||
font-size: calc(10.3 * var(--u)); color: var(--ink);
|
||||
font-size: 10.3px; color: var(--ink);
|
||||
line-height: 1.55; text-align: center;
|
||||
margin-top: calc(5 * var(--u)); max-width: calc(136 * var(--u));
|
||||
}
|
||||
@@ -222,7 +222,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
.cam-step {
|
||||
position: absolute; left: 0; right: 0; top: 7.1%;
|
||||
text-align: center;
|
||||
color: #fff; font-size: calc(21.5 * var(--u)); font-weight: 500;
|
||||
color: #fff; font-size: 21.5px; font-weight: 500;
|
||||
line-height: 1.5; letter-spacing: calc(1 * var(--u));
|
||||
text-shadow: 0 1px 10px rgba(0,0,0,.4);
|
||||
z-index: 2;
|
||||
@@ -232,7 +232,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
display: flex; justify-content: center; align-items: center; gap: calc(26 * var(--u));
|
||||
z-index: 2;
|
||||
}
|
||||
.cam-guide span { color: #fff; font-size: calc(14.5 * var(--u)); font-weight: 400; }
|
||||
.cam-guide span { color: #fff; font-size: 14.5px; font-weight: 400; }
|
||||
.cam-guide i { width: 0; height: calc(15 * var(--u)); border-left: 1px solid rgba(255,255,255,.7); }
|
||||
.cam-controls {
|
||||
position: absolute; left: 0; right: 0; bottom: 5.2%;
|
||||
@@ -281,7 +281,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
.cam-status {
|
||||
position: absolute; top: 17px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: calc(11 * var(--u)); color: rgba(255,255,255,.85);
|
||||
font-size: 11px; color: rgba(255,255,255,.85);
|
||||
background: rgba(0,0,0,.35);
|
||||
padding: 4px 12px; border-radius: 11px;
|
||||
z-index: 2; opacity: 0; transition: opacity .25s; white-space: nowrap;
|
||||
@@ -292,7 +292,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
.cam-hint {
|
||||
position: absolute; left: 50%; bottom: 27%;
|
||||
transform: translateX(-50%) translateY(6px);
|
||||
font-size: calc(17 * var(--u)); font-weight: 500; color: #fff;
|
||||
font-size: 17px; font-weight: 500; color: #fff;
|
||||
text-shadow: 0 1px 8px rgba(0,0,0,.65);
|
||||
z-index: 2; opacity: 0; pointer-events: none;
|
||||
transition: opacity .2s ease, transform .2s ease;
|
||||
@@ -305,7 +305,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
.cam-nudge {
|
||||
position: absolute; left: 50%; bottom: 16.5%;
|
||||
transform: translateX(-50%) translateY(6px);
|
||||
font-size: calc(11.5 * var(--u)); color: #fff;
|
||||
font-size: 11.5px; color: #fff;
|
||||
background: rgba(0,0,0,.55);
|
||||
padding: 6px 14px; border-radius: 14px;
|
||||
z-index: 2; opacity: 0; pointer-events: none;
|
||||
@@ -336,14 +336,14 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
border: solid #fff; border-width: 0 0 calc(3 * var(--u)) calc(3 * var(--u));
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
.cam-next b { font-size: calc(17 * var(--u)); font-weight: 500; }
|
||||
.cam-next span { margin-top: calc(8 * var(--u)); font-size: calc(14 * var(--u)); color: rgba(255,255,255,.85); line-height: 1.5; }
|
||||
.cam-next b { font-size: 17px; font-weight: 500; }
|
||||
.cam-next span { margin-top: calc(8 * var(--u)); font-size: 14px; color: rgba(255,255,255,.85); line-height: 1.5; }
|
||||
|
||||
/* ═══════════ 08 报告页 ═══════════ */
|
||||
#s-report { background: var(--bg); }
|
||||
.report-body { padding: 0 calc(18 * var(--u)) calc(28 * var(--u)); }
|
||||
.report-title {
|
||||
font-size: calc(23 * var(--u)); font-weight: 700; color: var(--ink);
|
||||
font-size: 23px; font-weight: 700; color: var(--ink);
|
||||
text-align: center; letter-spacing: calc(.5 * var(--u));
|
||||
padding: calc(32 * var(--u)) 0 calc(20 * var(--u));
|
||||
}
|
||||
@@ -351,7 +351,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
background: #fff; border-radius: calc(8 * var(--u));
|
||||
margin-bottom: calc(12 * var(--u));
|
||||
}
|
||||
.stat-label { font-size: calc(12 * var(--u)); color: var(--ink); }
|
||||
.stat-label { font-size: 12px; color: var(--ink); }
|
||||
.stat-value { color: var(--ink); line-height: 1; white-space: nowrap; }
|
||||
.stat-value b { font-weight: 500; letter-spacing: calc(.5 * var(--u)); }
|
||||
.stat-value i { font-style: normal; font-weight: 500; }
|
||||
@@ -371,20 +371,20 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
position: absolute; inset: 0;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
}
|
||||
.ring-center .stat-value b { font-size: calc(40 * var(--u)); }
|
||||
.ring-center .stat-value i { font-size: calc(14 * var(--u)); }
|
||||
.ring-center .stat-label { margin-top: calc(3 * var(--u)); font-size: calc(11.5 * var(--u)); color: var(--para); }
|
||||
.ring-center .stat-value b { font-size: 40px; }
|
||||
.ring-center .stat-value i { font-size: 14px; }
|
||||
.ring-center .stat-label { margin-top: calc(3 * var(--u)); font-size: 11.5px; color: var(--para); }
|
||||
.sum-age {
|
||||
flex: 1; min-width: 0;
|
||||
display: flex; flex-direction: column; align-items: flex-start;
|
||||
padding-left: calc(26 * var(--u));
|
||||
}
|
||||
.stat-age { margin-top: calc(6 * var(--u)); }
|
||||
.stat-age b { font-size: calc(58 * var(--u)); }
|
||||
.stat-age i { font-size: calc(19 * var(--u)); margin-left: 2px; }
|
||||
.stat-age b { font-size: 58px; }
|
||||
.stat-age i { font-size: 19px; margin-left: 2px; }
|
||||
.age-pill {
|
||||
margin-top: calc(7 * var(--u));
|
||||
font-size: calc(9.5 * var(--u)); color: var(--grey-2);
|
||||
font-size: 9.5px; color: var(--grey-2);
|
||||
background: #F2F2F2; border-radius: calc(9 * var(--u));
|
||||
padding: calc(2 * var(--u)) calc(10 * var(--u));
|
||||
white-space: nowrap;
|
||||
@@ -400,9 +400,9 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
padding: 0 0 calc(4 * var(--u)) calc(14 * var(--u));
|
||||
}
|
||||
.dim + .dim { border-left: 1px solid #EFEFEF; }
|
||||
.dim-name { font-size: calc(12 * var(--u)); color: var(--ink); }
|
||||
.dim-score { margin-top: calc(4 * var(--u)); font-size: calc(30 * var(--u)); font-weight: 500; color: var(--ink); line-height: 1.1; }
|
||||
.dim-score b { font-size: calc(42 * var(--u)); font-weight: 500; }
|
||||
.dim-name { font-size: 12px; color: var(--ink); }
|
||||
.dim-score { margin-top: calc(4 * var(--u)); font-size: 30px; font-weight: 500; color: var(--ink); line-height: 1.1; }
|
||||
.dim-score b { font-size: 42px; font-weight: 500; }
|
||||
.dim-score.dim-bad { color: var(--bad); }
|
||||
.dim-bar {
|
||||
display: block; margin-top: calc(6 * var(--u));
|
||||
@@ -415,7 +415,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
.dim-flag {
|
||||
visibility: hidden;
|
||||
margin-top: calc(6 * var(--u));
|
||||
font-style: normal; font-size: calc(9 * var(--u)); color: var(--ink);
|
||||
font-style: normal; font-size: 9px; color: var(--ink);
|
||||
background: #F8F4A6; border-radius: calc(8 * var(--u));
|
||||
padding: calc(1 * var(--u)) calc(8 * var(--u));
|
||||
}
|
||||
@@ -429,15 +429,15 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
content: ""; position: absolute; left: calc(14 * var(--u)); top: calc(12 * var(--u)); bottom: calc(12 * var(--u));
|
||||
width: calc(3 * var(--u)); border-radius: 2px; background: var(--yellow);
|
||||
}
|
||||
.dims-note-l1 { font-size: calc(13 * var(--u)); color: var(--para); }
|
||||
.dims-note-l1 { font-size: 13px; color: var(--para); }
|
||||
.dims-note-l1 b { font-weight: 500; color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.dims-note-l2 { margin-top: calc(4 * var(--u)); font-size: calc(12 * var(--u)); color: var(--grey); }
|
||||
.dims-note-l2 { margin-top: calc(4 * var(--u)); font-size: 12px; color: var(--grey); }
|
||||
|
||||
/* 维度详情卡(原布局):紧塑力/活力感/稳定力三个 Tab,每个 Tab 两条「竖排标题 + 左图右文」;检测图点击放大 */
|
||||
.card-detail { padding: 0 calc(19 * var(--u)) calc(19 * var(--u)); }
|
||||
.detail-tabs { display: flex; border-bottom: 1px solid #F0F0F0; }
|
||||
.dtab { flex: 1; padding: calc(13 * var(--u)) 0 0; font-weight: 500; color: var(--ink); }
|
||||
.dtab-tx { position: relative; display: inline-block; font-size: calc(10.1 * var(--u)); padding: 0 calc(5 * var(--u)) calc(7 * var(--u)); }
|
||||
.dtab-tx { position: relative; display: inline-block; font-size: 10.1px; padding: 0 calc(5 * var(--u)) calc(7 * var(--u)); }
|
||||
.dtab.active { font-weight: 700; }
|
||||
.dtab.active .dtab-tx::after {
|
||||
content: ""; position: absolute; left: 50%; transform: translateX(-50%); bottom: -1px;
|
||||
@@ -458,28 +458,28 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
.ditem-title {
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: calc(4 * var(--u));
|
||||
font-size: calc(11.5 * var(--u)); font-weight: 500; color: var(--ink);
|
||||
font-size: 11.5px; font-weight: 500; color: var(--ink);
|
||||
margin-right: calc(3.5 * var(--u)); flex-shrink: 0;
|
||||
}
|
||||
.ditem-para { font-size: calc(10.1 * var(--u)); font-weight: 400; color: var(--para); line-height: 1.41; overflow-wrap: anywhere; }
|
||||
.ditem-para { font-size: 10.1px; font-weight: 400; color: var(--para); line-height: 1.41; overflow-wrap: anywhere; }
|
||||
|
||||
.vm-notes { font-size: calc(10 * var(--u)); color: var(--grey-3); line-height: 1.7; margin: calc(14 * var(--u)) calc(6 * var(--u)) 0; }
|
||||
.vm-notes { font-size: 10px; color: var(--grey-3); line-height: 1.7; margin: calc(14 * var(--u)) calc(6 * var(--u)) 0; }
|
||||
.report-disclaimer {
|
||||
font-size: calc(10 * var(--u)); color: var(--para);
|
||||
font-size: 10px; color: var(--para);
|
||||
line-height: 1.7; margin: calc(18 * var(--u)) calc(6 * var(--u)) calc(14 * var(--u));
|
||||
}
|
||||
|
||||
/* 护理建议 / 精华推荐 */
|
||||
.card-care, .card-essence { padding: calc(20 * var(--u)) calc(22 * var(--u)) calc(18 * var(--u)); }
|
||||
.card-heading { font-size: calc(17 * var(--u)); font-weight: 700; color: #000; margin-bottom: calc(10 * var(--u)); }
|
||||
.care-item { font-size: calc(13.5 * var(--u)); color: var(--para); line-height: 1.7; margin-bottom: calc(10 * var(--u)); overflow-wrap: anywhere; }
|
||||
.care-summary { font-size: calc(13.5 * var(--u)); font-weight: 500; color: #000; margin-top: calc(4 * var(--u)); }
|
||||
.essence-name { font-size: calc(16 * var(--u)); font-weight: 700; color: var(--ink); }
|
||||
.essence-triggers { font-size: calc(11 * var(--u)); color: var(--grey-3); margin-top: calc(6 * var(--u)); line-height: 1.5; }
|
||||
.card-heading { font-size: 17px; font-weight: 700; color: #000; margin-bottom: calc(10 * var(--u)); }
|
||||
.care-item { font-size: 13.5px; color: var(--para); line-height: 1.7; margin-bottom: calc(10 * var(--u)); overflow-wrap: anywhere; }
|
||||
.care-summary { font-size: 13.5px; font-weight: 500; color: #000; margin-top: calc(4 * var(--u)); }
|
||||
.essence-name { font-size: 16px; font-weight: 700; color: var(--ink); }
|
||||
.essence-triggers { font-size: 11px; color: var(--grey-3); margin-top: calc(6 * var(--u)); line-height: 1.5; }
|
||||
|
||||
/* 产品推荐:大标题落在灰底;白卡左文案右图(各占一半) */
|
||||
.prod-title {
|
||||
font-size: calc(23 * var(--u)); font-weight: 700; color: var(--ink);
|
||||
font-size: 23px; font-weight: 700; color: var(--ink);
|
||||
line-height: 1.45; text-align: center; letter-spacing: calc(1 * var(--u));
|
||||
margin: calc(30 * var(--u)) 0 calc(18 * var(--u));
|
||||
}
|
||||
@@ -491,8 +491,8 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
display: flex; flex-direction: column; align-items: flex-start;
|
||||
}
|
||||
.prod-badge { width: calc(44 * var(--u)); height: auto; }
|
||||
.prod-name { font-size: calc(14.5 * var(--u)); font-weight: 700; color: var(--ink); line-height: 1.45; margin-top: calc(10 * var(--u)); }
|
||||
.prod-sub { font-size: calc(9.5 * var(--u)); color: var(--grey-3); margin-top: auto; padding-top: calc(10 * var(--u)); }
|
||||
.prod-name { font-size: 14.5px; font-weight: 700; color: var(--ink); line-height: 1.45; margin-top: calc(10 * var(--u)); }
|
||||
.prod-sub { font-size: 9.5px; color: var(--grey-3); margin-top: auto; padding-top: calc(10 * var(--u)); }
|
||||
.prod-scroll { flex: 0 0 51%; display: flex; overflow: hidden; }
|
||||
.prod-scroll img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.btn-more { margin: calc(26 * var(--u)) auto 0; }
|
||||
@@ -528,11 +528,11 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
}
|
||||
.popup-close img { width: calc(19 * var(--u)); height: calc(19 * var(--u)); }
|
||||
.popup-title {
|
||||
font-size: calc(23 * var(--u)); font-weight: 500; color: var(--ink);
|
||||
font-size: 23px; font-weight: 500; color: var(--ink);
|
||||
line-height: 1.38; letter-spacing: calc(.5 * var(--u));
|
||||
margin-bottom: calc(14 * var(--u));
|
||||
}
|
||||
.popup-body { font-size: calc(11.6 * var(--u)); color: var(--ink); line-height: 1.6; margin-bottom: calc(26 * var(--u)); }
|
||||
.popup-body { font-size: 11.6px; color: var(--ink); line-height: 1.6; margin-bottom: calc(26 * var(--u)); }
|
||||
.privacy-row { display: flex; align-items: center; gap: calc(9 * var(--u)); cursor: pointer; }
|
||||
.checkbox {
|
||||
width: calc(12 * var(--u)); height: calc(12 * var(--u)); border-radius: 50%;
|
||||
@@ -544,7 +544,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
inset: 2px; border-radius: 50%;
|
||||
background: var(--ink-2);
|
||||
}
|
||||
.privacy-text { font-size: calc(10.3 * var(--u)); color: var(--grey-2); }
|
||||
.privacy-text { font-size: 10.3px; color: var(--grey-2); }
|
||||
.privacy-link { color: var(--ink); font-weight: 500; text-decoration: underline; cursor: pointer; }
|
||||
.privacy-link:active { opacity: .7; }
|
||||
.popup-btn { margin: auto auto 0; }
|
||||
@@ -566,10 +566,10 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
.overlay-dark { background: rgba(16,16,16,.82); }
|
||||
.analyzing {
|
||||
color: #fff; text-align: center;
|
||||
font-size: calc(14 * var(--u)); letter-spacing: 1px;
|
||||
font-size: 14px; letter-spacing: 1px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 16px;
|
||||
}
|
||||
.analyzing-tip { font-size: calc(11 * var(--u)); color: rgba(255,255,255,.45); letter-spacing: 0; }
|
||||
.analyzing-tip { font-size: 11px; color: rgba(255,255,255,.45); letter-spacing: 0; }
|
||||
.spinner {
|
||||
width: 34px; height: 34px;
|
||||
border: 3px solid rgba(255,255,255,.25);
|
||||
@@ -585,7 +585,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
|
||||
transform: translateX(-50%) translateY(8px);
|
||||
max-width: 86%;
|
||||
background: rgba(20,20,20,.85); color: #fff;
|
||||
font-size: calc(13 * var(--u)); line-height: 1.5; text-align: center;
|
||||
font-size: 13px; line-height: 1.5; text-align: center;
|
||||
padding: 9px 18px; border-radius: 18px;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: all .25s ease;
|
||||
|
||||
@@ -1,84 +1,102 @@
|
||||
/* 10.09 PSD: exclude the native WeChat header; retain flexible H5 viewport. */
|
||||
@font-face { font-family: MiSans; src: url(../assets/fonts/misans/MiSans-Regular.woff2) format('woff2'); font-weight: 100 500; font-display: swap; }
|
||||
@font-face { font-family: MiSans; src: url(../assets/fonts/misans/MiSans-Semibold.woff2) format('woff2'); font-weight: 600 900; font-display: swap; }
|
||||
/* Font faces are registered lazily by app.js on entering profile/camera/report.
|
||||
Hidden wheel-picker text can otherwise trigger font downloads at startup. */
|
||||
:root { --font: "MiSans","PingFang SC","Microsoft YaHei",sans-serif; }
|
||||
[hidden] { display: none !important; }
|
||||
#s-login { background: #142632 url(../assets/img/ui-20261009/bg-login-h5.jpg) center/cover no-repeat; }
|
||||
/* Keep the original licensed font intact. Entry/account screens use system fonts
|
||||
so login never competes with the two full Chinese font downloads (~9.5 MB). */
|
||||
#s-login, #s-splash, #s-history, #p-login-success, #toast { --font: "PingFang SC","Microsoft YaHei",system-ui,sans-serif; font-family: var(--font); }
|
||||
#s-login { background: #142632 url(../assets/img/ui-20261009/bg-login-h5.webp) center/cover no-repeat; }
|
||||
.login-brand { position: absolute; width: calc(145 * var(--u)); left: 50%; top: 14%; transform: translateX(-50%); }
|
||||
.login-bottom { position: absolute; left: 0; right: 0; bottom: max(5%, env(safe-area-inset-bottom)); display: flex; flex-direction: column; align-items: center; gap: calc(12 * var(--u)); }
|
||||
.login-button { width: calc(270 * var(--u)); height: calc(40 * var(--u)); display: flex; align-items: center; justify-content: center; gap: calc(14 * var(--u)); border-radius: calc(5 * var(--u)); font-size: calc(14 * var(--u)); }
|
||||
.login-button { width: calc(270 * var(--u)); height: calc(40 * var(--u)); display: flex; align-items: center; justify-content: center; gap: calc(14 * var(--u)); border-radius: calc(5 * var(--u)); font-size: 14px; }
|
||||
.login-button img { width: calc(23 * var(--u)); height: calc(20 * var(--u)); object-fit: contain; }
|
||||
.login-wechat { background: var(--yellow); color: #000; }
|
||||
.login-browser { color: #fff; border: 1px solid #ffffff80; }
|
||||
.login-consent { display: flex; align-items: center; gap: calc(8 * var(--u)); max-width: 90%; color: #eee; font-size: calc(10 * var(--u)); margin-top: calc(9 * var(--u)); }
|
||||
.login-consent { display: flex; align-items: center; gap: calc(8 * var(--u)); max-width: 90%; color: #eee; font-size: 10px; margin-top: calc(9 * var(--u)); }
|
||||
.login-consent input { accent-color: var(--yellow); width: 13px; height: 13px; }
|
||||
.login-consent button { color: var(--yellow); text-decoration: underline; font-size: inherit; }
|
||||
.login-message { min-height: calc(28 * var(--u)); max-width: 84%; color: #eee; text-align: center; font-size: calc(10 * var(--u)); line-height: 1.5; }
|
||||
.splash-bg, .guide-bg { background-image: url(../assets/img/ui-20261009/bg-splash-h5.jpg); }
|
||||
.splash-slogan { top: 7.2%; font-size: calc(23 * var(--u)); line-height: 1.16; letter-spacing: 0; font-weight: 400; }
|
||||
.login-message { min-height: calc(28 * var(--u)); max-width: 84%; color: #eee; text-align: center; font-size: 10px; line-height: 1.5; }
|
||||
.splash-bg, .guide-bg { background-image: url(../assets/img/ui-20261009/bg-splash-h5.webp); }
|
||||
.splash-slogan { top: 7.2%; font-size: 23px; line-height: 1.16; letter-spacing: 0; font-weight: 400; }
|
||||
.splash-bottom { bottom: 6.5%; gap: calc(9 * var(--u)); }
|
||||
.brand-row { margin-bottom: calc(7 * var(--u)); gap: calc(5 * var(--u)); }
|
||||
.brand-logo { width: calc(48 * var(--u)); }
|
||||
.brand-text { font-size: calc(19 * var(--u)); letter-spacing: 0; }
|
||||
.brand-text { font-size: 19px; letter-spacing: 0; }
|
||||
.btn-history { background: #fff; }
|
||||
.profile-bg { background: url(../assets/img/ui-20261009/bg-profile-h5.jpg) center/cover no-repeat; }
|
||||
.skin-desc { max-width: calc(150 * var(--u)); font-size: calc(11.5 * var(--u)); }
|
||||
#s-history { background: #efefef url(../assets/img/ui-20261009/bg-history-h5.jpg) center/cover fixed; }
|
||||
.profile-bg { background: url(../assets/img/ui-20261009/bg-profile-h5.webp) center/cover no-repeat; }
|
||||
.skin-desc { max-width: calc(150 * var(--u)); font-size: 11.5px; }
|
||||
#s-history { background: #efefef url(../assets/img/ui-20261009/bg-history-h5.webp) center/cover fixed; }
|
||||
.history-content { padding: calc(28 * var(--u)) calc(17 * var(--u)) calc(36 * var(--u)); }
|
||||
.history-count { text-align: center; padding: calc(16 * var(--u)) calc(12 * var(--u)) calc(21 * var(--u)); }
|
||||
.history-count > span { color: #888; font-size: calc(12 * var(--u)); }
|
||||
.history-count > span { color: #888; font-size: 12px; }
|
||||
.history-count > span::after { content: ''; display: block; width: calc(45 * var(--u)); margin: calc(12 * var(--u)) auto; border-bottom: 1px solid #eee; }
|
||||
.history-count p { font-size: calc(12 * var(--u)); }
|
||||
.identity-note { color: #888; font-size: calc(10 * var(--u)); line-height: 1.6; margin: calc(12 * var(--u)) calc(4 * var(--u)) calc(20 * var(--u)); }
|
||||
.history-count p { font-size: 12px; }
|
||||
.identity-note { color: #888; font-size: 10px; line-height: 1.6; margin: calc(12 * var(--u)) calc(4 * var(--u)) calc(20 * var(--u)); }
|
||||
.history-item { padding: calc(13 * var(--u)) calc(19 * var(--u)) calc(10 * var(--u)); overflow: hidden; margin-top: calc(6 * var(--u)); }
|
||||
.history-item p { font-size: calc(12 * var(--u)); padding-bottom: calc(7 * var(--u)); border-bottom: 1px solid #eee; }
|
||||
.history-item p { font-size: 12px; padding-bottom: calc(7 * var(--u)); border-bottom: 1px solid #eee; }
|
||||
.history-item p span { color: #888; margin-right: calc(8 * var(--u)); }
|
||||
.history-detail { display: block; margin: calc(7 * var(--u)) 0 0 auto; background: var(--yellow); border-radius: calc(5 * var(--u)); width: calc(80 * var(--u)); font-size: calc(12 * var(--u)); min-height: calc(23 * var(--u)); }
|
||||
.history-detail { display: block; margin: calc(7 * var(--u)) 0 0 auto; background: var(--yellow); border-radius: calc(5 * var(--u)); width: calc(80 * var(--u)); font-size: 12px; min-height: calc(23 * var(--u)); }
|
||||
.history-retry { display: block; margin: 20px auto; color: #707070; text-decoration: underline; font-size: 12px; }
|
||||
#s-report { background: #efefef url(../assets/img/ui-20261009/bg-report-h5.jpg) center top/100% auto; }
|
||||
#s-report { background: #efefef url(../assets/img/ui-20261009/bg-report-h5.webp) center top/100% auto; }
|
||||
.report-body { padding: calc(28 * var(--u)) calc(17 * var(--u)) calc(42 * var(--u)); }
|
||||
.report-title { font-size: calc(23 * var(--u)); padding: 0; margin-bottom: calc(23 * var(--u)); }
|
||||
.report-title { font-size: 23px; padding: 0; margin-bottom: calc(23 * var(--u)); }
|
||||
.card { border-radius: calc(7 * var(--u)); }
|
||||
.card-summary { display: grid; grid-template-columns: 1fr 1fr; gap: calc(15 * var(--u)) 0; padding: calc(16 * var(--u)) calc(14 * var(--u)); }
|
||||
.sum-score { width: auto; flex: none; }
|
||||
.sum-age { min-width: 0; padding-left: calc(16 * var(--u)); align-items: center; }
|
||||
.sum-age .stat-label { font-size: calc(12 * var(--u)); }
|
||||
.stat-age b { font-size: calc(54 * var(--u)); }
|
||||
.sum-age .stat-label { font-size: 12px; }
|
||||
.stat-age b { font-size: 54px; }
|
||||
.card-dims { grid-column: 1 / -1; padding: 0; }
|
||||
.dims-row { margin: 0; }
|
||||
.dim-name { font-size: calc(11 * var(--u)); }
|
||||
.dim-score { font-size: calc(27 * var(--u)); }
|
||||
.dim-score b { font-size: calc(36 * var(--u)); }
|
||||
.dim-flag { font-size: calc(8 * var(--u)); }
|
||||
.dim-name { font-size: 11px; }
|
||||
.dim-score { font-size: 27px; }
|
||||
.dim-score b { font-size: 36px; }
|
||||
.dim-flag { font-size: 8px; }
|
||||
.age-pill, .dims-note { display: none !important; }
|
||||
.dimension-title { font-size: calc(23 * var(--u)); text-align: center; margin: calc(24 * var(--u)) 0 calc(12 * var(--u)); }
|
||||
.dimension-title { font-size: 23px; text-align: center; margin: calc(24 * var(--u)) 0 calc(12 * var(--u)); }
|
||||
.card-detail { margin-top: 0; padding: calc(4 * var(--u)) calc(39 * var(--u)) calc(19 * var(--u)); }
|
||||
.detail-tabs { border: none; }
|
||||
.dtab-tx { font-size: calc(12 * var(--u)); padding-bottom: calc(10 * var(--u)); }
|
||||
.dtab-tx { font-size: 12px; padding-bottom: calc(10 * var(--u)); }
|
||||
.dtab.active .dtab-tx::after { width: calc(76 * var(--u)); height: calc(7 * var(--u)); }
|
||||
.dpanel.active { padding-top: calc(10 * var(--u)); }
|
||||
.ditem { display: block; }
|
||||
.viz-frame { position: relative; width: calc(215 * var(--u)); height: calc(278 * var(--u)); margin: 0 auto calc(12 * var(--u)); background: #f6f6f6; border-radius: calc(5 * var(--u)); overflow: hidden; }
|
||||
.ditem-img { width: 100%; height: 100%; object-fit: cover; border-radius: 0; position: relative; z-index: 1; }
|
||||
.viz-missing { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #888; font-size: calc(12 * var(--u)); }
|
||||
.ditem-img { width: 100%; height: 100%; object-fit: contain; border-radius: 0; position: relative; z-index: 1; }
|
||||
.viz-missing { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #888; font-size: 12px; }
|
||||
.ditem-img:not([hidden]) + .viz-missing { display: none; }
|
||||
.ditem-text { border-top: 1px solid #eee; padding-top: calc(10 * var(--u)); }
|
||||
.ditem-para { font-size: calc(12 * var(--u)); line-height: 1.55; }
|
||||
.ditem-para { font-size: 12px; line-height: 1.55; white-space: pre-line; }
|
||||
.card-detail { touch-action: pan-y; }
|
||||
.viz-caption { color: #707070; font-size: 11px; line-height: 1.6; margin-top: 10px; }
|
||||
.card-care { padding: calc(20 * var(--u)) calc(25 * var(--u)); }
|
||||
.card-heading { font-size: calc(23 * var(--u)); text-align: center; margin-bottom: calc(18 * var(--u)); }
|
||||
.care-item, .care-summary { font-size: calc(12 * var(--u)); }
|
||||
.card-heading { font-size: 23px; text-align: center; margin-bottom: calc(18 * var(--u)); }
|
||||
.care-item, .care-summary { font-size: 12px; }
|
||||
.prod-card { min-height: calc(175 * var(--u)); }
|
||||
.prod-info { padding: calc(32 * var(--u)) calc(15 * var(--u)) calc(20 * var(--u)); }
|
||||
#product-title { font-size: calc(14 * var(--u)); font-weight: 600; white-space: nowrap; }
|
||||
.prod-name { font-size: calc(12 * var(--u)); font-weight: 400; margin-top: calc(6 * var(--u)); }
|
||||
#product-title { font-size: 14px; font-weight: 600; white-space: nowrap; }
|
||||
.prod-info { min-width: 0; padding-left: clamp(8px, 2.5vw, 15px); padding-right: clamp(8px, 2.5vw, 15px); }
|
||||
.product-line { display: block; white-space: nowrap; }
|
||||
.prod-name { font-size: 12px; font-weight: 400; margin-top: calc(6 * var(--u)); }
|
||||
.prod-badge { width: calc(36 * var(--u)); margin-top: calc(20 * var(--u)); }
|
||||
.prod-scroll { flex-basis: 50%; }
|
||||
.prod-scroll img { object-fit: cover; aspect-ratio: 1 / 1; }
|
||||
#p-privacy { background: linear-gradient(#0008,#0008),url(../assets/img/ui-20261009/bg-splash-h5.jpg) center/cover; }
|
||||
#p-privacy { background: linear-gradient(#0008,#0008),url(../assets/img/ui-20261009/bg-splash-h5.webp) center/cover; }
|
||||
#p-photoguide .popup { padding: calc(62 * var(--u)) calc(30 * var(--u)) calc(40 * var(--u)); }
|
||||
.popup-title-center { text-align: left; margin: 0 0 calc(18 * var(--u)); }
|
||||
.guide-description { font-size: calc(12 * var(--u)); line-height: 1.6; margin-bottom: calc(35 * var(--u)); }
|
||||
.guide-description { font-size: 12px; line-height: 1.6; margin-bottom: calc(35 * var(--u)); }
|
||||
.cam-feature-lines { fill: none; stroke: #fff; stroke-width: 2.5; stroke-dasharray: 5 5; filter: drop-shadow(0 1px 1px #000); }
|
||||
#s-cam:not(.cam-live) .cam-feature-lines { stroke: #111; filter: none; }
|
||||
.font-credit { display: block; text-align: center; color: #888; font-size: 10px; margin: 24px auto 0; }
|
||||
@media (max-height: 560px) { .login-brand { top: 8%; } .login-bottom { bottom: 2%; } .splash-slogan { font-size: calc(18 * var(--u)); } }
|
||||
.account-status { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-radius: 8px; font-size: 12px; }
|
||||
.account-status span::before { content: ''; display: inline-block; width: 7px; height: 7px; margin-right: 7px; border-radius: 50%; background: #b7bac0; }
|
||||
.account-status[data-kind="wechat"] span::before { background: #34ca7b; }
|
||||
.account-status small { font-size: 11px; opacity: .8; }
|
||||
.account-status-home { position: absolute; top: max(10px, env(safe-area-inset-top)); right: 12px; z-index: 2; background: #0008; color: #fff; }
|
||||
#s-splash .splash-slogan { top: max(7.2%, calc(env(safe-area-inset-top) + 52px)); }
|
||||
.account-status-history { width: 100%; background: #fff; margin-bottom: 14px; text-align: left; }
|
||||
.login-success-popup { min-height: 0; text-align: center; gap: 18px; align-items: center; padding: 30px 24px; }
|
||||
.login-success-icon { width: 48px; height: 48px; border-radius: 50%; background: #e9f8ef; color: #17994f; font-size: 30px; line-height: 48px; }
|
||||
.login-success-popup h2 { font-size: 20px; font-weight: 600; }
|
||||
.login-success-popup p { font-size: 14px; line-height: 1.7; color: #666; white-space: pre-line; }
|
||||
@media (max-height: 560px) { .login-brand { top: 8%; } .login-bottom { bottom: 2%; } }
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<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="preload" as="image" href="assets/img/ui-20261009/bg-login-h5.webp" fetchpriority="high">
|
||||
<link rel="stylesheet" href="vendor/mobile-select/mobile-select.css">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<link rel="stylesheet" href="css/ui-20261009.css">
|
||||
@@ -31,6 +31,7 @@
|
||||
<section class="screen" id="s-history">
|
||||
<div class="history-content">
|
||||
<h1 class="report-title">您的报告</h1>
|
||||
<button class="account-status account-status-history" data-action="switch-login" aria-label="切换登录方式"><span data-auth-status></span><small>切换登录方式 ›</small></button>
|
||||
<div class="card history-count"><span>测试数量</span><p id="history-count">正在读取报告…</p></div>
|
||||
<p class="identity-note" id="identity-note"></p>
|
||||
<div id="history-list" aria-live="polite"></div>
|
||||
@@ -40,6 +41,7 @@
|
||||
</section>
|
||||
<section class="screen" id="s-splash">
|
||||
<div class="splash-bg"></div>
|
||||
<button class="account-status account-status-home" data-action="switch-login" aria-label="切换登录方式"><span data-auth-status></span><small>切换 ›</small></button>
|
||||
<p class="splash-slogan">ONE ME,<br>FROM THE INSIDE OUT<br>以我为始 自有表现</p>
|
||||
<div class="splash-bottom">
|
||||
<div class="brand-row">
|
||||
@@ -223,6 +225,7 @@
|
||||
<div class="dpanel" data-panel="1">
|
||||
<div class="ditem"><div class="viz-frame"><img id="viz-smooth" class="ditem-img" alt="平滑度检测图" hidden><span class="viz-missing">本次未获取平滑度检测图</span></div>
|
||||
<div class="ditem-text"><p class="ditem-para" id="tx-smooth">检测完成后显示分析结果。</p></div></div>
|
||||
<p class="viz-caption" id="smooth-map-note"></p>
|
||||
</div>
|
||||
<div class="dpanel" data-panel="2">
|
||||
<div class="ditem"><div class="viz-frame"><img id="viz-water" class="ditem-img" alt="水分检测图" hidden><span class="viz-missing">本次未获取水分检测图</span></div>
|
||||
@@ -304,7 +307,7 @@
|
||||
<img src="assets/img/ui-20261009/icon-close.png" alt="关闭">
|
||||
</button>
|
||||
<h2 class="popup-title popup-title-center">请拍摄或上传<br>正面及左右侧面照片</h2>
|
||||
<p class="guide-description">请在光线充足处,取下眼镜,保持面部清晰入镜。</p>
|
||||
<p class="guide-description">为使测试结果更准确,请关闭美颜/滤镜、并确保光线充足均匀。</p>
|
||||
<button class="btn btn-primary popup-btn" data-action="start-camera">开始</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -460,7 +463,15 @@
|
||||
<!-- ═══════════ Toast ═══════════ -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="vendor/faceapi/face-api.js" defer></script>
|
||||
<div class="overlay" id="p-login-success" role="dialog" aria-modal="true" aria-labelledby="login-success-title" aria-describedby="login-success-message">
|
||||
<div class="popup login-success-popup">
|
||||
<div class="login-success-icon" aria-hidden="true">✓</div>
|
||||
<h2 id="login-success-title">登录成功</h2>
|
||||
<p id="login-success-message"></p>
|
||||
<button class="btn btn-primary" data-action="login-confirm">我知道了</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/head-pose.js" defer></script>
|
||||
<script src="js/skin-score.js" defer></script>
|
||||
<script src="vendor/mobile-select/mobile-select.iife.min.js" defer></script>
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
/* Browser identity is a random credential, never a fingerprint or a report. */
|
||||
window.AccountClient = (function () {
|
||||
'use strict';
|
||||
var key = 'juzi.identity.v1', user = null;
|
||||
function read() { try { return localStorage.getItem(key); } catch (_) { return null; } }
|
||||
var key = 'juzi.identity.v1', kindKey = 'juzi.auth.kind.v1', user = null;
|
||||
function read(name) { try { return localStorage.getItem(name || key); } catch (_) { return null; } }
|
||||
function rememberKind() { try { if (user) localStorage.setItem(kindKey, user.kind); } catch (_) {} }
|
||||
function request(url, data) {
|
||||
var controller = new AbortController();
|
||||
// Bound only authentication waits. Full report snapshots contain photos and
|
||||
// must retain their existing download budget on a slow mobile connection.
|
||||
var timer = url.indexOf('api/auth/') === 0 ? setTimeout(function () { controller.abort(); }, 12000) : null;
|
||||
return fetch(url, { credentials: 'same-origin', method: data ? 'POST' : 'GET',
|
||||
signal: controller.signal,
|
||||
headers: data ? { 'Content-Type': 'application/json' } : {},
|
||||
body: data ? JSON.stringify(data) : undefined }).then(function (r) {
|
||||
return r.json().then(function (v) { if (!r.ok || !v.ok) throw new Error(v.message || '请求失败'); return v; });
|
||||
});
|
||||
}).catch(function (error) {
|
||||
if (error.name === 'AbortError') throw new Error('网络响应超时,请检查网络后重试');
|
||||
throw error;
|
||||
}).finally(function () { clearTimeout(timer); });
|
||||
}
|
||||
function browser(consent) {
|
||||
return request('api/auth/browser', { consent: consent, token: read() }).then(function (v) {
|
||||
try { localStorage.setItem(key, v.token); } catch (_) { /* session cookie still works */ }
|
||||
user = v.user; return v;
|
||||
user = v.user; rememberKind(); return v;
|
||||
});
|
||||
}
|
||||
function init() {
|
||||
function init(options) {
|
||||
return request('api/auth/session').then(function (v) {
|
||||
user = v.user;
|
||||
if (!user && read()) return browser(true).catch(function (e) {
|
||||
rememberKind();
|
||||
// A missing WeChat session is not permission to switch to a browser account.
|
||||
// OAuth returns must also stay explicit when its cookie was not retained.
|
||||
if (!user && !(options && options.restoreBrowser === false) && read(kindKey) !== 'wechat' && read()) return browser(true).catch(function (e) {
|
||||
// Do not replace an existing credential on a transient server/network error.
|
||||
if (/身份已失效/.test(e.message)) { try { localStorage.removeItem(key); } catch (_) {} }
|
||||
throw e;
|
||||
|
||||
@@ -32,8 +32,38 @@
|
||||
|
||||
/* ─── 屏幕管理:页面内不放返回按钮,前进时 pushState,微信/浏览器返回键经 popstate 回到上一屏 ─── */
|
||||
var currentScreen = 's-login';
|
||||
var fontStylesLoaded = false;
|
||||
function loadAppFont() {
|
||||
if (fontStylesLoaded) return;
|
||||
fontStylesLoaded = true;
|
||||
var link = document.createElement('link');
|
||||
link.rel = 'stylesheet'; link.href = 'css/fonts.css';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
function renderAccountStatus() {
|
||||
var user = AccountClient.user();
|
||||
document.querySelectorAll('[data-auth-status]').forEach(function (label) {
|
||||
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : '本机浏览器已登录') : '尚未登录';
|
||||
label.parentElement.dataset.kind = user ? user.kind : '';
|
||||
});
|
||||
}
|
||||
function loginSucceeded() {
|
||||
showScreen('s-splash', 'replace');
|
||||
$('#login-success-message').textContent = AccountClient.user().kind === 'wechat'
|
||||
? '当前使用:微信登录\n同一微信账号可跨手机查看报告。'
|
||||
: '当前使用:本机浏览器登录\n报告仅能通过当前浏览器身份查看。';
|
||||
openOverlay('p-login-success');
|
||||
document.querySelectorAll('.screen').forEach(function (screen) { screen.inert = true; });
|
||||
$('[data-action="login-confirm"]').focus();
|
||||
}
|
||||
function closeLoginSuccess() {
|
||||
closeOverlay('p-login-success');
|
||||
document.querySelectorAll('.screen').forEach(function (screen) { screen.inert = false; });
|
||||
$('#s-splash [data-action="open-privacy"]').focus({ preventScroll: true });
|
||||
}
|
||||
function showScreen(id, nav) { // nav: 'push' 新增历史 | 'replace' 替换当前历史 | 缺省不动历史
|
||||
if (id !== 's-login' && !AccountClient.user() && !(/[?&]mock=1/.test(location.search))) id = 's-login';
|
||||
if (['s-profile', 's-cam', 's-report'].includes(id)) loadAppFont();
|
||||
document.querySelectorAll('.screen').forEach(function (s) {
|
||||
s.classList.toggle('active', s.id === id);
|
||||
});
|
||||
@@ -42,6 +72,7 @@
|
||||
document.body.scrollTop = 0;
|
||||
sc.scrollTop = 0;
|
||||
currentScreen = id;
|
||||
renderAccountStatus();
|
||||
if (id === 's-report') resetReport(); // 每次进入报告页都还原初始状态
|
||||
if (nav === 'push') history.pushState({ screen: id }, '');
|
||||
else if (nav === 'replace') history.replaceState({ screen: id }, '');
|
||||
@@ -52,6 +83,7 @@
|
||||
window.addEventListener('popstate', function (e) {
|
||||
if (Date.now() < popSilentUntil) { popSilentUntil = 0; return; }
|
||||
var to = (e.state && e.state.screen) || 's-splash';
|
||||
if ($('#p-login-success').classList.contains('show')) closeLoginSuccess();
|
||||
['p-privacy', 'p-photoguide', 'p-policy', 'p-zoom'].forEach(closeOverlay);
|
||||
if (currentScreen === 's-cam') leaveCamera();
|
||||
if (to === 's-cam') { to = 's-profile'; history.replaceState({ screen: to }, ''); } // 「前进」回拍照页不自动开相机
|
||||
@@ -60,7 +92,12 @@
|
||||
|
||||
/* ─── 报告页状态重置(各维度卡回到第一个子 Tab、产品横滑归零) ─── */
|
||||
function setDetailTab(card, idx) {
|
||||
card.querySelectorAll('.dtab').forEach(function (t) { t.classList.toggle('active', t.dataset.tab === idx); });
|
||||
card.querySelectorAll('.dtab').forEach(function (t) {
|
||||
var selected = t.dataset.tab === idx;
|
||||
t.classList.toggle('active', selected);
|
||||
t.setAttribute('aria-selected', String(selected));
|
||||
t.setAttribute('tabindex', selected ? '0' : '-1');
|
||||
});
|
||||
card.querySelectorAll('.dpanel').forEach(function (p) { p.classList.toggle('active', p.dataset.panel === idx); });
|
||||
}
|
||||
function resetReport() {
|
||||
@@ -86,6 +123,11 @@
|
||||
bindBackdropClose('p-policy');
|
||||
// ESC 关闭(分析中过渡不响应;放大图/协议层在最上,优先关)
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if ($('#p-login-success').classList.contains('show')) {
|
||||
if (e.key === 'Escape' || e.key === 'Esc') closeLoginSuccess();
|
||||
if (e.key === 'Tab') { e.preventDefault(); $('[data-action="login-confirm"]').focus(); }
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Escape' && e.key !== 'Esc') return;
|
||||
var order = ['p-zoom', 'p-policy', 'p-privacy', 'p-photoguide'];
|
||||
for (var i = 0; i < order.length; i++) {
|
||||
@@ -323,7 +365,7 @@
|
||||
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框,快门出现
|
||||
toast('已切换为系统相机拍摄');
|
||||
// 系统相机拍回的照片也要校验(有脸、角度对),模型照常预加载
|
||||
if (!IS_MOCK && typeof faceapi !== 'undefined') loadModels().catch(function () {});
|
||||
if (!IS_MOCK) loadModels().catch(function () {});
|
||||
}
|
||||
// 从视频帧截取照片(完整帧、摄像头原始视角,压到最长边 1280 的 JPEG)
|
||||
// 抓完整帧而非取景框可见区:新 UI 轮廓外全黑,用户只关注轮廓内人脸;按可见区裁切在侧脸/偏站位时会切掉人脸(Face++ INVALID_IMAGE_FACE)
|
||||
@@ -545,7 +587,28 @@
|
||||
var CENTER_TOL = 0.18; // 人脸中心偏离取景中线超过屏宽 18% → 提示移入取景框
|
||||
var LUMA_DARK = 65, LUMA_BRIGHT = 235; // 脸部平均亮度(0–255):过暗/过曝提示调整光线
|
||||
|
||||
var modelsPromise = null, modelsOk = false, detectDisabled = false; // detectDisabled:face-api 缺失→全程手动;模型加载失败仅当次禁用,下次进拍照页重试
|
||||
var modelsPromise = null, modelsOk = false, detectDisabled = false;
|
||||
var faceApiPromise = null;
|
||||
function loadFaceApi() {
|
||||
if (window.faceapi) return Promise.resolve();
|
||||
if (!faceApiPromise) {
|
||||
faceApiPromise = new Promise(function (resolve, reject) {
|
||||
var script = document.createElement('script');
|
||||
var timer = setTimeout(function () { finish(new Error('检测引擎加载超时')); }, 20000);
|
||||
function finish(error) {
|
||||
clearTimeout(timer);
|
||||
script.onload = script.onerror = null;
|
||||
if (error) { script.remove(); faceApiPromise = null; reject(error); }
|
||||
else resolve();
|
||||
}
|
||||
script.src = 'vendor/faceapi/face-api.js';
|
||||
script.onload = function () { finish(window.faceapi ? null : new Error('检测引擎不可用')); };
|
||||
script.onerror = function () { finish(new Error('检测引擎加载失败')); };
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
return faceApiPromise;
|
||||
}
|
||||
var detRunning = false, detTimer = null; // 检测循环
|
||||
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
|
||||
var nudgeTimer = null; // 12s 无进展提示
|
||||
@@ -555,10 +618,10 @@
|
||||
// 模型只加载一次(缓存 promise);失败清空缓存,下次进拍照页重试
|
||||
function loadModels() {
|
||||
if (!modelsPromise) {
|
||||
modelsPromise = Promise.all([
|
||||
modelsPromise = loadFaceApi().then(function () { return Promise.all([
|
||||
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
|
||||
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL)
|
||||
]).then(function () { modelsOk = true; },
|
||||
]); }).then(function () { modelsOk = true; },
|
||||
function (err) { modelsPromise = null; throw err; });
|
||||
}
|
||||
return modelsPromise;
|
||||
@@ -968,11 +1031,7 @@
|
||||
var VIZ_CAT = {
|
||||
contour: { img: '#ditem-img1', photo: true }, // 轮廓:用户正脸实拍
|
||||
wrinkles: { img: '#ditem-img2', overlay: 'texture_enhanced_lines', dim: 0.55 }, // 皱纹:正脸压暗+皱纹增强线(图层几乎全透明,直挂在白卡上不可读,故合成)
|
||||
smooth: { img: '#viz-smooth', photo: true, marks: [ // 光滑:正脸+色斑/痣/痤疮标记
|
||||
{ key: 'brown_spot', color: '#B26A00' }, // 色斑
|
||||
{ key: 'mole', color: '#282828' }, // 痣
|
||||
{ key: 'acne', color: '#D30C0C' } // 痤疮
|
||||
] },
|
||||
smooth: { img: '#viz-smooth', overlays: ['texture_enhanced_pores', 'texture_enhanced_blackheads'] },
|
||||
water: { img: '#viz-water', blendMap: 'water_area' },
|
||||
tone: { img: '#viz-tone', blendMap: 'brown_area' }, // 肤色水润:正脸×水分图正片叠底(water_area,接口返回)
|
||||
sens: { img: '#viz-sens', blendMap: 'red_area' }, // 敏感:正脸×红区图正片叠底(直挂白底过淡不可读)
|
||||
@@ -985,6 +1044,25 @@
|
||||
};
|
||||
|
||||
/* viz-pure-begin(纯函数段:与 DOM 无关,node 冒烟按锚点抽离断言,勿改锚点注释) */
|
||||
// Rectangle coordinates come from the original upload; crop only the rendered
|
||||
// composite, never the photo sent to the algorithm or saved in the report.
|
||||
function vizFaceCrop(width, height, rectangle) {
|
||||
var full = { left: 0, top: 0, width: width, height: height, focused: false };
|
||||
if (!rectangle) return full;
|
||||
var keys = ['left', 'top', 'width', 'height'];
|
||||
if (!keys.every(function (key) { return typeof rectangle[key] === 'number' && isFinite(rectangle[key]); })) return full;
|
||||
if (rectangle.width <= 0 || rectangle.height <= 0 || rectangle.left < 0 || rectangle.top < 0 ||
|
||||
rectangle.left + rectangle.width > width || rectangle.top + rectangle.height > height) return full;
|
||||
var ratio = 215 / 278;
|
||||
var ch = Math.min(Math.max(rectangle.height * 1.55, rectangle.width * 1.18 / ratio), height, width / ratio);
|
||||
var cw = ch * ratio;
|
||||
if (cw < rectangle.width || ch < rectangle.height) return full;
|
||||
var left = Math.max(0, Math.min(width - cw, rectangle.left + rectangle.width / 2 - cw / 2));
|
||||
var top = Math.max(0, Math.min(height - ch, rectangle.top - rectangle.height * .3));
|
||||
// Never omit a part of the detected face in an edge-of-frame photograph.
|
||||
if (top + ch < rectangle.top + rectangle.height) top = rectangle.top + rectangle.height - ch;
|
||||
return { left: left, top: top, width: cw, height: ch, focused: true };
|
||||
}
|
||||
// 从 result 提取某分类的标记集合:polygon 与 rectangle 都保留,缺该标记类型则跳过
|
||||
function vizCollectMarks(markDefs, result) {
|
||||
var out = [];
|
||||
@@ -1050,26 +1128,32 @@
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
// canvas 合成:照片绘制到自然尺寸 → (可选压暗)→ (可选盖透明增强图层)→ 画坐标标记 → dataURL 挂到卡片 <img>
|
||||
// canvas 合成:照片和图层使用同一人脸裁切区 → (可选压暗/增强)→ 坐标标记 → 展示图
|
||||
var vizEpoch = 0;
|
||||
function vizCompose(photoURL, overlayURL, marks, imgEl, opts) {
|
||||
var epoch = vizEpoch;
|
||||
vizLoadImage(photoURL, function (photo) {
|
||||
if (!photo) return; // 照片不可用:保留占位
|
||||
var finish = function (overlay) {
|
||||
var finish = function (overlays) {
|
||||
if (epoch !== vizEpoch) return;
|
||||
var w = photo.naturalWidth, h = photo.naturalHeight;
|
||||
var crop = vizFaceCrop(photo.naturalWidth, photo.naturalHeight, opts && opts.faceRectangle);
|
||||
var scale = Math.min(1, 960 / crop.width);
|
||||
var w = Math.round(crop.width * scale), h = Math.round(crop.height * scale);
|
||||
var cv = document.createElement('canvas');
|
||||
cv.width = w; cv.height = h;
|
||||
var ctx = cv.getContext('2d');
|
||||
ctx.drawImage(photo, 0, 0, w, h);
|
||||
ctx.drawImage(photo, crop.left, crop.top, crop.width, crop.height, 0, 0, w, h);
|
||||
if (opts && opts.dim) { ctx.fillStyle = 'rgba(0,0,0,' + opts.dim + ')'; ctx.fillRect(0, 0, w, h); } // 压暗底色让增强线可读
|
||||
if (overlay) {
|
||||
overlays.forEach(function (overlay) {
|
||||
if (!overlay) return;
|
||||
if (opts && opts.blend) ctx.globalCompositeOperation = opts.blend; // 正片叠底:白底图层(红区/色沉)叠到脸上
|
||||
ctx.drawImage(overlay, 0, 0, w, h);
|
||||
var ox = overlay.naturalWidth / photo.naturalWidth, oy = overlay.naturalHeight / photo.naturalHeight;
|
||||
ctx.drawImage(overlay, crop.left * ox, crop.top * oy, crop.width * ox, crop.height * oy, 0, 0, w, h);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
var scaled = vizScaleMarks(marks, w / photo.naturalWidth);
|
||||
});
|
||||
ctx.save();
|
||||
ctx.translate(-crop.left * scale, -crop.top * scale);
|
||||
var scaled = vizScaleMarks(marks, scale);
|
||||
var lw = Math.max(3, Math.round(w / 200)); // 线宽随图走,卡片缩略后仍可见
|
||||
var minBox = Math.round(w / 36); // 过小标记放大到最小框,保证缩略可见
|
||||
for (var i = 0; i < scaled.length; i++) {
|
||||
@@ -1096,23 +1180,42 @@
|
||||
ctx.strokeRect(cx - rw / 2, cy - rh / 2, rw, rh);
|
||||
}
|
||||
}
|
||||
try { imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false; } catch (e) { /* 合成失败保留占位 */ }
|
||||
ctx.restore();
|
||||
try {
|
||||
imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false;
|
||||
imgEl.dataset.crop = crop.focused ? 'face' : 'full';
|
||||
} catch (e) { /* 合成失败保留占位 */ }
|
||||
};
|
||||
if (overlayURL) vizLoadImage(overlayURL, finish);
|
||||
else finish(null);
|
||||
var urls = Array.isArray(overlayURL) ? overlayURL : overlayURL ? [overlayURL] : [];
|
||||
Promise.all(urls.map(function (url) { return new Promise(function (resolve) { vizLoadImage(url, resolve); }); })).then(finish);
|
||||
});
|
||||
}
|
||||
|
||||
// 报告页 6 张检测图统一入口:任一输入缺失 → 该卡保留既有占位(不报错)
|
||||
// 报告页 7 张检测图统一入口:缺失输入按分类降级(不报错)
|
||||
function renderViz(payload, frontPhoto) {
|
||||
vizEpoch++;
|
||||
document.querySelectorAll('.ditem-img').forEach(function (img) { img.hidden = true; img.removeAttribute('src'); });
|
||||
var result = payload && payload.result;
|
||||
var maps = (result && result.face_maps) || {};
|
||||
var rectangle = payload && payload.face_rectangle;
|
||||
for (var key in VIZ_CAT) {
|
||||
var cfg = VIZ_CAT[key];
|
||||
var imgEl = $(cfg.img);
|
||||
if (!imgEl) continue;
|
||||
if (cfg.overlays) {
|
||||
var mapNotes = [], overlays = [];
|
||||
cfg.overlays.forEach(function (name) {
|
||||
var url = vizMapURL(maps[name]);
|
||||
if (!url) return;
|
||||
overlays.push(url);
|
||||
mapNotes.push(name === 'texture_enhanced_pores' ? '蓝色为毛孔增强标记' : '红色为黑头增强标记');
|
||||
});
|
||||
$('#smooth-map-note').textContent = overlays.length
|
||||
? mapNotes.join(',') + ';标记不代表严重程度,具体以检测计数和结果为准。'
|
||||
: '本次未获取毛孔、黑头增强图;此处仅展示拍摄照片,不绘制色斑、痣或痤疮框。';
|
||||
if (frontPhoto) vizCompose(frontPhoto, overlays, [], imgEl, { faceRectangle: rectangle });
|
||||
continue;
|
||||
}
|
||||
if (cfg.map) { // 整幅图层:图层缺失 → 保留占位
|
||||
var u = vizMapURL(maps[cfg.map]);
|
||||
if (u) { imgEl.src = u; imgEl.hidden = false; }
|
||||
@@ -1122,21 +1225,20 @@
|
||||
var bu = vizMapURL(maps[cfg.blendMap]);
|
||||
if (!bu) continue;
|
||||
if (!frontPhoto) { imgEl.src = bu; imgEl.hidden = false; continue; }
|
||||
vizCompose(frontPhoto, bu, [], imgEl, { blend: 'multiply' });
|
||||
vizCompose(frontPhoto, bu, [], imgEl, { blend: 'multiply', faceRectangle: rectangle });
|
||||
continue;
|
||||
}
|
||||
if (cfg.overlay) { // 透明增强图层(皱纹):有照片则合成(可按 cfg.dim 压暗),无照片单挂图层;图层缺失 → 保留占位
|
||||
var ov = vizMapURL(maps[cfg.overlay]);
|
||||
if (!ov) continue;
|
||||
if (!frontPhoto) { imgEl.src = ov; imgEl.hidden = false; continue; }
|
||||
vizCompose(frontPhoto, ov, [], imgEl, { dim: cfg.dim });
|
||||
vizCompose(frontPhoto, ov, [], imgEl, { dim: cfg.dim, faceRectangle: rectangle });
|
||||
continue;
|
||||
}
|
||||
// 照片类(轮廓/光滑/痘痘):无照片 → 保留占位;有坐标则 canvas 叠加,无坐标纯照片
|
||||
if (!frontPhoto) continue;
|
||||
var marks = cfg.marks ? vizCollectMarks(cfg.marks, result) : [];
|
||||
if (!marks.length) { imgEl.src = frontPhoto; imgEl.hidden = false; continue; }
|
||||
vizCompose(frontPhoto, null, marks, imgEl);
|
||||
vizCompose(frontPhoto, null, marks, imgEl, { faceRectangle: rectangle });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1197,7 +1299,11 @@
|
||||
$('#product-unavailable').hidden = !!product;
|
||||
if (product) {
|
||||
$('#product-title').textContent = product.title;
|
||||
$('#product-name').textContent = product.name + '双仓精华液';
|
||||
$('#product-name').replaceChildren();
|
||||
[product.name, '双仓精华液'].forEach(function (line) {
|
||||
var span = document.createElement('span'); span.className = 'product-line'; span.textContent = line;
|
||||
$('#product-name').appendChild(span);
|
||||
});
|
||||
$('#product-image').src = product.image;
|
||||
$('#product-image').alt = product.name + '双仓精华液';
|
||||
}
|
||||
@@ -1206,12 +1312,6 @@
|
||||
/* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */
|
||||
function beginDetect() {
|
||||
if (IS_MOCK || camFallback) { resetDetectState(); return; }
|
||||
if (typeof faceapi === 'undefined') {
|
||||
detectDisabled = true; // face-api 本身缺失:全程手动,不再重试
|
||||
resetDetectState();
|
||||
toast('自动识别不可用,已切换手动拍摄');
|
||||
return;
|
||||
}
|
||||
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
|
||||
resetDetectState();
|
||||
if (!modelsOk) showCamStatus('检测引擎加载中…');
|
||||
@@ -1244,7 +1344,47 @@
|
||||
|
||||
/* ─── 报告页:每张维度卡各自的子 Tab;点检测图放大,再点返回 ─── */
|
||||
document.querySelectorAll('.card-detail').forEach(function (card) {
|
||||
card.querySelector('.detail-tabs').setAttribute('role', 'tablist');
|
||||
card.querySelectorAll('.dtab').forEach(function (tab) { tab.setAttribute('role', 'tab'); });
|
||||
setDetailTab(card, '0');
|
||||
var gesture = null, suppressImageClick = false;
|
||||
card.addEventListener('pointerdown', function (e) {
|
||||
if (e.isPrimary === false || (e.pointerType === 'mouse' && e.button !== 0)) return;
|
||||
gesture = { id: e.pointerId, x: e.clientX, y: e.clientY, horizontal: false };
|
||||
suppressImageClick = false;
|
||||
});
|
||||
card.addEventListener('pointermove', function (e) {
|
||||
if (!gesture || gesture.id !== e.pointerId) return;
|
||||
var dx = e.clientX - gesture.x, dy = e.clientY - gesture.y;
|
||||
if (Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(dy) * 1.4) {
|
||||
gesture.horizontal = true;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
}
|
||||
});
|
||||
card.addEventListener('pointerup', function (e) {
|
||||
if (!gesture || gesture.id !== e.pointerId) return;
|
||||
var dx = e.clientX - gesture.x, dy = e.clientY - gesture.y;
|
||||
if (gesture.horizontal && Math.abs(dx) >= 45 && Math.abs(dx) > Math.abs(dy) * 1.4) {
|
||||
var tabs = Array.from(card.querySelectorAll('.dtab'));
|
||||
var active = tabs.findIndex(function (tab) { return tab.classList.contains('active'); });
|
||||
var next = Math.max(0, Math.min(tabs.length - 1, active + (dx < 0 ? 1 : -1)));
|
||||
setDetailTab(card, tabs[next].dataset.tab);
|
||||
suppressImageClick = true;
|
||||
}
|
||||
gesture = null;
|
||||
});
|
||||
card.addEventListener('pointercancel', function () { gesture = null; });
|
||||
card.addEventListener('dragstart', function (e) { if (e.target.closest('.ditem-img')) e.preventDefault(); });
|
||||
card.addEventListener('keydown', function (e) {
|
||||
var tab = e.target.closest('.dtab');
|
||||
if (!tab || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
|
||||
e.preventDefault();
|
||||
var tabs = Array.from(card.querySelectorAll('.dtab')), index = tabs.indexOf(tab);
|
||||
index = e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : (index + (e.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
|
||||
setDetailTab(card, tabs[index].dataset.tab); tabs[index].focus({ preventScroll: true });
|
||||
});
|
||||
card.addEventListener('click', function (e) {
|
||||
if (suppressImageClick) { suppressImageClick = false; e.preventDefault(); return; }
|
||||
var btn = e.target.closest('.dtab');
|
||||
if (btn) { setDetailTab(card, btn.dataset.tab); return; }
|
||||
var img = e.target.closest('.ditem-img');
|
||||
@@ -1265,6 +1405,7 @@
|
||||
'open-policy': function () { openOverlay('p-policy'); },
|
||||
'login-browser': function () { loginBrowser(); },
|
||||
'login-wechat': function () { loginWechat(); },
|
||||
'login-confirm': closeLoginSuccess,
|
||||
'open-privacy': function () { if (!AccountClient.user()) { showScreen('s-login', 'replace'); return; } openOverlay('p-privacy'); },
|
||||
'close-privacy': function () { closeOverlay('p-privacy'); },
|
||||
'close-policy': function () { closeOverlay('p-policy'); },
|
||||
@@ -1304,7 +1445,13 @@
|
||||
startCamera(); // 复用启停与预热(camSeqCam 防竞态)
|
||||
},
|
||||
'history': function () { openHistory(); },
|
||||
'history-retry': function () { renderHistoryList(); }
|
||||
'history-retry': function () { renderHistoryList(); },
|
||||
'switch-login': function () {
|
||||
historySeq++;
|
||||
$('#login-consent').checked = false;
|
||||
$('#login-message').textContent = '微信与本机浏览器为独立账号,报告不会自动合并。请选择登录方式;返回可保留当前登录。';
|
||||
showScreen('s-login', 'push');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
@@ -1364,25 +1511,20 @@
|
||||
loginBusy = true;
|
||||
$('#login-message').textContent = '正在登录…';
|
||||
AccountClient.browser(true).then(function () {
|
||||
showScreen('s-splash', 'replace');
|
||||
loginSucceeded();
|
||||
}).catch(function (e) { $('#login-message').textContent = e.message; })
|
||||
.finally(function () { loginBusy = false; });
|
||||
}
|
||||
function loginWechat() {
|
||||
if (!loginAllowed()) return;
|
||||
if (!/MicroMessenger/i.test(navigator.userAgent)) {
|
||||
$('#login-message').textContent = '请在微信内打开本页面,或使用本机浏览器登录。';
|
||||
return;
|
||||
}
|
||||
loginBusy = true;
|
||||
AccountClient.request('api/auth/config').then(function (data) {
|
||||
if (!data.wechat) {
|
||||
$('#login-message').textContent = data.reason + '。报告仍会保存至服务端。';
|
||||
return;
|
||||
}
|
||||
if (!/MicroMessenger/i.test(navigator.userAgent)) {
|
||||
$('#login-message').textContent = '请在微信内打开本页面,或使用本机浏览器登录。';
|
||||
return;
|
||||
}
|
||||
return AccountClient.request('api/auth/wechat', { consent: true }).then(function (data) {
|
||||
location.assign(data.url);
|
||||
});
|
||||
$('#login-message').textContent = '正在前往微信授权…';
|
||||
AccountClient.request('api/auth/wechat', { consent: true }).then(function (data) {
|
||||
location.assign(data.url);
|
||||
}).catch(function (e) { $('#login-message').textContent = e.message; })
|
||||
.finally(function () { loginBusy = false; });
|
||||
}
|
||||
@@ -1427,14 +1569,34 @@
|
||||
history.replaceState({ screen: 's-login' }, ''); // 刷新后从启动页开始,历史栈的屏标记与之对齐
|
||||
renderCamStep();
|
||||
if (!(/[?&]mock=1/.test(location.search) && /[?&]screen=report/.test(location.search))) {
|
||||
var loginError = /[?&]login_error=([^&#]*)/.exec(location.search);
|
||||
var loginSuccess = /[?&]login=success(?:&|$)/.test(location.search);
|
||||
var oauthReturn = !!loginError || loginSuccess;
|
||||
loginBusy = true;
|
||||
AccountClient.init().then(function () {
|
||||
if (AccountClient.user()) showScreen('s-splash', 'replace');
|
||||
else $('#login-message').textContent = /[?&]login_error=/.test(location.search)
|
||||
? '微信登录未完成,可重试或使用本机浏览器登录。'
|
||||
: '本机登录仅限当前浏览器,不支持换手机找回。';
|
||||
AccountClient.init({ restoreBrowser: !oauthReturn }).then(function () {
|
||||
if (loginError) {
|
||||
var code = loginError[1];
|
||||
$('#login-message').textContent = code === 'state'
|
||||
? '登录链接已过期或会话丢失,请在同一微信窗口重新点击微信登录。'
|
||||
: code === 'network' ? '暂时无法连接微信服务,请稍后重新登录。'
|
||||
: '微信授权未完成,请重试;如仍失败,请将错误页面截图发给管理员。也可选择本机浏览器登录。';
|
||||
} else if (loginSuccess && (!AccountClient.user() || AccountClient.user().kind !== 'wechat')) {
|
||||
$('#login-message').textContent = '微信返回后登录会话未能保存,请在同一微信窗口重新登录。';
|
||||
} else if (AccountClient.user()) {
|
||||
if (loginSuccess) loginSucceeded();
|
||||
else showScreen('s-splash', 'replace');
|
||||
}
|
||||
else $('#login-message').textContent = '微信登录可跨手机查看报告;本机登录仅限当前浏览器。';
|
||||
}).catch(function (e) { $('#login-message').textContent = e.message; })
|
||||
.finally(function () { loginBusy = false; });
|
||||
.finally(function () {
|
||||
loginBusy = false;
|
||||
if (oauthReturn) {
|
||||
var clean = new URL(location.href);
|
||||
clean.searchParams.delete('login'); clean.searchParams.delete('login_error');
|
||||
if (clean.hash === '#wechat_redirect') clean.hash = '';
|
||||
history.replaceState(history.state, '', clean.pathname + clean.search + clean.hash);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口)
|
||||
// 有 MOCK_FIXTURE 时走 fixture(检测可视化同步出图),否则回退 SkinScore 内置样本
|
||||
|
||||
@@ -17,6 +17,47 @@
|
||||
var levelMap = { '预警': '改善', '改善': '良好', '良好': '优秀' };
|
||||
var dimNames = { firm: '紧塑力', vita: '透亮感', stab: '稳韧力' };
|
||||
function numeric(value) { return typeof value === 'number' && isFinite(value); }
|
||||
function scalar(value) { return numeric(value) ? value : value && numeric(value.value) ? value.value : null; }
|
||||
var wrinkleDefs = [
|
||||
['forehead', '抬头纹(额头纹)', ['forehead_wrinkle_info'], 'forehead_count'],
|
||||
['left_nasolabial', '左法令纹', ['left_nasolabial_wrinkle_info', 'left_nasolabial_fold_info'], 'left_nasolabial_count'],
|
||||
['right_nasolabial', '右法令纹', ['right_nasolabial_wrinkle_info', 'right_nasolabial_fold_info'], 'right_nasolabial_count'],
|
||||
['left_crowsfeet', '左鱼尾纹', ['left_crowsfeet_wrinkle_info'], 'left_crowsfeet_count'],
|
||||
['right_crowsfeet', '右鱼尾纹', ['right_crowsfeet_wrinkle_info'], 'right_crowsfeet_count'],
|
||||
['left_eye', '左眼部细纹', ['left_eye_wrinkle_info'], 'left_undereye_count'],
|
||||
['right_eye', '右眼部细纹', ['right_eye_wrinkle_info'], 'right_undereye_count'],
|
||||
['left_mouth', '左嘴角纹', ['left_mouth_wrinkle_info'], 'left_mouth_count'],
|
||||
['right_mouth', '右嘴角纹', ['right_mouth_wrinkle_info'], 'right_mouth_count'],
|
||||
['glabella', '眉间纹', ['glabella_wrinkle_info'], 'glabella_count'],
|
||||
['left_cheek', '左脸颊纹', ['left_cheek_wrinkle_info'], 'left_cheek_count'],
|
||||
['right_cheek', '右脸颊纹', ['right_cheek_wrinkle_info'], 'right_cheek_count']
|
||||
];
|
||||
var wrinkleMetrics = [
|
||||
['wrinkle_score', '皱纹分'], ['wrinkle_severity_level', '严重等级'],
|
||||
['wrinkle_deep_num', '深纹条数'], ['wrinkle_shallow_num', '浅纹条数'],
|
||||
['wrinkle_norm_length', '标准化长度'], ['wrinkle_norm_depth', '标准化深度'],
|
||||
['wrinkle_pixel_density', '像素密度'], ['wrinkle_area_ratio', '面积比例'], ['wrinkle_deep_ratio', '深纹比例']
|
||||
];
|
||||
function wrinkleDetails(result) {
|
||||
result = result || {};
|
||||
return wrinkleDefs.map(function (def) {
|
||||
var node = null;
|
||||
def[2].some(function (key) { if (result[key] && typeof result[key] === 'object') { node = result[key]; return true; } return false; });
|
||||
var values = {};
|
||||
wrinkleMetrics.forEach(function (metric) { values[metric[0]] = scalar(node && node[metric[0]]); });
|
||||
// Region counts are a valid fallback; a missing count is never zero.
|
||||
if (values.wrinkle_deep_num == null) values.wrinkle_deep_num = scalar(result.wrinkle_count && result.wrinkle_count[def[3]]);
|
||||
var fine = result.fine_line || result.fine_line_count;
|
||||
if (values.wrinkle_shallow_num == null) values.wrinkle_shallow_num = scalar(fine && fine[def[3]]);
|
||||
return { key: def[0], name: def[1], metrics: values };
|
||||
});
|
||||
}
|
||||
function metricText(value) { return value == null ? '未获取' : String(Math.round(value * 10000) / 10000); }
|
||||
function wrinkleText(rows) {
|
||||
return '\n分区明细(左右以本人为准;抬头纹与额头纹为同一分区):\n' + rows.map(function (row) {
|
||||
return row.name + ':' + wrinkleMetrics.map(function (metric) { return metric[1] + ' ' + metricText(row.metrics[metric[0]]); }).join(',') + '。';
|
||||
}).join('\n');
|
||||
}
|
||||
function fill(template, vars) {
|
||||
return template.replace(/\{([a-z_]+)\}/g, function(_, key) {
|
||||
var v = vars[key];
|
||||
@@ -55,9 +96,15 @@
|
||||
if (vars.sens_area == null) template = template.replace(/泛红面积(?:达 | )\{sens_area\}%[,;]/g, '泛红面积未获取;');
|
||||
if (vars.pore_cnt == null) template = template.replace(/粗大毛孔 \{pore_cnt\} 颗/g, '毛孔计数未获取');
|
||||
if (vars.blackhead_cnt == null) template = template.replace(/黑头 \{blackhead_cnt\} 颗/g, '黑头计数未获取');
|
||||
if (d[0] === 'smooth' && valid && level === '优秀') {
|
||||
// A high aggregate score does not mean every localized pore is absent.
|
||||
if (vars.pore_cnt == null || vars.blackhead_cnt == null) template = '肌肤质感整体较好,但毛孔或黑头计数未完整获取,请结合检测图查看。';
|
||||
else if (vars.pore_cnt > 0 || vars.blackhead_cnt > 0) template = '肌肤质感整体较好;本次检测到粗大毛孔 {pore_cnt} 颗、黑头 {blackhead_cnt} 颗,局部仍有可改善之处。';
|
||||
}
|
||||
var details = d[0] === 'wrinkles' ? wrinkleDetails(result) : null;
|
||||
var advice = level ? Copy.care[d[0]][level] : null;
|
||||
return { key: d[0], name: d[1], dim: d[2], source: d[3], P: cat.P, level: level || null,
|
||||
text: valid ? fill(template, vars) : '本次未检出有效数据,请重新拍摄后查看。',
|
||||
text: (valid ? fill(template, vars) : '本次未检出有效数据,请重新拍摄后查看。') + (details ? wrinkleText(details) : ''),
|
||||
valid: valid, advice: valid ? advice : null };
|
||||
});
|
||||
// Preserve selection of the two weakest original categories. Tone expands into
|
||||
@@ -67,7 +114,7 @@
|
||||
var chosen = views.filter(function(v) { return ranked.indexOf(v.source) >= 0 && v.advice; });
|
||||
var care = chosen.map(function(v) { return { name: v.name, text: v.advice.care + v.advice.lifestyle }; });
|
||||
var product = products.filter(function(p) { return p.match === vm.essence.name; })[0] || null;
|
||||
return { version: Copy.version, dims: dimNames, views: views, care: care, product: product };
|
||||
return { version: Copy.version + '+feedback-2026-10-12', dims: dimNames, views: views, care: care, product: product };
|
||||
}
|
||||
return { build: build, products: products, levelMap: levelMap, dimNames: dimNames };
|
||||
return { build: build, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails };
|
||||
});
|
||||
|
||||
@@ -80,6 +80,8 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
limits.set(key, value); return ++value.count <= 60;
|
||||
}
|
||||
function redirect(res, suffix) { res.writeHead(302, { Location: prefix + suffix, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }); res.end(); }
|
||||
// Operational events only: never log code/state, cookies, OpenID, secrets or tokens.
|
||||
function wechatEvent(event, fields = {}) { console.log('[auth/wechat] ' + event + ' ' + JSON.stringify(fields)); }
|
||||
async function route(req, res, pathname) {
|
||||
if (!pathname.startsWith('/api/auth/') && !pathname.startsWith('/api/reports')) return false;
|
||||
try {
|
||||
@@ -123,17 +125,24 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
const state = url.searchParams.get('state') || '', code = url.searchParams.get('code');
|
||||
const row = db.states[hash(state)], binding = cookies(req).juzi_oauth || '';
|
||||
delete db.states[hash(state)]; save(); cookie(res, 'juzi_oauth', '', 0);
|
||||
if (!available || !code || !row || row.expires < Date.now() || row.binding !== hash(binding)) { redirect(res, '?login_error=state'); return true; }
|
||||
if (!available || !code || !row || row.expires < Date.now() || row.binding !== hash(binding)) {
|
||||
wechatEvent('callback_rejected', { reason: 'state' }); redirect(res, '?login_error=state'); return true;
|
||||
}
|
||||
const tokenUrl = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
|
||||
tokenUrl.search = new URLSearchParams({ appid: appId, secret, code, grant_type: 'authorization_code' }).toString();
|
||||
let data;
|
||||
try { const r = await fetch(tokenUrl, { signal: AbortSignal.timeout(10000) }); data = await r.json(); }
|
||||
catch (_) { redirect(res, '?login_error=network'); return true; }
|
||||
if (!data.openid || data.errcode) { redirect(res, '?login_error=wechat'); return true; }
|
||||
let data, upstreamStatus;
|
||||
try { const r = await fetch(tokenUrl, { signal: AbortSignal.timeout(10000) }); upstreamStatus = r.status; data = await r.json(); }
|
||||
catch (_) { wechatEvent('exchange_failed', { reason: 'network' }); redirect(res, '?login_error=network'); return true; }
|
||||
if (!data || typeof data.openid !== 'string' || !data.openid || data.errcode || (upstreamStatus && upstreamStatus !== 200)) {
|
||||
wechatEvent('exchange_failed', { reason: 'wechat', errcode: data && Number.isInteger(data.errcode) ? data.errcode : null,
|
||||
httpStatus: upstreamStatus || null });
|
||||
redirect(res, '?login_error=wechat'); return true;
|
||||
}
|
||||
const identity = hash(appId + ':' + data.openid);
|
||||
let user = db.users.find(u => u.kind === 'wechat' && u.identity === identity);
|
||||
const newAccount = !user;
|
||||
if (!user) { user = { id: crypto.randomUUID(), kind: 'wechat', identity, consent: new Date().toISOString(), reports: [] }; db.users.push(user); }
|
||||
login(res, user); redirect(res, '?login=success');
|
||||
login(res, user); wechatEvent('login_success', { newAccount }); redirect(res, '?login=success');
|
||||
} else if (pathname.startsWith('/api/reports') && req.method === 'GET') {
|
||||
const user = session(req);
|
||||
if (!user) { json(res, 401, { ok: false, message: '请先登录' }); return true; }
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const zlib = require('zlib');
|
||||
|
||||
const PORT = Number(process.env.PORT) || 8080;
|
||||
// 只绑回环:公网入口是 nginx(1me.xajuzi.com/state/),8080 不再对公网裸奔;需局域网调试时 HOST=0.0.0.0
|
||||
@@ -84,11 +86,26 @@ function serveStatic(req, res, pathname) {
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); }
|
||||
const ext = path.extname(file);
|
||||
// html/css/js 不缓存(迭代期样式/脚本改动必须即时到端;图片/字体/音频走默认)
|
||||
const headers = { 'Content-Type': MIME[ext] || 'application/octet-stream' };
|
||||
if (['.html', '.css', '.js'].includes(ext)) headers['Cache-Control'] = 'no-cache';
|
||||
res.writeHead(200, headers);
|
||||
res.end(data);
|
||||
// Revalidate every time: updates remain immediate; unchanged resources use
|
||||
// 304 instead of downloading again. APIs/private reports never enter here.
|
||||
const headers = { 'Content-Type': MIME[ext] || 'application/octet-stream',
|
||||
'Cache-Control': 'no-cache', ETag: 'W/"' + crypto.createHash('sha256').update(data).digest('hex') + '"' };
|
||||
const compressible = /\.(html|css|js|json|svg)$/.test(file);
|
||||
if (compressible) headers.Vary = 'Accept-Encoding';
|
||||
if ((req.headers['if-none-match'] || '').split(/\s*,\s*/).includes(headers.ETag)) {
|
||||
res.writeHead(304, headers); return res.end();
|
||||
}
|
||||
// Compress static public text only, not authenticated JSON or photographs.
|
||||
const gzipAccepted = (req.headers['accept-encoding'] || '').split(',').some(value => {
|
||||
const parts = value.trim().split(';');
|
||||
return parts[0] === 'gzip' && !parts.slice(1).some(p => /^\s*q=0(?:\.0*)?\s*$/.test(p));
|
||||
});
|
||||
if (compressible && data.length > 1024 && gzipAccepted) {
|
||||
zlib.gzip(data, (error, compressed) => {
|
||||
if (!error) headers['Content-Encoding'] = 'gzip';
|
||||
res.writeHead(200, headers); res.end(error ? data : compressed);
|
||||
});
|
||||
} else { res.writeHead(200, headers); res.end(data); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,25 @@ const ReportUI = require('../js/report-ui');
|
||||
return (await request('/api/auth/session', null, callback.cookie)).data.user;
|
||||
}
|
||||
assert.deepEqual(await wechat(), await wechat());
|
||||
for (const scenario of [
|
||||
{ data: { errcode: 40029 }, error: 'wechat' },
|
||||
{ data: null, error: 'wechat' },
|
||||
{ data: { openid: {} }, error: 'wechat' },
|
||||
{ data: { openid: 'must-not-login' }, status: 500, error: 'wechat' },
|
||||
{ network: true, error: 'network' }
|
||||
]) {
|
||||
global.fetch = (url, options) => String(url).startsWith('https://api.weixin.qq.com/')
|
||||
? scenario.network ? Promise.reject(new Error('fixture network failure'))
|
||||
: Promise.resolve({ status: scenario.status || 200, json: async () => scenario.data })
|
||||
: realFetch(url, options);
|
||||
const started = await request('/api/auth/wechat', { consent: true });
|
||||
const state = new URL(started.data.url).searchParams.get('state');
|
||||
const failed = await request('/api/auth/wechat/callback?code=bad&state=' + state, null, started.cookie);
|
||||
assert.equal(failed.location, '/state/?login_error=' + scenario.error);
|
||||
assert(!failed.cookie.includes('juzi_session='));
|
||||
assert.equal((await request('/api/auth/session', null, failed.cookie)).data.user, null);
|
||||
assert((await request('/api/auth/wechat/callback?code=bad&state=' + state, null, started.cookie)).location.includes('login_error=state'));
|
||||
}
|
||||
} finally { global.fetch = realFetch; }
|
||||
accounts = create(root, { TRUST_LOOPBACK_PROXY: '1' });
|
||||
// Malformed credentials exercise the limiter without creating 60 users.
|
||||
|
||||
@@ -22,6 +22,7 @@ await page.addInitScript(() => {
|
||||
});
|
||||
try {
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.addScriptTag({ url: base + 'vendor/faceapi/face-api.js' });
|
||||
// Real models from production paths, actual landmark inference on the sample image.
|
||||
const detected = await page.evaluate(async data => {
|
||||
await Promise.all([
|
||||
@@ -35,11 +36,13 @@ try {
|
||||
assert(detected && detected.landmarks === 68 && detected.score > 0.3);
|
||||
console.log('PASS: real production models load and detect sample face with 68 landmarks');
|
||||
// Keep subsequent error-flow uploads independent of angle validation.
|
||||
await page.route('**/vendor/faceapi/face-api.js', route => route.abort());
|
||||
await page.evaluate(() => { window.faceapi = undefined; });
|
||||
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-browser"]');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.click('[data-action="login-confirm"]');
|
||||
await page.click('[data-action="open-privacy"]');
|
||||
await page.click('#privacy-check');
|
||||
await page.click('[data-action="agree-continue"]');
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
const assert = require('assert/strict');
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const path = require('path');
|
||||
const SkinScore = require('../js/skin-score');
|
||||
const ReportUI = require('../js/report-ui');
|
||||
const result = JSON.parse(fs.readFileSync(path.join(__dirname, '../../reference/facepp-skinanalyze/examples/ref_3view_raw.json'))).result;
|
||||
const details = ReportUI.wrinkleDetails(result);
|
||||
assert.equal(details.length, 12);
|
||||
assert.equal(new Set(details.map(row => row.key)).size, 12);
|
||||
assert.equal(details[0].name, '抬头纹(额头纹)');
|
||||
assert.deepEqual(details[0].metrics, {
|
||||
wrinkle_score: 13, wrinkle_severity_level: 1, wrinkle_deep_num: 22, wrinkle_shallow_num: 18,
|
||||
wrinkle_norm_length: result.forehead_wrinkle_info.wrinkle_norm_length,
|
||||
wrinkle_norm_depth: result.forehead_wrinkle_info.wrinkle_norm_depth,
|
||||
wrinkle_pixel_density: result.forehead_wrinkle_info.wrinkle_pixel_density,
|
||||
wrinkle_area_ratio: result.forehead_wrinkle_info.wrinkle_area_ratio,
|
||||
wrinkle_deep_ratio: result.forehead_wrinkle_info.wrinkle_deep_ratio
|
||||
});
|
||||
assert.equal(details.find(row => row.key === 'left_eye').metrics.wrinkle_deep_num, 58);
|
||||
assert.equal(details.find(row => row.key === 'right_eye').metrics.wrinkle_shallow_num, 46);
|
||||
assert.equal(details.find(row => row.key === 'left_cheek').metrics.wrinkle_score, 0);
|
||||
const alternate = ReportUI.wrinkleDetails({ right_nasolabial_fold_info: { wrinkle_score: { value: 31 }, wrinkle_severity_level: { value: 2 } },
|
||||
wrinkle_count: { right_nasolabial_count: 5 }, fine_line: { right_nasolabial_count: 0 } });
|
||||
assert.equal(alternate.find(row => row.key === 'right_nasolabial').metrics.wrinkle_score, 31);
|
||||
assert.equal(alternate.find(row => row.key === 'right_nasolabial').metrics.wrinkle_deep_num, 5);
|
||||
assert.equal(alternate.find(row => row.key === 'right_nasolabial').metrics.wrinkle_shallow_num, 0);
|
||||
assert(Object.values(alternate[0].metrics).every(value => value === null));
|
||||
const model = SkinScore.buildViewModel(result, SkinScore.MOCK_SIDE, { realAge: 30 });
|
||||
const modelBefore = JSON.stringify(model), rawBefore = JSON.stringify(result);
|
||||
const presentation = ReportUI.build(model, result);
|
||||
const text = presentation.views.find(view => view.key === 'wrinkles').text;
|
||||
for (const row of details) assert(text.includes(row.name));
|
||||
assert(text.includes('标准化长度 1.2088'));
|
||||
assert.equal(JSON.stringify(model), modelBefore);
|
||||
assert.equal(JSON.stringify(result), rawBefore);
|
||||
const excellent = JSON.parse(JSON.stringify(model));
|
||||
excellent.cats.smooth.level = '良好'; // Legacy level name maps to new "优秀".
|
||||
excellent.cats.smooth.vars.pore_cnt = 7;
|
||||
excellent.cats.smooth.vars.blackhead_cnt = 2;
|
||||
let smooth = ReportUI.build(excellent, result).views.find(view => view.key === 'smooth');
|
||||
assert(smooth.text.includes('粗大毛孔 7 颗、黑头 2 颗'));
|
||||
assert(!smooth.text.includes('无明显粗大毛孔'));
|
||||
assert.equal(smooth.level, '优秀');
|
||||
excellent.cats.smooth.vars.pore_cnt = null;
|
||||
smooth = ReportUI.build(excellent, result).views.find(view => view.key === 'smooth');
|
||||
assert(smooth.text.includes('未完整获取'));
|
||||
const source = fs.readFileSync(path.join(__dirname, '../js/app.js'), 'utf8');
|
||||
const pure = source.match(/\/\* viz-pure-begin[\s\S]*?\*\/([\s\S]*?)\/\* viz-pure-end \*\//)[1];
|
||||
const sandbox = {}; vm.runInNewContext(pure, sandbox);
|
||||
const crop = sandbox.vizFaceCrop(4000, 3000, { left: 1479, top: 1133, width: 1116, height: 1116 });
|
||||
assert(crop.focused);
|
||||
assert(crop.width < 1600 && crop.height < 1800);
|
||||
assert(crop.left <= 1479 && crop.top <= 1133);
|
||||
assert(crop.left + crop.width >= 2595 && crop.top + crop.height >= 2249);
|
||||
assert(Math.abs(crop.width / crop.height - 215 / 278) < 1e-8);
|
||||
for (const rect of [null, { left: NaN }, { left: 0, top: 0, width: -1, height: 20 },
|
||||
{ left: 3900, top: 0, width: 1000, height: 1000 }, { left: 0, top: 0, width: '100', height: 100 }]) {
|
||||
const full = sandbox.vizFaceCrop(4000, 3000, rect);
|
||||
assert.equal(full.focused, false); assert.equal(full.width, 4000);
|
||||
}
|
||||
for (const rect of [{ left: 0, top: 0, width: 300, height: 300 }, { left: 700, top: 700, width: 300, height: 300 }]) {
|
||||
const edge = sandbox.vizFaceCrop(1000, 1000, rect);
|
||||
assert(edge.focused); assert(edge.left >= 0 && edge.top >= 0);
|
||||
assert(edge.left + edge.width <= 1000 && edge.top + edge.height <= 1000);
|
||||
assert(edge.left <= rect.left && edge.top <= rect.top);
|
||||
assert(edge.left + edge.width >= rect.left + rect.width && edge.top + edge.height >= rect.top + rect.height);
|
||||
}
|
||||
console.log('PASS: 12 unique wrinkle regions, all nine real metrics, aliases/count fallbacks, missing≠zero; positive pore counts vs excellent score; no raw/model/algorithm mutations; face crop/aspect/edge containment/invalid-box fallback');
|
||||
@@ -0,0 +1,110 @@
|
||||
// Read-only mock page. No account creation, report writes or Face++ call.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
|
||||
const base = process.env.H5_BASE_URL || 'http://127.0.0.1:8080/';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
await page.goto(base + '?mock=1&screen=report');
|
||||
await page.waitForFunction(() => document.querySelector('#stat-score b').textContent !== '--' && [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth));
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
async function swipe(card, dx, dy = 0) {
|
||||
const frame = card.locator('.dpanel.active .viz-frame');
|
||||
await frame.scrollIntoViewIfNeeded();
|
||||
const box = await frame.boundingBox(), x = box.x + box.width / 2, y = Math.min(500, box.y + box.height / 2);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] });
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: x + dx * i / 8, y: y + dy * i / 8 }] });
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
const cards = page.locator('.card-detail');
|
||||
await swipe(cards.nth(0), -85);
|
||||
assert.equal(await cards.nth(0).locator('.dtab.active').getAttribute('data-tab'), '1');
|
||||
assert.equal(await page.locator('#p-zoom.show').count(), 0);
|
||||
await swipe(cards.nth(0), 85);
|
||||
assert.equal(await cards.nth(0).locator('.dtab.active').getAttribute('data-tab'), '0');
|
||||
await swipe(cards.nth(0), 0, -85);
|
||||
assert.equal(await cards.nth(0).locator('.dtab.active').getAttribute('data-tab'), '0', 'vertical scrolling must not change tab');
|
||||
await swipe(cards.nth(1), -85);
|
||||
assert.equal(await cards.nth(1).locator('.dtab.active').getAttribute('data-tab'), '1');
|
||||
await swipe(cards.nth(1), -85);
|
||||
assert.equal(await cards.nth(1).locator('.dtab.active').getAttribute('data-tab'), '2');
|
||||
await swipe(cards.nth(1), 85);
|
||||
await cards.nth(1).locator('.dtab').first().click();
|
||||
assert.equal(await cards.nth(1).locator('.dtab.active').getAttribute('data-tab'), '0');
|
||||
await cards.nth(1).locator('.dtab').first().focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
assert.equal(await cards.nth(1).locator('.dtab.active').getAttribute('data-tab'), '1');
|
||||
await swipe(cards.nth(2), -85);
|
||||
assert.equal(await cards.nth(2).locator('.dtab.active').getAttribute('data-tab'), '1');
|
||||
assert.equal(await cards.nth(0).locator('.dtab.active').getAttribute('data-tab'), '0');
|
||||
const image = cards.nth(0).locator('.dpanel.active .ditem-img');
|
||||
await image.click();
|
||||
await page.waitForSelector('#p-zoom.show');
|
||||
assert.equal(await page.locator('#zoom-img').getAttribute('src'), await image.getAttribute('src'));
|
||||
await page.click('#p-zoom');
|
||||
await page.waitForSelector('#p-zoom.show', { state: 'hidden' });
|
||||
await page.waitForTimeout(300);
|
||||
assert((await page.locator('#ditem-para2').textContent()).includes('抬头纹(额头纹)'));
|
||||
assert((await page.locator('#ditem-para2').textContent()).includes('右法令纹'));
|
||||
assert((await page.locator('#smooth-map-note').textContent()).includes('毛孔'));
|
||||
const focused = await page.locator('.ditem-img').evaluateAll(images => images.map(i => ({ focused: i.dataset.crop, ratio: i.naturalWidth / i.naturalHeight })));
|
||||
assert(focused.every(i => i.focused === 'face' && Math.abs(i.ratio - 215 / 278) < .005));
|
||||
const fontSizes = [];
|
||||
for (const width of [320, 375, 390, 414, 480]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await page.waitForTimeout(150);
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'page overflow ' + width);
|
||||
fontSizes.push(await page.locator('#tx-tone').evaluate(e => getComputedStyle(e).fontSize));
|
||||
assert.equal(await page.locator('#product-name .product-line').count(), 2);
|
||||
for (const line of await page.locator('#product-name .product-line').all()) {
|
||||
assert.equal(await line.evaluate(e => getComputedStyle(e).whiteSpace), 'nowrap');
|
||||
assert(await line.evaluate(e => e.scrollWidth <= e.clientWidth), 'product line overflow ' + width);
|
||||
}
|
||||
}
|
||||
assert(fontSizes.every(size => size === '12px'), JSON.stringify(fontSizes));
|
||||
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--yellow').trim()), '#F5F078');
|
||||
if (process.env.H5_ARTIFACT_DIR) {
|
||||
fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true });
|
||||
await page.setViewportSize({ width: 375, height: 726 });
|
||||
await image.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, '01-face-focused.png'), animations: 'disabled' });
|
||||
await cards.nth(0).locator('.dtab').nth(1).click();
|
||||
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, '02-wrinkle-regions.png'), animations: 'disabled' });
|
||||
await cards.nth(1).locator('.dtab').nth(1).click();
|
||||
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, '03-smooth-maps.png'), animations: 'disabled' });
|
||||
await page.locator('.card-product').scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, '04-product-lines.png'), animations: 'disabled' });
|
||||
}
|
||||
// Change only the in-browser fixture. Exercise the actual renderer and age-only
|
||||
// recommendation outside boundary windows, without production report writes.
|
||||
for (const [age, name, tier] of [[40, '以我自在有势挺括', 'II'], [60, '以我从容有态构塑', 'III']]) {
|
||||
await page.route('**/assets/mock/report-fixture.js', async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, body: await response.text() + '\nMOCK_FIXTURE.result.skin_age = { value: ' + age + ' };\ndelete MOCK_FIXTURE.result.face_maps.texture_enhanced_blackheads;' });
|
||||
});
|
||||
await page.goto(base + '?mock=1&screen=report');
|
||||
await page.waitForFunction(() => document.querySelector('#product-name .product-line'));
|
||||
assert.equal(await page.locator('#product-title').textContent(), "1'me 主场精华 " + tier);
|
||||
assert.equal(await page.locator('#product-name .product-line').first().textContent(), name);
|
||||
assert(!(await page.locator('#smooth-map-note').textContent()).includes('红色'), 'missing blackhead map must not be claimed');
|
||||
for (const width of [320, 375, 390, 414, 480]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
for (const line of await page.locator('#product-name .product-line').all()) {
|
||||
assert(await line.evaluate(e => e.scrollWidth <= e.clientWidth), 'tier ' + tier + ' product line overflow ' + width);
|
||||
}
|
||||
assert(await page.locator('#product-title').evaluate(e => e.scrollWidth <= e.clientWidth), 'tier ' + tier + ' title overflow ' + width);
|
||||
}
|
||||
await page.unroute('**/assets/mock/report-fixture.js');
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: real mobile swipes in three independent groups, vertical scroll vs swipe, click/keyboard tabs, no zoom after swipe, zoom still works, seven face-focused maps, 12-region copy, fixed fonts/all three product lines in five widths, partial map caption, PSD yellow, no JS errors');
|
||||
} finally { await browser.close(); }
|
||||
@@ -119,6 +119,7 @@ try {
|
||||
await page.locator('#login-consent').check();
|
||||
await page.locator('[data-action="login-browser"]').click();
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.click('[data-action="login-confirm"]');
|
||||
await page.getByRole('button', { name: '开始检测' }).click();
|
||||
await page.locator('#privacy-check').click();
|
||||
await page.getByRole('button', { name: '继续' }).click();
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Isolated server/data; no production account, report or paid API call.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import zlib from 'node:zlib';
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
|
||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-login-perf-'));
|
||||
const child = spawn(process.execPath, ['server.js'], {
|
||||
cwd: fileURLToPath(new URL('../', import.meta.url)),
|
||||
env: { ...process.env, PORT: '18091', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
|
||||
SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
const base = 'http://127.0.0.1:18091/';
|
||||
let browser;
|
||||
async function raw(route, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(base + route, { headers }, response => {
|
||||
const chunks = [];
|
||||
response.on('data', data => chunks.push(data));
|
||||
response.on('end', () => resolve({ status: response.statusCode, headers: response.headers, body: Buffer.concat(chunks) }));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('test startup timeout')), 10000);
|
||||
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timer); resolve(); } });
|
||||
child.once('exit', code => { clearTimeout(timer); reject(new Error('server exited: ' + code)); });
|
||||
});
|
||||
for (const route of ['index.html', 'js/app.js', 'css/ui-20261009.css', 'vendor/faceapi/face-api.js']) {
|
||||
const plain = await raw(route);
|
||||
const compressed = await raw(route, { 'Accept-Encoding': 'gzip' });
|
||||
assert.equal(compressed.status, 200);
|
||||
assert.equal(compressed.headers['content-encoding'], 'gzip');
|
||||
assert.equal(compressed.headers.vary, 'Accept-Encoding');
|
||||
assert.equal(compressed.headers['cache-control'], 'no-cache');
|
||||
assert.deepEqual(zlib.gunzipSync(compressed.body), plain.body);
|
||||
assert(compressed.body.length < plain.body.length * .5, route);
|
||||
const cached = await raw(route, { 'Accept-Encoding': 'gzip', 'If-None-Match': compressed.headers.etag });
|
||||
assert.equal(cached.status, 304); assert.equal(cached.body.length, 0);
|
||||
assert.equal((await raw(route, { 'Accept-Encoding': 'gzip;q=0' })).headers['content-encoding'], undefined);
|
||||
}
|
||||
const session = await raw('api/auth/session', { 'Accept-Encoding': 'gzip' });
|
||||
assert.equal(session.headers['cache-control'], 'no-store');
|
||||
assert.equal(session.headers.etag, undefined);
|
||||
assert.equal(session.headers['content-encoding'], undefined);
|
||||
assert.equal((await raw('data/accounts/identities.json')).status, 403);
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
|
||||
const errors = [], requests = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
page.on('request', request => requests.push(new URL(request.url()).pathname));
|
||||
// If the old eager script is reintroduced, it will fail instead of silently
|
||||
// passing on a fast developer machine. Login must not depend on this request.
|
||||
await page.route('**/vendor/faceapi/face-api.js', route => route.abort());
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Network.enable');
|
||||
await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 150,
|
||||
downloadThroughput: 250000, uploadThroughput: 125000 });
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||||
const start = Date.now();
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
|
||||
const readyMs = Date.now() - start;
|
||||
await page.waitForLoadState('networkidle');
|
||||
assert(!requests.some(p => /faceapi|models|\.woff2$|bg-splash\.jpg$/.test(p)), JSON.stringify(requests));
|
||||
const resourceBytes = await page.evaluate(() => performance.getEntriesByType('resource').reduce((sum, r) => sum + r.encodedBodySize, 0));
|
||||
assert(resourceBytes < 350000, 'login resource budget exceeded: ' + resourceBytes);
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-browser"]');
|
||||
await page.waitForSelector('#p-login-success.show');
|
||||
const artifacts = process.env.H5_ARTIFACT_DIR;
|
||||
if (artifacts) {
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
await page.screenshot({ path: path.join(artifacts, 'browser-login-success.png') });
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await page.locator('#p-login-success.show').count(), 0);
|
||||
assert.equal(await page.locator('#s-splash').evaluate(e => e.inert), false);
|
||||
for (const width of [320, 375, 390, 480]) {
|
||||
await page.setViewportSize({ width, height: 726 });
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
const badge = await page.locator('.account-status-home').boundingBox();
|
||||
const slogan = await page.locator('#s-splash .splash-slogan').boundingBox();
|
||||
assert(badge.y + badge.height <= slogan.y, 'account badge overlaps home slogan at ' + width);
|
||||
}
|
||||
await page.waitForLoadState('networkidle');
|
||||
assert(!requests.some(p => /faceapi|models|\.woff2$/.test(p)), JSON.stringify(requests));
|
||||
if (artifacts) await page.screenshot({ path: path.join(artifacts, 'browser-home-status.png') });
|
||||
await page.click('[data-action="history"]');
|
||||
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('次'));
|
||||
assert.equal(await page.locator('#s-history [data-auth-status]').textContent(), '本机浏览器已登录');
|
||||
if (artifacts) await page.screenshot({ path: path.join(artifacts, 'browser-history-status.png') });
|
||||
await page.click('#s-history [data-action="switch-login"]');
|
||||
assert.equal(await page.locator('#s-login.active').count(), 1);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: gzip/304/revalidation, no-store private API, login without fonts/face engine, success dialog/Escape/status/switching/layout; throttled cold login=' + readyMs + 'ms, resources=' + resourceBytes + ' bytes');
|
||||
// Lazy engine loading must still support real models and retry after failure.
|
||||
await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 });
|
||||
await page.goBack();
|
||||
await page.goBack();
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.evaluate(() => { navigator.mediaDevices.getUserMedia = () => Promise.reject(new DOMException('test denial', 'NotAllowedError')); });
|
||||
await page.click('[data-action="open-privacy"]');
|
||||
await page.click('#privacy-check');
|
||||
await page.click('[data-action="agree-continue"]');
|
||||
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
|
||||
await page.click(selector);
|
||||
await page.click('.ms-mobile-select.ms-show .ms-ensure');
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
await page.click('[data-action="submit-profile"]');
|
||||
await page.click('[data-action="start-camera"]');
|
||||
await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('系统相机'));
|
||||
await page.waitForTimeout(300);
|
||||
assert.equal(requests.filter(p => p.endsWith('/face-api.js')).length, 1, 'engine must load only when entering camera');
|
||||
await page.goBack();
|
||||
await page.waitForSelector('#s-profile.active');
|
||||
await page.unroute('**/vendor/faceapi/face-api.js');
|
||||
await page.click('[data-action="submit-profile"]');
|
||||
await page.click('[data-action="start-camera"]');
|
||||
await page.waitForFunction(() => window.faceapi && faceapi.nets.tinyFaceDetector.isLoaded && faceapi.nets.faceLandmark68Net.isLoaded);
|
||||
assert.equal(requests.filter(p => p.endsWith('/face-api.js')).length, 2, 'failed lazy engine should be retried');
|
||||
const sample = fs.readFileSync(fileURLToPath(new URL('../../reference/facepp-skinanalyze/examples/images/C3_180.jpg', import.meta.url)));
|
||||
const face = await page.evaluate(async data => {
|
||||
const image = await faceapi.fetchImage(data);
|
||||
const found = await faceapi.detectSingleFace(image, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .3 })).withFaceLandmarks();
|
||||
return found ? found.landmarks.positions.length : 0;
|
||||
}, 'data:image/jpeg;base64,' + sample.toString('base64'));
|
||||
assert.equal(face, 68, 'lazy-loaded engine must perform real landmark inference');
|
||||
console.log('PASS: no engine at login; camera loads engine/models; failed script retries; real 68-point inference works');
|
||||
// A stalled session request must leave a recoverable message, not spin forever
|
||||
// or discard the saved browser credential. Only this test shortens the timeout.
|
||||
const token = await page.evaluate(() => localStorage.getItem('juzi.identity.v1'));
|
||||
await page.addInitScript(() => {
|
||||
const original = window.setTimeout;
|
||||
window.setTimeout = (fn, ms, ...args) => original(fn, ms === 12000 ? 100 : ms, ...args);
|
||||
});
|
||||
await page.route('**/api/auth/session', () => {});
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('网络响应超时'));
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.identity.v1')), token);
|
||||
assert.equal(await page.locator('#p-login-success.show').count(), 0);
|
||||
console.log('PASS: stalled session timeout is visible and preserves browser identity');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
const stopped = once(child, 'exit'); child.kill(); await stopped;
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -37,6 +37,7 @@ try {
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-browser"]');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.click('[data-action="login-confirm"]');
|
||||
const api = context.request;
|
||||
assert.equal((await api.get(base + 'api/reports')).status(), 200);
|
||||
const cookie = (await context.cookies()).find(c => c.httpOnly);
|
||||
|
||||
@@ -42,6 +42,7 @@ try {
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-browser"]');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.click('[data-action="login-confirm"]');
|
||||
const api = page.context().request;
|
||||
const result = await (await api.post(base + 'api/skin-analyze', { data: { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0], profile: { real_age: 60, daily_skin_type: '干性肌肤' } } })).json();
|
||||
assert.equal(result.ok, true);
|
||||
|
||||
@@ -24,6 +24,10 @@ await page.click('[data-action="login-wechat"]');
|
||||
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('本机浏览器'));
|
||||
await page.click('[data-action="login-browser"]');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.waitForSelector('#p-login-success.show');
|
||||
await page.screenshot({ path: new URL('02-login-success.png', root).pathname });
|
||||
await page.click('[data-action="login-confirm"]');
|
||||
await page.waitForTimeout(400);
|
||||
await page.waitForFunction(() => !document.querySelector('#toast').classList.contains('show'));
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: new URL('02-home.png', root).pathname });
|
||||
|
||||
@@ -9,6 +9,8 @@ import { createRequire } from 'node:module';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
const require = createRequire(import.meta.url);
|
||||
const createAccounts = require('../lib/accounts');
|
||||
const SkinScore = require('../js/skin-score');
|
||||
const ReportUI = require('../js/report-ui');
|
||||
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-wechat-test-'));
|
||||
const h5 = fileURLToPath(new URL('../', import.meta.url));
|
||||
@@ -35,12 +37,42 @@ try {
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const base = 'https://skin.example.test:' + server.address().port + '/state/';
|
||||
accounts = createAccounts(path.join(root, 'accounts'), { WECHAT_APP_ID: 'test-app', WECHAT_APP_SECRET: 'test-secret', PUBLIC_BASE_URL: base });
|
||||
let upstreamError = false, openid = 'test-same-wechat-user';
|
||||
global.fetch = async url => {
|
||||
assert.equal(new URL(url).origin, 'https://api.weixin.qq.com');
|
||||
return { ok: true, json: async () => ({ openid: 'test-same-wechat-user' }) };
|
||||
return { ok: true, status: 200, json: async () => upstreamError ? { errcode: 40029 } : { openid } };
|
||||
};
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-proxy-server', '--host-resolver-rules=MAP skin.example.test 127.0.0.1'] });
|
||||
// Fetch in Chromium so its test-host resolver and cookie jar are both used.
|
||||
async function api(page, route, data) {
|
||||
return page.evaluate(async ({ route, data }) => {
|
||||
const response = await fetch('api/' + route, { method: data ? 'POST' : 'GET',
|
||||
headers: data ? { 'Content-Type': 'application/json' } : {}, body: data ? JSON.stringify(data) : undefined });
|
||||
return { status: response.status, data: await response.json() };
|
||||
}, { route, data });
|
||||
}
|
||||
let firstId;
|
||||
const vm = SkinScore.buildViewModel(SkinScore.MOCK_RESULT, SkinScore.MOCK_SIDE, { realAge: 30 });
|
||||
const saved = { id: 'RwechatCrossDevice', created_at: new Date().toISOString(), vm,
|
||||
presentation: ReportUI.build(vm, SkinScore.MOCK_RESULT), photos: [null, null, null],
|
||||
payload: { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE } };
|
||||
async function confirmLogin(page, kind) {
|
||||
await page.waitForSelector('#p-login-success.show');
|
||||
assert.equal(await page.locator('#login-success-title').textContent(), '登录成功');
|
||||
assert((await page.locator('#login-success-message').textContent()).includes(kind === 'wechat' ? '当前使用:微信登录' : '当前使用:本机浏览器登录'));
|
||||
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), kind === 'wechat' ? '微信已登录' : '本机浏览器已登录');
|
||||
if (process.env.H5_ARTIFACT_DIR) {
|
||||
fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true });
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, kind + '-login-success.png'), animations: 'disabled' });
|
||||
}
|
||||
assert.equal(await page.evaluate(() => document.activeElement.dataset.action), 'login-confirm');
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.evaluate(() => document.activeElement.dataset.action), 'login-confirm');
|
||||
await page.click('[data-action="login-confirm"]');
|
||||
assert.equal(await page.locator('#p-login-success.show').count(), 0);
|
||||
assert.equal(await page.locator('#s-splash').evaluate(e => e.inert), false);
|
||||
}
|
||||
for (let device = 0; device < 2; device++) {
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true, userAgent: 'Mozilla/5.0 MicroMessenger/8.0', viewport: { width: 375, height: 726 } });
|
||||
const page = await context.newPage();
|
||||
@@ -56,22 +88,98 @@ try {
|
||||
});
|
||||
await page.goto(base);
|
||||
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
|
||||
let browserId, browserToken;
|
||||
if (device === 0) {
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-browser"]');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await confirmLogin(page, 'browser');
|
||||
browserId = await page.evaluate(() => AccountClient.user().id);
|
||||
browserToken = await page.evaluate(() => localStorage.getItem('juzi.identity.v1'));
|
||||
accounts.storeReport(browserId, { ...saved, id: 'RbrowserOriginal' });
|
||||
await page.click('[data-action="history"]');
|
||||
await page.waitForSelector('[data-report-id="RbrowserOriginal"]');
|
||||
await page.click('[data-action="switch-login"]');
|
||||
assert.equal(await page.locator('#login-consent').isChecked(), false);
|
||||
assert((await page.locator('#login-message').textContent()).includes('报告不会自动合并'));
|
||||
upstreamError = true;
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-wechat"]');
|
||||
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('微信授权未完成'));
|
||||
assert.equal(await page.locator('#s-login.active').count(), 1, 'OAuth failure must not masquerade as browser login success');
|
||||
assert.equal(await page.locator('#p-login-success.show').count(), 0);
|
||||
await page.waitForFunction(() => !location.search.includes('login_error'));
|
||||
upstreamError = false;
|
||||
}
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-wechat"]');
|
||||
// Redirects may preserve WeChat's fragment; it is not sent to the backend.
|
||||
await page.waitForURL(url => url.origin + url.pathname === base && url.searchParams.get('login') === 'success');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await confirmLogin(page, 'wechat');
|
||||
await page.waitForFunction(() => !location.search.includes('login=success'));
|
||||
const user = await page.evaluate(() => AccountClient.user());
|
||||
assert.equal(user.kind, 'wechat');
|
||||
if (firstId) assert.equal(user.id, firstId); else firstId = user.id;
|
||||
if (device === 0) accounts.storeReport(user.id, saved);
|
||||
const cookie = (await context.cookies()).find(c => c.name === 'juzi_session');
|
||||
assert(cookie.secure && cookie.httpOnly && cookie.path === '/state/' && cookie.sameSite === 'Lax');
|
||||
await page.click('[data-action="history"]');
|
||||
await page.waitForFunction(() => document.querySelector('#identity-note').textContent.includes('同一微信账号'));
|
||||
assert.equal(await page.locator('#s-history [data-auth-status]').textContent(), '微信已登录');
|
||||
await page.waitForSelector('[data-report-id="RwechatCrossDevice"]');
|
||||
assert.equal(await page.locator('[data-report-id="RbrowserOriginal"]').count(), 0);
|
||||
assert.deepEqual((await api(page, 'reports/' + saved.id)).data.report, JSON.parse(JSON.stringify(saved)));
|
||||
if (device === 0) {
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.identity.v1')), browserToken);
|
||||
await context.clearCookies();
|
||||
await page.reload();
|
||||
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
|
||||
assert.equal(await page.locator('#s-login.active').count(), 1, 'lost WeChat session must not silently restore the browser account');
|
||||
assert.equal((await api(page, 'auth/session')).data.user, null);
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-wechat"]');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await confirmLogin(page, 'wechat');
|
||||
assert.equal(await page.evaluate(() => AccountClient.user().id), firstId);
|
||||
await page.click('[data-action="history"]');
|
||||
await page.click('[data-action="switch-login"]');
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-browser"]');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await confirmLogin(page, 'browser');
|
||||
assert.equal(await page.evaluate(() => AccountClient.user().id), browserId);
|
||||
await page.click('[data-action="history"]');
|
||||
await page.waitForSelector('[data-report-id="RbrowserOriginal"]');
|
||||
assert.equal(await page.locator('[data-report-id="RwechatCrossDevice"]').count(), 0);
|
||||
assert.equal((await api(page, 'reports/' + saved.id)).status, 404);
|
||||
// An OAuth success URL with a lost cookie must not restore an unrelated browser credential.
|
||||
await context.clearCookies();
|
||||
await page.goto(base + '?login=success');
|
||||
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('会话未能保存'));
|
||||
assert.equal((await api(page, 'auth/session')).data.user, null);
|
||||
await page.waitForFunction(() => !location.search.includes('login=success'));
|
||||
await page.reload(); // Ordinary browser restore is still supported.
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
assert.equal(await page.evaluate(() => AccountClient.user().id), browserId);
|
||||
assert.equal(await page.locator('#p-login-success.show').count(), 0, 'session restore must not repeatedly open a success dialog');
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
}
|
||||
console.log('PASS: HTTPS /state/ login → simulated WeChat → callback with bound Secure cookie → homepage/history; two browser contexts share WeChat identity');
|
||||
// Different WeChat users must never see each other's reports.
|
||||
openid = 'test-different-wechat-user';
|
||||
const stranger = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const strangerPage = await stranger.newPage();
|
||||
await strangerPage.goto(base);
|
||||
const start = await api(strangerPage, 'auth/wechat', { consent: true });
|
||||
const auth = new URL(start.data.url), callback = new URL(auth.searchParams.get('redirect_uri'));
|
||||
callback.search = new URLSearchParams({ code: 'test-code', state: auth.searchParams.get('state') });
|
||||
await strangerPage.goto(callback.href);
|
||||
const strangerUser = (await api(strangerPage, 'auth/session')).data.user;
|
||||
assert(strangerUser && strangerUser.id !== firstId);
|
||||
assert.equal((await api(strangerPage, 'reports/' + saved.id)).status, 404);
|
||||
assert.equal((await api(strangerPage, 'reports')).data.total, 0);
|
||||
await stranger.close();
|
||||
console.log('PASS: HTTPS /state/ simulated WeChat login, Secure cookies, same identity/report across devices, different-user isolation, explicit account switching, preserved browser reports, OAuth failure/missing-cookie handling, no silent browser fallback');
|
||||
} finally {
|
||||
global.fetch = realFetch;
|
||||
if (browser) await browser.close();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Public HTTPS preflight. Uses only deliberately invalid OAuth codes; creates no
|
||||
// user/report, makes no paid Face++ call, and never simulates a production OpenID.
|
||||
// H5_BASE_URL=https://1me.xajuzi.com/state/ node h5/test/wechat-production.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
assert(process.env.H5_BASE_URL, 'Set H5_BASE_URL explicitly for the production preflight');
|
||||
const base = new URL(process.env.H5_BASE_URL);
|
||||
assert.equal(base.protocol, 'https:');
|
||||
assert(base.pathname.endsWith('/'), 'H5_BASE_URL must have a trailing slash');
|
||||
const expectedAppId = process.env.WECHAT_EXPECTED_APP_ID || 'wx645d9a7573f1fe9a';
|
||||
async function api(route, options = {}) {
|
||||
return fetch(new URL('api/' + route, base), { ...options, redirect: 'manual', signal: AbortSignal.timeout(20000) });
|
||||
}
|
||||
const config = await api('auth/config');
|
||||
assert.equal(config.status, 200);
|
||||
assert.equal((await config.json()).wechat, true);
|
||||
for (const [data, origin, status] of [
|
||||
[{ consent: false }, base.origin, 400],
|
||||
[{ consent: true }, 'https://invalid.example', 403]
|
||||
]) {
|
||||
const r = await api('auth/wechat', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify(data) });
|
||||
assert.equal(r.status, status);
|
||||
}
|
||||
async function start() {
|
||||
const r = await api('auth/wechat', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: base.origin }, body: JSON.stringify({ consent: true }) });
|
||||
assert.equal(r.status, 200);
|
||||
const cookies = r.headers.getSetCookie();
|
||||
const binding = cookies.find(c => c.startsWith('juzi_oauth='));
|
||||
assert(binding && binding.includes('Secure') && binding.includes('HttpOnly') && binding.includes('SameSite=Lax'));
|
||||
assert(binding.includes('Path=' + base.pathname + ';'));
|
||||
assert(!cookies.some(c => c.startsWith('juzi_session=')));
|
||||
const auth = new URL((await r.json()).url);
|
||||
assert.equal(auth.origin + auth.pathname, 'https://open.weixin.qq.com/connect/oauth2/authorize');
|
||||
assert.equal(auth.searchParams.get('appid'), expectedAppId);
|
||||
assert.equal(auth.searchParams.get('scope'), 'snsapi_base');
|
||||
assert.equal(auth.searchParams.get('response_type'), 'code');
|
||||
assert.equal(auth.searchParams.get('redirect_uri'), new URL('api/auth/wechat/callback', base).href);
|
||||
assert.equal(auth.hash, '#wechat_redirect');
|
||||
assert(!auth.searchParams.has('secret'));
|
||||
const state = auth.searchParams.get('state');
|
||||
assert(/^[a-f0-9]{64}$/.test(state));
|
||||
return { cookie: binding.split(';')[0], state };
|
||||
}
|
||||
async function callback(row, cookie, expectedError) {
|
||||
const query = new URLSearchParams({ code: 'codex-invalid-' + randomUUID(), state: row.state });
|
||||
const r = await api('auth/wechat/callback?' + query, { headers: cookie ? { Cookie: cookie } : {} });
|
||||
assert.equal(r.status, 302);
|
||||
assert.equal(r.headers.get('location'), base.pathname + '?login_error=' + expectedError);
|
||||
assert(!r.headers.getSetCookie().some(c => c.startsWith('juzi_session=')), 'invalid callback must not issue a session');
|
||||
}
|
||||
const missingBinding = await start();
|
||||
await callback(missingBinding, '', 'state');
|
||||
const invalidCode = await start();
|
||||
await callback(invalidCode, invalidCode.cookie, 'wechat');
|
||||
await callback(invalidCode, invalidCode.cookie, 'state');
|
||||
assert.equal((await api('auth/session')).status, 200);
|
||||
assert.equal((await (await api('auth/session')).json()).user, null);
|
||||
assert.equal((await api('reports')).status, 401);
|
||||
assert.equal((await api('admin/records')).status, 401);
|
||||
assert.equal((await fetch(new URL('admin.html', base))).status, 401);
|
||||
assert.equal((await api('health')).status, 200);
|
||||
assert.equal((await fetch(new URL('/MP_verify_kpx234XimQGtFPo6.txt', base))).status, 200);
|
||||
console.log('PASS: public HTTPS WeChat config, consent/CSRF, correct authorization URL, Secure scoped cookies, missing binding, real invalid-code rejection, state replay rejection, anonymous report denial, admin protection and domain file; REAL client authorization remains untested');
|
||||
@@ -12,6 +12,8 @@ for name in ['bg-splash', 'bg-login', 'bg-profile', 'bg-history', 'bg-report']:
|
||||
# Native status/navigation area belongs to the host, not the H5 document.
|
||||
top = 253 if name == 'bg-report' else 248
|
||||
source.crop((0, top, source.width, source.height)).save(root / (name + '-h5.jpg'), quality=92, optimize=True)
|
||||
# Mobile delivery variant; preserve the full-resolution PSD exports/JPEGs.
|
||||
Image.open(root / (name + '-h5.jpg')).save(root / (name + '-h5.webp'), quality=85, method=6)
|
||||
|
||||
geometry = []
|
||||
for direction in ['front', 'left', 'right']:
|
||||
|
||||