feat(auth): clarify login identity and speed up mobile entry

This commit is contained in:
xsl
2026-10-12 11:01:49 +08:00
parent 85790ef985
commit d2931a41e4
28 changed files with 1903 additions and 1046 deletions
@@ -2,13 +2,13 @@
更新: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 做账号关联,不请求头像、昵称。
+6
View File
@@ -2,6 +2,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 白名单。
@@ -0,0 +1,38 @@
# 登录反馈与加载优化(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++。
+16 -12
View File
@@ -3,24 +3,24 @@
"1": "线上闭环测试(不含真实微信授权)",
"2": "app.js",
"3": "skin-score.js",
"4": "i",
"4": "k",
"5": "r",
"6": ".runAndExtendWithFaceDetections",
"7": "Box",
"6": "extractFaceTensors",
"7": "ComputeSingleFaceDescriptorTask",
"8": "admin.js",
"9": "__awaiter",
"10": "TinyYolov2Base",
"10": ".forwardInput",
"11": "lib/accounts.js",
"12": "server.js",
"13": "files",
"14": "FaceLandmarks",
"15": ".runAndExtendWithFaceDetection",
"14": "AgeGenderNet",
"15": ".runAndExtendWithFaceDetections",
"16": "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范",
"17": "extractFaces",
"18": "1'me 在线肌肤测试 H5",
"19": "head-pose.js",
"20": "build_fixtures.py",
"21": "AgeGenderNet",
"21": "disposeUnusedWeightTensors",
"22": ".loadFromDisk",
"23": "facepp-skinanalyze(旷视皮肤分析专业版调用技能)",
"24": "巨子测肤 · 1'me 语音播报内容设计文档 V1.0",
@@ -42,7 +42,7 @@
"40": ".predictAgeAndGender",
"41": "test/accounts.js",
"42": "export_psd_assets.py",
"43": ".toString",
"43": ".toBatchTensor",
"44": "start-local.sh",
"45": "PredictAllAgeAndGenderWithFaceAlignmentTask",
"46": "PredictAllFaceExpressionsWithFaceAlignmentTask",
@@ -54,7 +54,7 @@
"52": "region-data.js",
"53": "护理建议(全量文本,按需求摘抄)",
"54": "UI 对齐 10.09 设计稿:调研与实施计划",
"55": "pn",
"55": "i",
"56": "report-ui.js",
"57": "regression.js",
"58": ".dispose",
@@ -78,14 +78,18 @@
"76": "TinyYolov2",
"77": "drawFaceExpressions",
"78": "n",
"79": "isValidNumber",
"79": "微信登录联调-2026-10-12.md",
"80": "线上微信登录部署记录",
"81": "wechat-production.mjs",
"82": "ComputeAllFaceDescriptorsTask",
"83": "10.09 新版 H5 实施与验收",
"84": "微信登录联调:2026-10-12",
"85": "FaceLandmarks",
"87": "产品推荐规则:肤龄基准与边界调整",
"92": "部署",
"91": "login-performance.mjs",
"94": "Box",
"95": "PredictAllFaceExpressionsTask",
"97": "PredictSingleAgeAndGenderWithFaceAlignmentTask",
"98": "PredictSingleFaceExpressionsWithFaceAlignmentTask"
"98": "PredictSingleFaceExpressionsWithFaceAlignmentTask",
"99": "PredictSingleFaceExpressionsTask"
}
+1 -1
View File
@@ -1 +1 @@
{"0": "4e953aa5accd7c7a", "1": "cb6ea50bd4e4a5ca", "2": "9cf893b45c314643", "3": "1f737ca8edb978f2", "4": "5f6112d5f635312b", "5": "eef22a8f3d286cb0", "6": "435ca504c453b5ca", "7": "cd3c9a7feaf8faee", "8": "1ecf47531e1d507b", "9": "abb09e22736891ca", "10": "b78b7363e38a9a47", "11": "4a490abd5e503318", "12": "d3e946c5907aa577", "13": "7e3b558a587a4ebe", "14": "b3282012327aedbb", "15": "8bd61518dd267641", "16": "95add6a975c0cb72", "17": "fceb16a66797d550", "18": "9dec2afb5aedd74b", "19": "0a373ad2197fd1a6", "20": "aecf2fce44ba49c5", "21": "e6363b2654e6584b", "22": "caf0f067881ef97e", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "46e8db3e6e3f37ea", "26": "83b0fd3b14363e4d", "27": "eab427ce9a5219e9", "28": "3c28e8d4be0a55e3", "29": "c608f1c6492e1cb1", "30": "73c13793fca09f56", "31": "fbbcfc059751cc6a", "32": "f6bcf56c4589316b", "33": "75f750c5b7d9f63d", "34": "f024ff7942935690", "35": "23f1d406f6c779ae", "36": "a3859fc25c864a81", "37": "cb8f26249485324a", "38": "9a25b1230b5fe649", "39": "fdd95375b4a5ddb3", "40": "a2b13160223b5a98", "41": "4bc370be8afd7db1", "42": "ffd960136fde00ad", "43": "15d6968c6dfb2e19", "44": "852c1bb2446edd9c", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "47": "e594ecaa8e7d1538", "48": "e042d7fe7351bcb9", "49": "1f976711cda158dc", "50": "bbb2b57d72922b16", "51": "eb6a182204867275", "52": "2ec3c8c088568ba9", "53": "68c45056378341a7", "54": "c58d41e8e580fdc6", "55": "1ac1eedae197df20", "56": "91d7ab49459e0b92", "57": "f2a40b5f1b29f669", "58": "36e826942fb5c839", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "ed382bc83b2567aa", "62": "1366ae24c7e5897d", "63": "c256b43f75d7809e", "64": "ce0e68dab611f022", "65": "6281db8274251503", "66": "769f05ad8584c01f", "67": "3e126d8deaf6e200", "68": "6b91f3679541de79", "69": "e511846eeba03076", "70": "bf75bfc99d4f9e34", "71": "4195478d5c528371", "72": "c84f196a7bd35861", "73": "2b887bf601ccfb9f", "74": "ae2a154311e1f53a", "75": "69fdd23278518884", "76": "c96276581a8c98d4", "77": "979dcc63399620ad", "78": "9c8639405659c6ae", "79": "1c27e5f448dc4f9c", "80": "315325cde1352e98", "81": "37c559741825be74", "82": "3dd68cce72af5314", "83": "1284897282eb47a3", "84": "3f747b286aec1e85", "87": "e5fa7717989eda80", "92": "7dc1445ea84a39a8", "97": "01f68dfc34a29d32", "98": "8c471c7baeaaca54"}
{"0": "bf880d08a452d7b9", "1": "cb6ea50bd4e4a5ca", "2": "e45f02a2d81f378b", "3": "1f737ca8edb978f2", "4": "68c8c4a53446cb29", "5": "d50faa21d10efe9c", "6": "21004e7ec716999e", "7": "7ac475ea9bc280f8", "8": "1ecf47531e1d507b", "9": "bb9def5316182bbf", "10": "880a74c6d255e5cc", "11": "4a490abd5e503318", "12": "53594b653ad48743", "13": "7e3b558a587a4ebe", "14": "d982c7c094e0d01c", "15": "4184c65898b38dd8", "16": "95add6a975c0cb72", "17": "d522c79a290badbf", "18": "38bb0b185ceed116", "19": "0a373ad2197fd1a6", "20": "aecf2fce44ba49c5", "21": "8835aeeaa165e9f9", "22": "af8f2189aee7bcc9", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "46e8db3e6e3f37ea", "26": "83b0fd3b14363e4d", "27": "eab427ce9a5219e9", "28": "08e85dab33772c23", "29": "c608f1c6492e1cb1", "30": "73c13793fca09f56", "31": "fbbcfc059751cc6a", "32": "f6bcf56c4589316b", "33": "75f750c5b7d9f63d", "34": "f024ff7942935690", "35": "23f1d406f6c779ae", "36": "a3859fc25c864a81", "37": "cb8f26249485324a", "38": "9a25b1230b5fe649", "39": "fdd95375b4a5ddb3", "40": "a2b13160223b5a98", "41": "4bc370be8afd7db1", "42": "ffd960136fde00ad", "43": "b434f4a4ecab270c", "44": "852c1bb2446edd9c", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "47": "e594ecaa8e7d1538", "48": "e042d7fe7351bcb9", "49": "1f976711cda158dc", "50": "bbb2b57d72922b16", "51": "eb6a182204867275", "52": "2ec3c8c088568ba9", "53": "68c45056378341a7", "54": "c58d41e8e580fdc6", "55": "ac5257538f52da80", "56": "91d7ab49459e0b92", "57": "f2a40b5f1b29f669", "58": "16bec50ccad83d20", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "5f4ba3be5e4fe3af", "62": "1366ae24c7e5897d", "63": "c256b43f75d7809e", "64": "ce0e68dab611f022", "65": "74ae017de0ffd36b", "66": "2b037735e1f5b0f6", "67": "3e126d8deaf6e200", "68": "6b91f3679541de79", "69": "e511846eeba03076", "70": "bf75bfc99d4f9e34", "71": "4195478d5c528371", "72": "c84f196a7bd35861", "73": "2b887bf601ccfb9f", "74": "ae2a154311e1f53a", "75": "69fdd23278518884", "76": "c96276581a8c98d4", "77": "d5ce16a18b491627", "78": "bff404f6e2fa3713", "79": "f31ac836426a58b6", "80": "315325cde1352e98", "81": "37c559741825be74", "82": "3dd68cce72af5314", "83": "1284897282eb47a3", "84": "d8bb296741d3d05c", "85": "1d30b164aa092dc6", "87": "e5fa7717989eda80", "91": "59c31f858c7b6970", "94": "c6eac02ad7178f8e", "95": "e800d832c255899a", "97": "01f68dfc34a29d32", "98": "8c471c7baeaaca54", "99": "cb5ac3401283c378"}
+89 -85
View File
@@ -1,16 +1,16 @@
# Graph Report - juzi (2026-10-12)
## Corpus Check
- 68 files · ~4,185,405 words
- 70 files · ~4,212,892 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 1204 nodes · 2589 edges · 89 communities (72 shown, 17 thin omitted)
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 179 edges (avg confidence: 0.53)
- 1222 nodes · 2613 edges · 93 communities (72 shown, 21 thin omitted)
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 180 edges (avg confidence: 0.53)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `0ec5e5dc`
- Built from commit: `85790ef9`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -19,24 +19,24 @@
- 线上闭环测试(不含真实微信授权)
- app.js
- skin-score.js
- i
- k
- r
- .runAndExtendWithFaceDetections
- Box
- extractFaceTensors
- ComputeSingleFaceDescriptorTask
- admin.js
- __awaiter
- TinyYolov2Base
- .forwardInput
- lib/accounts.js
- server.js
- files
- FaceLandmarks
- .runAndExtendWithFaceDetection
- AgeGenderNet
- .runAndExtendWithFaceDetections
- 巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范
- extractFaces
- 1'me 在线肌肤测试 H5
- head-pose.js
- build_fixtures.py
- AgeGenderNet
- disposeUnusedWeightTensors
- .loadFromDisk
- facepp-skinanalyze(旷视皮肤分析专业版调用技能)
- 巨子测肤 · 1'me 语音播报内容设计文档 V1.0
@@ -57,7 +57,7 @@
- .predictAgeAndGender
- test/accounts.js
- export_psd_assets.py
- .toString
- .toBatchTensor
- start-local.sh
- PredictAllAgeAndGenderWithFaceAlignmentTask
- PredictAllFaceExpressionsWithFaceAlignmentTask
@@ -67,7 +67,7 @@
- call_skinanalyze_3view.sh
- 护理建议(全量文本,按需求摘抄)
- UI 对齐 10.09 设计稿:调研与实施计划
- pn
- i
- report-ui.js
- regression.js
- .dispose
@@ -87,16 +87,20 @@
- TinyYolov2
- drawFaceExpressions
- n
- isValidNumber
- 微信登录联调-2026-10-12.md
- 线上微信登录部署记录
- wechat-production.mjs
- ComputeAllFaceDescriptorsTask
- 10.09 新版 H5 实施与验收
- 微信登录联调:2026-10-12
- FaceLandmarks
- 产品推荐规则:肤龄基准与边界调整
- 部署
- login-performance.mjs
- Box
- PredictAllFaceExpressionsTask
- PredictSingleAgeAndGenderWithFaceAlignmentTask
- PredictSingleFaceExpressionsWithFaceAlignmentTask
- PredictSingleFaceExpressionsTask
## God Nodes (most connected - your core abstractions)
1. `__awaiter()` - 59 edges
@@ -125,51 +129,47 @@
## Import Cycles
- None detected.
## Communities (89 total, 17 thin omitted)
## Communities (93 total, 21 thin omitted)
### Community 0 - "face-api.js"
Cohesion: 0.02
Nodes (32): cn(), computeReshapedDimensions(), createBrowserEnv(), createFileSystem(), createNodejsEnv(), dr(), en(), extractBoundingBoxes() (+24 more)
Nodes (29): cn(), createBrowserEnv(), createFileSystem(), createNodejsEnv(), dr(), en(), FaceDetection(), ff() (+21 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 (79): applyMirror(), armNudge(), beginDetect(), bindBackdropClose(), busyWithFlow(), captureFromVideo(), checkStill(), closeLoginSuccess() (+71 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 (27): b(), ba(), ca(), da(), dn(), ee(), et(), fa() (+19 more)
### Community 4 - "k"
Cohesion: 0.15
Nodes (15): b(), ba(), ca(), da(), fa(), k(), ne(), pa() (+7 more)
### Community 5 - "r"
Cohesion: 0.15
Nodes (34): a(), bf(), cf(), d(), drawContour(), dt(), e(), ea() (+26 more)
Cohesion: 0.14
Nodes (34): a(), ae(), bf(), cf(), d(), drawContour(), dt(), ea() (+26 more)
### Community 6 - ".runAndExtendWithFaceDetections"
Cohesion: 0.33
Nodes (5): DetectAllFacesTask(), extendWithFaceDetection(), isWithFaceDetection(), isWithFaceLandmarks(), resizeResults()
### Community 7 - "Box"
Cohesion: 0.16
Nodes (3): Box(), ComputeSingleFaceDescriptorTask(), extendWithFaceDescriptor()
### Community 6 - "extractFaceTensors"
Cohesion: 0.25
Nodes (4): DetectAllFaceLandmarksTask(), extractFaceTensors(), isTensor3D(), isTensor4D()
### Community 8 - "admin.js"
Cohesion: 0.21
Nodes (28): ageText(), api(), bind(), closeDetail(), collectFilters(), dimsHtml(), doRetry(), dSec() (+20 more)
### Community 9 - "__awaiter"
Cohesion: 0.11
Nodes (15): __awaiter(), bufferToImage(), ComposableTask(), extendWithFaceExpressions(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult(), FaceRecognitionNet(), fetchImage() (+7 more)
Cohesion: 0.14
Nodes (14): __awaiter(), extendWithFaceDescriptor(), extendWithFaceExpressions(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult(), FaceRecognitionNet(), fetchImage(), fetchJson() (+6 more)
### Community 10 - "TinyYolov2Base"
Cohesion: 0.10
Nodes (22): boxPredictionLayer(), convLayer(), convWithBatchNorm(), decodeBoxesLayer(), depthwiseConvLayer(), depthwiseSeparableConv$1(), fullyConnectedLayer(), getCenterCoordinatesAndSizesLayer() (+14 more)
### Community 10 - ".forwardInput"
Cohesion: 0.22
Nodes (9): boxPredictionLayer(), decodeBoxesLayer(), depthwiseConvLayer(), getCenterCoordinatesAndSizesLayer(), getStridesForLayerIdx(), mobileNetV1(), outputLayer(), pointwiseConvLayer() (+1 more)
### Community 11 - "lib/accounts.js"
Cohesion: 0.17
@@ -177,31 +177,31 @@ Nodes (20): atomic(), body(), cookie(), cookies(), crypto, fs, json(), login() (
### 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 - "FaceLandmarks"
Cohesion: 0.05
Nodes (25): conv(), conv$1(), convDown(), convLayer$1(), convNoRelu(), denseBlock3(), denseBlock4(), depthwiseSeparableConv() (+17 more)
### Community 14 - "AgeGenderNet"
Cohesion: 0.10
Nodes (6): AgeGenderNet(), extractParams$1(), FaceExpressionNet(), FaceLandmark68NetBase(), FaceLandmark68TinyNet(), FaceProcessor()
### Community 15 - ".runAndExtendWithFaceDetection"
Cohesion: 0.23
Nodes (6): allFacesMtcnn(), allFacesSsdMobilenetv1(), allFacesTinyYolov2(), DetectAllFaceLandmarksTask(), detectAllFaces(), DetectSingleFaceTask()
### Community 15 - ".runAndExtendWithFaceDetections"
Cohesion: 0.18
Nodes (8): allFacesMtcnn(), allFacesSsdMobilenetv1(), allFacesTinyYolov2(), detectAllFaces(), DetectAllFacesTask(), DetectSingleFaceTask(), extendWithFaceDetection(), resizeResults()
### 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.17
Nodes (20): awaitMediaLoaded(), createCanvas(), createCanvasFromMedia(), extractFaces(), extractFaceTensors(), extractImagePatches(), getContext2dOrThrow(), getEnv() (+12 more)
Cohesion: 0.25
Nodes (16): awaitMediaLoaded(), bufferToImage(), createCanvas(), createCanvasFromMedia(), extractFaces(), extractImagePatches(), getContext2dOrThrow(), getEnv() (+8 more)
### Community 18 - "1'me 在线肌肤测试 H5"
Cohesion: 0.17
Nodes (12): 10.09 更新, 1'me 在线肌肤测试 H5, Admin API(统一返回 `{ok:false,message}` 错误), 后台查看(admin.html), 密钥配置, 已知占位清单, 技术说明, 拍照交互(动态捕捉自动三连拍) (+4 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
@@ -211,13 +211,13 @@ Nodes (8): assertPoints(), check(), clamp(), dist(), estimate(), makeFrontal(),
Cohesion: 0.40
Nodes (9): build_case1(), build_case2(), build_truth(), col(), dump(), main(), parse_value(), set_path() (+1 more)
### Community 21 - "AgeGenderNet"
Cohesion: 0.05
Nodes (28): AgeGenderNet(), disposeUnusedWeightTensors(), extractorsFactory$3(), extractorsFactory$5(), extractorsFactory$7(), extractorsFactory$9(), extractParamsFromWeigthMap(), extractParamsFromWeigthMap$1() (+20 more)
### Community 21 - "disposeUnusedWeightTensors"
Cohesion: 0.09
Nodes (24): disposeUnusedWeightTensors(), extractorsFactory$3(), extractorsFactory$5(), extractorsFactory$7(), extractorsFactory$9(), extractParamsFromWeigthMap(), extractParamsFromWeigthMap$1(), extractParamsFromWeigthMap$2() (+16 more)
### Community 22 - ".loadFromDisk"
Cohesion: 0.24
Nodes (4): fetchJson(), getModelUris(), loadWeightMap(), TinyFaceDetector()
Cohesion: 0.22
Nodes (4): ComposableTask(), getModelUris(), Gf(), TinyFaceDetector()
### Community 23 - "facepp-skinanalyze(旷视皮肤分析专业版调用技能)"
Cohesion: 0.20
@@ -240,8 +240,8 @@ 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.10
Nodes (30): Bo(), C(), cd(), cl(), co(), eo(), fo(), Ft() (+22 more)
Cohesion: 0.12
Nodes (28): Bo(), C(), cd(), cl(), co(), e(), eo(), fo() (+20 more)
### Community 29 - "face-api.js 本地化依赖(h5/vendor/faceapi/)"
Cohesion: 0.29
@@ -279,9 +279,9 @@ Nodes (9): assert, create, fs, http, os, path, ReportUI, SkinScore (+1 more)
Cohesion: 0.70
Nodes (4): bounds(), layer_at(), main(), save_image()
### Community 43 - ".toString"
Cohesion: 0.29
Nodes (6): ae(), FaceMatch(), la(), pr(), pt(), vt()
### Community 43 - ".toBatchTensor"
Cohesion: 0.09
Nodes (28): conv(), conv$1(), convDown(), convLayer(), convLayer$1(), convNoRelu(), convWithBatchNorm(), denseBlock3() (+20 more)
### Community 44 - "start-local.sh"
Cohesion: 0.50
@@ -303,9 +303,9 @@ 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 - "pn"
Cohesion: 0.29
Nodes (7): bn(), fn(), ln(), pn(), un(), wn(), xn()
### Community 55 - "i"
Cohesion: 0.10
Nodes (21): bn(), dn(), ee(), et(), fn(), i(), je(), ln() (+13 more)
### Community 56 - "report-ui.js"
Cohesion: 0.90
@@ -316,12 +316,12 @@ Cohesion: 0.60
Nodes (5): diffCtx(), loadJSON(), main(), okNum(), okStr()
### Community 58 - ".dispose"
Cohesion: 0.17
Nodes (12): extendWithFaceLandmarks(), getSizesForScale(), iou(), nonMaxSuppression(), nonMaxSuppression$1(), normalize$1(), ObjectDetection(), rescaleAndNormalize() (+4 more)
Cohesion: 0.14
Nodes (17): computeReshapedDimensions(), extractBoundingBoxes(), getSizesForScale(), iou(), isEven(), NetInput(), nonMaxSuppression(), nonMaxSuppression$1() (+9 more)
### Community 61 - "NeuralNetwork"
Cohesion: 0.07
Nodes (26): createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractConvParamsFactory(), extractFCParamsFactory() (+18 more)
Cohesion: 0.06
Nodes (28): createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractConvParamsFactory(), extractFCParamsFactory() (+20 more)
### Community 62 - "提交前 Review(2026-10-11)"
Cohesion: 0.40
@@ -337,10 +337,10 @@ Nodes (5): browser(), init(), read(), rememberKind(), request()
### Community 65 - "微信登录接入与公网部署说明"
Cohesion: 0.22
Nodes (7): 公网服务器配置, 公网真机验收清单, 反向代理与后台保护, 已完成的链路, 微信登录接入与公网部署说明, 数据、迁移与备份, 结论与当前阻碍
Nodes (7): 公网服务器配置, 公网真机验收清单, 反向代理与后台保护, 已完成的链路, 微信登录接入与公网部署说明, 数据、迁移与备份, 本机开发环境限制(不代表线上状态)
### Community 66 - "wechat-browser.mjs"
Cohesion: 0.25
Cohesion: 0.22
Nodes (6): createAccounts, h5, ReportUI, require, root, SkinScore
### Community 68 - "analyze-timeout.js"
@@ -353,15 +353,15 @@ Nodes (3): artifacts, errors, samples
### Community 77 - "drawFaceExpressions"
Cohesion: 0.22
Nodes (6): DrawBox(), drawDetections(), drawFaceExpressions(), DrawFaceLandmarks(), FaceExpressions(), isWithFaceExpressions()
Nodes (8): DrawBox(), drawDetections(), drawFaceExpressions(), DrawFaceLandmarks(), FaceExpressions(), isWithFaceDetection(), isWithFaceExpressions(), isWithFaceLandmarks()
### Community 78 - "n"
Cohesion: 0.11
Nodes (29): be(), bi(), ce(), de(), Ef(), er(), fe(), Ie() (+21 more)
Cohesion: 0.10
Nodes (31): be(), bi(), ce(), de(), Ef(), er(), fe(), Ie() (+23 more)
### Community 79 - "isValidNumber"
Cohesion: 0.33
Nodes (5): bgrToRgbTensor(), Dimensions(), isValidNumber(), isValidProbablitiy(), isWithGender()
### Community 79 - "微信登录联调-2026-10-12.md"
Cohesion: 0.29
Nodes (4): 体验变化, 慢的原因与处理, 登录反馈与加载优化(2026-10-12), 验证
### Community 80 - "线上微信登录部署记录"
Cohesion: 0.33
@@ -376,31 +376,35 @@ Cohesion: 0.29
Nodes (5): 10.09 新版 H5 实施与验收, 按确认项实现, 文档未定义部分的实施口径, 素材再生成, 验证命令
### Community 84 - "微信登录联调:2026-10-12"
Cohesion: 0.29
Nodes (5): 微信登录联调:2026-10-12, 本轮修正, 白名单与凭据, 真实微信验收与排查, 自动验证
Cohesion: 0.33
Nodes (6): 后续真机确认与体验优化, 微信登录联调:2026-10-12, 本轮修正, 白名单与凭据, 真实微信验收与排查, 自动验证
### Community 85 - "FaceLandmarks"
Cohesion: 0.08
Nodes (12): bgrToRgbTensor(), Dimensions(), extendWithFaceLandmarks(), FaceLandmarks(), FaceLandmarks5(), FaceLandmarks68(), getCenterPoint(), isValidNumber() (+4 more)
### Community 87 - "产品推荐规则:肤龄基准与边界调整"
Cohesion: 0.33
Nodes (6): 产品推荐规则:肤龄基准与边界调整, 决策顺序, 后台与前端一致性, 实施与验证范围, 已明确的要求, 已确认的三个条件
### Community 92 - "部署"
Cohesion: 0.67
Nodes (3): Windows 本机部署(提测用), 线上部署(juzi.xiangsilian.com), 部署
### Community 94 - "Box"
Cohesion: 0.20
Nodes (3): Box(), isDimensions(), ObjectDetection()
## Knowledge Gaps
- **190 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+185 more)
- **198 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+193 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 `i` to `face-api.js`, `app.js`, `r`, `.toString`, `lib/accounts.js`, `n`, `C`?**
- **Why does `r()` connect `r` to `face-api.js`, `.toBatchTensor`, `server.js`, `n`, `i`, `C`?**
_High betweenness centrality (0.063) - this node is a cross-community bridge._
- **Why does `r()` connect `r` to `face-api.js`, `i`, `.toString`, `server.js`, `n`, `FaceLandmarks`, `C`?**
_High betweenness centrality (0.055) - this node is a cross-community bridge._
- **Why does `renderReport()` connect `app.js` to `i`, `n`?**
_High betweenness centrality (0.048) - this node is a cross-community bridge._
- **Why does `renderReport()` connect `app.js` to `k`, `n`?**
_High betweenness centrality (0.051) - this node is a cross-community bridge._
- **Why does `k()` connect `k` to `face-api.js`, `app.js`, `r`, `lib/accounts.js`, `n`, `C`?**
_High betweenness centrality (0.047) - 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?**
@@ -408,4 +412,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?**
_190 weakly-connected nodes found - possible documentation gaps or missing edges._
_198 weakly-connected nodes found - possible documentation gaps or missing edges._
File diff suppressed because one or more lines are too long
+1344 -868
View File
File diff suppressed because it is too large Load Diff
+67 -32
View File
@@ -40,8 +40,8 @@
"semantic_hash": ""
},
"h5/js/app.js": {
"mtime": 1791770434.3399014,
"ast_hash": "174b15c983614449ca67a7e5b81a8bbf",
"mtime": 1791773775.2349546,
"ast_hash": "cda77fb341a0f2573ed3a57c8e536fbb",
"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": 1791770434.3399014,
"ast_hash": "cfdedacf49c9a83c23155abb39fc7447",
"mtime": 1791773528.470933,
"ast_hash": "a8248357be1c886421c44845b6038203",
"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": 1791770434.3399014,
"ast_hash": "d14c4ec5374a8ada79e0e7cd1cc294a0",
"mtime": 1791774062.6429472,
"ast_hash": "3197b952f29f9e45176d4300082adaae",
"semantic_hash": ""
},
"h5/js/camera-geometry.js": {
"mtime": 1791646239.2012978,
"mtime": 1791773589.894928,
"ast_hash": "08048bc5e491ca47275afe067bedbc1c",
"semantic_hash": ""
},
@@ -1260,13 +1260,13 @@
"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": 1791770706.5669558,
"ast_hash": "b290838863e54d0d6261cb4e43cc0904",
"mtime": 1791774038.1869662,
"ast_hash": "61af7cde98a3299fcc4619e4776cce2a",
"semantic_hash": ""
},
"h5/assets/fonts/misans/README.md": {
@@ -1475,27 +1475,27 @@
"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": ""
},
@@ -1530,8 +1530,8 @@
"semantic_hash": ""
},
"h5/test/wechat-browser.mjs": {
"mtime": 1791770609.8948362,
"ast_hash": "9e3344d5618e59e5e765d16d4b0903b0",
"mtime": 1791773972.891023,
"ast_hash": "c27ca11dea3026358188f80affee2d6b",
"semantic_hash": ""
},
"docs/2026-10-11\u63d0\u4ea4\u524dReview.md": {
@@ -1545,8 +1545,8 @@
"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": {
@@ -1555,8 +1555,8 @@
"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": {
@@ -1575,13 +1575,48 @@
"semantic_hash": ""
},
"docs/\u5fae\u4fe1\u767b\u5f55\u8054\u8c03-2026-10-12.md": {
"mtime": 1791770818.4989572,
"ast_hash": "55253912b649c3ca707acdf2631a9e7f",
"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": 1791774062.6429472,
"ast_hash": "ffa81f4a1a2b47615d6a4b7a27de0d08",
"semantic_hash": ""
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

+3
View File
@@ -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; }
+22 -8
View File
@@ -1,9 +1,12 @@
/* 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)); }
@@ -14,16 +17,16 @@
.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-bg, .guide-bg { background-image: url(../assets/img/ui-20261009/bg-splash-h5.webp); }
.splash-slogan { top: 7.2%; font-size: calc(23 * var(--u)); 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; }
.btn-history { background: #fff; }
.profile-bg { background: url(../assets/img/ui-20261009/bg-profile-h5.jpg) center/cover no-repeat; }
.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: calc(11.5 * var(--u)); }
#s-history { background: #efefef url(../assets/img/ui-20261009/bg-history-h5.jpg) center/cover fixed; }
#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)); }
@@ -35,7 +38,7 @@
.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-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)); }
.card { border-radius: calc(7 * var(--u)); }
@@ -74,11 +77,22 @@
.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)); }
.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; }
.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%; } .splash-slogan { font-size: calc(18 * var(--u)); } }
+12 -3
View File
@@ -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,16 +31,17 @@
<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>
<button class="history-retry" data-action="history-retry">刷新列表</button>
<button class="history-retry" data-action="switch-login">切换登录方式</button>
<small class="font-credit">本应用使用 MiSans 字体</small>
</div>
</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">
@@ -461,7 +462,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>
+9 -1
View File
@@ -5,11 +5,19 @@ window.AccountClient = (function () {
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) {
+75 -24
View File
@@ -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 }, ''); } // 「前进」回拍照页不自动开相机
@@ -86,6 +118,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 +360,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 +582,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 +613,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;
@@ -1206,12 +1264,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('检测引擎加载中…');
@@ -1265,6 +1317,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'); },
@@ -1370,25 +1423,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; });
}
@@ -1446,7 +1494,10 @@
: '微信授权未完成,请重试;如仍失败,请将错误页面截图发给管理员。也可选择本机浏览器登录。';
} else if (loginSuccess && (!AccountClient.user() || AccountClient.user().kind !== 'wechat')) {
$('#login-message').textContent = '微信返回后登录会话未能保存,请在同一微信窗口重新登录。';
} else if (AccountClient.user()) showScreen('s-splash', 'replace');
} 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 () {
+22 -5
View File
@@ -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); }
});
}
+3
View File
@@ -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"]');
+1
View File
@@ -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();
+156
View File
@@ -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 });
}
+1
View File
@@ -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);
+1
View File
@@ -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);
+4
View File
@@ -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 });
+24
View File
@@ -56,6 +56,23 @@ try {
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();
@@ -76,6 +93,7 @@ try {
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' });
@@ -89,12 +107,14 @@ try {
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"]');
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');
@@ -104,6 +124,7 @@ try {
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)));
@@ -117,12 +138,14 @@ try {
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"]');
@@ -137,6 +160,7 @@ try {
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();
+2
View File
@@ -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']: