Compare commits

...
7 Commits
48 changed files with 4172 additions and 1929 deletions
@@ -0,0 +1,29 @@
# 原始肌龄显示与鸿蒙滚轮兼容
## 范围
- 生物可视年龄直接展示算法原始肌龄,取消实际年龄 ±3 的展示修正。原始肌龄缺失显示 `--`,不以实际年龄或旧展示值补齐,不额外四舍五入。
- 新报告 `skinAgeDisplay` 与 `skinAgeRaw` 相同,保留兼容字段 `ageClampNote` 但值为空。推荐规则、评分和其他话术不变。
- 历史报告回看优先读取快照 `vm.skinAgeRaw`;更老的快照缺该字段时,从保存的 `payload.result.skin_age` 读取。忽略旧的钳制展示值,不改写存量快照,不重算历史推荐/文案。后台列表、详情、CSV 的展示肌龄也改为读取原始肌龄,避免前后台不一致。
- `mobile-select@1.4.0` 原来通过 UA 字符串选择 touch/mouse,无法识别不带 Android 的 OpenHarmony/ArkWeb。保留上游版本与 MIT 声明,添加本地补丁:通过 `navigator.maxTouchPoints > 0` 或 `ontouchstart` 能力选择触摸事件,无触摸能力则使用鼠标事件;不再按系统名称判断。
- 同时按事件的 `touches/changedTouches` 读取坐标,不依赖全局 `TouchEvent` 构造函数,避免无该构造函数的鼠标环境报错。弹层挂载、页面禁滑和滚轮联动逻辑不变。
## 回归
```bash
node h5/test/raw-skin-age.js
node h5/js/skin-score.js
node h5/test/recommendation.js
node h5/test/regression.js
node h5/test/report-persistence.mjs
node h5/test/harmony-picker.mjs
node h5/test/profile-interaction.mjs
node h5/test/guest-privacy.mjs
node h5/test/report-touch-layout.mjs
```
鸿蒙专项使用 Chromium 的真实触摸/鼠标分发配合不同 UA 与能力配置,逐项验证年、月、省、市四轮、地区联动、确认回填、重开定位和表单提交。覆盖 Android 触摸、OpenHarmony 5、OpenHarmony 6+微信、桌面 UA 的触摸设备、无 TouchEvent 构造函数的桌面鼠标、Android UA 的无触摸环境。
这属于 UA/能力兼容回归,不等同于鸿蒙真机或 ArkWeb 内核验证。测试使用隔离临时目录或拦截账号请求,不写线上报告,不调用付费检测。历史报告测试故意保留旧展示值 57 岁,确认 H5/后台改显示原始肌龄且原文件字节不变。
用户已确认发版。本次与《微信退出登录-2026-10-13.md》的修改一起提交,采用 Git bundle 快进同步到 `/home/ubuntu/juzi`,重启 `juzi-h5.service` 后校验线上资源、健康与浏览器交互。不代用户推送 origin,不改写既有报告或调用线上付费检测。摄像头切换不在本次修改范围内。
@@ -1,5 +1,7 @@
# 基础信息记忆与肤质拖动
> 2026-10-13 调整:以下偏好缓存仅对微信登录生效。游客仅保留当前页面操作中的临时表单值,不写缓存、不回填历史;刷新后重新填写。详见《游客不留存与弹窗护理间距调整-2026-10-13.md》。
## 范围
- 出生年月、性别、地区每次点滚轮“确定”即保存,不必拍照或提交报告。
+11
View File
@@ -0,0 +1,11 @@
# 微信退出登录
- 首页与「我的报告」的状态区,仅微信已登录时显示「退出登录」。游客模式不显示,不恢复旧的「切换登录方式」按钮。
- 点击使用系统确认框提示:确认退出微信登录?历史报告将保留,重新微信登录后可查看。取消不调用退出接口。
- 确认后调用现有 `api/auth/logout`,撤销当前服务端会话和 Cookie;成功才清除前端登录状态并回到入口页。失败提示错误,保留当前页面/身份并允许重试;请求期间禁用退出按钮,防止重复提交。
- 不删除账号、报告快照或微信偏好缓存。退出后重新登录同一微信,仍可查看原有历史报告。
- 退出时清空历史列表并作废在途历史请求,限制旧报告的返回导航;旧微信请求迟到也不能在游客身份下打开报告。
验证:`node h5/test/wechat-browser.mjs`(模拟微信、真实 HTTPS Cookie)覆盖取消、接口失败、确认退出、服务端会话撤销、返回键、迟到报告、游客不显示退出及重新微信登录后快照文件字节不变。`node h5/test/guest-privacy.mjs`、`node h5/test/report-persistence.mjs` 回归游客与历史报告。
用户已确认与原始肌龄显示、鸿蒙滚轮兼容一起发版。发布沿用 Git bundle 快进同步和服务重启,不代用户推送 origin,不删除历史报告。微信退出流程使用隔离模拟账号验证;线上不操作真实微信账号、报告或付费接口。
@@ -0,0 +1,24 @@
# 拍摄角度不合格震动提醒
按用户确认的规则实现,纳入 2026-10-13 相机与首页更新发版。
- 实时检测确认单人、清晰度、距离与居中等前置条件通过,但角度不合格连续约 1 秒后,调用一次 30ms 轻震。
- 持续角度不合格时,相邻提醒至少间隔 4 秒;不使用独立定时器,只有收到新检测结果才可能提醒。
- 合格、无人脸、其他质量问题或检测失败立即取消震动并重置连续计时;漏帧也中断计时。检测结果间隔超过 2 秒会重新累计,避免推理卡顿被误认为持续不合格。
- 自动拍摄、手动快门和拍摄完成均不再震动。
- 拍摄锁、摄像头切换/未就绪、离开拍照页及切后台禁止提醒。停止检测、步骤重置或开始拍摄时取消尚未结束的震动。
- 前后提示保持 4 秒限频,不因角度短暂合格、镜头切换或步骤重置绕过冷却。
- 不支持接口、接口拒绝或抛出错误时静默忽略,不影响原有文字、语音和自动拍摄。
没有改变拍摄角度、距离、亮度、稳定时间或评分推荐算法。不是所有手机都能产生物理震动,微信/鸿蒙需真机复验,iOS 当前按无震动降级。
验证命令:
```sh
node h5/test/customer-feedback-second.js
node h5/test/customer-feedback-fourth.js
node h5/test/camera-flip-browser.mjs
node h5/test/capture-quality-browser.mjs
```
测试涵盖定时边界、持续限频、合格取消、无人脸、非角度问题、拍摄/切换/后台隔离、不支持/拒绝安全降级,以及相机翻转和真实本地模型三连拍回归。浏览器测试使用模拟相机和拦截的身份/报告接口,不调用付费检测、不写真实账号或报告。
@@ -0,0 +1,34 @@
# 游客不留存与弹窗护理间距调整
## 发版范围
本次已完成本地开发、截图与用户确认,按要求提交和部署。发布沿用 Git bundle 快进同步至 `/home/ubuntu/juzi` 并重启 `juzi-h5.service`;不代用户推送 origin,不删除既有服务器历史数据或更改微信配置。发布后校验静态资源哈希、服务健康、免登录入口、协议同意前后临时会话边界和游客空历史;不调用线上付费检测或写入真实报告。
- 欢迎弹窗保留完整句子「我已阅读并同意相关隐私协议」,仅「隐私协议」四字加粗并下划线;「相关」恢复为普通文字,点击协议不勾选同意。
- 报告护理建议标题的下方间距由 18u 减为 9u(375px 屏宽对应 18px→9px),仍左对齐,不影响其他标题。
- 仅微信登录保留个人偏好和历史报告。微信现有账号、最近 5 份完整报告、跨手机查询、后台记录与原始结果留存不变。
- 游客入口改为「免登录体验」:不要求登录页勾选协议,不发起登录请求、不弹登录成功,直接进入首页。开始检测时仍须在欢迎弹窗明确同意隐私协议,之后才建立后台检测所需的临时会话。首页/报告列表的「切换登录」按钮删除,状态改为不可点击的「游客模式」/「微信已登录」。微信登录保留在原入口页,以及游客历史页的「微信登录,保存报告」入口。
- 游客使用内存中临时身份和非持久 Cookie,不落盘账号/会话,不再发送或保存可恢复的游客凭据。浏览器旧游客身份、基础信息和早期本地报告缓存清除;不清空其他应用或微信的缓存。
- 游客基础信息只在当前页面操作中使用,例如拍摄失败返回可继续重拍;不写 localStorage,刷新/新游客登录后重新填写。游客报告仅供本次查看;「我的报告」显示不保存提示,不请求历史列表、不展示旧游客报告。
- 服务端同样限制:游客成功/失败均不新增 records.jsonl、照片、原始结果和完整报告快照;检测和评分在内存中正常进行。检测响应设为 no-store,游客不出现「保存失败」误提示。该规则指本项目不留存,不代表上游检测供应商的数据政策发生变化。
- 已有服务器游客历史数据不自动删除(避免未经确认批量清理);旧持久游客凭据不能恢复身份或读取历史。后续若需清理存量数据,应另行确认范围与备份。
## 验证
使用隔离临时目录、固定图片/上游响应与模拟微信;不操作线上账号/数据,不调用付费检测。
```bash
H5_ARTIFACT_DIR=/tmp/juzi-guest-privacy-20261013 node h5/test/guest-privacy.mjs
node h5/test/accounts.js
node h5/test/profile-store.js
node h5/test/profile-interaction.mjs
node h5/test/report-persistence.mjs
node h5/test/wechat-browser.mjs
node h5/test/customer-feedback-fourth.mjs
node h5/test/login-performance.mjs
node h5/test/report-touch-layout.mjs
```
新增端到端测试比对游客成功/失败前后的整个临时数据目录字节,确保无身份、照片、档案、报告、检测原文或后台记录写入;同一服务随后切换微信,验证报告/照片/后台记录正常保存。既有测试更新为仅微信持久化,保留跨设备、历史快照不可变、账号隔离和基础信息/拍照/报告交互回归。
截图:guest-entry.png、guest-home.png、welcome-privacy.png、care-spacing.png、guest-history.png(均为本地界面,报告内容为固定样例)。最新版可运行 `H5_ARTIFACT_DIR=/tmp/juzi-guest-no-login-20261013 node h5/test/guest-privacy.mjs` 生成。
@@ -0,0 +1,29 @@
# 相机翻转性能优化
本次优化纳入 2026-10-13 相机与首页更新发版。
## 修改范围
- 相机首帧 readyState ≥ 2 且宽高有效后即可显示,检查周期 50ms;去掉原有三轮 300ms 分辨率预热等待。
- 翻转过程中保留上一帧及原来的镜像方向,显示“正在切换摄像头…”。静帧仅保存在内存 Canvas,恢复预览或离开页面后清空,不上传、不持久化。
- 启动/翻转时禁用翻转按钮,拍摄和步骤推进期间拒绝翻转。
- 新镜头失败、首帧等待超时或浏览器明确返回错误的 facingMode 时,尝试恢复原镜头一次;恢复也失败才降级到系统相机。
- 翻转及恢复请求设置 12 秒超时,初次权限询问不设置该超时。首帧单独设置 12 秒超时,不再超时强行认为成功。迟到的流及时停止。
- 检测循环增加代次令牌,旧镜头推理结果不可更新新镜头状态或启动第二条循环;旧推理未结束时不叠加新推理。
- 模型加载回调也校验镜头令牌,避免旧回调覆盖切换状态。
拍摄角度、亮度、尺寸、清晰度、稳住时间、照片编码、评分和推荐规则均未修改。
## 验证
- `node h5/test/camera-flip-browser.mjs`:模拟相机验证首帧显示时延、静帧、重复点击、拍摄锁、失败/忽略 facingMode/超时恢复、旧推理隔离、离开后迟到流释放。
- `node h5/test/capture-quality-browser.mjs`:真实本地模型、三角度自动拍摄、最终 JPEG 复验、语音顺序、摄像头释放通过。
- `node h5/test/login-performance.mjs`:登录轻量加载、相机模型按需加载/失败重试等通过。
- 无付费检测调用、真实账号和报告写入。
模拟相机对比:原实现首帧后额外等待约 900ms,两轮优化后约 57~91ms(受测试机器调度影响)。不是手机真机性能结论。
## 仍需真机验证
微信 Android、iOS、鸿蒙:连续翻转、后置转前置、翻转时返回/切后台、拒绝相机权限、拍摄中点击翻转。
摄像头硬件/微信浏览器重新启动本身的耗时无法消除;不提供 getSettings().facingMode 的浏览器只能依赖 facingMode ideal 选择,无法保证识别其是否忽略请求。
+57 -25
View File
@@ -4,28 +4,28 @@
"2": "app.js",
"3": "skin-score.js",
"4": "customer-feedback-second.js",
"5": "t",
"5": "disposeUnusedWeightTensors",
"6": "n",
"7": "test/profile-store.js",
"8": "admin.js",
"9": "__awaiter",
"10": "wechat-browser.mjs",
"10": "k",
"11": "lib/accounts.js",
"12": "server.js",
"13": "files",
"14": ".draw",
"15": "extractFaces",
"14": "showScreen",
"15": "test/accounts.js",
"16": "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范",
"17": "report-persistence.mjs",
"17": "vizCompose",
"18": "1'me肌肤状态检测 H5",
"19": "customer-feedback-fourth.js",
"20": "build_fixtures.py",
"21": "disposeUnusedWeightTensors",
"22": "k",
"21": "extractFaces",
"22": "i",
"23": "facepp-skinanalyze(旷视皮肤分析专业版调用技能)",
"24": "巨子测肤 · 1'me 语音播报内容设计文档 V1.0",
"25": "mobile-select.iife.min.js",
"26": "NeuralNetwork",
"26": ".extractWeights",
"27": "gen_voice.py",
"28": "C",
"29": "face-api.js 本地化依赖(h5/vendor/faceapi/)",
@@ -40,12 +40,13 @@
"38": "FaceLandmark68Net",
"39": "UI交互界面调整",
"40": "第四轮反馈修改与验收",
"41": "showScreen",
"41": ".loadFromDisk",
"42": "layer_at",
"43": "TinyYolov2Base",
"43": "ComputeAllFaceDescriptorsTask",
"44": "start-local.sh",
"45": "PredictAllAgeAndGenderWithFaceAlignmentTask",
"45": "report-persistence.mjs",
"46": "PredictAllFaceExpressionsWithFaceAlignmentTask",
"47": "extractFaceTensors",
"48": "已修改",
"49": "mobile-select/README.md",
"50": "call_skinanalyze_3view.sh",
@@ -53,7 +54,7 @@
"52": "region-data.js",
"53": "护理建议(全量文本,按需求摘抄)",
"54": "UI 对齐 10.09 设计稿:调研与实施计划",
"55": "FaceLandmarks68",
"55": "guest-privacy.mjs",
"56": "report-ui.js",
"57": "regression.js",
"58": ".forwardInput",
@@ -62,9 +63,9 @@
"61": "报告布局与跟手交互调整",
"62": "提交前 Review(2026-10-11)",
"63": "e2e-browser.mjs",
"64": "start",
"64": "isValidNumber",
"65": "微信登录接入与公网部署说明",
"66": ".runAndExtendWithFaceDetections",
"66": "h5/README.md",
"67": "ui-20261009.mjs",
"68": "analyze-timeout.js",
"69": "misans/README.md",
@@ -76,14 +77,14 @@
"75": "prepare_h5_assets.py",
"76": "报告页滑动防冲突-2026-10-13.md",
"77": "drawFaceExpressions",
"78": ".toString",
"78": "FaceLandmarks68",
"79": "登录反馈与加载优化(2026-10-12)",
"80": "线上微信登录部署记录",
"81": "wechat-production.mjs",
"82": "拍摄约束放宽与标题更新",
"83": "10.09 新版 H5 实施与验收",
"84": "微信登录联调:2026-10-12",
"85": "test/accounts.js",
"85": "相机翻转性能优化",
"86": "report-touch-layout.mjs",
"87": "产品推荐规则:肤龄基准与边界调整",
"88": "ui-20261012/manifest.json",
@@ -95,9 +96,12 @@
"94": "第三轮反馈:PSD 对齐、验收及上线记录",
"95": "login-performance.mjs",
"96": "production-loop.mjs",
"99": "DetectSingleFaceLandmarksTask",
"100": "extractAllFacesAndComputeResults",
"102": "extractFaceTensors",
"97": "account-client.js",
"98": ".mul",
"99": "ComputeSingleFaceDescriptorTask",
"100": ".draw",
"101": "wechat-fixture.cjs",
"102": "DetectSingleFaceLandmarksTask",
"103": "第四轮反馈/傅艳NEW与项思炼的会话 2026年10月12日.md",
"104": "export_fourth_feedback_assets.py",
"105": "capture-quality-browser.mjs",
@@ -105,14 +109,42 @@
"107": "基础信息记忆与肤质拖动",
"108": "profile-interaction.mjs",
"109": "submitPhotos",
"110": "doCapture",
"111": "i",
"110": "startCamera",
"111": "extractWeightsFactory",
"112": "微信退出登录-2026-10-13.md",
"113": "swipe-track.js",
"114": "optimize_delivery_images.py",
"116": "convLayer",
"117": "startCamera",
"115": "normalize",
"116": ".run",
"117": "play",
"118": "f",
"119": "PredictSingleFaceExpressionsWithFaceAlignmentTask",
"120": "PredictAllFaceExpressionsTask",
"121": "wechat-browser.mjs",
"122": "doCapture",
"123": "评分圈内居中、基础信息页固定与正脸语音修复",
"124": "TinyYolov2",
"124": "FaceProcessor",
"125": "报告等待页实拍背景-2026-10-12.md",
"127": "height"
"126": "PredictSingleFaceExpressionsTask",
"127": "splash-brand-browser.mjs",
"128": "NeuralNetwork",
"129": "TinyYolov2Base",
"130": "FaceFeatureExtractor",
"131": "原始肌龄显示与鸿蒙滚轮兼容",
"132": "harmony-picker.mjs",
"133": ".toString",
"134": "camera-flip-browser.mjs",
"135": "AgeGenderNet",
"136": "convLayer",
"137": "height",
"138": "age",
"139": "recommendation.js",
"140": ".forwardInput",
"141": "FaceRecognitionNet",
"142": ".runAndExtendWithFaceDetections",
"143": "TinyXception",
"144": "FaceExpressionNet",
"145": "PredictAllAgeAndGenderWithFaceAlignmentTask",
"146": "PredictSingleAgeAndGenderWithFaceAlignmentTask",
"147": "拍摄角度不合格震动提醒-2026-10-13.md"
}
+1 -1
View File
@@ -1 +1 @@
{"0": "e93608cf288712b8", "1": "cb6ea50bd4e4a5ca", "2": "3427a6e8a9c7cb3e", "3": "1f737ca8edb978f2", "4": "56bf12952b0de42b", "5": "7eecef0f27ae2903", "6": "23e6fea2f88a24d1", "7": "ca1cdb7d0ae4e71e", "8": "a689fcb62e7754af", "9": "5b3557ee5c2bf900", "10": "2b037735e1f5b0f6", "11": "4a490abd5e503318", "12": "aeca274694e158d2", "13": "7e3b558a587a4ebe", "14": "bf75bfc99d4f9e34", "15": "6dfa695990b6977c", "16": "95add6a975c0cb72", "17": "e042d7fe7351bcb9", "18": "f93d5d85e671fcee", "19": "39abb1eef19df707", "20": "aecf2fce44ba49c5", "21": "6079f456ac9d4b2e", "22": "02495fa16ac642c3", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "46e8db3e6e3f37ea", "26": "a8588d8e77a6ae93", "27": "eab427ce9a5219e9", "28": "037cf5fc8fd22dd0", "29": "c608f1c6492e1cb1", "30": "73c13793fca09f56", "31": "fbbcfc059751cc6a", "32": "f6bcf56c4589316b", "33": "75f750c5b7d9f63d", "34": "f024ff7942935690", "35": "241be5abeab1da6f", "36": "a7d375041779bad4", "37": "cb8f26249485324a", "38": "9a25b1230b5fe649", "39": "fdd95375b4a5ddb3", "40": "5e546676ab96de03", "41": "71cbe5098f4c1b6f", "42": "916170b9f5d7346b", "43": "110bce64414980d4", "44": "852c1bb2446edd9c", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "48": "cf2f2424bc00436f", "49": "1f976711cda158dc", "50": "bbb2b57d72922b16", "51": "eb6a182204867275", "52": "2ec3c8c088568ba9", "53": "68c45056378341a7", "54": "c58d41e8e580fdc6", "55": "68c52c80f38f8b2c", "56": "9ee2ad45dc27851d", "57": "f2a40b5f1b29f669", "58": "d8c090d6837ee23a", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "5209ce2a267bf33b", "62": "1366ae24c7e5897d", "63": "c256b43f75d7809e", "64": "a01bccd7f7c24c78", "65": "bdee3c4a37df52f8", "66": "46204fb2e4d50c53", "67": "3e126d8deaf6e200", "68": "6b91f3679541de79", "69": "e511846eeba03076", "70": "be0c8310abda2467", "71": "589a2d5a76f3880a", "72": "c84f196a7bd35861", "73": "2b887bf601ccfb9f", "74": "ae2a154311e1f53a", "75": "69fdd23278518884", "76": "3c4c278d6026bc3a", "77": "b4e08c87a078a832", "78": "71f300fdfae6e5a6", "79": "86f177bf0a7dd07c", "80": "315325cde1352e98", "81": "37c559741825be74", "82": "200f25fbad7af8fb", "83": "1284897282eb47a3", "84": "2fb17901aa87e0e7", "85": "4bc370be8afd7db1", "86": "386c6fcc7be918e0", "87": "e5fa7717989eda80", "88": "8b5ab5232cef3457", "89": "9e3363b80eeceeea", "90": "dd1c246e19a2d032", "91": "83b0fd3b14363e4d", "92": "fced244d93677d90", "93": "5a9014c4a7f87699", "94": "713d04996c4627df", "95": "59c31f858c7b6970", "96": "4195478d5c528371", "99": "23f1d406f6c779ae", "100": "e42f384dfedcff89", "102": "4dba2da8e6a57bef", "103": "6ffbe07b521a061b", "104": "e114a7aebaaca2f1", "105": "af50c66d16c81bba", "106": "8bc72c179793e1c2", "107": "d9c195d9ccdb137e", "108": "e0fc0a42e33bda91", "109": "4afb1715f62865b1", "110": "52f28eb30e80851f", "111": "b34758de9b00285f", "113": "0d99f86b9e41c4bb", "114": "b9d7c266afa84437", "116": "d1ec12eee057e765", "117": "cc191db3bbde70d7", "123": "b091e3eb82b456eb", "124": "c96276581a8c98d4", "125": "e4f29216745ec758", "127": "746b316833e9c203"}
{"0": "017a6130270ab9f2", "1": "cb6ea50bd4e4a5ca", "2": "4d1bb0fdf94e716d", "3": "125a85b2d8e78e52", "4": "4b1835ae3a82cf12", "5": "b5b4a03db0f5697e", "6": "16df50aadff513fb", "7": "03b56b13a08b5f83", "8": "a689fcb62e7754af", "9": "a579e3315ea1fb63", "10": "188dd5e33354e35d", "11": "4a490abd5e503318", "12": "aeca274694e158d2", "13": "7e3b558a587a4ebe", "14": "58417b287cc79e90", "15": "4bc370be8afd7db1", "16": "95add6a975c0cb72", "17": "288c2a98831b0021", "18": "f93d5d85e671fcee", "19": "5272556cf61d08b3", "20": "aecf2fce44ba49c5", "21": "2405e1edb003a83e", "22": "54d7329fed675057", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "46e8db3e6e3f37ea", "26": "16bdd538850fbb24", "27": "eab427ce9a5219e9", "28": "f2d553fdb7f3d983", "29": "c608f1c6492e1cb1", "30": "73c13793fca09f56", "31": "fbbcfc059751cc6a", "32": "f6bcf56c4589316b", "33": "75f750c5b7d9f63d", "34": "f024ff7942935690", "35": "241be5abeab1da6f", "36": "a7d375041779bad4", "37": "cb8f26249485324a", "38": "9a25b1230b5fe649", "39": "fdd95375b4a5ddb3", "40": "5e546676ab96de03", "41": "00dab8704da757dc", "42": "916170b9f5d7346b", "43": "3dd68cce72af5314", "44": "852c1bb2446edd9c", "45": "e042d7fe7351bcb9", "46": "65718282b3c32109", "47": "e1aa21690d4f0dae", "48": "cf2f2424bc00436f", "49": "1f976711cda158dc", "50": "bbb2b57d72922b16", "51": "eb6a182204867275", "52": "2ec3c8c088568ba9", "53": "68c45056378341a7", "54": "c58d41e8e580fdc6", "55": "812950f51be20e40", "56": "9ee2ad45dc27851d", "57": "f2a40b5f1b29f669", "58": "80b3d789d41454ea", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "5209ce2a267bf33b", "62": "1366ae24c7e5897d", "63": "c256b43f75d7809e", "64": "1c27e5f448dc4f9c", "65": "bdee3c4a37df52f8", "66": "9727b30d720298e3", "67": "3e126d8deaf6e200", "68": "6b91f3679541de79", "69": "e511846eeba03076", "70": "be0c8310abda2467", "71": "589a2d5a76f3880a", "72": "c84f196a7bd35861", "73": "2b887bf601ccfb9f", "74": "ae2a154311e1f53a", "75": "69fdd23278518884", "76": "3c4c278d6026bc3a", "77": "979dcc63399620ad", "78": "ac00532d5d77886f", "79": "c6cfdfe181232ecc", "80": "315325cde1352e98", "81": "37c559741825be74", "82": "200f25fbad7af8fb", "83": "1284897282eb47a3", "84": "d8bb296741d3d05c", "85": "7067e88534a91ab4", "86": "386c6fcc7be918e0", "87": "e5fa7717989eda80", "88": "8b5ab5232cef3457", "89": "9e3363b80eeceeea", "90": "dd1c246e19a2d032", "91": "83b0fd3b14363e4d", "92": "fced244d93677d90", "93": "5a9014c4a7f87699", "94": "713d04996c4627df", "95": "59c31f858c7b6970", "96": "4195478d5c528371", "97": "505ea719b994ac5e", "98": "e6ef8c2f1b997e99", "99": "7ac475ea9bc280f8", "100": "bf75bfc99d4f9e34", "101": "beb276de6301634c", "102": "23f1d406f6c779ae", "103": "6ffbe07b521a061b", "104": "e114a7aebaaca2f1", "105": "af50c66d16c81bba", "106": "8bc72c179793e1c2", "107": "3877490226a78145", "108": "e0fc0a42e33bda91", "109": "4afb1715f62865b1", "110": "f1660fde8e75d368", "111": "72618e564850b9ce", "112": "af995dd35403b4dd", "113": "0d99f86b9e41c4bb", "114": "b9d7c266afa84437", "115": "7a0da13554c97235", "116": "8d6f4b5e4f67bcb7", "117": "679b8985ab775959", "118": "f5fa3e3d7d829c42", "119": "8c471c7baeaaca54", "120": "e800d832c255899a", "121": "2b037735e1f5b0f6", "122": "61b6fafa306cb441", "123": "b091e3eb82b456eb", "124": "94ead9feda213ac6", "125": "e4f29216745ec758", "126": "cb5ac3401283c378", "127": "006218e34885dbe0", "128": "d96493a5f110bd4c", "129": "327fcc91276306b0", "130": "9209646b8de02314", "131": "482dbb7ec33d8c23", "132": "3daacabc5062b554", "133": "89e609b2678905d3", "134": "e7c90d6ef8276a1e", "135": "14fdf4e4c496519f", "136": "8616cd6d33fcb6cb", "137": "746b316833e9c203", "138": "b234c5e4ac7f632d", "139": "e594ecaa8e7d1538", "140": "5fec2bbca3634aec", "141": "b186b90877868930", "142": "46204fb2e4d50c53", "143": "9beb9113cf6685a2", "144": "e858d75e995c92e5", "145": "92ab3e5134ed8685", "146": "01f68dfc34a29d32", "147": "0e6cad06ebfcfb29"}
+215 -109
View File
@@ -1,16 +1,16 @@
# Graph Report - juzi (2026-10-13)
## Corpus Check
- 107 files · ~4,905,775 words
- 118 files · ~4,910,480 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 1457 nodes · 2904 edges · 116 communities (91 shown, 25 thin omitted)
- 1507 nodes · 2958 edges · 148 communities (112 shown, 36 thin omitted)
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 210 edges (avg confidence: 0.57)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `4c478b16`
- Built from commit: `73813061`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -20,28 +20,28 @@
- app.js
- skin-score.js
- customer-feedback-second.js
- t
- disposeUnusedWeightTensors
- n
- test/profile-store.js
- admin.js
- __awaiter
- wechat-browser.mjs
- k
- lib/accounts.js
- server.js
- files
- .draw
- extractFaces
- showScreen
- test/accounts.js
- 巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范
- report-persistence.mjs
- vizCompose
- 1'me肌肤状态检测 H5
- customer-feedback-fourth.js
- build_fixtures.py
- disposeUnusedWeightTensors
- k
- extractFaces
- i
- facepp-skinanalyze(旷视皮肤分析专业版调用技能)
- 巨子测肤 · 1'me 语音播报内容设计文档 V1.0
- mobile-select.iife.min.js
- NeuralNetwork
- .extractWeights
- gen_voice.py
- C
- face-api.js 本地化依赖(h5/vendor/faceapi/)
@@ -54,18 +54,19 @@
- FaceLandmark68Net
- UI交互界面调整
- 第四轮反馈修改与验收
- showScreen
- .loadFromDisk
- layer_at
- TinyYolov2Base
- ComputeAllFaceDescriptorsTask
- start-local.sh
- PredictAllAgeAndGenderWithFaceAlignmentTask
- report-persistence.mjs
- PredictAllFaceExpressionsWithFaceAlignmentTask
- extractFaceTensors
- 已修改
- mobile-select/README.md
- call_skinanalyze_3view.sh
- 护理建议(全量文本,按需求摘抄)
- UI 对齐 10.09 设计稿:调研与实施计划
- FaceLandmarks68
- guest-privacy.mjs
- report-ui.js
- regression.js
- .forwardInput
@@ -73,9 +74,9 @@
- 报告布局与跟手交互调整
- 提交前 Review(2026-10-11)
- e2e-browser.mjs
- start
- isValidNumber
- 微信登录接入与公网部署说明
- .runAndExtendWithFaceDetections
- h5/README.md
- ui-20261009.mjs
- analyze-timeout.js
- misans/README.md
@@ -84,14 +85,14 @@
- build_report_copy.py
- 报告页滑动防冲突-2026-10-13.md
- drawFaceExpressions
- .toString
- FaceLandmarks68
- 登录反馈与加载优化(2026-10-12)
- 线上微信登录部署记录
- wechat-production.mjs
- 拍摄约束放宽与标题更新
- 10.09 新版 H5 实施与验收
- 微信登录联调:2026-10-12
- test/accounts.js
- 相机翻转性能优化
- report-touch-layout.mjs
- 产品推荐规则:肤龄基准与边界调整
- ui-20261012/manifest.json
@@ -102,22 +103,51 @@
- 第三轮反馈:PSD 对齐、验收及上线记录
- login-performance.mjs
- production-loop.mjs
- account-client.js
- .mul
- ComputeSingleFaceDescriptorTask
- .draw
- wechat-fixture.cjs
- DetectSingleFaceLandmarksTask
- extractAllFacesAndComputeResults
- extractFaceTensors
- 第四轮反馈/傅艳NEW与项思炼的会话 2026年10月12日.md
- 第三轮反馈20261012/傅艳NEW与项思炼的会话 2026年10月12日.md
- 基础信息记忆与肤质拖动
- profile-interaction.mjs
- submitPhotos
- doCapture
- i
- convLayer
- startCamera
- extractWeightsFactory
- 微信退出登录-2026-10-13.md
- normalize
- .run
- play
- f
- PredictSingleFaceExpressionsWithFaceAlignmentTask
- PredictAllFaceExpressionsTask
- wechat-browser.mjs
- doCapture
- 评分圈内居中、基础信息页固定与正脸语音修复
- TinyYolov2
- FaceProcessor
- 报告等待页实拍背景-2026-10-12.md
- PredictSingleFaceExpressionsTask
- NeuralNetwork
- TinyYolov2Base
- FaceFeatureExtractor
- 原始肌龄显示与鸿蒙滚轮兼容
- harmony-picker.mjs
- .toString
- AgeGenderNet
- convLayer
- height
- age
- recommendation.js
- .forwardInput
- FaceRecognitionNet
- .runAndExtendWithFaceDetections
- TinyXception
- FaceExpressionNet
- PredictAllAgeAndGenderWithFaceAlignmentTask
- PredictSingleAgeAndGenderWithFaceAlignmentTask
- 拍摄角度不合格震动提醒-2026-10-13.md
## God Nodes (most connected - your core abstractions)
1. `__awaiter()` - 59 edges
@@ -146,51 +176,51 @@
## Import Cycles
- None detected.
## Communities (116 total, 25 thin omitted)
## Communities (148 total, 36 thin omitted)
### Community 0 - "face-api.js"
Cohesion: 0.02
Nodes (41): bgrToRgbTensor(), cn(), computeReshapedDimensions(), createBrowserEnv(), createFileSystem(), createNodejsEnv(), decodeBoxesLayer(), Dimensions() (+33 more)
Nodes (42): boxPredictionLayer(), cn(), computeReshapedDimensions(), createBrowserEnv(), createFileSystem(), createNodejsEnv(), decodeBoxesLayer(), depthwiseConvLayer() (+34 more)
### Community 1 - "线上闭环测试(不含真实微信授权)"
Cohesion: 0.25
Nodes (7): 发现并修复, 可复现命令, 已通过的检查, 线上闭环测试(不含真实微信授权), 结论, 话术及仓库同步, 验证边界
### Community 2 - "app.js"
Cohesion: 0.11
Nodes (22): cancelSkinDrag(), esc(), goSkin(), openHistory(), renderHistoryList(), renderReport(), renderViz(), restartScanCycle() (+14 more)
Cohesion: 0.12
Nodes (17): cancelSkinDrag(), decode(), drain(), goSkin(), load(), loginAllowed(), loginWechat(), preload() (+9 more)
### Community 3 - "skin-score.js"
Cohesion: 0.12
Nodes (45): acneGrade(), buildViewModel(), calcAcne(), calcContour(), calcSens(), calcSmooth(), calcTone(), calcWrinkles() (+37 more)
Cohesion: 0.10
Nodes (49): acneGrade(), buildViewModel(), calcAcne(), calcContour(), calcSens(), calcSmooth(), calcTone(), calcWrinkles() (+41 more)
### Community 4 - "customer-feedback-second.js"
Cohesion: 0.17
Nodes (10): assert, context, face, fs, geometry, original, path, source (+2 more)
Cohesion: 0.14
Nodes (11): assert, context, events, face, fs, geometry, original, path (+3 more)
### Community 5 - "t"
Cohesion: 0.09
Nodes (31): be(), bi(), Bo(), ce(), cl(), de(), e(), Ef() (+23 more)
### Community 5 - "disposeUnusedWeightTensors"
Cohesion: 0.12
Nodes (23): disposeUnusedWeightTensors(), extractorsFactory$3(), extractorsFactory$5(), extractorsFactory$7(), extractorsFactory$9(), extractParamsFromWeigthMap(), extractParamsFromWeigthMap$1(), extractParamsFromWeigthMap$2() (+15 more)
### Community 6 - "n"
Cohesion: 0.14
Nodes (37): a(), ae(), bf(), cf(), d(), drawContour(), dt(), ea() (+29 more)
Cohesion: 0.12
Nodes (36): ae(), be(), bf(), bi(), ce(), de(), e(), Ef() (+28 more)
### Community 7 - "test/profile-store.js"
Cohesion: 0.07
Nodes (29): browser(), init(), read(), rememberKind(), request(), age(), clean(), create() (+21 more)
Cohesion: 0.14
Nodes (13): assert, data, empty, now, other, Profile, quota, regions (+5 more)
### Community 8 - "admin.js"
Cohesion: 0.20
Nodes (29): ageText(), api(), bind(), captureQualityHtml(), closeDetail(), collectFilters(), dimsHtml(), doRetry() (+21 more)
### Community 9 - "__awaiter"
Cohesion: 0.13
Nodes (12): __awaiter(), ComposableTask(), fetchImage(), fetchJson(), fetchNetWeights(), fetchOrThrow(), __generator(), getModelUris() (+4 more)
Cohesion: 0.14
Nodes (13): __awaiter(), extendWithFaceDescriptor(), extendWithFaceExpressions(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult(), fetchImage(), fetchJson(), fetchNetWeights() (+5 more)
### Community 10 - "wechat-browser.mjs"
Cohesion: 0.22
Nodes (6): createAccounts, h5, ReportUI, require, root, SkinScore
### Community 10 - "k"
Cohesion: 0.18
Nodes (11): b(), gn(), k(), kn(), ne(), tt(), wo(), Yt() (+3 more)
### Community 11 - "lib/accounts.js"
Cohesion: 0.17
@@ -204,17 +234,21 @@ Nodes (52): accounts, appendRecord(), callFacepp(), CaptureDiagnostics, CRED, cr
Cohesion: 0.12
Nodes (15): files, analyze-fail, analyzing, countdown-321, hint-far, hint-front, hint-hold, hint-near (+7 more)
### Community 15 - "extractFaces"
Cohesion: 0.17
Nodes (18): awaitMediaLoaded(), bufferToImage(), createCanvas(), createCanvasFromMedia(), extractFaces(), extractImagePatches(), getContext2dOrThrow(), getEnv() (+10 more)
### Community 14 - "showScreen"
Cohesion: 0.27
Nodes (10): enterGuest(), loadAppFont(), loginSucceeded(), logoutWechat(), openOverlay(), prepareScanArtwork(), renderAccountStatus(), resetReport() (+2 more)
### Community 15 - "test/accounts.js"
Cohesion: 0.22
Nodes (9): assert, create, fs, http, os, path, ReportUI, SkinScore (+1 more)
### 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 - "report-persistence.mjs"
Cohesion: 0.29
Nodes (6): child, dataRoot, fx, h5, ReportUI, require
### Community 17 - "vizCompose"
Cohesion: 0.14
Nodes (14): esc(), openHistory(), renderHistoryList(), renderReport(), renderViz(), setDim(), vizCollectMarks(), vizCompose() (+6 more)
### Community 18 - "1'me肌肤状态检测 H5"
Cohesion: 0.13
@@ -222,19 +256,19 @@ Nodes (15): 10.09 更新, 1'me肌肤状态检测 H5, Admin API(统一返回 `{ok
### Community 19 - "customer-feedback-fourth.js"
Cohesion: 0.06
Nodes (37): evaluate(), finite(), assertPoints(), check(), clamp(), dist(), estimate(), makeFrontal() (+29 more)
Nodes (38): evaluate(), finite(), assertPoints(), check(), clamp(), dist(), estimate(), makeFrontal() (+30 more)
### Community 20 - "build_fixtures.py"
Cohesion: 0.40
Nodes (9): build_case1(), build_case2(), build_truth(), col(), dump(), main(), parse_value(), set_path() (+1 more)
### Community 21 - "disposeUnusedWeightTensors"
Cohesion: 0.05
Nodes (39): conv(), conv$1(), convDown(), convLayer$1(), convNoRelu(), denseBlock3(), denseBlock4(), depthwiseSeparableConv() (+31 more)
### Community 21 - "extractFaces"
Cohesion: 0.19
Nodes (19): awaitMediaLoaded(), bufferToImage(), createCanvas(), createCanvasFromMedia(), extractFaces(), extractImagePatches(), getContext2dOrThrow(), getEnv() (+11 more)
### Community 22 - "k"
Cohesion: 0.10
Nodes (21): b(), ba(), ca(), da(), fa(), gn(), k(), kn() (+13 more)
### Community 22 - "i"
Cohesion: 0.08
Nodes (31): ba(), bn(), ca(), cl(), da(), dn(), ee(), et() (+23 more)
### Community 23 - "facepp-skinanalyze(旷视皮肤分析专业版调用技能)"
Cohesion: 0.20
@@ -248,17 +282,17 @@ Nodes (8): 1. 音频格式约定(已定), 2. 播报清单(14 条), 3. 播放策
Cohesion: 0.20
Nodes (16): _arrayLikeToArray(), _arrayWithHoles(), _arrayWithoutHoles(), _classCallCheck(), _createClass(), _defineProperties(), h(), _iterableToArray() (+8 more)
### Community 26 - "NeuralNetwork"
Cohesion: 0.05
Nodes (30): AgeGenderNet(), createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractConvParamsFactory() (+22 more)
### Community 26 - ".extractWeights"
Cohesion: 0.22
Nodes (10): createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractConvParamsFactory(), extractorsFactory$1() (+2 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.11
Nodes (25): ao(), C(), cd(), co(), Do(), eo(), fo(), Ft() (+17 more)
Cohesion: 0.10
Nodes (28): ao(), Bo(), C(), cd(), co(), Do(), eo(), fo() (+20 more)
### Community 29 - "face-api.js 本地化依赖(h5/vendor/faceapi/)"
Cohesion: 0.29
@@ -288,9 +322,9 @@ Nodes (4): 1meAI测肤旷视UI问题反馈, UI交互界面调整, 交互, **设
Cohesion: 0.22
Nodes (8): PSD 来源, 前端参数及限制, 后台校准留痕, 已确认并实现, 生产部署及只读验收, 皱纹(紧塑力)原文, 第四轮反馈修改与验收, 验证
### Community 41 - "showScreen"
Cohesion: 0.22
Nodes (11): loadAppFont(), loginAllowed(), loginBrowser(), loginSucceeded(), loginWechat(), openOverlay(), prepareScanArtwork(), renderAccountStatus() (+3 more)
### Community 41 - ".loadFromDisk"
Cohesion: 0.29
Nodes (3): ComposableTask(), getModelUris(), TinyFaceDetector()
### Community 42 - "layer_at"
Cohesion: 0.18
@@ -300,6 +334,14 @@ Nodes (11): list, bounds(), layer_at(), main(), save_image(), composite(), main(
Cohesion: 0.50
Nodes (3): HOST, PORT, start-local.sh script
### Community 45 - "report-persistence.mjs"
Cohesion: 0.29
Nodes (6): child, dataRoot, fx, h5, ReportUI, require
### Community 47 - "extractFaceTensors"
Cohesion: 0.10
Nodes (11): Box(), DetectAllFaceLandmarksTask(), extendWithFaceDetection(), extendWithFaceLandmarks(), extractFaceTensors(), FaceLandmarks(), isWithFaceDetection(), isWithFaceLandmarks() (+3 more)
### Community 48 - "已修改"
Cohesion: 0.22
Nodes (8): 12 日第二批:明确项修改, 公网部署与复核(2026-10-12,北京时间), 准备捕捉提醒, 字重, 已修改, 拍照展示, 首页高清批注, 验证与状态
@@ -312,6 +354,10 @@ 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 - "guest-privacy.mjs"
Cohesion: 0.25
Nodes (5): child, dataRoot, fx, h5, require
### Community 56 - "report-ui.js"
Cohesion: 0.50
Nodes (7): build(), count(), fill(), focusOf(), numeric(), scalar(), wrinkleDetails()
@@ -321,8 +367,8 @@ Cohesion: 0.60
Nodes (5): diffCtx(), loadJSON(), main(), okNum(), okStr()
### Community 58 - ".forwardInput"
Cohesion: 0.19
Nodes (8): FaceRecognitionNet(), iou(), nonMaxSuppression(), nonMaxSuppression$1(), scale(), stage1(), stage2(), stage3()
Cohesion: 0.16
Nodes (9): iou(), nonMaxSuppression(), nonMaxSuppression$1(), scale(), SsdMobilenetv1(), stage1(), stage2(), stage3() (+1 more)
### Community 61 - "报告布局与跟手交互调整"
Cohesion: 0.33
@@ -336,14 +382,18 @@ Nodes (4): 仍需公网/业务确认, 已发现并修复, 提交前 Review(202
Cohesion: 0.29
Nodes (4): EXPECTED, fails, pageErrors, pwRoot
### Community 64 - "start"
Cohesion: 0.29
Nodes (7): decode(), drain(), load(), preload(), start(), stopCur(), unlock()
### Community 64 - "isValidNumber"
Cohesion: 0.33
Nodes (5): bgrToRgbTensor(), Dimensions(), isValidNumber(), isValidProbablitiy(), isWithGender()
### Community 65 - "微信登录接入与公网部署说明"
Cohesion: 0.29
Nodes (7): 公网服务器配置, 公网真机验收清单, 反向代理与后台保护, 已完成的链路, 微信登录接入与公网部署说明, 数据、迁移与备份, 本机开发环境限制(不代表线上状态)
### Community 66 - "h5/README.md"
Cohesion: 0.33
Nodes (3): 发版范围, 游客不留存与弹窗护理间距调整, 验证
### Community 68 - "analyze-timeout.js"
Cohesion: 0.20
Nodes (7): assert, end, fs, path, source, start, vm
@@ -353,15 +403,11 @@ Cohesion: 0.29
Nodes (3): errors, samples, unexpectedWrites
### Community 77 - "drawFaceExpressions"
Cohesion: 0.12
Nodes (10): DrawBox(), drawDetections(), drawFaceExpressions(), DrawFaceLandmarks(), FaceExpressions(), isWithFaceDetection(), isWithFaceExpressions(), isWithFaceLandmarks() (+2 more)
### Community 78 - ".toString"
Cohesion: 0.12
Nodes (11): FaceLandmarks5(), FaceMatch(), getCenterPoint(), la(), Point(), pr(), pt(), vt() (+3 more)
Cohesion: 0.22
Nodes (6): DrawBox(), drawDetections(), drawFaceExpressions(), DrawFaceLandmarks(), FaceExpressions(), isWithFaceExpressions()
### Community 79 - "登录反馈与加载优化(2026-10-12)"
Cohesion: 0.33
Cohesion: 0.25
Nodes (5): 体验变化, 公网部署后复测, 慢的原因与处理, 登录反馈与加载优化(2026-10-12), 验证
### Community 80 - "线上微信登录部署记录"
@@ -381,12 +427,12 @@ Cohesion: 0.29
Nodes (5): 10.09 新版 H5 实施与验收, 按确认项实现, 文档未定义部分的实施口径, 素材再生成, 验证命令
### Community 84 - "微信登录联调:2026-10-12"
Cohesion: 0.22
Cohesion: 0.33
Nodes (6): 后续真机确认与体验优化, 微信登录联调:2026-10-12, 本轮修正, 白名单与凭据, 真实微信验收与排查, 自动验证
### Community 85 - "test/accounts.js"
Cohesion: 0.22
Nodes (9): assert, create, fs, http, os, path, ReportUI, SkinScore (+1 more)
### Community 85 - "相机翻转性能优化"
Cohesion: 0.40
Nodes (4): 仍需真机验证, 修改范围, 相机翻转性能优化, 验证
### Community 87 - "产品推荐规则:肤龄基准与边界调整"
Cohesion: 0.33
@@ -416,16 +462,20 @@ Nodes (5): 原稿与导出可追溯性, 已完成, 本地验证, 生产部署及
Cohesion: 0.50
Nodes (3): artifacts, errors, samples
### Community 100 - "extractAllFacesAndComputeResults"
Cohesion: 0.09
Nodes (12): ComputeAllFaceDescriptorsTask(), ComputeSingleFaceDescriptorTask(), extendWithAge(), extendWithFaceDescriptor(), extendWithFaceExpressions(), extendWithGender(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult() (+4 more)
### Community 97 - "account-client.js"
Cohesion: 0.53
Nodes (8): browser(), clearGuestCache(), ensureGuest(), guest(), init(), logout(), rememberKind(), request()
### Community 102 - "extractFaceTensors"
Cohesion: 0.10
Nodes (11): Box(), DetectAllFaceLandmarksTask(), extendWithFaceDetection(), extendWithFaceLandmarks(), extractFaceTensors(), FaceLandmarks(), isDimensions(), isTensor3D() (+3 more)
### Community 98 - ".mul"
Cohesion: 0.50
Nodes (3): wl(), wn(), xn()
### Community 101 - "wechat-fixture.cjs"
Cohesion: 0.50
Nodes (3): crypto, fs, path
### Community 107 - "基础信息记忆与肤质拖动"
Cohesion: 0.33
Cohesion: 0.40
Nodes (4): 回归命令, 基础信息记忆与肤质拖动, 肤质交互, 范围
### Community 108 - "profile-interaction.mjs"
@@ -436,44 +486,100 @@ Nodes (3): dragBy(), h5, touch()
Cohesion: 0.24
Nodes (12): bindBackdropClose(), closeLoginSuccess(), closeOverlay(), handleCamHidden(), leaveCamera(), photosByView(), popHistorySilently(), requestAnalyze() (+4 more)
### Community 110 - "doCapture"
### Community 110 - "startCamera"
Cohesion: 0.21
Nodes (12): beginDetect(), detTick(), enableFallback(), getCamStream(), loadFaceApi(), loadModels(), maybeStartDetect(), onCamTrackEnded() (+4 more)
### Community 111 - "extractWeightsFactory"
Cohesion: 0.17
Nodes (22): armNudge(), busyWithFlow(), captureFromVideo(), captureHaptic(), captureStageHint(), checkStill(), doCapture(), dropHint() (+14 more)
Nodes (8): extractorsFactory(), extractorsFactory$4(), extractParams(), extractParams$5(), extractParams$6(), extractParamsTiny(), extractWeightsFactory(), TinyFaceFeatureExtractor()
### Community 111 - "i"
Cohesion: 0.12
Nodes (18): bn(), dn(), ee(), et(), fn(), i(), je(), ln() (+10 more)
### Community 115 - "normalize"
Cohesion: 0.27
Nodes (8): conv(), denseBlock3(), denseBlock4(), depthwiseSeparableConv(), mainBlock(), normalize(), reductionBlock(), __spreadArrays()
### Community 116 - "convLayer"
Cohesion: 0.14
Nodes (17): boxPredictionLayer(), convLayer(), convWithBatchNorm(), depthwiseConvLayer(), depthwiseSeparableConv$1(), fullyConnectedLayer(), getStridesForLayerIdx(), leaky() (+9 more)
### Community 116 - ".run"
Cohesion: 0.29
Nodes (4): extendWithAge(), extendWithGender(), PredictAllAgeAndGenderTask(), PredictSingleAgeAndGenderTask()
### Community 117 - "startCamera"
### Community 117 - "play"
Cohesion: 0.33
Nodes (6): applyMirror(), busyWithFlow(), effMirror(), handleCamVisible(), play(), renderCamStep()
### Community 118 - "f"
Cohesion: 0.20
Nodes (24): a(), cf(), d(), drawContour(), dt(), ea(), f(), g() (+16 more)
### Community 121 - "wechat-browser.mjs"
Cohesion: 0.22
Nodes (6): createAccounts, h5, ReportUI, require, root, SkinScore
### Community 122 - "doCapture"
Cohesion: 0.15
Nodes (17): applyMirror(), beginDetect(), detectFrame(), detTick(), effMirror(), enableFallback(), getCamStream(), handleCamVisible() (+9 more)
Nodes (24): armNudge(), captureFromVideo(), captureHaptic(), captureStageHint(), checkStill(), detectFrame(), doCapture(), dropHint() (+16 more)
### Community 123 - "评分圈内居中、基础信息页固定与正脸语音修复"
Cohesion: 0.40
Nodes (4): 发布, 范围, 评分圈内居中、基础信息页固定与正脸语音修复, 验证
### Community 127 - "height"
### Community 124 - "FaceProcessor"
Cohesion: 0.22
Nodes (3): extractParams$1(), FaceLandmark68TinyNet(), FaceProcessor()
### Community 129 - "TinyYolov2Base"
Cohesion: 0.29
Nodes (5): convWithBatchNorm(), depthwiseSeparableConv$1(), leaky(), TinyYolov2Base(), validateConfig()
### Community 130 - "FaceFeatureExtractor"
Cohesion: 0.22
Nodes (5): extractFCParamsFactory(), extractorsFactory$8(), extractParams$3(), extractParams$7(), FaceFeatureExtractor()
### Community 131 - "原始肌龄显示与鸿蒙滚轮兼容"
Cohesion: 0.50
Nodes (3): 原始肌龄显示与鸿蒙滚轮兼容, 回归, 范围
### Community 133 - ".toString"
Cohesion: 0.20
Nodes (7): FaceLandmarks5(), FaceMatch(), getCenterPoint(), la(), pr(), pt(), vt()
### Community 136 - "convLayer"
Cohesion: 0.43
Nodes (8): convLayer(), fullyConnectedLayer(), ONet(), PNet(), prelu(), qh(), RNet(), sharedLayer()
### Community 137 - "height"
Cohesion: 0.38
Nodes (5): height(), FaceLandmark68NetBase(), getSizesForScale(), normalize$1(), rescaleAndNormalize()
### Community 138 - "age"
Cohesion: 0.43
Nodes (4): age(), clean(), create(), key()
### Community 139 - "recommendation.js"
Cohesion: 0.33
Nodes (6): assert, check(), missing, raw, SS, UI
### Community 140 - ".forwardInput"
Cohesion: 0.48
Nodes (6): conv$1(), convDown(), convLayer$1(), convNoRelu(), residual(), residualDown()
### Community 141 - "FaceRecognitionNet"
Cohesion: 0.33
Nodes (3): extractorsFactory$2(), extractParams$4(), FaceRecognitionNet()
## Knowledge Gaps
- **320 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+315 more)
- **345 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+340 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **25 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **36 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 `height()` connect `height` to `app.js`, `customer-feedback.js`, `scan-placeholder.mjs`, `extractFaceTensors`, `showScreen`, `__awaiter`, `profile-interaction.mjs`, `extractFaces`, `.forwardInput`?**
_High betweenness centrality (0.062) - this node is a cross-community bridge._
- **Why does `v()` connect `n` to `report-ui.js`, `face-api.js`, `app.js`, `C`?**
_High betweenness centrality (0.045) - this node is a cross-community bridge._
- **Why does `k()` connect `k` to `face-api.js`, `app.js`, `t`, `n`, `lib/accounts.js`?**
_High betweenness centrality (0.039) - this node is a cross-community bridge._
- **Why does `height()` connect `height` to `app.js`, `customer-feedback.js`, `scan-placeholder.mjs`, `__awaiter`, `profile-interaction.mjs`, `showScreen`, `extractFaceTensors`, `extractFaces`, `.forwardInput`?**
_High betweenness centrality (0.068) - this node is a cross-community bridge._
- **Why does `v()` connect `f` to `report-ui.js`, `vizCompose`, `C`, `face-api.js`?**
_High betweenness centrality (0.046) - this node is a cross-community bridge._
- **Why does `fill()` connect `report-ui.js` to `f`?**
_High betweenness centrality (0.037) - 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?**
@@ -481,4 +587,4 @@ _Questions this graph is uniquely positioned to answer:_
- **Are the 9 inferred relationships involving `i()` (e.g. with `da()` and `f()`) actually correct?**
_`i()` has 9 INFERRED edges - model-reasoned connections that need verification._
- **What connects `source`, `document_size`, `pages` to the rest of the system?**
_320 weakly-connected nodes found - possible documentation gaps or missing edges._
_345 weakly-connected nodes found - possible documentation gaps or missing edges._
File diff suppressed because one or more lines are too long
+2663 -1493
View File
File diff suppressed because it is too large Load Diff
+111 -56
View File
@@ -40,8 +40,8 @@
"semantic_hash": ""
},
"h5/js/app.js": {
"mtime": 1791823225.0391843,
"ast_hash": "4787d84f22ac0257204e8ddbdd421140",
"mtime": 1791884874.9650493,
"ast_hash": "1e78662941dfedadd108f24c820676cc",
"semantic_hash": ""
},
"h5/js/head-pose.js": {
@@ -50,13 +50,13 @@
"semantic_hash": ""
},
"h5/js/skin-score.js": {
"mtime": 1791735501.357809,
"ast_hash": "783b6c9abbe2bf1832e8eafe5aead7e3",
"mtime": 1791878308.0809805,
"ast_hash": "59d3331a75a32837e04a21afa24fb34a",
"semantic_hash": ""
},
"h5/server.js": {
"mtime": 1791800754.2112646,
"ast_hash": "9983e6c978b3357e31bbfce59202dcde",
"mtime": 1791878239.2009728,
"ast_hash": "fd843f8a1ed62e33c8294acbe78b56d0",
"semantic_hash": ""
},
"h5/test/build_fixtures.py": {
@@ -135,13 +135,13 @@
"semantic_hash": ""
},
"h5/README.md": {
"mtime": 1791811100.15898,
"ast_hash": "4b8b63aa8184ead90d868aff34e9a6e3",
"mtime": 1791878573.716988,
"ast_hash": "74712e76c626625674a70aa0dcad5a63",
"semantic_hash": ""
},
"h5/index.html": {
"mtime": 1791821148.115547,
"ast_hash": "770a726605078c6c0eebd95304e8e954",
"mtime": 1791894154.8087292,
"ast_hash": "f3c662c2976d141a844328daaf6269f3",
"semantic_hash": ""
},
"h5/vendor/faceapi/README.md": {
@@ -770,8 +770,8 @@
"semantic_hash": ""
},
"h5/test/e2e-browser.mjs": {
"mtime": 1791773638.010959,
"ast_hash": "9327216df7c2e80f2615cb7f800b7f1a",
"mtime": 1791875688.6889038,
"ast_hash": "ca6e17bc320f31adb34e5b9e0ed28f81",
"semantic_hash": ""
},
"h5/assets/svg/prod-badge.svg": {
@@ -830,8 +830,8 @@
"semantic_hash": ""
},
"h5/vendor/mobile-select/mobile-select.iife.min.js": {
"mtime": 1791559485.790093,
"ast_hash": "52578c3b95933f96493d3b879d353421",
"mtime": 1791878239.1849728,
"ast_hash": "9aeccc95862e05a14439c9cc906af5e5",
"semantic_hash": ""
},
"tools/export_psd_assets.py": {
@@ -865,8 +865,8 @@
"semantic_hash": ""
},
"h5/vendor/mobile-select/README.md": {
"mtime": 1791559485.790093,
"ast_hash": "fe3287899b96f598c60f9bc030a1368e",
"mtime": 1791878239.2009728,
"ast_hash": "f8b834ff5056a3402914e3c99d4807ff",
"semantic_hash": ""
},
"design/ui-20261009/assets-overview.jpg": {
@@ -1140,8 +1140,8 @@
"semantic_hash": ""
},
"h5/assets/img/ui-20261009/icon-switch-camera.png": {
"mtime": 1791642110.915571,
"ast_hash": "6197c8000c21156ffecdf77f9c5ed5ba",
"mtime": 1791884685.3970492,
"ast_hash": "a2805c01722e185dfca450a1eed8c70d",
"semantic_hash": ""
},
"h5/assets/img/ui-20261009/icon-wechat.png": {
@@ -1230,8 +1230,8 @@
"semantic_hash": ""
},
"h5/js/account-client.js": {
"mtime": 1791774062.6429472,
"ast_hash": "3197b952f29f9e45176d4300082adaae",
"mtime": 1791878732.5729809,
"ast_hash": "6e1c148f77744345313e2a7ecf1ea756",
"semantic_hash": ""
},
"h5/js/camera-geometry.js": {
@@ -1250,23 +1250,23 @@
"semantic_hash": ""
},
"h5/lib/accounts.js": {
"mtime": 1791806985.2188702,
"ast_hash": "a8116f973f1e01dcbfbe2e1e26c7c826",
"mtime": 1791872963.6609082,
"ast_hash": "cfc1a9590dc7e1ee9a8faf2fa23d6eaa",
"semantic_hash": ""
},
"h5/test/accounts.js": {
"mtime": 1791770576.0104883,
"ast_hash": "d927f5d1486b56505ba4da49ec6a769c",
"mtime": 1791873124.7889152,
"ast_hash": "85fe766a7c305ffee3a8c904e038d8a6",
"semantic_hash": ""
},
"h5/test/report-persistence.mjs": {
"mtime": 1791793231.819183,
"ast_hash": "24636f96fd5cd576cde11ef9e911f3ff",
"mtime": 1791878308.0849807,
"ast_hash": "00d39c0fc96afe463fb1fbfe08b70656",
"semantic_hash": ""
},
"h5/test/ui-20261009.mjs": {
"mtime": 1791800811.939266,
"ast_hash": "778edb5314c2d35ae75c05344b96f532",
"mtime": 1791875688.6929038,
"ast_hash": "22779f48c489e35943f89eae9a81c17b",
"semantic_hash": ""
},
"tools/build_report_copy.py": {
@@ -1530,8 +1530,8 @@
"semantic_hash": ""
},
"h5/test/wechat-browser.mjs": {
"mtime": 1791800811.939266,
"ast_hash": "616790aae9cf9b41d30ee8b8da979cb0",
"mtime": 1791879046.0249834,
"ast_hash": "af64c3a7fcd5c9338f9b86a584f987b6",
"semantic_hash": ""
},
"docs/2026-10-11\u63d0\u4ea4\u524dReview.md": {
@@ -1545,8 +1545,8 @@
"semantic_hash": ""
},
"h5/test/production-loop.mjs": {
"mtime": 1791773638.010959,
"ast_hash": "0d0d46ec3ecfce9e767e7f31050befe2",
"mtime": 1791875833.91293,
"ast_hash": "60cd5e73b5600b731b0e01b0e12e12f4",
"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": 1791773638.010959,
"ast_hash": "8cb2774864d3d59bd5a418ad61819129",
"mtime": 1791875688.6929038,
"ast_hash": "00147b9f66e099be169cb84bf6331e66",
"semantic_hash": ""
},
"docs/\u7ebf\u4e0a\u95ed\u73af\u6d4b\u8bd5-2026-10-11.md": {
@@ -1565,8 +1565,8 @@
"semantic_hash": ""
},
"h5/test/recommendation.js": {
"mtime": 1791735559.0619977,
"ast_hash": "589f7046d94485dfc1df20a6129640e0",
"mtime": 1791878308.0849807,
"ast_hash": "36742c00e21d68daae2e4678b185b3a1",
"semantic_hash": ""
},
"docs/\u4ea7\u54c1\u63a8\u8350\u89c4\u5219-\u80a4\u9f84\u7248.md": {
@@ -1585,8 +1585,8 @@
"semantic_hash": ""
},
"h5/test/login-performance.mjs": {
"mtime": 1791800811.939266,
"ast_hash": "ffe9df947564cdef24e8f842cbc577f1",
"mtime": 1791875688.6889038,
"ast_hash": "1f0d6e798cf1df853eb1309a171b2122",
"semantic_hash": ""
},
"h5/assets/img/ui-20261009/bg-history-h5.webp": {
@@ -1675,8 +1675,8 @@
"semantic_hash": ""
},
"h5/test/customer-feedback-second.js": {
"mtime": 1791819755.8747528,
"ast_hash": "03ea3689f3c86e78a1f5bd46966f4145",
"mtime": 1791884874.9650493,
"ast_hash": "a16f86030060a31b8e44cd2f673c1559",
"semantic_hash": ""
},
"h5/test/customer-feedback-second.mjs": {
@@ -1805,8 +1805,8 @@
"semantic_hash": ""
},
"h5/js/capture-quality.js": {
"mtime": 1791809137.1269257,
"ast_hash": "ec66579ee0cde06a3fa045d612da69c1",
"mtime": 1791884831.1770513,
"ast_hash": "fa113a7f933091cdec309d5988cfea8a",
"semantic_hash": ""
},
"h5/lib/capture-diagnostics.js": {
@@ -1820,13 +1820,13 @@
"semantic_hash": ""
},
"h5/test/customer-feedback-fourth.js": {
"mtime": 1791819755.8706622,
"ast_hash": "92a53685400808b24315f85ed851566d",
"mtime": 1791884945.1610558,
"ast_hash": "716783c536dd5f2b9e6725f2ac759793",
"semantic_hash": ""
},
"h5/test/customer-feedback-fourth.mjs": {
"mtime": 1791809137.130926,
"ast_hash": "a07ebd722efaf2d8b6e382f566a5f649",
"mtime": 1791876371.4169395,
"ast_hash": "382525c8787f18026f6638f0f960d1e0",
"semantic_hash": ""
},
"tools/export_fourth_feedback_assets.py": {
@@ -1890,23 +1890,23 @@
"semantic_hash": ""
},
"h5/js/profile-store.js": {
"mtime": 1791810317.830946,
"ast_hash": "5723356fa753e9a66fdee448e617390e",
"mtime": 1791872891.6128974,
"ast_hash": "67650e3352c06e2a9ab8965d8da3d421",
"semantic_hash": ""
},
"h5/test/profile-interaction.mjs": {
"mtime": 1791820047.336818,
"ast_hash": "2efa96cee5dcf3671644b1b82f890ee3",
"mtime": 1791873124.7889152,
"ast_hash": "4f1fcae2e04530f3a85b33e5ee12453d",
"semantic_hash": ""
},
"h5/test/profile-store.js": {
"mtime": 1791810317.8349462,
"ast_hash": "66a0e7d6ddc5eee9cd69c097257746d1",
"mtime": 1791873040.5609126,
"ast_hash": "412a4fc48325d5ad886994c0e22adad0",
"semantic_hash": ""
},
"docs/\u57fa\u7840\u4fe1\u606f\u8bb0\u5fc6\u4e0e\u80a4\u8d28\u62d6\u52a8-2026-10-12.md": {
"mtime": 1791810600.0989332,
"ast_hash": "cec2f2e829751e8b5d2e5e40c348f5e4",
"mtime": 1791873518.1209404,
"ast_hash": "e6e11bfd6642c38c51d11ebe85499b98",
"semantic_hash": ""
},
"docs/\u62a5\u544a\u7b49\u5f85\u9875\u5b9e\u62cd\u80cc\u666f-2026-10-12.md": {
@@ -1930,8 +1930,8 @@
"semantic_hash": ""
},
"h5/test/scan-placeholder.mjs": {
"mtime": 1791815038.0590124,
"ast_hash": "bb8566ee5f3ad2a6bf95aafafb7145ba",
"mtime": 1791885786.4050858,
"ast_hash": "98f0327cec6167ceb0a435240f763e4c",
"semantic_hash": ""
},
"tools/optimize_delivery_images.py": {
@@ -1973,5 +1973,60 @@
"mtime": 1791821522.1000154,
"ast_hash": "517105aaf52427102a325e0450142446",
"semantic_hash": ""
},
"h5/test/guest-privacy.mjs": {
"mtime": 1791878787.1969805,
"ast_hash": "b6143cba8c2aff80dd5c4687208b9f36",
"semantic_hash": ""
},
"h5/test/wechat-fixture.cjs": {
"mtime": 1791873124.7849152,
"ast_hash": "bf1d2d3b6c76743b42c9fcfa3f18444d",
"semantic_hash": ""
},
"docs/\u6e38\u5ba2\u4e0d\u7559\u5b58\u4e0e\u5f39\u7a97\u62a4\u7406\u95f4\u8ddd\u8c03\u6574-2026-10-13.md": {
"mtime": 1791876185.0449448,
"ast_hash": "905d6f5ae894843f99e66a15ad3d8b33",
"semantic_hash": ""
},
"h5/test/harmony-picker.mjs": {
"mtime": 1791878418.4889727,
"ast_hash": "299b3a47e9212d7e758fc89cfffc39f8",
"semantic_hash": ""
},
"h5/test/raw-skin-age.js": {
"mtime": 1791878418.4889727,
"ast_hash": "44f22ac698b2249c8611ff302528dbb9",
"semantic_hash": ""
},
"docs/\u539f\u59cb\u808c\u9f84\u663e\u793a\u4e0e\u9e3f\u8499\u6eda\u8f6e\u517c\u5bb9-2026-10-13.md": {
"mtime": 1791879146.5129797,
"ast_hash": "99e4254133761034e9057465d4edeb6f",
"semantic_hash": ""
},
"docs/\u5fae\u4fe1\u9000\u51fa\u767b\u5f55-2026-10-13.md": {
"mtime": 1791879146.5129797,
"ast_hash": "e0fe3daeaa1eeeacf18ff18d8eee69e4",
"semantic_hash": ""
},
"h5/test/camera-flip-browser.mjs": {
"mtime": 1791881142.0210207,
"ast_hash": "b64cb9f61c398ce1aa12f59bc27cb481",
"semantic_hash": ""
},
"docs/\u76f8\u673a\u7ffb\u8f6c\u6027\u80fd\u4f18\u5316-2026-10-13.md": {
"mtime": 1791885450.2930646,
"ast_hash": "5d45e9c3fdde5ef718721caf5dbc88a7",
"semantic_hash": ""
},
"h5/test/splash-brand-browser.mjs": {
"mtime": 1791894154.8087292,
"ast_hash": "f0927d459653a70bb8fbc668fa066dad",
"semantic_hash": ""
},
"docs/\u62cd\u6444\u89d2\u5ea6\u4e0d\u5408\u683c\u9707\u52a8\u63d0\u9192-2026-10-13.md": {
"mtime": 1791885450.2930646,
"ast_hash": "fcbbc5db7241f97579efaf14eaa5135e",
"semantic_hash": ""
}
}
+4 -2
View File
@@ -79,6 +79,8 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
## 档案页与报告页
2026-10-13 当前留存规则:仅微信登录记住本机基础信息、在后端保留最近 5 份完整报告并支持跨设备查看。游客使用仅内存的临时会话,不持久化身份/基础信息/历史报告,也不新增后台检测记录、照片或原始结果;本次报告仍可正常生成并查看,刷新后基础信息不回填。旧服务器数据不自动删除。详见 [游客不留存与样式调整](../docs/游客不留存与弹窗护理间距调整-2026-10-13.md)。下方早期的 localStorage 报告历史说明已被此规则替代。
- **档案页**:出生年月 / 性别 / 所在地区三个滚轮选择器同一风格(`vendor/mobile-select`,MIT),出生年月自动换算年龄;地区为省–市两级联动,数据为 `js/region-data.js`(由 china-division 2.7.0 `pc.json` 生成,另含港澳台与「海外」)。当前账号在本机浏览器记住已确认的基础信息和日常肤质,再次进入自动回填,滚轮也定位到原值;年龄按出生年月重新计算。肤质支持跟手拖动、松手吸附、轻扫切换及边界回弹,纵向手势仍滚动页面。详见 [基础信息记忆与肤质拖动](../docs/基础信息记忆与肤质拖动-2026-10-12.md)。
- **报告页**:无返回按钮与页头标题;总分环形图 + 肌肤年龄与「比实际年龄年轻/大 N 岁」;三维度分数 + 进度条(最低分标红并挂「优先关注」);维度详情为原布局——一张卡内紧塑力/活力感/稳定力三个 Tab,每个 Tab 两条「竖排标题 + 左图右文」;点检测图全屏放大,再点或按返回键回到报告。
@@ -89,10 +91,10 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
3. 评分由 `js/skin-score.js` 引擎完成,公式/锁值/话术/产品推荐以 `docs/三维度评分与话术生成需求文档_V2.3.docx` 为最高契约(V2.3 口径,含 §8.5 护理建议五段):
- 6 分类:轮廓 = min(眼袋映射分, 下颌线角度分);皱纹(12 分区);光滑(毛孔/黑头/纹理);肤色水润(色素×水润);敏感(泛红面积取 `red_spot.red_spot_area`/红区分);痘痘(分级映射×丘疹 `acne.count` 计数扣分);分组按实际年龄;
- 三维度木桶 min:紧塑力(轮廓+皱纹)、活力感(光滑+肤色水润)、稳定力(敏感+痘痘);
- S = 7 + 3×P/100;档位:良好(P≥90)/改善(80–90)/预警(<80);肤龄钳制到实际年龄 ±3 并留痕;三视图版 18 条话术;
- S = 7 + 3×P/100;档位:良好(P≥90)/改善(80–90)/预警(<80);生物可视年龄直接展示算法原始肌龄,不再按实际年龄 ±3 修正;三视图版 18 条话术;
- 精华推荐三重判据(均分档/最差项约束/年龄组档)取最强档,triggers 记录命中判据。
4. 前端 `SkinScore.buildViewModel(data.result, data.side, { realAge, skinType })` → viewModel 直渲报告页:
- 头部:综合评分(`vm.total`,缺失回退三维度 S 均分×10)、肤龄 `vm.skinAgeDisplay` + 钳制标记 `vm.ageClampNote`;三维度 S(1 位小数)+ 档位配色(良好绿/改善琥珀/预警红);
- 头部:综合评分(`vm.total`,缺失回退三维度 S 均分×10)、肤龄读取 `SkinScore.reportSkinAge(vm, result)`;历史报告同样读取原始肌龄而非旧修正展示值,原值缺失显示 `--`;三维度 S(1 位小数)+ 档位配色(良好绿/改善琥珀/预警红);
- 三个 Tab 面板各展示所辖两分类:分类名、S + 档位徽章、话术正文(`vm.cats.*.text`);P 值不直接展示,`P=null` 的分类显示"本次未检出有效数据";
- 报告底部:降级标注(`vm.notes` 中与侧脸/下颌线/补拍相关的条目,去掉接口质量码后展示)、护理建议五段(`vm.careAdvice`)、精华推荐卡(`vm.essence.name` + 命中判据;推荐正文已在护理建议第 4 段,卡内不重复);
- 近期优先关注 = 最差维度(`vm.focus`);6 分类各配检测可视化图:轮廓=正脸实拍、皱纹=正脸压暗+`texture_enhanced_lines` 皱纹增强线(线图层几乎全透明,直挂白底不可读,故与压暗沉脸合成)、光滑=正脸+色斑/痣/痤疮标记叠加、肤色=`brown_area` 色沉图、敏感=`red_area` 红区图、痘痘=正脸+闭口粉刺/痤疮标记叠加(marks 按原像素坐标叠加到正脸实拍)。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+5
View File
@@ -220,6 +220,11 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
#cam-video.mirror { transform: scaleX(-1); } /* 前置镜头镜像预览 */
#s-cam.cam-live #cam-video { display: block; }
#s-cam.cam-live .cam-bg { display: none; }
#cam-frozen { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:none; z-index:0; pointer-events:none; }
#cam-frozen.mirror { transform:scaleX(-1); }
#s-cam.cam-switching #cam-frozen { display:block; }
#s-cam.cam-switching .cam-bg { display:none; }
.cam-flip:disabled { opacity:.5; }
/* 纵向定位按 WebView 口径:Figma 帧 812 高扣掉顶部 86px 微信导航栏 = 726 高视口 */
.cam-step {
position: absolute; left: 0; right: 0; top: 7.1%;
+1 -1
View File
@@ -95,7 +95,7 @@
}
.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; }
#s-cam:not(.cam-live):not(.cam-switching) .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; }
+14 -1
View File
@@ -1,6 +1,7 @@
/* Third-round PSD, 2026-10-12. Measurements include text transforms.
Native 248px WeChat header is excluded. Fixed text sizes/line breaks are
intentional; flexible photo backgrounds and safe areas accommodate phones. */
.account-logout { font-size: 11px; opacity: .8; white-space: nowrap; }
.splash-slogan {
left: calc(50% - 1.041667px); right: auto;
width: 249.305556px; height: 75.347222px;
@@ -8,6 +9,14 @@
top: max(6.838833%, calc(env(safe-area-inset-top) + 48px));
}
#s-splash .splash-slogan { top: max(6.838833%, calc(env(safe-area-inset-top) + 48px)); }
/* Keep the PSD Chinese line; both English lines share the exact regular font. */
.splash-slogan-updated .splash-slogan-copy { width:100%; height:100%; clip-path:inset(68% 0 0); }
.splash-slogan-brand, .splash-slogan-english { position:absolute; top:0; left:50%; transform:translateX(-50%); font-family:Arial,sans-serif; font-size:23px; font-weight:400; line-height:1; white-space:nowrap; }
.splash-slogan-english { top:26.736111px; }
#s-splash [data-action="open-privacy"],
#s-splash [data-action="open-privacy"]:focus,
#s-splash [data-action="open-privacy"]:active { border:0; outline:none; box-shadow:none; -webkit-appearance:none; appearance:none; }
#s-splash [data-action="open-privacy"]:focus-visible { filter:brightness(.94); }
.splash-bottom { bottom: max(5.882353%, env(safe-area-inset-bottom)); gap: calc(7.291667 * var(--u)); padding-bottom: 0; }
.brand-row { margin-bottom: calc(10.416667 * var(--u)); gap: 0; }
.brand-lockup { width: 179.513889px; height: 20.486111px; }
@@ -78,6 +87,10 @@
.scan-stage { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(100vw, 480px, calc(100dvh * 1080 / 2091)); height: min(100dvh, calc(min(100vw, 480px) * 2091 / 1080)); }
@supports not (height: 100dvh) { .scan-stage { width: min(100vw, 480px, calc(100vh * 1080 / 2091)); height: min(100vh, calc(min(100vw, 480px) * 2091 / 1080)); } }
.scan-artwork { width: 100%; height: 100%; object-fit: contain; }
/* Cover the old flip icon baked into the PSD raster (843,1846–921,1924).
Share the camera's replacement asset; decorative only, never a control. */
.scan-flip-icon { position:absolute; left:77.314815%; top:87.900526%; width:8.888889%; height:4.591105%; background:#000; display:flex; align-items:center; justify-content:center; pointer-events:none; }
.scan-flip-icon img { width:83.333333%; height:83.333333%; object-fit:contain; }
.scan-face { position: absolute; inset: 0; -webkit-mask-size: 100% 100%; mask-size: 100% 100%; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; overflow: hidden; }
.scan-beam { position: absolute; left: 0; right: 0; top: 20%; height: 18%; background: linear-gradient(to bottom, #F5F078 0%, #F5F078 35%, rgba(245,240,120,0) 100%); }
#p-analyzing:not(.scan-artwork-ready) .scan-stage { visibility: hidden; }
@@ -120,7 +133,7 @@
#stat-score { display: flex; align-items: baseline; justify-content: center; }
#stat-score b { display: block; letter-spacing: 0; }
#stat-score i { position: static; margin-left: 2px; }
.card-care .card-heading { text-align: left; }
.card-care .card-heading { text-align: left; margin-bottom: calc(9 * var(--u)); }
/* The profile is a fixed page, not a scroll container. Keep normal-height PSD
spacing; shrink only vertical whitespace/photo height on shorter viewports.
+18 -7
View File
@@ -24,7 +24,7 @@
<img class="login-brand" src="assets/img/ui-20261009/brand-login.png" alt="1’me 以我">
<div class="login-bottom">
<button class="login-button login-wechat" data-action="login-wechat"><img src="assets/img/ui-20261009/icon-wechat.png" alt="">微信登录</button>
<button class="login-button login-browser" data-action="login-browser"><img src="assets/img/ui-20261009/icon-guest.png" alt="">游客登录</button>
<button class="login-button login-browser" data-action="guest-experience"><img src="assets/img/ui-20261009/icon-guest.png" alt="">游客登陆</button>
<label class="login-consent"><input type="checkbox" id="login-consent"><span>同意<button type="button" data-action="open-policy">用户协议、隐私政策和儿童保护政策</button></span></label>
<p class="login-message" id="login-message" role="status">正在检查登录状态…</p>
</div>
@@ -32,7 +32,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="account-status account-status-history"><span data-auth-status></span><button type="button" class="account-logout" data-action="logout-wechat" hidden>退出登录</button></div>
<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>
@@ -42,8 +42,12 @@
</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>
<img class="splash-slogan" src="assets/img/ui-20261012/splash-slogan.png" width="718" height="217" loading="lazy" alt="ONE ME,FROM THE INSIDE OUT;以我为始 自有表现">
<div class="account-status account-status-home"><span data-auth-status></span><button type="button" class="account-logout" data-action="logout-wechat" hidden>退出登录</button></div>
<div class="splash-slogan splash-slogan-updated" role="img" aria-label="1'me,FROM THE INSIDE OUT;以我为始 自有表现">
<img class="splash-slogan-copy" src="assets/img/ui-20261012/splash-slogan.png" width="718" height="217" loading="lazy" alt="">
<span class="splash-slogan-brand" aria-hidden="true">1'me,</span>
<span class="splash-slogan-english" aria-hidden="true">FROM THE INSIDE OUT</span>
</div>
<div class="splash-bottom">
<div class="brand-row">
<img src="assets/img/ui-20261012/brand-lockup.png" width="517" height="59" loading="lazy" alt="1'me 肌肤状态检测" class="brand-lockup">
@@ -159,7 +163,7 @@
<img src="assets/img/ui-20261009/icon-shutter.png" alt="快门">
</button>
<button class="cam-side cam-flip" data-action="flip" aria-label="切换">
<img src="assets/img/ui-20261009/icon-switch-camera.png" alt="切换">
<img src="assets/img/ui-20261009/icon-switch-camera.png?v=20261013" alt="切换">
</button>
</div>
<div class="cam-fill" id="cam-fill"></div>
@@ -291,7 +295,7 @@
<p class="popup-body">我们将采集您的面部影像,为您生成专属肌肤分析与状态管理方案。所有数据严格保密,仅用于本次检测分析。</p>
<label class="privacy-row" id="privacy-row">
<span class="checkbox" id="privacy-check"></span>
<span class="privacy-text">我已阅读并同意<a class="privacy-link" id="privacy-policy-link">相关隐私协议</a></span>
<span class="privacy-text">我已阅读并同意相关<a class="privacy-link" id="privacy-policy-link">隐私协议</a></span>
</label>
<button class="btn btn-primary popup-btn" data-action="agree-continue">继续</button>
</div>
@@ -300,7 +304,13 @@
<!-- ═══════════ 弹窗:拍照引导 ═══════════ -->
<!-- 背景只保留启动页底图 + Slogan(不叠档案表单),对齐设计稿 04 -->
<div class="overlay overlay-guide" id="p-photoguide">
<div class="guide-bg"><img class="splash-slogan" src="assets/img/ui-20261012/splash-slogan.png" width="718" height="217" loading="lazy" alt="ONE ME,FROM THE INSIDE OUT;以我为始 自有表现"></div>
<div class="guide-bg">
<div class="splash-slogan splash-slogan-updated" role="img" aria-label="1'me,FROM THE INSIDE OUT;以我为始 自有表现">
<img class="splash-slogan-copy" src="assets/img/ui-20261012/splash-slogan.png" width="718" height="217" loading="lazy" alt="">
<span class="splash-slogan-brand" aria-hidden="true">1'me,</span>
<span class="splash-slogan-english" aria-hidden="true">FROM THE INSIDE OUT</span>
</div>
</div>
<div class="popup">
<button class="popup-close" data-action="close-photoguide" aria-label="关闭">
<img src="assets/img/ui-20261009/icon-close.png" alt="关闭">
@@ -457,6 +467,7 @@
<div class="scan-stage" aria-hidden="true">
<img id="scan-artwork" class="scan-artwork" data-src="assets/img/ui-20261012/scan-base.webp" width="1080" height="2091" alt="" draggable="false">
<div id="scan-face" class="scan-face" data-mask="assets/img/ui-20261012/scan-face-mask.png"><i class="scan-beam"></i></div>
<span class="scan-flip-icon"><img src="assets/img/ui-20261009/icon-switch-camera.png?v=20261013" width="80" height="80" alt="" draggable="false"></span>
</div>
<p class="scan-status" role="status">检测中…</p>
<button class="scan-cancel" type="button" data-action="cancel-analysis">取消</button>
+37 -15
View File
@@ -1,9 +1,19 @@
/* Browser identity is a random credential, never a fingerprint or a report. */
/* Guest access uses an ephemeral server session, never a saved browser identity. */
window.AccountClient = (function () {
'use strict';
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 (_) {} }
var kindKey = 'juzi.auth.kind.v1', user = null, guestReady = false;
function clearGuestCache() {
try {
localStorage.removeItem('juzi.identity.v1');
localStorage.removeItem('juzi.reports.v1'); // Retired local guest-report cache.
Object.keys(localStorage).filter(function (name) { return name.indexOf('juzi.profile.v1.browser.') === 0; })
.forEach(function (name) { localStorage.removeItem(name); });
if (localStorage.getItem(kindKey) === 'browser') localStorage.removeItem(kindKey);
} catch (_) {}
}
function rememberKind() {
try { if (user && user.kind === 'wechat') localStorage.setItem(kindKey, 'wechat'); else localStorage.removeItem(kindKey); } catch (_) {}
}
function request(url, data) {
var controller = new AbortController();
// Bound only authentication waits. Full report snapshots contain photos and
@@ -20,24 +30,36 @@ window.AccountClient = (function () {
}).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; rememberKind(); return v;
clearGuestCache();
return request('api/auth/browser', { consent: consent }).then(function (v) {
user = v.user; guestReady = true; rememberKind(); return v;
});
}
function guest() {
clearGuestCache();
user = { id: 'guest-page-' + Date.now() + '-' + Math.random().toString(36).slice(2), kind: 'browser' };
guestReady = false; rememberKind(); return user;
}
function ensureGuest() {
// The visitor entry is entirely local. Create temporary server access only
// after explicit consent in the welcome dialog, not on entry/button tap.
if (!user || user.kind === 'wechat' || guestReady) return Promise.resolve({ user: user });
return browser(true);
}
function init(options) {
clearGuestCache();
return request('api/auth/session').then(function (v) {
user = v.user;
guestReady = !!(user && user.kind === 'browser');
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;
});
// Never restore a guest credential or silently fall back from WeChat.
return v;
});
}
return { request: request, init: init, browser: browser, user: function () { return user; } };
function logout() {
return request('api/auth/logout', {}).then(function (v) {
user = null; guestReady = false; rememberKind(); return v;
});
}
return { request: request, init: init, browser: browser, guest: guest, ensureGuest: ensureGuest, logout: logout, user: function () { return user; } };
})();
+182 -63
View File
@@ -64,7 +64,7 @@
}
/* ─── 屏幕管理:页面内不放返回按钮,前进时 pushState,微信/浏览器返回键经 popstate 回到上一屏 ─── */
var currentScreen = 's-login';
var currentScreen = 's-login', reportOwner = null, logoutBusy = false;
var fontStylesLoaded = false;
function loadAppFont() {
if (fontStylesLoaded) return;
@@ -76,15 +76,18 @@
function renderAccountStatus() {
var user = AccountClient.user();
document.querySelectorAll('[data-auth-status]').forEach(function (label) {
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : '游客已登录') : '尚未登录';
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : '游客模式') : '尚未登录';
label.parentElement.dataset.kind = user ? user.kind : '';
});
document.querySelectorAll('[data-action="logout-wechat"]').forEach(function (button) {
button.hidden = !user || user.kind !== 'wechat';
button.disabled = logoutBusy;
button.textContent = logoutBusy ? '正在退出…' : '退出登录';
});
}
function loginSucceeded() {
showScreen('s-splash', 'replace');
$('#login-success-message').textContent = AccountClient.user().kind === 'wechat'
? '当前使用:微信登录\n同一微信账号可跨手机查看报告。'
: '当前使用:游客登录\n游客报告仅可在当前浏览器查看,不能跨手机同步。';
$('#login-success-message').textContent = '当前使用:微信登录\n同一微信账号可跨手机查看报告。';
openOverlay('p-login-success');
document.querySelectorAll('.screen').forEach(function (screen) { screen.inert = true; });
$('[data-action="login-confirm"]').focus();
@@ -96,6 +99,7 @@
}
function showScreen(id, nav) { // nav: 'push' 新增历史 | 'replace' 替换当前历史 | 缺省不动历史
if (id !== 's-login' && !AccountClient.user() && !(/[?&]mock=1/.test(location.search))) id = 's-login';
if (id === 's-report' && !IS_MOCK && reportOwner !== (AccountClient.user() && AccountClient.user().id)) id = 's-splash';
if (id === 's-profile' || profileOwner !== ProfileStore.key(AccountClient.user())) restoreProfile();
if (['s-profile', 's-cam', 's-report'].includes(id)) loadAppFont();
if (id === 's-profile' || id === 's-cam') prepareScanArtwork();
@@ -331,7 +335,11 @@
}
function restoreProfile() {
var user = AccountClient.user();
profileOwner = ProfileStore.key(user);
var owner = user && user.kind === 'browser' ? 'guest:' + user.id : ProfileStore.key(user);
// Keep the current form while a guest retries a capture in this page only.
// Reload/new guest session discards it; saveProfile never writes this owner.
if (user && user.kind === 'browser' && profileOwner === owner) return;
profileOwner = owner;
var saved = ProfileStore.clean(profileStore.read(user), window.REGION_DATA, SKINS.length);
var y = Number(saved.birth.slice(0, 4)), m = Number(saved.birth.slice(5));
if (saved.birth && YEARS.indexOf(y + '年') < 0) saved.birth = '';
@@ -394,6 +402,23 @@
var IS_MOCK = /[?&]mock=1/.test(location.search); // mock:不加载 face-api、不跑检测,analyze 直返内置样本
var camVideo = $('#cam-video'), camFile = $('#cam-file'), albumFile = $('#album-file');
var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头
var camBusy = false, camReadyTimer = null, camOpenTimer = null;
var camFrozen = document.createElement('canvas');
camFrozen.id = 'cam-frozen';
camVideo.parentNode.insertBefore(camFrozen, camVideo);
function freezeCamera() {
if (!camLive || !camVideo.videoWidth) return;
camFrozen.width = camVideo.videoWidth;
camFrozen.height = camVideo.videoHeight;
camFrozen.getContext('2d').drawImage(camVideo, 0, 0);
camFrozen.classList.toggle('mirror', camMirrored);
$('#s-cam').classList.add('cam-switching');
}
function setCameraBusy(busy) {
camBusy = busy;
$('.cam-flip').disabled = busy;
$('.cam-flip').setAttribute('aria-busy', String(busy));
}
var photos = []; // 三连拍(dataURL),拍摄中与 CAM_STEPS 同序;送检前经 photosByView 重排为 [正脸, 左脸, 右脸]
var captureDiagnostics = [], holdState = null;
@@ -420,44 +445,67 @@
throw err;
});
}
// 相机预热:部分设备开头 1~2s 以低分辨率出画(画面显脸巨大),等分辨率稳定两拍再放开取景与检测
// 首帧可用即展示,不再额外等待三轮 300ms。定时器由 stopCamera 统一取消。
function waitVideoStable(done) {
var lastW = 0, stableN = 0, tries = 0;
var t = setInterval(function () {
var w = camVideo.videoWidth;
tries++;
if (w && w === lastW) {
if (++stableN >= 2) { clearInterval(t); done(); }
} else { stableN = 0; lastW = w; }
if (tries > 40) { clearInterval(t); done(); } // 12s 兜底,异常也不卡死
}, 300);
var started = Date.now();
camReadyTimer = setInterval(function () {
var ready = camVideo.readyState >= 2 && camVideo.videoWidth > 0 && camVideo.videoHeight > 0;
if (ready || Date.now() - started >= 12000) {
clearInterval(camReadyTimer); camReadyTimer = null;
done(ready);
}
}, 50);
}
function startCamera() {
if (camStream) { stopCamera(); }
function startCamera(restoreFacing, keepFrozen) {
stopCamera(keepFrozen);
camFallback = false;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; }
setCameraBusy(true);
if (keepFrozen) showCamStatus('正在切换摄像头…');
camSeqCam++;
var token = camSeqCam;
function failed() {
if (token !== camSeqCam) return;
stopCamera(keepFrozen);
if (restoreFacing) {
camFacing = restoreFacing;
camMirrored = camFacing === 'user';
toast('切换失败,正在恢复原摄像头');
startCamera(null, keepFrozen);
} else { stopCamera(); enableFallback(); }
}
// 翻转/恢复不涉及首次权限询问,超时恢复;迟到的流仍会被令牌检查释放。
if (keepFrozen) camOpenTimer = setTimeout(failed, 12000);
getCamStream().then(function (stream) {
if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放
clearTimeout(camOpenTimer); camOpenTimer = null;
camStream = stream;
camVideo.srcObject = stream;
var track = stream.getVideoTracks()[0];
var actualFacing = track && track.getSettings && track.getSettings().facingMode;
if (actualFacing === 'user' || actualFacing === 'environment') {
if (restoreFacing && actualFacing !== camFacing) { failed(); return; }
camFacing = actualFacing;
}
camMirrored = camFacing === 'user';
applyMirror();
// iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连
stream.getTracks().forEach(function (t) {
t.onended = function () { onCamTrackEnded(token); };
});
waitVideoStable(function () { // 预热完成前不出实时画面(静态示意底图还在),避免开头"大脸"闪变
waitVideoStable(function (ready) {
if (token !== camSeqCam) return;
if (!ready) { failed(); return; }
camLive = true;
$('#s-cam').classList.add('cam-live');
$('#s-cam').classList.remove('cam-switching');
camFrozen.width = camFrozen.height = 0;
setCameraBusy(false);
$('#cam-status').classList.remove('show');
beginDetect(); // 确认相机可用后才加载检测模型(无相机权限设备不白加载)
maybeStartDetect(); // 模型已就绪时即刻开跑检测循环
});
}).catch(function () {
if (token !== camSeqCam) return;
enableFallback();
});
}).catch(failed);
}
// track 被系统结束(iOS 打断)且页面仍在拍照前台:toast 后自动重连,重连失败走 startCamera 内的 enableFallback
function onCamTrackEnded(token) {
@@ -467,8 +515,15 @@
toast('摄像头连接中断,正在重连');
startCamera();
}
function stopCamera() {
function stopCamera(keepFrozen) {
camSeqCam++; // 使进行中的 getUserMedia 回调失效
clearInterval(camReadyTimer); clearTimeout(camOpenTimer);
camReadyTimer = camOpenTimer = null;
setCameraBusy(false);
if (!keepFrozen) {
$('#s-cam').classList.remove('cam-switching');
camFrozen.width = camFrozen.height = 0;
}
stopDetect(); // 检测循环/无进展提示一并收尾
$('#cam-status').classList.remove('show');
if (camStream) { camStream.getTracks().forEach(function (t) { t.stop(); }); camStream = null; }
@@ -738,9 +793,8 @@
}
return faceApiPromise;
}
var detRunning = false, detTimer = null; // 检测循环
var detRunning = false, detTimer = null, detGeneration = 0, detInFlight = false; // 检测循环
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
var readyHapticAt = 0; // 抖动重试时最多每两秒提醒一次
var nudgeTimer = null; // 12s 无进展提示
var capturing = false; // 白屏补光抓帧中(暂停姿态判定)
var lastHint = ''; // 提示文案去重
@@ -802,7 +856,7 @@
function resetDetectState() {
okSince = 0;
holdState = null;
readyHapticAt = 0;
resetAngleHaptic();
missFrames = 0;
oppFrames = 0;
lastHint = '';
@@ -827,34 +881,43 @@
function maybeStartDetect() {
if (detRunning || IS_MOCK || detectDisabled || !modelsOk || !camLive || currentScreen !== 's-cam') return;
detRunning = true;
detGeneration++;
syncCamMode();
detTick();
detTick(detGeneration);
}
function stopDetect() {
resetAngleHaptic();
detRunning = false;
detGeneration++;
clearTimeout(detTimer);
clearTimeout(nudgeTimer);
hideNudge();
}
// 递归 setTimeout 而非 setInterval:检测是异步的,避免帧与帧堆叠
function detTick() {
if (!detRunning) return;
function detTick(generation) {
if (!detRunning || generation !== detGeneration) return;
detTimer = setTimeout(function () {
if (!detRunning) return;
detectFrame().then(detTick, detTick);
if (!detRunning || generation !== detGeneration) return;
if (detInFlight) { detTick(generation); return; }
detInFlight = true;
function finished() {
detInFlight = false;
detTick(generation);
}
Promise.resolve().then(detectFrame).then(finished, finished);
}, DETECT_INTERVAL);
}
function detectFrame() {
if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态
var step = camStep, seq = camSeq; // 快照:检测是异步的,帧落地时步骤/流程可能已变
var step = camStep, seq = camSeq, generation = detGeneration; // 旧镜头推理结果不能污染新镜头
return faceapi
.detectAllFaces(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
.withFaceLandmarks()
.then(function (d) {
if (step !== camStep || seq !== camSeq) return; // 跨步骤陈帧/流程已重置:丢弃
if (step !== camStep || seq !== camSeq || generation !== detGeneration) return;
if (detRunning && !shutterLock && !capturing) onDetect(d);
})
.catch(function () { if (step === camStep && seq === camSeq) { holdState = null; okSince = 0; } }); // Unchecked frames break continuous readiness.
.catch(function () { if (step === camStep && seq === camSeq && generation === detGeneration) { holdState = null; okSince = 0; resetAngleHaptic(); } });
}
// 人脸框中心相对取景中线的水平偏移(占屏宽比例);按 object-fit:cover + 预览缩放换算到屏幕,镜像只影响符号
function faceOffsetX(box) {
@@ -879,11 +942,34 @@
/* camera-haptic-pure-begin */
function captureHaptic(nav) {
// Unsupported/blocked vibration must never interrupt automatic capture.
try { return typeof nav.vibrate === 'function' && nav.vibrate([70, 40, 70]) !== false; }
try { return !!nav && typeof nav.vibrate === 'function' && nav.vibrate(30) !== false; }
catch (e) { return false; }
}
var angleHapticState = { since: null, lastFrame: null, lastBuzz: null, vibrating: false };
function resetAngleHaptic() {
if (angleHapticState.vibrating) {
try { if (typeof navigator.vibrate === 'function') navigator.vibrate(0); } catch (_) {}
}
angleHapticState.since = angleHapticState.lastFrame = null;
angleHapticState.vibrating = false;
}
function updateAngleHaptic(invalid, now) {
if (!invalid) { resetAngleHaptic(); return; }
var s = angleHapticState;
// A missing/failed frame breaks continuity; a stalled detector must not
// produce a reminder as though it observed the user throughout the gap.
if (s.since === null || now < s.lastFrame || now - s.lastFrame > 2000) s.since = now;
s.lastFrame = now;
if (now - s.since >= 1000 && (s.lastBuzz === null || now - s.lastBuzz >= 4000)) {
s.lastBuzz = now;
s.vibrating = captureHaptic(navigator);
}
}
/* camera-haptic-pure-end */
function onDetect(d) {
if (shutterLock || capturing || camBusy || !camLive || currentScreen !== 's-cam' || document.hidden) {
resetAngleHaptic(); return;
}
var count = Array.isArray(d) ? d.length : d ? 1 : 0;
if (Array.isArray(d)) d = d[0];
var hint = '', ok = false;
@@ -892,6 +978,7 @@
if (noFace) {
hint = '未检测到人脸';
} else if (!hasFace) {
resetAngleHaptic();
holdState = null; okSince = 0;
return; // Keep the hint quiet, but never count a missing frame as stable.
} else {
@@ -918,6 +1005,7 @@
hint = verdict.hint; ok = verdict.ok;
window.__dbgRatio = ratio; // 自动化校准/测试用(检测框高/视频高)
}
updateAngleHaptic(!!(verdict && verdict.reason === 'angle'), Date.now());
setMask(ok ? 'ok' : (noFace ? 'no-face' : ''));
// 合格时「保持不动」只显示不播报:随即抓拍,「拍摄完成」会截断它
setHint(ok ? '保持不动' : hint, ok, ok);
@@ -926,7 +1014,6 @@
holdState = held.state;
if (holdState.frames === 1) {
okSince = holdState.since;
if (!readyHapticAt || okSince - readyHapticAt >= 2000) { readyHapticAt = okSince; captureHaptic(navigator); }
}
if (held.ready) { okSince = 0; holdState = null; doCapture(); }
}
@@ -1001,7 +1088,7 @@
if (!camLive) return;
shutterLock = true;
capturing = true;
if (isManualMode()) captureHaptic(navigator); // Manual shutter has no readiness callback.
resetAngleHaptic(); // Only invalid angles vibrate, never the shutter itself.
clearTimeout(nudgeTimer);
hideNudge();
var seq = camSeq;
@@ -1073,6 +1160,7 @@
}
renderReport(vm, photos[0], photos[1], payload); // img1=正脸实拍,payload 供检测可视化取 face_maps/marks
if (IS_MOCK) toast('演示报告,不保存至我的报告');
else if (AccountClient.user() && AccountClient.user().kind !== 'wechat') toast('游客报告仅供本次查看,不保存历史记录');
else if (!payload.report_saved) toast('报告生成成功,但保存失败,请勿关闭页面并联系管理员');
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机
@@ -1398,6 +1486,7 @@
}
function renderReport(vm, frontPhoto, sidePhoto, payload) {
reportOwner = AccountClient.user() && AccountClient.user().id;
// 头部:综合评分(vm.total 缺失时回退三维度 S 均分×10)
var total = vm.total;
if (total == null) {
@@ -1409,10 +1498,11 @@
$('#stat-score').innerHTML = total != null ? '<b>' + total + '</b><i>分</i>' : '<b>--</b><i>分</i>';
var ring = $('#ring-fg');
ring.style.strokeDashoffset = total != null ? 326.73 * (1 - Math.max(0, Math.min(100, total)) / 100) : 326.73;
$('#stat-age').innerHTML = vm.skinAgeDisplay != null ? '<b>' + vm.skinAgeDisplay + '</b><i>岁</i>' : '<b>--</b><i>岁</i>';
var displayedAge = SkinScore.reportSkinAge(vm, payload && payload.result);
$('#stat-age').innerHTML = displayedAge != null ? '<b>' + displayedAge + '</b><i>岁</i>' : '<b>--</b><i>岁</i>';
// 肌龄对比胶囊(新版设计「比实际年龄年轻2岁」);缺实际年龄或肌龄时不展示
var pill = $('#stat-age-note');
var diff = vm.skinAgeDisplay != null && vm.realAge ? vm.skinAgeDisplay - vm.realAge : null;
var diff = displayedAge != null && vm.realAge ? displayedAge - vm.realAge : null;
pill.hidden = diff == null;
pill.textContent = diff == null ? '' : diff < 0 ? '比实际年龄年轻' + (-diff) + '岁' : diff > 0 ? '比实际年龄大' + diff + '岁' : '与实际年龄相当';
@@ -1470,14 +1560,17 @@
/* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */
function beginDetect() {
var token = camSeqCam;
if (IS_MOCK || camFallback) { resetDetectState(); return; }
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
resetDetectState();
if (!modelsOk) showCamStatus('检测引擎加载中…');
loadModels().then(function () {
if (token !== camSeqCam || !camLive) return;
$('#cam-status').classList.remove('show');
maybeStartDetect();
}).catch(function () {
if (token !== camSeqCam || !camLive) return;
$('#cam-status').classList.remove('show');
detectDisabled = true; // 仅禁用当次;下次进拍照页 beginDetect 复位后重试
resetDetectState();
@@ -1561,9 +1654,10 @@
});
/* ─── 动作分发 ─── */
var privacyBusy = false;
var actions = {
'open-policy': function () { openOverlay('p-policy'); },
'login-browser': function () { loginBrowser(); },
'guest-experience': function () { enterGuest(); },
'login-wechat': function () { loginWechat(); },
'login-confirm': closeLoginSuccess,
'open-privacy': function () { if (!AccountClient.user()) { showScreen('s-login', 'replace'); return; } openOverlay('p-privacy'); },
@@ -1571,8 +1665,13 @@
'close-policy': function () { closeOverlay('p-policy'); },
'agree-continue': function () {
if (!agreed) { toast('请先阅读并同意隐私协议'); return; }
closeOverlay('p-privacy');
showScreen('s-profile', 'push');
if (privacyBusy) return;
privacyBusy = true;
var button = $('#p-privacy [data-action="agree-continue"]'); button.disabled = true;
AccountClient.ensureGuest().then(function () {
if (!$('#p-privacy').classList.contains('show')) return;
closeOverlay('p-privacy'); showScreen('s-profile', 'push');
}).catch(function (e) { toast(e.message); }).finally(function () { privacyBusy = false; button.disabled = false; });
},
'submit-profile': function () {
$('#f-age').value = ProfileStore.age($('#f-birth').value) || '';
@@ -1602,17 +1701,20 @@
},
'flip': function () { // 切换前后镜头
if (camFallback) { toast('系统相机模式下请在系统相机内切换'); return; }
if (!camStream) { toast('相机启动中,请稍候'); return; }
if (camBusy || !camLive) { toast('相机启动中,请稍候'); return; }
if (shutterLock || capturing) { toast('拍摄中,请稍候'); return; }
var previousFacing = camFacing;
freezeCamera();
camFacing = camFacing === 'user' ? 'environment' : 'user';
camMirrored = camFacing === 'user';
startCamera(); // 复用启停与预热(camSeqCam 防竞态)
startCamera(previousFacing, true);
},
'history': function () { openHistory(); },
'history-retry': function () { renderHistoryList(); },
'switch-login': function () {
'logout-wechat': logoutWechat,
'open-wechat-login': function () {
historySeq++;
$('#login-consent').checked = false;
$('#login-message').textContent = '微信与游客为独立账号,报告不会自动合并。请选择登录方式;返回可保留当前登录。';
$('#login-message').textContent = '微信登录后,后续检测报告可保存和跨手机查看。';
showScreen('s-login', 'push');
}
};
@@ -1628,13 +1730,17 @@
var historySeq = 0;
function renderHistoryList() {
var seq = ++historySeq, box = $('#history-list');
if (!AccountClient.user() || AccountClient.user().kind !== 'wechat') {
$('#history-count').textContent = '游客模式不保存检测记录';
$('#identity-note').textContent = '游客不保存个人信息和检测记录,报告仅供本次查看。微信登录后可保留历史报告。';
box.innerHTML = '<p class="history-empty">暂无历史报告<br>请使用微信登录,后续检测报告会自动保存。<br><button class="history-retry" data-action="open-wechat-login">微信登录,保存报告</button></p>';
return;
}
box.textContent = '正在读取报告…';
AccountClient.request('api/reports').then(function (data) {
if (seq !== historySeq) return;
$('#history-count').textContent = '您最近已进行过' + data.total + '次面部问题检测';
$('#identity-note').textContent = AccountClient.user().kind === 'wechat'
? '保留最近 5 份报告,同一微信账号可跨手机查看。'
: '保留最近 5 份报告。游客报告仅可在当前浏览器查看,不能跨手机同步;清除浏览器数据后无法找回。';
$('#identity-note').textContent = '保留最近 5 份报告,同一微信账号可跨手机查看。';
if (!data.items.length) { box.innerHTML = '<p class="history-empty">暂无报告<br>完成一次检测后,报告会自动保存在这里。</p>'; return; }
box.innerHTML = data.items.map(function (item) {
var date = new Date(item.created_at).toLocaleDateString('sv-SE');
@@ -1655,8 +1761,9 @@
var btn = event.target.closest('[data-report-id]');
if (!btn || btn.disabled) return;
btn.disabled = true;
var seq = historySeq, owner = AccountClient.user() && AccountClient.user().id;
AccountClient.request('api/reports/' + encodeURIComponent(btn.dataset.reportId)).then(function (data) {
if (currentScreen !== 's-history') return;
if (currentScreen !== 's-history' || seq !== historySeq || owner !== (AccountClient.user() && AccountClient.user().id)) return;
var entry = data.report;
renderReport(entry.vm, entry.photos[0], entry.photos[1], Object.assign({}, entry.payload, { presentation: entry.presentation }));
showScreen('s-report', 'push');
@@ -1664,24 +1771,36 @@
});
var loginBusy = false;
function logoutWechat() {
var user = AccountClient.user();
if (logoutBusy || !user || user.kind !== 'wechat') return;
if (!window.confirm('确认退出微信登录?\n历史报告将保留,重新微信登录后可查看。')) return;
logoutBusy = true; renderAccountStatus();
AccountClient.logout().then(function () {
historySeq++; reportOwner = null;
$('#history-list').textContent = ''; $('#history-count').textContent = ''; $('#identity-note').textContent = '';
$('#login-consent').checked = false;
$('#login-message').textContent = '已退出微信登录,历史报告已保留。';
showScreen('s-login', 'replace');
$('[data-action="login-wechat"]').focus({ preventScroll: true });
}).catch(function (e) { toast('退出失败:' + e.message); }).finally(function () {
logoutBusy = false; renderAccountStatus();
});
}
function loginAllowed() {
if (loginBusy) return false;
if (!$('#login-consent').checked) { toast('请先阅读并同意用户协议和隐私政策'); return false; }
return true;
}
function loginBrowser() {
if (!loginAllowed()) return;
loginBusy = true;
$('#login-message').textContent = '正在登录…';
AccountClient.browser(true).then(function () {
loginSucceeded();
}).catch(function (e) { $('#login-message').textContent = e.message; })
.finally(function () { loginBusy = false; });
function enterGuest() {
if (loginBusy) return;
AccountClient.guest();
showScreen('s-splash', 'replace');
}
function loginWechat() {
if (!loginAllowed()) return;
if (!/MicroMessenger/i.test(navigator.userAgent)) {
$('#login-message').textContent = '请在微信内打开本页面,或使用游客登录。';
$('#login-message').textContent = '请在微信内打开本页面,或选择免登录体验。';
return;
}
loginBusy = true;
@@ -1742,14 +1861,14 @@
$('#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 = '微信登录可跨手机查看报告;游客登录仅限当前浏览器。';
else $('#login-message').textContent = '微信登录可保留历史报告;游客不保存个人信息和检测记录。';
}).catch(function (e) { $('#login-message').textContent = e.message; })
.finally(function () {
loginBusy = false;
+2 -2
View File
@@ -12,7 +12,7 @@
yawDrift: 8, pitchDrift: 8, centerDrift: 0.055, sizeDrift: 0.065 });
function finite(v) { return typeof v === 'number' && isFinite(v); }
function evaluate(d, options) {
var failed = function(hint) { return { ok: false, hint: hint }; };
var failed = function(hint, reason) { return { ok: false, hint: hint, reason: reason || 'quality' }; };
if (!d) return failed('未检测到人脸');
if (options.count !== 1) return failed('请保持单人出镜');
var box = d.detection.box, w = options.width, h = options.height;
@@ -31,7 +31,7 @@
var target = options.target;
if (options.ignoreDirection && target !== 'front') target = pose.yaw < 0 ? 'left' : 'right';
var check = HeadPose.check(d.landmarks.positions, target, options.mirrored);
if (!check.ok) return failed(check.hint);
if (!check.ok) return failed(check.hint, finite(check.yaw) ? 'angle' : 'quality');
if (!finite(options.luma)) return failed('请保持面部清晰');
if (options.luma < config.dark) return failed('请到光线充足处');
if (options.luma > config.bright) return failed('光线过强,请避开强光');
+1 -1
View File
@@ -5,7 +5,7 @@
})(typeof window !== 'undefined' ? window : this, function() {
'use strict';
function key(user) {
return user && ['browser', 'wechat'].indexOf(user.kind) >= 0 && typeof user.id === 'string' && user.id.length <= 128 && user.id
return user && user.kind === 'wechat' && typeof user.id === 'string' && user.id.length <= 128 && user.id
? 'juzi.profile.v1.' + user.kind + '.' + encodeURIComponent(user.id) : null;
}
function age(birth, now) {
+12 -8
View File
@@ -689,14 +689,11 @@
var notes = [];
var realAge = val(profile.realAge);
// 肌肤年龄:原值不改写,展示值钳制到 realAge±3 并留痕
// 生物可视年龄直接使用算法原始肌龄,不再按实际年龄钳制。
var skinAge = val(r.skin_age);
var skinAgeDisplay = skinAge, ageClampNote = '';
if (realAge == null) {
notes.push('未提供实际年龄:不修正展示肌龄;产品推荐仍使用接口原始肌龄');
} else if (skinAge != null) {
skinAgeDisplay = Math.min(Math.max(skinAge, realAge - 3), realAge + 3);
if (skinAgeDisplay !== skinAge) ageClampNote = '触发(原' + skinAge + '→' + skinAgeDisplay + ')';
notes.push('未提供实际年龄:肌龄展示与产品推荐均使用接口原始肌龄');
}
var skinTypeCn = skinTypeCnOf(r, profile); // {skin_type_cn}:接口枚举优先,回退用户自选肤质
@@ -898,7 +895,14 @@
right: { right_jawline_info: { right_jawline_angle: 113.4, right_jawline_angle_level: 1, right_jawline_quality: 1 } } // 偏离 2.6° → 94.8
};
// Historical snapshots may retain an old clamped display value. Read their
// original age without modifying the stored snapshot or recalculating copy.
function reportSkinAge(vm, result) {
if (vm && Object.prototype.hasOwnProperty.call(vm, 'skinAgeRaw')) return val(vm.skinAgeRaw);
return val(result && result.skin_age);
}
return {
reportSkinAge: reportSkinAge,
buildViewModel: buildViewModel,
recommendProduct: recommendProduct,
MOCK_RESULT: MOCK_RESULT,
@@ -941,7 +945,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
ok(vm.cats.acne.P === 85, '痘痘P = min(分级映射85, 100−14.5=85.5) = 85');
ok(vm.cats.wrinkles.S === 7.69 && vm.cats.smooth.S === 9.45 && vm.cats.tone.S === 9.45, 'S=7+3×P/100 两位小数(含 9.445→9.45 浮点修正)');
ok(vm.dims.firm.P === 23 && vm.dims.vita.P === 81.5 && vm.dims.stab.P === 85, '维度木桶 min:紧塑力23 / 活力感81.5 / 稳定力85');
ok(vm.skinAgeRaw === 20 && vm.skinAgeDisplay === 22 && vm.ageClampNote === '触发(原20→22)', '肌肤年龄钳制 20→22 留痕,原值不改写');
ok(vm.skinAgeRaw === 20 && vm.skinAgeDisplay === 20 && vm.ageClampNote === '', '肌肤年龄直接展示原值20,不按实际年龄钳制');
ok(vm.group === '25+组', '原始肌龄20 → 25+组');
ok(vm.essence.name === '25+束紧精华' && vm.recommendation.tier === 1,
'肌龄小于32,即使皱纹低分也固定25+,不再使用最差项升档');
@@ -974,14 +978,14 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
&& vm2.dims.stab.P === Math.min(vm2.cats.sens.P, vm2.cats.acne.P), '三维度 P = min(旗下两分类)');
ok(vm2.group === '25+组', '实际年龄40不影响推荐,原始肌龄20仍为25+组');
console.log('== T3 钳制边界 ==');
console.log('== T3 原始肌龄展示 ==');
var r3 = clone(SS.MOCK_RESULT);
r3.skin_age = { value: 24 };
var vm3 = SS.buildViewModel(r3, SS.MOCK_SIDE, { realAge: 25 });
ok(vm3.skinAgeDisplay === 24 && vm3.ageClampNote === '', '±3 以内不钳制、不留痕');
r3.skin_age = { value: 40 };
var vm3b = SS.buildViewModel(r3, SS.MOCK_SIDE, { realAge: 25 });
ok(vm3b.skinAgeDisplay === 28 && vm3b.ageClampNote === '触发(原40→28)' && vm3b.skinAgeRaw === 40, '上边界钳制 40→28');
ok(vm3b.skinAgeDisplay === 40 && vm3b.ageClampNote === '' && vm3b.skinAgeRaw === 40, '原始肌龄40不因实际年龄25而改变');
console.log('== T4 最差项不再覆盖肤龄推荐 ==');
var r4 = clone(SS.MOCK_RESULT);
+26 -15
View File
@@ -27,6 +27,7 @@ module.exports = function createAccounts(root, env = process.env) {
const prefix = base ? base.pathname.replace(/\/?$/, '/') : '/';
if (base) { base.pathname = prefix; base.search = ''; base.hash = ''; }
const secure = !!(base && base.protocol === 'https:');
const guestSessions = new Map(); // No guest identities/sessions are written to disk.
const appId = env.WECHAT_APP_ID || '';
const secret = env.WECHAT_APP_SECRET || '';
const available = !!(appId && secret && secure && !/^(localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(base.hostname));
@@ -34,14 +35,16 @@ module.exports = function createAccounts(root, env = process.env) {
return Object.fromEntries((req.headers.cookie || '').split(';').map(s => s.trim().split('=')));
}
function cookie(res, name, value, age) {
const line = `${name}=${value}; Path=${prefix}; HttpOnly; SameSite=Lax; Max-Age=${age}${secure ? '; Secure' : ''}`;
const line = `${name}=${value}; Path=${prefix}; HttpOnly; SameSite=Lax${age == null ? '' : '; Max-Age=' + age}${secure ? '; Secure' : ''}`;
const old = res.getHeader('Set-Cookie') || [];
res.setHeader('Set-Cookie', [].concat(old, line));
}
function session(req) {
const token = cookies(req).juzi_session;
const guest = token && guestSessions.get(hash(token));
if (guest && guest.expires > Date.now()) return guest.user;
const row = token && db.sessions[hash(token)];
return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user) : null;
return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === 'wechat') : null;
}
function login(res, user) {
for (const key of Object.keys(db.sessions)) if (db.sessions[key].expires < Date.now()) delete db.sessions[key];
@@ -94,19 +97,22 @@ module.exports = function createAccounts(root, env = process.env) {
if (!rate(req)) { json(res, 429, { ok: false, message: '操作频繁,请稍后再试' }); return true; }
const data = await body(req);
if (data.consent !== true) { json(res, 400, { ok: false, message: '请先阅读并同意协议' }); return true; }
let token = data.token, user;
if (token) {
if (typeof token !== 'string' || !/^[a-f0-9]{64}$/.test(token)) token = null;
user = token && db.users.find(u => u.kind === 'browser' && u.credential === hash(token));
if (!user) { json(res, 401, { ok: false, message: '游客身份已失效,请重新登录' }); return true; }
} else {
token = random(); user = { id: crypto.randomUUID(), kind: 'browser', credential: hash(token), consent: new Date().toISOString(), reports: [] };
db.users.push(user);
}
login(res, user); json(res, 200, { ok: true, user: publicUser(user), token });
// Old persistent guest tokens cannot restore identity or history.
if (data.token) { json(res, 401, { ok: false, message: '游客身份已失效,请重新登录' }); return true; }
for (const [key, value] of guestSessions) if (value.expires < Date.now()) guestSessions.delete(key);
const previous = cookies(req).juzi_session;
if (previous) guestSessions.delete(hash(previous));
const token = random(), user = { id: crypto.randomUUID(), kind: 'browser' };
guestSessions.set(hash(token), { user, expires: Date.now() + 8 * 3600000 });
cookie(res, 'juzi_session', token, null);
json(res, 200, { ok: true, user: publicUser(user) });
} else if (pathname === '/api/auth/logout' && req.method === 'POST') {
const token = cookies(req).juzi_session; if (token) delete db.sessions[hash(token)];
cookie(res, 'juzi_session', '', 0); save(); json(res, 200, { ok: true });
const token = cookies(req).juzi_session;
if (token) {
guestSessions.delete(hash(token));
if (db.sessions[hash(token)]) { delete db.sessions[hash(token)]; save(); }
}
cookie(res, 'juzi_session', '', 0); json(res, 200, { ok: true });
} else if (pathname === '/api/auth/wechat' && req.method === 'POST') {
const data = await body(req);
if (data.consent !== true) { json(res, 400, { ok: false, message: '请先阅读并同意协议' }); return true; }
@@ -147,6 +153,11 @@ module.exports = function createAccounts(root, env = process.env) {
const user = session(req);
if (!user) { json(res, 401, { ok: false, message: '请先登录' }); return true; }
const id = pathname.slice('/api/reports/'.length);
if (user.kind !== 'wechat') {
json(res, pathname === '/api/reports' ? 200 : 404,
pathname === '/api/reports' ? { ok: true, total: 0, items: [] } : { ok: false, message: '游客模式不保存历史报告' });
return true;
}
if (pathname === '/api/reports') json(res, 200, { ok: true, total: user.reports.length, items: user.reports });
else if (/^R[\w-]+$/.test(id) && user.reports.some(r => r.id === id)) {
const file = path.join(snapshots, user.id, id + '.json');
@@ -159,7 +170,7 @@ module.exports = function createAccounts(root, env = process.env) {
}
function storeReport(ownerId, snapshot) {
const user = db.users.find(u => u.id === ownerId);
if (!user || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner');
if (!user || user.kind !== 'wechat' || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner');
const dir = path.join(snapshots, user.id); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
atomic(path.join(dir, snapshot.id + '.json'), snapshot);
user.reports = [{ id: snapshot.id, created_at: snapshot.created_at, total: snapshot.vm.total, skinAge: snapshot.vm.skinAgeDisplay }]
+23 -18
View File
@@ -267,7 +267,7 @@ function scoreProfile(rec) {
}
/* 检测成功:用 SkinScore.buildViewModel 计算并补全记录字段(复用同一份公式) */
function fillSuccess(rec, fpJson) {
function fillSuccess(rec, fpJson, persist = true) {
// Observation only: do not block, re-shoot, retry or alter scoring based on it.
rec.capture_quality = CaptureDiagnostics.assess(fpJson);
const vm = SkinScore.buildViewModel(fpJson.result, {
@@ -299,7 +299,7 @@ function fillSuccess(rec, fpJson) {
rec.essence_line = vm.essence ? vm.essence.text : null;
rec.trigger = (vm.essence && vm.essence.triggers) ? vm.essence.triggers.join(',') : null;
rec.notes = vm.notes || [];
rec.raw_file = dumpRaw(fpJson);
rec.raw_file = persist ? dumpRaw(fpJson) : null;
}
/* 检测失败:补失败字段 */
@@ -311,7 +311,7 @@ function fillFailure(rec, code) {
}
/* 执行一次检测:计时调用 Face++,按结果补全记录(成功/失败都落库)。返回 Face++ 原始返回 */
async function runDetect(rec, front, left, right) {
async function runDetect(rec, front, left, right, persist = true) {
rec.detect_status = '检测中';
rec.updated_at = nowIso();
const t0 = Date.now();
@@ -325,7 +325,7 @@ async function runDetect(rec, front, left, right) {
fillFailure(rec, 'INTERNAL_ERROR');
} else {
try {
fillSuccess(rec, fpJson);
fillSuccess(rec, fpJson, persist);
} catch (e) { // 评分异常按检测失败落库,但不改写 C 端成功响应(原始结果仍透传)
console.log(`[analyze] 评分异常: ${e.message}`);
fillFailure(rec, 'INTERNAL_ERROR');
@@ -372,10 +372,13 @@ async function callFacepp(front, left, right) {
}
async function handleAnalyze(req, res) {
res.setHeader('Cache-Control', 'no-store');
let rec = null; // 记录:正脸照片校验通过后创建(此前失败无从落库)
let persist = false;
try {
const owner = accounts.session(req);
if (!owner) return sendJson(res, 401, { ok: false, message: '请先登录后检测' });
persist = owner.kind === 'wechat';
if (!accounts.sameOrigin(req)) return sendJson(res, 403, { ok: false, message: '请求来源不匹配' });
const body = JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8'));
// 三个 image 字段先做类型检查:非字符串视为缺失(左/右图缺失时仅发正脸,接口自动降级为单视图)
@@ -394,7 +397,7 @@ async function handleAnalyze(req, res) {
/* ── 记录落库:生成 record_id → 写照片 → 落一条 detect_status=检测中 ── */
const profile = normalizeProfile(body.profile);
const recordId = newRecordId();
const photos = writePhotos(recordId, { front, left, right });
const photos = persist ? writePhotos(recordId, { front, left, right }) : {};
rec = {
record_id: recordId,
owner_id: owner.id,
@@ -420,11 +423,13 @@ async function handleAnalyze(req, res) {
retry_of: null, retry_count: 0
};
rec.capture_diagnostics = CaptureDiagnostics.normalize(body.capture_diagnostics);
appendRecord(rec);
console.log(`[record] 新检测记录 ${recordId}(${rec.shoot_status},照片${Object.keys(photos).length}/3)`);
if (persist) {
appendRecord(rec);
console.log(`[record] 新检测记录 ${recordId}(${rec.shoot_status},照片${Object.keys(photos).length}/3)`);
}
const fpJson = await runDetect(rec, front, left, right);
updateRecord(rec);
const fpJson = await runDetect(rec, front, left, right, persist);
if (persist) updateRecord(rec);
if (fpJson.error_message) {
const code = fpJson.error_message.split(':')[0];
@@ -458,10 +463,12 @@ async function handleAnalyze(req, res) {
try {
snapshotVm = SkinScore.buildViewModel(result, payload.side, scoreProfile(rec));
presentation = rec.presentation || ReportUI.build(snapshotVm, result);
accounts.storeReport(owner.id, { id: recordId, created_at: rec.created_at,
version: '2026-10-09', profile, vm: snapshotVm, presentation, payload,
photos: [front, left, right].map(b64 => b64 ? toDataUrl(b64) : null) });
reportSaved = true;
if (persist) {
accounts.storeReport(owner.id, { id: recordId, created_at: rec.created_at,
version: '2026-10-09', profile, vm: snapshotVm, presentation, payload,
photos: [front, left, right].map(b64 => b64 ? toDataUrl(b64) : null) });
reportSaved = true;
}
} catch (e) { console.error('[report] 完整报告保存失败:', e.message); }
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({
@@ -475,7 +482,7 @@ async function handleAnalyze(req, res) {
}
}));
} catch (e) {
if (rec) { // 记录已创建但流程异常:按检测失败补全,便于后台看到这条调用
if (rec && persist) { // 仅微信检测失败记录落库;游客不保留异常记录。
fillFailure(rec, e.message === 'TOO_LARGE' ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR');
rec.updated_at = nowIso();
updateRecord(rec);
@@ -507,10 +514,8 @@ function photoUrls(rec) {
/* 列表用的字段子集(不含 raw_json 与文案全文) */
function displaySkinAge(rec) {
if (Object.prototype.hasOwnProperty.call(rec, 'skin_age_display')) return rec.skin_age_display;
// 旧记录没有展示值,沿用生成时的规则,不重算推荐或改写文件。
if (rec.skin_age == null || rec.real_age == null) return rec.skin_age;
return Math.min(Math.max(rec.skin_age, rec.real_age - 3), rec.real_age + 3);
// Match H5 even for old records; do not rewrite historical files.
return SkinScore.reportSkinAge({ skinAgeRaw: rec.skin_age });
}
function listItem(rec) {
return {
+13 -6
View File
@@ -26,8 +26,16 @@ const ReportUI = require('../js/report-ui');
assert.equal((await request('/api/reports')).status, 401);
assert.equal((await request('/api/auth/browser', {})).status, 400);
assert.equal((await request('/api/auth/browser', { consent: true }, '', { Origin: 'https://unrelated.example' })).status, 403);
const a = await request('/api/auth/browser', { consent: true });
const b = await request('/api/auth/browser', { consent: true });
const guest = await request('/api/auth/browser', { consent: true });
assert.equal(guest.data.token, undefined, 'guest credentials are never persisted by clients');
assert(!guest.headers.get('set-cookie').includes('Max-Age='), 'guest cookie is session-only');
assert.equal(fs.existsSync(path.join(root, 'identities.json')), false, 'guest login does not write an identity');
assert.throws(() => accounts.storeReport(guest.data.user.id, { id: 'Rguest' }));
assert.equal((await request('/api/reports', null, guest.cookie)).data.total, 0);
accounts = create(root, {});
assert.equal((await request('/api/auth/session', null, guest.cookie)).data.user, null, 'process restart forgets guests');
const [a, b] = require('./wechat-fixture.cjs')(root, 2).map(entry => ({ ...entry, data: { user: entry.user }, headers: guest.headers }));
accounts = create(root, {});
assert.notEqual(a.data.user.id, b.data.user.id);
assert(a.headers.get('set-cookie').includes('HttpOnly'));
const vm = SkinScore.buildViewModel(SkinScore.MOCK_RESULT, SkinScore.MOCK_SIDE, { realAge: 30, skinType: '干性肌肤' });
@@ -48,11 +56,10 @@ const ReportUI = require('../js/report-ui');
assert.equal((await request('/api/reports/Rtest0', null, a.cookie)).status, 404);
accounts = create(root, {}); // Process restart reloads sessions and reports.
assert.equal((await request('/api/reports', null, a.cookie)).data.total, 5);
const restored = await request('/api/auth/browser', { consent: true, token: a.data.token });
assert.equal(restored.data.user.id, a.data.user.id);
assert.equal((await request('/api/auth/session', null, a.cookie)).data.user.id, a.data.user.id);
assert.equal((await request('/api/auth/browser', { consent: true, token: 'bad' })).status, 401);
await request('/api/auth/logout', {}, restored.cookie);
assert.equal((await request('/api/reports', null, restored.cookie)).status, 401);
await request('/api/auth/logout', {}, a.cookie);
assert.equal((await request('/api/reports', null, a.cookie)).status, 401);
assert.equal((await request('/api/auth/config')).data.wechat, false);
accounts = create(root, { WECHAT_APP_ID: 'test-app', WECHAT_APP_SECRET: 'test-secret', PUBLIC_BASE_URL: 'https://skin.example.com/state' });
const start = await request('/api/auth/wechat', { consent: true });
+1 -2
View File
@@ -40,9 +40,8 @@ try {
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.click('[data-action="guest-experience"]');
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"]');
+121
View File
@@ -0,0 +1,121 @@
// Canvas cameras and intercepted auth; no personal images, writes or paid API.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { execSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] });
try {
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.route('**/api/**', route => {
assert.equal(route.request().method(), 'GET', 'no account/report writes');
return route.fulfill({ json: { ok: true, user: { id: 'camera-fixture', kind: 'browser' } } });
});
const source = fs.readFileSync(fileURLToPath(new URL('../js/app.js', import.meta.url)), 'utf8');
await page.route('**/js/app.js*', route => route.fulfill({ contentType: 'text/javascript', body: source.replace(/\}\)\(\);\s*$/, `
window.__cameraTest = {
enter: function() { showScreen('s-cam'); startCamera(); renderCamStep(); },
stop: stopCamera,
capture: function(value) { shutterLock = value; },
startDetection: function() { modelsOk = true; detectDisabled = false; IS_MOCK = false; maybeStartDetect(); },
state: function() { return { live: camLive, busy: camBusy, facing: camFacing, fallback: camFallback }; }
};
})();`) }));
await page.addInitScript(() => {
const nativeTimeout = window.setTimeout.bind(window);
window.setTimeout = (fn, ms, ...args) => nativeTimeout(fn, window.__shortCameraTimeout && ms === 12000 ? 200 : ms, ...args);
window.__calls = []; window.__delay = 0; window.__fail = false; window.__ignoreFacing = false;
window.__streams = []; window.__frameAt = 0;
navigator.mediaDevices.getUserMedia = async constraints => {
const facing = constraints.video.facingMode.ideal;
window.__calls.push(facing);
const fail = window.__fail, delay = window.__delay;
window.__fail = false;
await new Promise(r => setTimeout(r, delay));
if (fail) throw new Error('fixture camera unavailable');
const canvas = document.createElement('canvas'); canvas.width = 640; canvas.height = 480;
const ctx = canvas.getContext('2d');
ctx.fillStyle = facing === 'user' ? '#316a94' : '#d18e48'; ctx.fillRect(0, 0, 640, 480);
const stream = canvas.captureStream(20), track = stream.getVideoTracks()[0];
const settings = track.getSettings.bind(track);
track.getSettings = () => ({ ...settings(), facingMode: window.__ignoreFacing ? 'user' : facing });
const timer = setInterval(() => ctx.fillRect(0, 0, 640, 480), 40);
const stop = track.stop.bind(track); track.stop = () => { clearInterval(timer); stop(); };
window.__streams.push(stream);
return stream;
};
document.addEventListener('DOMContentLoaded', () => {
document.querySelector('#cam-video').addEventListener('loadeddata', () => { window.__frameAt = performance.now(); });
});
});
await page.goto((process.env.H5_BASE_URL || 'http://127.0.0.1:8080/') + '?mock=1');
await page.evaluate(() => window.__cameraTest.enter());
const live = () => page.waitForFunction(() => window.__cameraTest.state().live);
await live();
for (const delay of [0, 1000]) {
await page.evaluate(delay => { window.__delay = delay; document.querySelector('.cam-flip').click(); }, delay);
assert(await page.locator('#cam-frozen').isVisible(), 'frozen preview during switching');
const count = await page.evaluate(() => window.__calls.length);
await page.evaluate(() => document.querySelector('.cam-flip').click());
assert.equal(await page.evaluate(() => window.__calls.length), count, 'rapid click ignored');
await live();
const extra = await page.evaluate(() => performance.now() - window.__frameAt);
assert(extra < 500, `first-frame to visible latency ${extra}ms, no fixed 900ms wait`);
assert(!(await page.locator('#cam-frozen').isVisible()));
console.log('flip delay', delay, 'first-frame to visible', Math.round(extra), 'ms');
}
const before = await page.evaluate(() => window.__cameraTest.state().facing);
await page.evaluate(() => { window.__delay = 0; window.__fail = true; document.querySelector('.cam-flip').click(); });
await live();
assert.equal(await page.evaluate(() => window.__cameraTest.state().facing), before, 'failed flip restores camera');
assert.equal(await page.evaluate(() => window.__cameraTest.state().fallback), false);
await page.evaluate(() => { window.__ignoreFacing = true; document.querySelector('.cam-flip').click(); });
await live();
assert.equal(await page.evaluate(() => window.__cameraTest.state().facing), before, 'ignored facing is detected/restored');
await page.evaluate(() => {
window.__ignoreFacing = false; window.__shortCameraTimeout = true; window.__delay = 600;
document.querySelector('.cam-flip').click();
window.__delay = 0; // restoration succeeds while the original request is still pending
});
await live();
assert.equal(await page.evaluate(() => window.__cameraTest.state().facing), before, 'timeout restores original camera');
await page.waitForTimeout(700);
assert(await page.evaluate(() => window.__streams.slice(0, -2).every(s => s.getTracks().every(t => t.readyState === 'ended'))));
await page.evaluate(() => { window.__shortCameraTimeout = false; });
const count = await page.evaluate(() => window.__calls.length);
await page.evaluate(() => { window.__cameraTest.capture(true); document.querySelector('.cam-flip').click(); window.__cameraTest.capture(false); });
assert.equal(await page.evaluate(() => window.__calls.length), count, 'capture blocks flip');
// Pending inference from the old camera must neither update UI nor fork loops.
await page.evaluate(() => {
window.__detectCalls = 0; window.__concurrent = 0; window.__maxConcurrent = 0;
window.faceapi = {
nets: { tinyFaceDetector: { loadFromUri: () => Promise.resolve() }, faceLandmark68Net: { loadFromUri: () => Promise.resolve() } },
TinyFaceDetectorOptions: function() {},
detectAllFaces: function() { return { withFaceLandmarks: function() {
window.__detectCalls++; window.__concurrent++;
window.__maxConcurrent = Math.max(window.__maxConcurrent, window.__concurrent);
return new Promise(resolve => { window.__resolveDetect = () => { window.__concurrent--; resolve([]); }; });
} }; }
};
window.__cameraTest.startDetection();
});
await page.waitForFunction(() => window.__detectCalls === 1);
await page.evaluate(() => { window.__ignoreFacing = false; document.querySelector('.cam-flip').click(); });
await live();
await page.waitForTimeout(600);
assert.equal(await page.evaluate(() => window.__detectCalls), 1, 'do not overlap old inference');
await page.evaluate(() => window.__resolveDetect());
await page.waitForFunction(() => window.__detectCalls === 2);
await page.evaluate(() => window.__resolveDetect());
await page.waitForFunction(() => window.__detectCalls === 3);
assert.equal(await page.evaluate(() => window.__maxConcurrent), 1, 'single detection loop');
await page.evaluate(() => { window.__delay = 600; document.querySelector('.cam-flip').click(); window.__cameraTest.stop(); });
await page.waitForTimeout(900);
assert.equal(await page.evaluate(() => window.__cameraTest.state().live), false, 'late stream cannot revive stopped camera');
assert(await page.evaluate(() => window.__streams.every(s => s.getTracks().every(t => t.readyState === 'ended'))), 'all streams released');
assert.equal(await page.evaluate(() => document.querySelector('#cam-frozen').width), 0, 'frozen face released');
assert.deepEqual(errors, []);
console.log('PASS camera flip: first frame, frozen preview, rapid clicks, recovery/timeout, capture lock, stale inference, cleanup');
} finally { await browser.close(); }
+6
View File
@@ -31,6 +31,7 @@ assert(!check(null).ok); assert(!check(face(), { count: 2 }).ok);
for (const yaw of [-14, 14]) assert(check(face(yaw)).ok, 'former strict front limit now passes');
for (const yaw of [-16, 16, -45, 45]) {
assert(!check(face(yaw)).ok);
assert.equal(check(face(yaw)).reason, 'angle');
assert.equal(check(face(yaw)).hint, '请正对屏幕', 'front correction must not ask for a side view before capture');
assert.equal(check(face(yaw), { mirrored: true }).hint, '请正对屏幕');
}
@@ -49,6 +50,11 @@ assert(check(face(-45), { target: 'right', mirrored: true }).ok);
assert(check(face(-45), { target: 'right', ignoreDirection: true }).ok);
assert(!check(face(25), { target: 'left', ignoreDirection: true }).ok);
for (const luma of [null, 50, 240, NaN]) assert(!check(face(), { luma }).ok);
assert.equal(check(face(), { luma: 50 }).reason, 'quality', 'lighting must not trigger angle vibration');
assert.equal(check(null).reason, 'quality');
assert.equal(check(face(), { count: 2 }).reason, 'quality');
const brokenLandmarks = face(); brokenLandmarks.landmarks.positions = [];
assert.equal(check(brokenLandmarks).reason, 'quality', 'unreadable pose is not confirmed wrong angle');
for (const luma of [70, 230]) assert(check(face(), { luma }).ok);
assert(check(face(), { offsetX: .15 }).ok);
assert(!check(face(), { offsetX: .17 }).ok);
+8 -6
View File
@@ -13,6 +13,7 @@ const ReportUI = require('../js/report-ui');
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const h5 = fileURLToPath(new URL('../', import.meta.url));
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-fourth-test-'));
const wechat = require('./wechat-fixture.cjs')(path.join(dataRoot, 'accounts'))[0];
const artifacts = process.env.H5_ARTIFACT_DIR;
const child = spawn(process.execPath, ['-e', `
const fs=require('fs'), vm=require('vm'), w={};
@@ -45,13 +46,14 @@ try {
await page.goto(base); await page.waitForLoadState('networkidle');
assert.equal(await page.title(), "1'me肌肤状态检测");
assert.equal(await page.locator('meta[property="og:title"]').getAttribute('content'), "1'me肌肤状态检测");
assert.equal((await page.locator('[data-action="login-browser"]').textContent()).trim(), '游客登录');
assert.equal((await page.locator('[data-action="guest-experience"]').textContent()).trim(), '游客登陆');
assert(!requests.some(p => /scan-(base|face)|\.woff2$|faceapi/.test(p)), 'no heavy/scanning entry requests');
await page.check('#login-consent'); await page.click('[data-action="login-browser"]');
await page.waitForSelector('#p-login-success.show');
assert((await page.locator('#login-success-message').textContent()).includes('当前使用:游客登录'));
await page.click('[data-action="login-confirm"]');
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), '游客已登录');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), '游客模式');
await page.context().addCookies([{ name: 'juzi_session', value: wechat.token, url: base }]);
await page.evaluate(() => AccountClient.init()); // Persistence scenarios require WeChat.
const api = page.context().request;
await page.addScriptTag({ url: base + 'assets/mock/report-fixture.js' });
const fixture = await page.evaluate(() => window.MOCK_FIXTURE);
+42 -10
View File
@@ -36,8 +36,8 @@ assert.equal(JSON.stringify(geometry.window.CameraGeometry), original);
for (const value of [null, '', 'M--Z', 'M1,2L1,2Z', 'M0,0L0,0L0,0Z', 'M0,0Q1,2 3,4Z']) assert.equal(context.smoothCameraOutline(value), value);
for (const nav of [null, {}, { vibrate: false }, { vibrate: () => false }, { vibrate: () => { throw Error('denied'); } }]) assert.equal(context.captureHaptic(nav), false);
let pattern;
assert(context.captureHaptic({ vibrate: p => { pattern = Array.from(p); return true; } }));
assert.deepEqual(pattern, [70, 40, 70]);
assert(context.captureHaptic({ vibrate: p => { pattern = p; return true; } }));
assert.equal(pattern, 30);
// Exercise the actual readiness state machine; only camera/pose inputs and clock
// are stubs; the relaxed shared hold policy now requires 450ms/2 frames.
@@ -47,7 +47,8 @@ const state = {
faceOffsetX: () => 0, CENTER_TOL: .18, CAM_STEPS: [{ target: 'front' }], camStep: 0,
HeadPose: { check: () => ({ ok: true }) }, sideYawFlip: false, faceLuma: () => 100,
LUMA_DARK: 65, LUMA_BRIGHT: 235, window: {}, setMask: () => {}, setHint: () => {},
okSince: 0, readyHapticAt: 0, OK_HOLD_MS: 450, Date: { now: () => now },
okSince: 0, OK_HOLD_MS: 450, Date: { now: () => now },
shutterLock: false, capturing: false, camBusy: false, camLive: true, currentScreen: 's-cam', document: { hidden: false },
holdState: null, CaptureQuality: {
evaluate: d => ({ ok: d.detection.box.height >= 100, hint: '保持不动', observation: { yaw: 0, pitch: 0, center_x: .5, center_y: .5, ratio: .5 } }),
hold: require('../js/capture-quality').hold
@@ -56,13 +57,13 @@ const state = {
};
vm.runInNewContext(extract('camera-haptic-pure') + source.slice(source.indexOf(' function onDetect(d)'), source.indexOf(' /* ─── 拍照流程')), state);
const face = { detection: { box: { height: 250 } }, landmarks: { positions: [] } };
state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 0);
now += 200; state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 0);
now += 250; state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 1);
state.onDetect(face); assert.equal(buzzes, 0); assert.equal(captures, 0);
now += 200; state.onDetect(face); assert.equal(buzzes, 0); assert.equal(captures, 0);
now += 250; state.onDetect(face); assert.equal(buzzes, 0); assert.equal(captures, 1);
now += 100; state.onDetect({ ...face, detection: { box: { height: 20 } } });
now += 100; state.onDetect(face); assert.equal(buzzes, 1, 'jitter must not repeatedly buzz');
now += 100; state.onDetect(face); assert.equal(buzzes, 0, 'good angles must not buzz');
now += 2100; state.onDetect({ ...face, detection: { box: { height: 20 } } });
state.onDetect(face); assert.equal(buzzes, 2);
state.onDetect(face); assert.equal(buzzes, 0);
state.navigator.vibrate = () => { throw Error('blocked'); };
now += 300; state.onDetect(face);
now += 150; state.onDetect(face); assert.equal(captures, 2, 'vibration failure must not prevent capture');
@@ -74,8 +75,39 @@ for (const manual of [true, false]) {
$: () => ({ classList: { add: () => {} } }), setTimeout: (_, ms) => scheduled.push(ms) };
vm.runInNewContext(extract('camera-haptic-pure') + source.slice(source.indexOf(' function doCapture()'), source.indexOf(' // 三张拍完:按视角重排')), shutter);
shutter.doCapture();
assert.equal(manualBuzzes, manual ? 1 : 0, 'auto must not double buzz; manual must retain its cue');
assert.equal(manualBuzzes, 0, 'neither auto nor manual shutter should buzz');
assert(shutter.shutterLock && shutter.capturing);
assert.deepEqual(scheduled, [300], 'blocked haptic must still schedule camera capture');
}
console.log('PASS: three bounded rounded contours; haptic safety, pre-capture cue, jitter throttling; relaxed 450ms/2-frame hold');
// Drive actual onDetect with angle-only failures, not UI hint string matching.
const events = [];
state.navigator.vibrate = p => { events.push({ p, at: now }); return true; };
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'angle', hint: '请正对屏幕' });
state.onDetect(face);
now += 999; state.onDetect(face); assert.equal(events.length, 0);
now += 1; state.onDetect(face); assert.equal(events.length, 1); assert.equal(events[0].p, 30);
for (let i = 0; i < 3; i++) { now += 1000; state.onDetect(face); }
assert.equal(events.length, 1, 'no repeat before 4 seconds');
now += 1000; state.onDetect(face); assert.equal(events.length, 2);
state.onDetect(null); assert.equal(events.at(-1).p, 0, 'missing face cancels vibration immediately');
const positives = () => events.filter(e => e.p > 0).length;
state.onDetect(face); now += 999; state.onDetect(face); assert.equal(positives(), 2, 'new continuous interval required');
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'quality', hint: '请靠近一点' });
now += 10000; state.onDetect(face); assert.equal(positives(), 2, 'distance/lighting failure does not buzz');
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'angle', hint: '请正对屏幕' });
state.onDetect(face); now += 1000; state.onDetect(face); assert.equal(positives(), 3);
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'quality', hint: '请到光线充足处' });
state.onDetect(face); assert.equal(events.at(-1).p, 0, 'valid angle cancels even when lighting fails');
for (const [key, value] of [['camBusy', true], ['shutterLock', true], ['capturing', true], ['camLive', false], ['currentScreen', 's-report']]) {
const previous = state[key]; state[key] = value;
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'angle' });
state.onDetect(face); now += 5000; state.onDetect(face);
assert.equal(positives(), 3, key + ' blocks reminders'); state[key] = previous;
}
state.document.hidden = true; state.onDetect(face); now += 5000; state.onDetect(face); assert.equal(positives(), 3);
state.document.hidden = false;
for (const nav of [{}, { vibrate: () => false }, { vibrate: () => { throw Error('blocked'); } }]) {
state.navigator = nav; state.resetAngleHaptic();
state.onDetect(face); now += 1000; assert.doesNotThrow(() => state.onDetect(face));
}
console.log('PASS: rounded contours; 1s invalid-angle cue, 4s throttle, cancellation/lifecycle, unsupported safety; unchanged 450ms/2-frame capture hold');
+1 -2
View File
@@ -117,9 +117,8 @@ try {
// 3) 完整 mock 流程:启动 → 档案 → 三连拍 → 报告,并记录语音起播
await page.goto(BASE + '/?mock=1', { waitUntil: 'networkidle' });
await page.locator('#login-consent').check();
await page.locator('[data-action="login-browser"]').click();
await page.locator('[data-action="guest-experience"]').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();
+140
View File
@@ -0,0 +1,140 @@
// Isolated backend and fixture-backed analysis. No production data or paid API.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
import { spawn, execSync } from 'node:child_process';
import { once } from 'node:events';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
const require = createRequire(import.meta.url);
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const h5 = fileURLToPath(new URL('../', import.meta.url));
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-guest-test-'));
const wechat = require('./wechat-fixture.cjs')(path.join(dataRoot, 'accounts'))[0];
const fx = {}; vm.runInNewContext(fs.readFileSync(path.join(h5, 'assets/mock/report-fixture.js'), 'utf8'), { window: fx });
const fixture = fx.MOCK_FIXTURE;
const child = spawn(process.execPath, ['-e', `
const fs=require('fs'),vm=require('vm'),w={};
vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w});
const f=w.MOCK_FIXTURE; let calls=0;
global.fetch=async()=>({ok:true,status:200,json:async()=>++calls===2?{error_message:'INVALID_IMAGE_FACE'}:
{request_id:'fixture',result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right}});
require('./server.js');
`], { cwd: h5, env: { ...process.env, PORT: '18115', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture' }, stdio: ['ignore', 'pipe', 'pipe'] });
let browser;
function diskSnapshot(dir = dataRoot, prefix = '') {
const files = {};
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const name = prefix + entry.name;
if (entry.isDirectory()) Object.assign(files, diskSnapshot(path.join(dir, entry.name), name + '/'));
else files[name] = fs.readFileSync(path.join(dir, entry.name)).toString('base64');
}
return files;
}
try {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('test startup timeout')), 10000);
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timer); resolve(); } });
child.once('exit', code => { clearTimeout(timer); reject(Error('server exited ' + code)); });
});
const base = 'http://127.0.0.1:18115/';
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
const errors = []; page.on('pageerror', e => errors.push(e.message));
const guestRequests = [];
page.on('request', r => { if (r.url().endsWith('/api/auth/browser')) guestRequests.push(r.url()); });
await page.addInitScript(() => {
localStorage.setItem('juzi.identity.v1', 'old-guest-token');
localStorage.setItem('juzi.reports.v1', '[{"id":"old-report"}]');
localStorage.setItem('juzi.profile.v1.browser.old', JSON.stringify({ gender: '男' }));
});
const baseline = diskSnapshot();
await page.goto(base);
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
assert.equal(await page.locator('#s-login.active').count(), 1, 'old guest token cannot silently restore login');
const artifacts = process.env.H5_ARTIFACT_DIR;
async function screenshot(name) {
await page.evaluate(async () => { await document.fonts.ready; window.scrollTo(0, 0); });
await page.waitForTimeout(400);
await page.screenshot({ path: path.join(artifacts, name + '.png'), animations: 'disabled' });
}
if (artifacts) { fs.mkdirSync(artifacts, { recursive: true }); await screenshot('guest-entry'); }
assert.equal(await page.locator('#login-consent').isChecked(), false);
assert.equal((await page.locator('[data-action="guest-experience"]').textContent()).trim(), '游客登陆');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
assert.equal(await page.locator('#p-login-success.show').count(), 0, 'guest entry never shows login success');
assert.equal(await page.locator('[data-action="switch-login"]').count(), 0, 'switch-login controls are removed');
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), '游客模式');
assert.equal(await page.locator('[data-action="logout-wechat"]:visible').count(), 0, 'guests have no logout control');
assert.equal(guestRequests.length, 0, 'guest entry performs no authentication request');
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.identity.v1')), null);
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.reports.v1')), null);
assert.equal(await page.evaluate(() => Object.keys(localStorage).some(k => k.startsWith('juzi.profile.v1.browser.'))), false);
assert.equal((await page.context().cookies()).some(c => c.name === 'juzi_session'), false);
if (artifacts) {
fs.mkdirSync(artifacts, { recursive: true });
await screenshot('guest-home');
}
await page.click('[data-action="open-privacy"]'); await page.waitForSelector('#p-privacy.show');
assert.equal(await page.locator('#privacy-policy-link').textContent(), '隐私协议');
const linkStyle = await page.locator('#privacy-policy-link').evaluate(el => ({ weight: getComputedStyle(el).fontWeight, decoration: getComputedStyle(el).textDecorationLine }));
assert(Number(linkStyle.weight) >= 600 && linkStyle.decoration.includes('underline'));
assert.equal(await page.locator('.privacy-text').evaluate(el => getComputedStyle(el).textDecorationLine), 'none');
if (artifacts) await screenshot('welcome-privacy');
await page.click('#privacy-policy-link'); await page.waitForSelector('#p-policy.show');
assert.equal(await page.locator('#privacy-check').evaluate(el => el.classList.contains('checked')), false, 'opening agreement does not tick consent');
await page.click('[data-action="close-policy"]');
await page.click('[data-action="agree-continue"]');
assert.equal(guestRequests.length, 0, 'no temporary server session before consent');
await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
await page.waitForSelector('#s-profile.active');
assert.equal(guestRequests.length, 1, 'temporary access is created only after welcome consent');
const cookie = (await page.context().cookies()).find(c => c.name === 'juzi_session');
assert.equal(cookie.expires, -1, 'temporary cookie is non-persistent');
await page.reload(); await page.waitForSelector('#s-splash.active');
const api = page.context().request;
const data = { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0],
profile: { real_age: 30, gender: '女', region: '北京市', daily_skin_type: '干性肌肤' } };
const guestResponse = await api.post(base + 'api/skin-analyze', { data });
assert.equal(guestResponse.headers()['cache-control'], 'no-store');
const guest = await guestResponse.json();
assert.equal(guest.ok, true); assert.equal(guest.report_saved, false); assert(guest.vm && guest.presentation);
assert.equal((await (await api.get(base + 'api/reports')).json()).total, 0);
assert.equal((await api.get(base + 'api/reports/' + guest.record_id)).status(), 404);
const failed = await (await api.post(base + 'api/skin-analyze', { data })).json();
assert.equal(failed.ok, false);
await page.waitForTimeout(150); assert.deepEqual(diskSnapshot(), baseline, 'guest success/failure writes no identity, report, photo, profile, raw result or admin record');
await page.click('[data-action="history"]');
assert((await page.locator('#identity-note').textContent()).includes('不保存个人信息和检测记录'));
assert.equal(await page.locator('[data-report-id]').count(), 0);
if (artifacts) await screenshot('guest-history');
// Same backend and payload: WeChat must continue to retain complete reports.
await page.context().addCookies([{ name: 'juzi_session', value: wechat.token, url: base }]);
const saved = await (await api.post(base + 'api/skin-analyze', { data })).json();
assert.equal(saved.ok, true); assert.equal(saved.report_saved, true);
assert.equal((await (await api.get(base + 'api/reports')).json()).total, 1);
assert.equal((await (await api.get(base + 'api/reports/' + saved.record_id)).json()).report.id, saved.record_id);
assert(fs.existsSync(path.join(dataRoot, 'records.jsonl')));
assert(fs.existsSync(path.join(dataRoot, 'photos', saved.record_id, 'front.jpg')));
// Visual sample only; not another analysis call.
await page.goto(base + '?mock=1&screen=report');
await page.waitForFunction(() => document.querySelector('#care-list').children.length > 1);
await page.evaluate(() => document.fonts.ready);
await page.locator('.card-care').scrollIntoViewIfNeeded();
const spacing = await page.locator('.card-care').evaluate(el => {
const heading = el.querySelector('.card-heading'), body = el.querySelector('#care-list');
return { margin: parseFloat(getComputedStyle(heading).marginBottom), gap: body.getBoundingClientRect().top - heading.getBoundingClientRect().bottom };
});
assert(Math.abs(spacing.margin - 9) < .1 && Math.abs(spacing.gap - 9) < 1, 'care title gap is halved from 18px to 9px');
if (artifacts) await page.locator('.card-care').screenshot({ path: path.join(artifacts, 'care-spacing.png') });
assert.deepEqual(errors, []);
console.log('PASS: four-character bold/underlined privacy link, half care gap; guest success/failure no disk writes or saved history/identity; WeChat retains snapshots/photos/admin records; screenshots; no paid calls');
} finally {
if (browser) await browser.close();
if (child.exitCode == null) { const stopped = once(child, 'exit'); child.kill(); await stopped; }
fs.rmSync(dataRoot, { recursive: true, force: true });
}
+104
View File
@@ -0,0 +1,104 @@
// Real app + actual touch/mouse dispatch with varying UA/capabilities.
// Chromium UA regression, not a substitute for HarmonyOS hardware verification.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
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 h5 = fileURLToPath(new URL('../', import.meta.url));
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-harmony-test-'));
let child, browser;
let base = process.env.H5_BASE_URL;
try {
if (!base) {
base = 'http://127.0.0.1:18116/';
child = spawn(process.execPath, ['server.js'], { cwd: h5, env: { ...process.env, PORT: '18116', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture' }, stdio: ['ignore', 'pipe', 'pipe'] });
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('test startup timeout')), 10000);
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timer); resolve(); } });
child.once('exit', code => { clearTimeout(timer); reject(Error('server exited ' + code)); });
});
}
browser = await chromium.launch({ headless: true });
const android = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36';
const desktop = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36';
const cases = [
{ name: 'Android touch', ua: android, touch: true },
{ name: 'OpenHarmony 5 touch', ua: 'Mozilla/5.0 (Phone; OpenHarmony 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 ArkWeb/4.1.6.1 Mobile', touch: true },
{ name: 'OpenHarmony 6 WeChat touch', ua: 'Mozilla/5.0 (Phone;OpenHarmony 6.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 ArkWeb/6.0.0.42 Mobile MicroMessenger/8.0', touch: true },
{ name: 'Desktop UA on touch hardware', ua: desktop, touch: true },
{ name: 'Desktop mouse without TouchEvent', ua: desktop, touch: false },
{ name: 'Android UA without touch hardware', ua: android, touch: false }
];
for (const fixture of cases) {
const context = await browser.newContext({ viewport: { width: 375, height: 726 }, isMobile: fixture.touch, hasTouch: fixture.touch, userAgent: fixture.ua });
const page = await context.newPage(), errors = [], writes = [];
page.setDefaultTimeout(30000);
page.on('pageerror', e => errors.push(e.message));
if (!fixture.touch) await page.addInitScript(() => { delete window.TouchEvent; });
await page.route('**/*', route => {
const req = route.request();
if (req.method() !== 'GET') { writes.push(req.url()); return route.abort(); }
if (/\.woff2?(\?|$)/.test(req.url())) return route.abort();
if (req.url().endsWith('/api/auth/session')) return route.fulfill({ json: { ok: true, user: { id: 'harmony-picker-fixture', kind: 'wechat' } } });
return route.continue();
});
await page.route('**/mobile-select.iife.min.js', async route => {
const response = await route.fetch();
await route.fulfill({ response, body: await response.text() + ';var OriginalMobileSelect=MobileSelect;window.__pickers={};MobileSelect=function(c){var p=new OriginalMobileSelect(c);window.__pickers[c.trigger]=p;return p;};' });
});
await page.goto(base, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#s-splash.active');
await page.click('[data-action="open-privacy"]'); await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
await page.waitForSelector('#s-profile.active');
const cdp = await context.newCDPSession(page);
for (const trigger of ['#pick-birth', '#pick-region']) {
await page.click(trigger); await page.waitForTimeout(350);
const events = await page.evaluate(t => __pickers[t].eventHandleMap.panel.event, trigger);
assert.deepEqual(events, fixture.touch ? ['touchstart', 'touchend', 'touchmove'] : ['mousedown', 'mousemove', 'mouseup'], fixture.name);
assert.equal(await page.locator('.ms-mobile-select.ms-show').evaluate(el => !!el.closest('#s-profile')), false, 'profile scroll lock cannot intercept picker touch');
for (const column of [0, 1]) {
if (trigger === '#pick-region' && column === 1) await page.evaluate(() => __pickers['#pick-region'].locatePosition(0, REGION_DATA.findIndex(p => p[0] === '浙江省')));
const before = await page.evaluate(t => JSON.stringify(__pickers[t].getCurValue()), trigger);
const wheel = page.locator('.ms-mobile-select.ms-show .ms-wheel').nth(column), box = await wheel.boundingBox();
const x = box.x + box.width / 2, y = box.y + box.height / 2;
if (fixture.touch) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] });
for (let i = 1; i <= 4; i++) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: y - i * 20, id: 1 }] });
await page.waitForTimeout(25);
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
} else {
await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.move(x, y - 80, { steps: 4 }); await page.mouse.up();
}
await page.waitForTimeout(250);
assert.notEqual(await page.evaluate(t => JSON.stringify(__pickers[t].getCurValue()), trigger), before, fixture.name + '/' + trigger + '/' + column + ' must move');
}
const chosen = await page.evaluate(t => __pickers[t].getCurValue(), trigger);
await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(250);
if (trigger === '#pick-birth') {
const value = parseInt(chosen[0]) + '-' + String(parseInt(chosen[1])).padStart(2, '0');
assert.equal(await page.locator('#f-birth').inputValue(), value);
} else {
const value = await page.locator('#f-region').inputValue();
assert(value.includes(chosen[0].value) && value.includes(chosen[1].value));
}
await page.click(trigger); await page.waitForTimeout(300);
assert.deepEqual(await page.evaluate(t => __pickers[t].getCurValue(), trigger), chosen, 'reopening retains confirmed values');
await page.click('.ms-mobile-select.ms-show .ms-cancel'); await page.waitForTimeout(250);
}
await page.click('#pick-gender'); await page.click('.ms-mobile-select.ms-show .ms-ensure');
await page.click('[data-action="submit-profile"]'); await page.waitForSelector('#p-photoguide.show');
assert.deepEqual(errors, []); assert.deepEqual(writes, []);
console.log('PASS ' + fixture.name + ': year/month/province/city drag, cascade, confirm/reopen, profile validation');
await context.close();
}
} finally {
if (browser) await browser.close();
if (child && child.exitCode == null) { const stopped = once(child, 'exit'); child.kill(); await stopped; }
fs.rmSync(dataRoot, { recursive: true, force: true });
}
+8 -7
View File
@@ -72,13 +72,13 @@ try {
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');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.waitForTimeout(400); // Allow the home entry animation to settle.
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.screenshot({ path: path.join(artifacts, 'guest-no-login.png') });
}
await page.keyboard.press('Escape');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
@@ -94,10 +94,11 @@ try {
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(), '游客已登录');
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('[data-action="switch-login"]').count(), 0);
await page.click('#s-history [data-action="open-wechat-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');
+20 -32
View File
@@ -35,22 +35,23 @@ try {
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.click('[data-action="guest-experience"]');
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);
assert(cookie && cookie.secure && cookie.sameSite === 'Lax' && cookie.path === new URL(base).pathname);
assert.equal((await api.post(base + 'api/skin-analyze', { data: {} })).status(), 400);
assert.equal((await api.post(base + 'api/skin-analyze', { data: {}, headers: { Origin: 'https://invalid.example' } })).status(), 403);
console.log('PASS: public HTTPS, secure scoped cookie, missing-image validation, cross-origin denial');
assert.equal((await api.get(base + 'api/reports')).status(), 401);
await page.click('[data-action="open-privacy"]');
await page.click('[data-action="agree-continue"]');
assert.equal(await page.locator('#s-profile.active').count(), 0);
await page.click('#privacy-check');
await page.click('[data-action="agree-continue"]');
await page.waitForSelector('#s-profile.active');
assert.equal((await api.get(base + 'api/reports')).status(), 200);
const cookie = (await context.cookies()).find(c => c.httpOnly);
assert(cookie && cookie.secure && cookie.sameSite === 'Lax' && cookie.path === new URL(base).pathname);
assert.equal((await api.post(base + 'api/skin-analyze', { data: {} })).status(), 400);
assert.equal((await api.post(base + 'api/skin-analyze', { data: {}, headers: { Origin: 'https://invalid.example' } })).status(), 403);
console.log('PASS: consent-gated temporary HTTPS session, secure scoped cookie, missing-image validation, cross-origin denial');
await page.click('[data-action="submit-profile"]');
assert.equal(await page.locator('#s-profile.active').count(), 1);
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
@@ -79,17 +80,12 @@ try {
assert.equal(response.status(), 200);
const result = await response.json();
assert.equal(result.ok, true, JSON.stringify({ code: result.code, message: result.message }));
assert.equal(result.report_saved, true);
assert.equal(result.report_saved, false, 'guest reports are deliberately not saved');
assert.equal(analyzeCalls, 1);
assert((await page.evaluate(() => window.__analyzeTimeouts)).includes(150000));
await page.waitForSelector('#s-report.active');
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth > 0));
const snap = (await (await api.get(base + 'api/reports/' + result.record_id)).json()).report;
assert.equal(snap.photos.filter(Boolean).length, 3);
assert.deepEqual(snap.presentation, result.presentation);
assert.deepEqual(snap.payload.result.face_maps, result.result.face_maps);
const images = await page.locator('.ditem-img').evaluateAll(nodes => nodes.map(i => i.src));
const copy = await page.locator('.ditem-para').allTextContents();
assert.equal((await api.get(base + 'api/reports/' + result.record_id)).status(), 404);
for (const card of await page.locator('.card-detail').all()) {
for (const tab of await card.locator('.dtab').all()) {
await tab.click();
@@ -102,31 +98,23 @@ try {
await page.waitForSelector('#p-zoom.show', { state: 'hidden' });
assert.equal(await page.locator('#s-report.active').count(), 1);
await page.screenshot({ path: path.join(artifacts, 'real-report.png'), fullPage: true });
console.log('PASS: three sample views → REAL Face++ → scoring → seven images/tabs → zoom/back → backend snapshot');
console.log('PASS: three sample views → REAL Face++ → scoring → seven images/tabs → zoom/back; guest report is not saved');
// Cookie loss is restored using this browser's stored random identity, not a new account.
// Cookie loss cannot restore a guest identity or any guest history.
await context.clearCookies();
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#s-login.active');
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="history"]');
await page.click('[data-report-id="' + result.record_id + '"]');
await page.waitForSelector('#s-report.active');
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth > 0));
assert.deepEqual(await page.locator('.ditem-img').evaluateAll(nodes => nodes.map(i => i.src)), images);
assert.deepEqual(await page.locator('.ditem-para').allTextContents(), copy);
await page.goBack();
await page.waitForSelector('#s-history.active');
// Read failure + explicit retry must recover without losing the report.
await page.route('**/api/reports', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ ok: false, message: '测试临时不可用' }) }));
await page.click('[data-action="history-retry"]');
await page.waitForFunction(() => document.querySelector('#history-list').textContent.includes('测试临时不可用'));
await page.unroute('**/api/reports');
await page.click('[data-action="history-retry"]');
await page.waitForSelector('[data-report-id="' + result.record_id + '"]');
assert.equal(await page.locator('[data-report-id]').count(), 0);
assert((await page.locator('#identity-note').textContent()).includes('不保存'));
await page.screenshot({ path: path.join(artifacts, 'real-history.png') });
await page.goBack();
await page.waitForSelector('#s-splash.active');
console.log('PASS: cookie restoration, report reload with identical seven images/copy, history error/retry, browser back');
console.log('PASS: guest has no credential restoration or saved history; refresh/back navigation works');
const other = await browser.newContext();
assert.equal((await other.request.get(base + 'api/reports/' + result.record_id)).status(), 401);
@@ -139,7 +127,7 @@ try {
assert.deepEqual(errors, []);
fs.writeFileSync(path.join(artifacts, 'summary.json'), JSON.stringify({ record_id: result.record_id, analyzeCalls, errors, tested_at: new Date().toISOString() }, null, 2));
console.log('PASS: cross-account isolation, admin/source/data protection, zero JS errors');
console.log('Test report (dedicated identity): ' + result.record_id + '; artifacts: ' + artifacts);
console.log('Temporary guest verification report (not saved): ' + result.record_id + '; artifacts: ' + artifacts);
} finally {
await browser.close();
}
+6 -2
View File
@@ -27,7 +27,7 @@ try {
const context = await browser.newContext({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true,
userAgent: 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36' });
const page = await context.newPage(), errors = [], blockedWrites = [];
let user = { id: 'profile-fixture-a', kind: 'browser' };
let user = { id: 'profile-fixture-a', kind: 'wechat' };
page.setDefaultTimeout(20000);
page.on('pageerror', e => errors.push(e.message));
await page.route('**/*', route => {
@@ -88,7 +88,7 @@ try {
user = { id: 'profile-fixture-b', kind: 'wechat' };
await enter(); assert.deepEqual(await values(), blank, 'another account does not inherit profile/picker/skin values');
await pick('#pick-gender'); assert.equal((await values()).gender, '女', 'new account wheel reset too');
user = { id: 'profile-fixture-a', kind: 'browser' };
user = { id: 'profile-fixture-a', kind: 'wechat' };
await page.evaluate(() => AccountClient.init());
await page.goBack(); await page.waitForSelector('#s-splash.active');
await enter(false); assert.deepEqual(await values(), original, 'switching back without a reload restores the right account');
@@ -182,6 +182,10 @@ try {
await enter(); assert.equal((await values()).skin, 3, 'skin selection also survives reload');
await page.click('[data-action="submit-profile"]');
await page.waitForSelector('#p-photoguide.show');
user = { id: 'profile-fixture-guest', kind: 'browser' };
await enter(); assert.deepEqual(await values(), blank, 'guests never inherit WeChat preferences');
await pick('#pick-gender', [1]); assert.equal((await values()).gender, '男');
await enter(); assert.deepEqual(await values(), blank, 'guest preferences do not survive reload');
assert.deepEqual(errors, []); assert.deepEqual(blockedWrites, [], 'no backend writes or paid requests');
console.log('PASS: actual profile defaults/wheels/reload/account isolation; real mobile finger-follow/snap/flick/edges/cancel; fixed profile/no vertical movement/controls fit in seven viewport sizes; real birth-wheel vertical touch still works; arrows/dots/keyboard/submission; no JS errors/backend writes');
} finally {
+4 -2
View File
@@ -2,7 +2,7 @@ const assert = require('node:assert/strict');
const Profile = require('../js/profile-store');
const regions = [['北京市', ['北京市']], ['浙江省', ['杭州市', '宁波市']]];
const now = new Date(2026, 9, 12);
const user = { id: 'fixture-a', kind: 'browser' }, other = { id: 'fixture-b', kind: 'browser' };
const user = { id: 'fixture-a', kind: 'wechat' }, other = { id: 'fixture-b', kind: 'wechat' };
const valid = { birth: '1991-11', gender: '男', region: { province: '浙江省', city: '宁波市' }, skinIndex: 3 };
const empty = { birth: '', gender: '', region: null, skinIndex: 0 };
assert.equal(Profile.age(valid.birth, now), 34);
@@ -30,7 +30,9 @@ assert.equal(store.read(user), null);
store.write(user, valid);
assert.deepEqual(Profile.create(() => storage).read(user), valid, 'survives a page reload');
assert.equal(store.read(other), null, 'different accounts are isolated');
assert.equal(store.read({ ...user, kind: 'wechat' }), null, 'login kinds are isolated');
assert.equal(store.read({ ...user, kind: 'browser' }), null, 'guests cannot read saved preferences');
store.write({ ...user, kind: 'browser' }, valid);
assert.equal(Profile.key({ ...user, kind: 'browser' }), null);
store.write(other, empty); assert.deepEqual(store.read(user), valid);
store.write(null, valid); assert.equal(data.size, 2, 'no unauthenticated writes');
for (const raw of ['broken', 'x'.repeat(4097)]) {
+22
View File
@@ -0,0 +1,22 @@
'use strict';
const assert = require('node:assert/strict');
const SS = require('../js/skin-score');
for (const raw of [20, 24, 28, 40, 50, 24.6, null]) {
for (const realAge of [25, 30, 60, undefined]) {
const result = structuredClone(SS.MOCK_RESULT);
if (raw == null) delete result.skin_age; else result.skin_age = { value: raw };
const before = JSON.stringify(result);
const vm = SS.buildViewModel(result, SS.MOCK_SIDE, { realAge });
assert.equal(vm.skinAgeRaw, raw); assert.equal(vm.skinAgeDisplay, raw);
assert.equal(vm.ageClampNote, ''); assert.equal(SS.reportSkinAge(vm, result), raw);
assert.equal(JSON.stringify(result), before);
}
}
const legacy = { skinAgeRaw: 24, skinAgeDisplay: 57, realAge: 60 };
assert.equal(SS.reportSkinAge(legacy), 24);
assert.equal(legacy.skinAgeDisplay, 57, 'historical VM is not mutated');
assert.equal(SS.reportSkinAge({ skinAgeDisplay: 57 }, { skin_age: { value: 24 } }), 24);
assert.equal(SS.reportSkinAge({ skinAgeDisplay: 57 }), null, 'clamped display is never a raw-age fallback');
assert.equal(SS.reportSkinAge({ skinAgeRaw: null, skinAgeDisplay: 57 }), null);
assert.equal(SS.reportSkinAge({}, { skin_age: '24.6' }), 24.6);
console.log('PASS: raw age across actual-age/decimal/missing cases; old display ignored; legacy payload fallback; no mutation or invented age');
+1 -1
View File
@@ -54,7 +54,7 @@ for (const realAge of [24, 40, 60, undefined]) {
assert.equal(vm.recommendation.skinAge, 24);
assert(Math.abs(vm.recommendation.scores.firm - 7.69) < 1e-12);
assert.equal(UI.build(vm, raw).product.tier, 1);
if (realAge === 60) assert.equal(vm.skinAgeDisplay, 57); // Display isn't the recommendation input.
assert.equal(vm.skinAgeDisplay, 24); // Display and recommendation both use raw skin age.
count++;
}
delete raw.skin_age;
+12 -10
View File
@@ -14,6 +14,7 @@ const ReportUI = require('../js/report-ui.js');
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const h5 = fileURLToPath(new URL('../', import.meta.url));
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-report-test-'));
const wechat = require('./wechat-fixture.cjs')(path.join(dataRoot, 'accounts'))[0];
const fx = {};
vm.runInNewContext(fs.readFileSync(path.join(h5, 'assets/mock/report-fixture.js'), 'utf8'), { window: fx });
const fixture = fx.MOCK_FIXTURE;
@@ -35,22 +36,19 @@ try {
const base = 'http://127.0.0.1:18089/';
browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
await page.context().addCookies([{ name: 'juzi_session', value: wechat.token, url: base }]);
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto(base);
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"]');
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);
assert.equal(result.report_saved, true);
assert(result.vm && result.presentation);
assert(result.vm.skinAgeRaw < 32);
assert.equal(result.vm.skinAgeDisplay, 57);
assert.equal(result.vm.essence.tier, 1); // Raw age, not actual60 or display57.
assert.equal(result.vm.skinAgeDisplay, result.vm.skinAgeRaw);
assert.equal(result.vm.essence.tier, 1); // Raw age, never actual60.
assert.equal(result.presentation.product.tier, 1);
const adminRecord = (await (await api.get(base + 'api/admin/records/' + result.record_id)).json()).record;
assert.deepEqual(adminRecord.presentation, result.presentation);
@@ -60,7 +58,7 @@ try {
for (const key of ['firm', 'vita', 'stab']) assert.equal(adminRecord.dims[key].S, result.vm.dims[key].S);
const list = await (await api.get(base + 'api/admin/records')).json();
assert.equal(list.items[0].skin_age, result.vm.skinAgeRaw);
assert.equal(list.items[0].skin_age_display, 57);
assert.equal(list.items[0].skin_age_display, result.vm.skinAgeRaw);
assert.deepEqual(list.items[0].recommendation, result.vm.recommendation);
const csv = await (await api.get(base + 'api/admin/export')).text();
assert(csv.includes('原始肌龄(推荐依据),前端展示肌龄'));
@@ -76,7 +74,7 @@ try {
const row = desktop.locator('#tbl-records tbody tr').first();
await row.waitFor();
assert((await row.textContent()).includes('原始 ' + result.vm.skinAgeRaw + '岁'));
assert((await row.textContent()).includes('展示 57岁'));
assert((await row.textContent()).includes('展示 ' + result.vm.skinAgeRaw + '岁'));
assert((await row.textContent()).includes("1'me 主场精华 I(25+)"));
for (const key of ['firm', 'vita', 'stab']) {
assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + result.vm.dims[key].S.toFixed(1) + ' ' + ReportUI.levelMap[result.vm.dims[key].level]));
@@ -95,7 +93,7 @@ try {
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('推荐依据'));
const detailText = await desktop.locator('#d-body').textContent();
assert(detailText.includes(result.vm.recommendation.reason));
assert(detailText.includes('前端展示肌龄 57岁'));
assert(detailText.includes('前端展示肌龄 ' + result.vm.skinAgeRaw + '岁'));
for (const view of result.presentation.views) assert(detailText.includes(view.text));
for (const care of result.presentation.care) assert(detailText.includes(care.text));
if (process.env.H5_ARTIFACT_DIR) {
@@ -148,19 +146,21 @@ try {
const legacy = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
legacy.presentation.product = { ...ReportUI.products[1], image: 'assets/img/ui-20261009/product-35.png' };
legacy.presentation.views[0].text = '历史报告原文,不应按新文案重新计算。';
legacy.vm.skinAgeDisplay = 57; // Old snapshot retains the retired display clamp.
legacy.vm.essence = { name: '35+支撑精华', text: '旧版推荐', triggers: ['最差项约束'] };
delete legacy.vm.recommendation;
fs.writeFileSync(snapshotFile, JSON.stringify(legacy));
record.essence = legacy.vm.essence;
delete record.presentation;
delete record.recommendation;
delete record.skin_age_display;
record.skin_age_display = 57;
fs.writeFileSync(recordFile, JSON.stringify(record) + '\n');
const storedBytes = fs.readFileSync(snapshotFile, 'utf8');
const recordBytes = fs.readFileSync(recordFile, 'utf8');
await page.click('[data-action="history"]');
await page.click('[data-report-id]');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#stat-age b').textContent(), String(result.vm.skinAgeRaw), 'old saved display clamp must not override original skin age');
assert.equal(await page.locator('#product-title').textContent(), ReportUI.products[1].title);
assert.equal(await page.locator('#product-image').getAttribute('src'), ReportUI.products[1].image, 'refresh artwork crop, never the historical recommendation/copy');
assert((await page.locator('.ditem-para').allTextContents()).includes(legacy.presentation.views[0].text));
@@ -168,6 +168,7 @@ try {
await desktop.locator('#tbl-records tbody tr').first().click();
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('历史报告原文'));
assert((await desktop.locator('#d-body').textContent()).includes('旧版规则(历史记录)'));
assert((await desktop.locator('#d-body').textContent()).includes('前端展示肌龄 ' + result.vm.skinAgeRaw + '岁'));
assert.equal(fs.readFileSync(snapshotFile, 'utf8'), storedBytes);
assert.equal(fs.readFileSync(recordFile, 'utf8'), recordBytes);
// Missing skin age must not leave the previous product visible in the H5.
@@ -183,6 +184,7 @@ try {
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('.card-product').isVisible(), false);
assert.equal(await page.locator('#product-unavailable').isVisible(), true);
assert.equal(await page.locator('#stat-age b').textContent(), '--');
assert.deepEqual(errors, []);
console.log('PASS: raw-age recommendation; admin/H5 scores, ages, product, seven-view copy, care, CSV, filters; saved/reloaded and legacy immutable snapshots; missing-age UI; owner isolation/private files/back');
} finally {
+7
View File
@@ -64,6 +64,10 @@ try {
assert.equal(await page.locator('#scan-photo').count(), 0, 'no user photo element');
await capture();
await page.waitForSelector('#p-analyzing.scan-artwork-ready');
await page.waitForFunction(() => { const i = document.querySelector('.scan-flip-icon img'); return i.complete && i.naturalWidth === 80 && i.naturalHeight === 80; });
assert.equal(await page.locator('.scan-flip-icon img').getAttribute('src'), await page.locator('.cam-flip img').getAttribute('src'), 'scan and camera share replacement icon');
assert.equal(await page.locator('.scan-flip-icon').evaluate(el => getComputedStyle(el).pointerEvents), 'none');
assert.equal(await page.locator('.scan-flip-icon [data-action], button.scan-flip-icon').count(), 0, 'scan flip stays decorative');
assert((await page.locator('#scan-face').evaluate(el => getComputedStyle(el).maskImage)).includes('scan-face-mask.png'));
assert.equal(await page.locator('.scan-beam').evaluate(el => getComputedStyle(el).animationName), 'report-scan');
const pixels = await page.locator('#scan-artwork').evaluate(image => {
@@ -75,6 +79,9 @@ try {
for (const [width, height] of [[320, 726], [375, 726], [390, 844], [414, 896], [480, 726], [375, 440]]) {
await page.setViewportSize({ width, height });
const art = await page.locator('#scan-artwork').boundingBox();
const icon = await page.locator('.scan-flip-icon img').boundingBox();
assert(Math.abs(icon.width / art.width - 80 / 1080) < .001, 'icon scales with PSD stage');
assert(Math.abs(icon.height - icon.width) < 1, 'replacement remains square');
assert(art.width <= width + 1 && art.height <= height + 1);
assert(Math.abs(art.width / art.height - 1080 / 2091) < .001, 'PSD is contained without cropping');
assert(Math.abs(art.x - (width - art.width) / 2) < 1 && Math.abs(art.y - (height - art.height) / 2) < 1);
+54
View File
@@ -0,0 +1,54 @@
// Read-only fixture: no accounts/reports created.
import assert from 'node:assert/strict';
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 browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport:{ width:375, height:726 } });
await page.route('**/api/**', route => {
assert.equal(route.request().method(), 'GET');
return route.fulfill({ json:{ ok:true, user:null } });
});
await page.goto(process.env.H5_BASE_URL || 'http://127.0.0.1:8080/');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
const brand = page.locator('#s-splash .splash-slogan');
assert.match(await brand.getAttribute('aria-label'), /^1'me/);
await page.waitForFunction(() => [...document.querySelectorAll('#s-splash .splash-slogan img')].every(i => i.complete && i.naturalWidth));
assert.equal(await page.locator('#s-splash .splash-slogan-brand').isVisible(), true);
assert.equal(await page.locator('#s-splash .splash-slogan-brand').textContent(), "1'me,");
assert.equal(await page.locator('#s-splash .splash-slogan-brand').evaluate(el => getComputedStyle(el).fontWeight), '400');
for (const root of ['#s-splash', '#p-photoguide']) {
assert.equal(await page.locator(root + ' .splash-slogan-brand').textContent(), "1'me,");
const fonts = await page.locator(root + ' .splash-slogan').evaluate(el => {
return ['.splash-slogan-brand', '.splash-slogan-english'].map(selector => {
const s = getComputedStyle(el.querySelector(selector));
return [s.fontFamily, s.fontSize, s.fontWeight, s.lineHeight];
});
});
assert.deepEqual(fonts[0], fonts[1], root + ': identical English font settings');
assert.equal(fonts[0][1], '23px');
assert.equal(fonts[0][2], '400');
assert.match(await page.locator(root + ' .splash-slogan').getAttribute('aria-label'), /^1'me/);
}
const button = page.locator('#s-splash [data-action="open-privacy"]');
await button.focus();
const style = await button.evaluate(el => {
const s = getComputedStyle(el);
return { border:s.borderTopWidth, outline:s.outlineStyle, shadow:s.boxShadow, background:s.backgroundColor };
});
assert.deepEqual(style, { border:'0px', outline:'none', shadow:'none', background:'rgb(245, 240, 120)' });
await button.evaluate(el => el.blur());
await page.waitForTimeout(400); // finish the entry screen fade before the screenshot
await page.screenshot({ path:'/tmp/juzi-splash-brand-20261013.png' });
await button.click();
await page.waitForSelector('#p-privacy.show');
await page.evaluate(() => {
document.querySelector('#p-privacy').classList.remove('show');
document.querySelector('#p-photoguide').classList.add('show');
});
await page.waitForTimeout(400);
await page.screenshot({ path:'/tmp/juzi-photoguide-brand-20261013.png' });
console.log('PASS splash brand, borderless focused start button, yellow fill, privacy entry');
} finally { await browser.close(); }
+8 -7
View File
@@ -17,27 +17,28 @@ await page.waitForFunction(() => !document.querySelector('#login-message').textC
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(400);
await page.screenshot({ path: new URL('01-login.png', root).pathname });
await page.click('[data-action="login-browser"]');
await page.click('[data-action="login-wechat"]');
assert(await page.locator('#s-login').evaluate(e => e.classList.contains('active')));
await page.check('#login-consent');
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.waitForFunction(() => document.querySelector('#login-message').textContent.includes('免登录'));
await page.click('[data-action="guest-experience"]');
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"]');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
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 });
await page.click('[data-action="history"]');
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('次'));
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('不保存'));
await page.waitForTimeout(400);
await page.screenshot({ path: new URL('03-history-empty.png', root).pathname });
await page.goBack();
await page.waitForSelector('#s-splash.active');
await page.reload();
await page.waitForSelector('#s-login.active');
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="open-privacy"]');
await page.waitForTimeout(400);
+65 -19
View File
@@ -72,6 +72,7 @@ try {
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);
assert.equal(await page.locator('#s-splash [data-action="logout-wechat"]').isVisible(), true);
}
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 } });
@@ -90,18 +91,19 @@ try {
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.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'browser');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
browserId = await page.evaluate(() => AccountClient.user().id);
browserToken = await page.evaluate(() => localStorage.getItem('juzi.identity.v1'));
accounts.storeReport(browserId, { ...saved, id: 'RbrowserOriginal' });
assert.equal(browserToken, null);
assert.throws(() => 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"]');
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('不保存'));
assert.equal(await page.locator('[data-report-id]').count(), 0);
await page.click('[data-action="open-wechat-login"]');
assert.equal(await page.locator('#login-consent').isChecked(), false);
assert((await page.locator('#login-message').textContent()).includes('报告不会自动合并'));
assert((await page.locator('#login-message').textContent()).includes('后续检测报告'));
upstreamError = true;
await page.check('#login-consent');
await page.click('[data-action="login-wechat"]');
@@ -140,28 +142,72 @@ try {
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"]');
assert.equal(await page.locator('[data-action="switch-login"]').count(), 0);
await context.clearCookies(); await page.goto(base);
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'browser');
assert.equal(await page.evaluate(() => AccountClient.user().id), browserId);
assert.equal(await page.locator('#p-login-success.show').count(), 0);
assert.notEqual(await page.evaluate(() => AccountClient.user().id), browserId, 'new guest login creates only a fresh temporary session');
await page.click('[data-action="history"]');
await page.waitForSelector('[data-report-id="RbrowserOriginal"]');
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('不保存'));
assert.equal(await page.locator('[data-report-id]').count(), 0);
assert.equal(await page.locator('[data-report-id="RwechatCrossDevice"]').count(), 0);
assert.equal((await api(page, 'reports/' + saved.id)).status, 404);
assert.equal((await api(page, 'reports/' + saved.id)).status, 401, 'no backend session before welcome consent');
// 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);
await page.reload(); // A guest is never restored from browser storage.
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
assert.equal(await page.locator('#s-login.active').count(), 1);
assert.equal(await page.evaluate(() => AccountClient.user()), null);
assert.equal(await page.locator('#p-login-success.show').count(), 0, 'session restore must not repeatedly open a success dialog');
}
if (device === 1) {
const reportFile = path.join(root, 'accounts/reports', user.id, saved.id + '.json');
const originalBytes = fs.readFileSync(reportFile, 'utf8');
const logout = page.locator('#s-history [data-action="logout-wechat"]');
assert.equal(await logout.textContent(), '退出登录');
page.once('dialog', dialog => { assert(dialog.message().includes('历史报告将保留')); dialog.dismiss(); });
await logout.click();
assert.equal((await api(page, 'auth/session')).data.user.id, user.id, 'cancel leaves session intact');
await page.route('**/api/auth/logout', route => route.fulfill({ status: 503, json: { ok: false, message: '退出测试失败' } }));
page.once('dialog', dialog => dialog.accept()); await logout.click();
await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('退出失败'));
assert.equal(await page.evaluate(() => AccountClient.user().id), user.id, 'failure does not fake local logout');
assert.equal((await api(page, 'auth/session')).data.user.id, user.id);
await page.unroute('**/api/auth/logout');
await page.waitForFunction(() => !document.querySelector('#s-history [data-action="logout-wechat"]').disabled);
let heldReport, markHeld;
const reportHeld = new Promise(resolve => { markHeld = resolve; });
await page.route('**/api/reports/' + saved.id, route => { heldReport = route; markHeld(); }, { times: 1 });
await page.click('[data-report-id="RwechatCrossDevice"]'); await reportHeld;
page.once('dialog', dialog => dialog.accept()); await logout.click();
await page.waitForSelector('#s-login.active');
assert.equal(await page.evaluate(() => AccountClient.user()), null);
assert.equal((await api(page, 'auth/session')).data.user, null);
assert.equal((await api(page, 'reports/' + saved.id)).status, 401);
assert.equal(await page.locator('#login-consent').isChecked(), false);
assert.equal(fs.readFileSync(reportFile, 'utf8'), originalBytes, 'logout retains exact historical file');
await page.goBack(); await page.waitForSelector('#s-login.active');
await page.click('[data-action="guest-experience"]'); await page.waitForSelector('#s-splash.active');
await page.click('[data-action="history"]'); await page.waitForSelector('#s-history.active');
await heldReport.fulfill({ json: { ok: true, report: JSON.parse(originalBytes) } });
await page.waitForTimeout(250);
assert.equal(await page.locator('#s-report.active').count(), 0, 'late WeChat report response cannot open under guest identity');
await page.unroute('**/api/reports/' + saved.id);
await page.click('[data-action="open-wechat-login"]');
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), user.id);
await page.click('[data-action="history"]'); await page.waitForSelector('[data-report-id="RwechatCrossDevice"]');
assert.deepEqual((await api(page, 'reports/' + saved.id)).data.report, JSON.parse(originalBytes));
assert.equal(fs.readFileSync(reportFile, 'utf8'), originalBytes);
console.log('PASS WeChat logout: confirm/cancel/failure, server session revoked, back guarded, re-login retains exact report');
}
assert.deepEqual(errors, []);
await context.close();
}
@@ -179,7 +225,7 @@ try {
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');
console.log('PASS: HTTPS /state/ simulated WeChat login, Secure cookies, same identity/report across devices, different-user isolation, explicit switching, ephemeral guests without history, OAuth failure/missing-cookie handling, no silent guest fallback');
} finally {
global.fetch = realFetch;
if (browser) await browser.close();
+20
View File
@@ -0,0 +1,20 @@
// Test-only persisted WeChat sessions. Never import from production code.
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
module.exports = function seedWechat(root, count = 1) {
const file = path.join(root, 'identities.json');
if (fs.existsSync(file)) throw Error('Wechat fixture requires an empty test store');
const db = { users: [], sessions: {}, states: {} }, entries = [];
for (let i = 0; i < count; i++) {
const user = { id: 'fixture-wechat-' + i, kind: 'wechat', identity: 'fixture-' + i, reports: [] };
const token = crypto.randomBytes(32).toString('hex');
db.users.push(user);
db.sessions[crypto.createHash('sha256').update(token).digest('hex')] = { user: user.id, expires: Date.now() + 86400000 };
entries.push({ user, token, cookie: 'juzi_session=' + token });
}
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(file, JSON.stringify(db));
return entries;
};
+3 -1
View File
@@ -1,4 +1,6 @@
# mobile-select 1.4.0
来源:npm `mobile-select@1.4.0`(MIT,见同目录 `LICENSE`),取 `dist/mobile-select.iife.min.js` 与 `dist/style/mobile-select.css`,未改动。
来源:npm `mobile-select@1.4.0`(MIT,见同目录 `LICENSE`),取 `dist/mobile-select.iife.min.js` 与 `dist/style/mobile-select.css`。
2026-10-13 本地兼容补丁:不再按 UA 名称判断触摸设备,改用 `navigator.maxTouchPoints > 0` 或 `ontouchstart` 能力;解决不带 Android 标识的 OpenHarmony/ArkWeb 被错误绑定鼠标事件的问题。事件坐标按 `touches/changedTouches` 读取,不依赖全局 `TouchEvent` 构造函数存在。上游版本和 MIT 声明保留,升级依赖时须保留此补丁并运行 `h5/test/harmony-picker.mjs` 回归。
档案页的出生年月 / 性别 / 地区滚轮由它渲染,样式覆盖写在 `css/style.css` 的 mobile-select 段。
File diff suppressed because one or more lines are too long