feat(h5): remember profile selections and add finger-following skin swipe

This commit is contained in:
xsl
2026-10-12 21:10:00 +08:00
parent c9352fe4ca
commit ce6dbc20d4
14 changed files with 2230 additions and 900 deletions
@@ -0,0 +1,34 @@
# 基础信息记忆与肤质拖动
## 范围
- 出生年月、性别、地区每次点滚轮“确定”即保存,不必拍照或提交报告。
- 日常肤质切换后也保存。再次进入基础信息页,包括刷新、返回、重新登录同一账号时,默认使用本机上次已确认的值;滚轮重新打开也定位到原选择。
- 保存出生年月而非固定年龄,进入页面和提交前按当前年月重新计算实际年龄。
- 使用当前登录账号的 `kind + id` 隔离本机缓存。切换微信/游客或另一个账号时,页面值与滚轮位置一起重置/恢复,不串用其他账号信息。
- 这是本机浏览器偏好缓存,不新增服务端档案同步接口;同一微信账号换手机仍可看服务器报告,但本次新增的基础信息默认值不跨设备。清除浏览器数据后需重新填写。
- 老版本没有单独保存出生年月,无法准确反推出原选择;更新后需确认一次,从此开始记住。缓存被禁用或容量不足时保持当前页面可用,在该页面内记忆,不保证刷新后保留。
- 缓存只包含出生年月、性别、省市、肤质序号,不复制身份凭据、照片、报告。
## 肤质交互
- 水平方向拖动时,图片、名称与说明一起实时跟随手指,不再等松手才切换。
- 松手按距离/轻扫速度吸附到相应选项;短距离慢拖回原项。
- 首尾拖动有弹性阻力,松手回到有效选项;系统取消手势时复位。
- 横竖方向锁定,保留 `touch-action: pan-y`,不阻塞页面上下滚动;屏幕尺寸变化后保持选项对齐。
- 原有箭头、圆点保留;补充左右方向键支持。
- 不改变 PSD 素材、颜色、页面布局、拍摄约束、评分或推荐规则。
## 回归命令
```bash
node h5/test/profile-store.js
node h5/test/profile-interaction.mjs
H5_BASE_URL=https://1me.xajuzi.com/state/ node h5/test/profile-interaction.mjs
```
浏览器测试使用真实页面与 mobile-select 控件,以模拟账号和真实触摸事件检查恢复、账号隔离、跟手位移、吸附、短拖、轻扫、首尾阻力、系统取消、纵向手势以及不同屏宽。测试只跳过字体下载、拦截登录会话并暴露滚轮实例用于选择测试值;所有非 GET 请求均阻止,不会新增线上账号、报告或付费检测调用。
本地已通过上述两套新增测试,以及第四轮反馈浏览器闭环、登录加载与超时、报告持久化、账号/OAuth 隔离、真实本地模型自动三连拍与最终 JPEG 复验、推荐 126 用例及算法真值 86 断言。
部署目标为 `ubuntu@1me.xajuzi.com:/home/ubuntu/juzi`,基线 `c9352fe`。沿用 Git bundle 快进同步,不代用户推送 origin,不改服务器环境配置或历史数据。上线后核对静态文件哈希、服务健康、匿名访问保护,并执行同一套只读模拟账号浏览器测试。
+16 -15
View File
@@ -4,9 +4,9 @@
"2": "app.js",
"3": "skin-score.js",
"4": "customer-feedback-second.js",
"5": "n",
"6": "extractFaceTensors",
"7": ".forwardInput",
"5": "f",
"6": "Box",
"7": "test/profile-store.js",
"8": "admin.js",
"9": "__awaiter",
"10": "wechat-browser.mjs",
@@ -42,7 +42,7 @@
"40": "第四轮反馈修改与验收",
"41": "NeuralNetwork",
"42": "layer_at",
"43": "normalize",
"43": "convLayer",
"44": "start-local.sh",
"45": "PredictAllAgeAndGenderWithFaceAlignmentTask",
"46": "PredictAllFaceExpressionsWithFaceAlignmentTask",
@@ -54,7 +54,7 @@
"52": "region-data.js",
"53": "护理建议(全量文本,按需求摘抄)",
"54": "UI 对齐 10.09 设计稿:调研与实施计划",
"55": "pn",
"55": "i",
"56": "report-ui.js",
"57": "regression.js",
"58": ".dispose",
@@ -65,19 +65,19 @@
"63": "e2e-browser.mjs",
"64": "account-client.js",
"65": "微信登录接入与公网部署说明",
"66": "FaceLandmarks68",
"66": "FaceLandmarks",
"67": "ui-20261009.mjs",
"68": "analyze-timeout.js",
"69": "misans/README.md",
"70": "extractConvParamsFactory",
"71": "extractAllFacesAndComputeResults",
"70": ".forwardInput",
"71": ".predictAgeAndGender",
"72": "build_report_copy.py",
"73": "camera-geometry.js",
"74": "report-copy.js",
"75": "prepare_h5_assets.py",
"76": ".toString",
"77": ".runAndExtendWithFaceDetections",
"78": "i",
"76": ".forwardInput",
"77": "extractFaceTensors",
"78": "n",
"79": "登录反馈与加载优化(2026-10-12)",
"80": "线上微信登录部署记录",
"81": "wechat-production.mjs",
@@ -98,13 +98,14 @@
"96": "production-loop.mjs",
"97": "PredictSingleAgeAndGenderWithFaceAlignmentTask",
"98": "PredictSingleFaceExpressionsWithFaceAlignmentTask",
"99": "DetectSingleFaceLandmarksTask",
"100": "ComputeAllFaceDescriptorsTask",
"99": "PredictSingleFaceExpressionsTask",
"100": "age",
"101": "drawFaceExpressions",
"102": "TinyYolov2",
"102": ".detect",
"103": "第四轮反馈/傅艳NEW与项思炼的会话 2026年10月12日.md",
"104": "export_fourth_feedback_assets.py",
"105": "capture-quality-browser.mjs",
"106": "第三轮反馈20261012/傅艳NEW与项思炼的会话 2026年10月12日.md",
"107": "ComputeSingleFaceDescriptorTask"
"107": "基础信息记忆与肤质拖动",
"108": "profile-interaction.mjs"
}
+1 -1
View File
@@ -1 +1 @@
{"0": "65cda48e6d2f67a3", "1": "cb6ea50bd4e4a5ca", "2": "b159712c832c558e", "3": "bcbfbc0b66f44357", "4": "56bf12952b0de42b", "5": "8e50b0e3c19c0a11", "6": "e154e5d8b03e5206", "7": "880a74c6d255e5cc", "8": "a689fcb62e7754af", "9": "63bb420d48d7a255", "10": "2b037735e1f5b0f6", "11": "4a490abd5e503318", "12": "aeca274694e158d2", "13": "7e3b558a587a4ebe", "14": "bf75bfc99d4f9e34", "15": "b1a71a621511068d", "16": "95add6a975c0cb72", "17": "e042d7fe7351bcb9", "18": "f93d5d85e671fcee", "19": "39abb1eef19df707", "20": "aecf2fce44ba49c5", "21": "59485c317afc6c2b", "22": "512a2535c88c4284", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "46e8db3e6e3f37ea", "26": "041694c55d6f8a30", "27": "eab427ce9a5219e9", "28": "d3fab0fa540bd925", "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": "d96493a5f110bd4c", "42": "916170b9f5d7346b", "43": "7986b9d5a423a3fc", "44": "852c1bb2446edd9c", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "47": "e594ecaa8e7d1538", "48": "cf2f2424bc00436f", "49": "1f976711cda158dc", "50": "bbb2b57d72922b16", "51": "eb6a182204867275", "52": "2ec3c8c088568ba9", "53": "68c45056378341a7", "54": "c58d41e8e580fdc6", "55": "82703230bd5ed1e1", "56": "9ee2ad45dc27851d", "57": "f2a40b5f1b29f669", "58": "803dac08b323abc2", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "0890ad2144e37304", "62": "1366ae24c7e5897d", "63": "c256b43f75d7809e", "64": "ce0e68dab611f022", "65": "74ae017de0ffd36b", "66": "b6d6154e2dd14637", "67": "3e126d8deaf6e200", "68": "6b91f3679541de79", "69": "e511846eeba03076", "70": "af5864c4ac65160d", "71": "06e9a65e05e71d35", "72": "c84f196a7bd35861", "73": "2b887bf601ccfb9f", "74": "ae2a154311e1f53a", "75": "69fdd23278518884", "76": "74310612e8e059f4", "77": "2ee521497bb8506a", "78": "5cb44ae4ec05213b", "79": "c6cfdfe181232ecc", "80": "315325cde1352e98", "81": "37c559741825be74", "82": "200f25fbad7af8fb", "83": "1284897282eb47a3", "84": "d8bb296741d3d05c", "85": "4bc370be8afd7db1", "86": "e858d75e995c92e5", "87": "e5fa7717989eda80", "88": "8b5ab5232cef3457", "89": "9e3363b80eeceeea", "90": "dd1c246e19a2d032", "91": "83b0fd3b14363e4d", "92": "fced244d93677d90", "93": "8bd61518dd267641", "94": "713d04996c4627df", "95": "59c31f858c7b6970", "96": "4195478d5c528371", "97": "01f68dfc34a29d32", "98": "8c471c7baeaaca54", "99": "23f1d406f6c779ae", "100": "3dd68cce72af5314", "101": "a0bc88eb14b70170", "102": "29790cf710874dd0", "103": "6ffbe07b521a061b", "104": "e114a7aebaaca2f1", "105": "0567be4dc8048b9c", "106": "8bc72c179793e1c2", "107": "7ac475ea9bc280f8"}
{"0": "200bd332d330e79e", "1": "cb6ea50bd4e4a5ca", "2": "86eae3fd1b848c2e", "3": "1f737ca8edb978f2", "4": "56bf12952b0de42b", "5": "06906d9c95022851", "6": "7cd2b329434ded47", "7": "03b56b13a08b5f83", "8": "a689fcb62e7754af", "9": "fce137e5717d2018", "10": "2b037735e1f5b0f6", "11": "4a490abd5e503318", "12": "aeca274694e158d2", "13": "7e3b558a587a4ebe", "14": "bf75bfc99d4f9e34", "15": "2c0eaf406546159b", "16": "95add6a975c0cb72", "17": "e042d7fe7351bcb9", "18": "f93d5d85e671fcee", "19": "39abb1eef19df707", "20": "aecf2fce44ba49c5", "21": "02ac512b5e01b983", "22": "f2045a9940596eb3", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "46e8db3e6e3f37ea", "26": "8367cbe6ea2844bc", "27": "eab427ce9a5219e9", "28": "4f01dfb5cd033c39", "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": "d96493a5f110bd4c", "42": "916170b9f5d7346b", "43": "4eaa3e8b9eed3bf1", "44": "852c1bb2446edd9c", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "47": "e594ecaa8e7d1538", "48": "cf2f2424bc00436f", "49": "1f976711cda158dc", "50": "bbb2b57d72922b16", "51": "eb6a182204867275", "52": "2ec3c8c088568ba9", "53": "68c45056378341a7", "54": "c58d41e8e580fdc6", "55": "af2dbb7480405e14", "56": "9ee2ad45dc27851d", "57": "f2a40b5f1b29f669", "58": "5a45b41665ef1112", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "94ead9feda213ac6", "62": "1366ae24c7e5897d", "63": "c256b43f75d7809e", "64": "ce0e68dab611f022", "65": "bdee3c4a37df52f8", "66": "7798792de7e51a25", "67": "3e126d8deaf6e200", "68": "6b91f3679541de79", "69": "e511846eeba03076", "70": "e39b60658e2c785e", "71": "a2b13160223b5a98", "72": "c84f196a7bd35861", "73": "2b887bf601ccfb9f", "74": "ae2a154311e1f53a", "75": "69fdd23278518884", "76": "880a74c6d255e5cc", "77": "2050a9da67ccfa24", "78": "9daf5044cb78b2d2", "79": "86f177bf0a7dd07c", "80": "315325cde1352e98", "81": "37c559741825be74", "82": "200f25fbad7af8fb", "83": "1284897282eb47a3", "84": "2fb17901aa87e0e7", "85": "4bc370be8afd7db1", "86": "e858d75e995c92e5", "87": "e5fa7717989eda80", "88": "8b5ab5232cef3457", "89": "9e3363b80eeceeea", "90": "dd1c246e19a2d032", "91": "83b0fd3b14363e4d", "92": "fced244d93677d90", "93": "8bd61518dd267641", "94": "713d04996c4627df", "95": "59c31f858c7b6970", "96": "4195478d5c528371", "97": "01f68dfc34a29d32", "98": "8c471c7baeaaca54", "99": "cb5ac3401283c378", "100": "b234c5e4ac7f632d", "101": "a0bc88eb14b70170", "102": "8dc2ed447b742130", "103": "6ffbe07b521a061b", "104": "e114a7aebaaca2f1", "105": "0567be4dc8048b9c", "106": "8bc72c179793e1c2", "107": "d9c195d9ccdb137e", "108": "e0fc0a42e33bda91"}
+104 -83
View File
@@ -1,16 +1,16 @@
# Graph Report - juzi (2026-10-12)
## Corpus Check
- 93 files · ~4,889,148 words
- 97 files · ~4,891,452 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 1387 nodes · 2814 edges · 108 communities (82 shown, 26 thin omitted)
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 189 edges (avg confidence: 0.54)
- 1420 nodes · 2864 edges · 109 communities (87 shown, 22 thin omitted)
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 199 edges (avg confidence: 0.55)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `3f165bf9`
- Built from commit: `c9352fe4`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -20,9 +20,9 @@
- app.js
- skin-score.js
- customer-feedback-second.js
- n
- extractFaceTensors
- .forwardInput
- f
- Box
- test/profile-store.js
- admin.js
- __awaiter
- wechat-browser.mjs
@@ -56,7 +56,7 @@
- 第四轮反馈修改与验收
- NeuralNetwork
- layer_at
- normalize
- convLayer
- start-local.sh
- PredictAllAgeAndGenderWithFaceAlignmentTask
- PredictAllFaceExpressionsWithFaceAlignmentTask
@@ -66,7 +66,7 @@
- call_skinanalyze_3view.sh
- 护理建议(全量文本,按需求摘抄)
- UI 对齐 10.09 设计稿:调研与实施计划
- pn
- i
- report-ui.js
- regression.js
- .dispose
@@ -76,16 +76,16 @@
- e2e-browser.mjs
- account-client.js
- 微信登录接入与公网部署说明
- FaceLandmarks68
- FaceLandmarks
- ui-20261009.mjs
- analyze-timeout.js
- misans/README.md
- extractConvParamsFactory
- extractAllFacesAndComputeResults
- .forwardInput
- .predictAgeAndGender
- build_report_copy.py
- .toString
- .runAndExtendWithFaceDetections
- i
- .forwardInput
- extractFaceTensors
- n
- 登录反馈与加载优化(2026-10-12)
- 线上微信登录部署记录
- wechat-production.mjs
@@ -105,13 +105,14 @@
- production-loop.mjs
- PredictSingleAgeAndGenderWithFaceAlignmentTask
- PredictSingleFaceExpressionsWithFaceAlignmentTask
- DetectSingleFaceLandmarksTask
- ComputeAllFaceDescriptorsTask
- PredictSingleFaceExpressionsTask
- age
- drawFaceExpressions
- TinyYolov2
- .detect
- 第四轮反馈/傅艳NEW与项思炼的会话 2026年10月12日.md
- 第三轮反馈20261012/傅艳NEW与项思炼的会话 2026年10月12日.md
- ComputeSingleFaceDescriptorTask
- 基础信息记忆与肤质拖动
- profile-interaction.mjs
## God Nodes (most connected - your core abstractions)
1. `__awaiter()` - 59 edges
@@ -128,23 +129,23 @@
## Surprising Connections (you probably didn't know these)
- `actualLines()` --indirect_call--> `el()` [INFERRED]
h5/test/customer-feedback-third.mjs → h5/js/admin.js
- `browser()` --indirect_call--> `key()` [INFERRED]
h5/js/account-client.js → h5/js/profile-store.js
- `init()` --indirect_call--> `key()` [INFERRED]
h5/js/account-client.js → h5/js/profile-store.js
- `loadRecords()` --indirect_call--> `it()` [INFERRED]
h5/js/admin.js → h5/vendor/faceapi/face-api.js
- `bind()` --indirect_call--> `a()` [INFERRED]
h5/js/admin.js → h5/vendor/faceapi/face-api.js
- `checkStill()` --indirect_call--> `d()` [INFERRED]
h5/js/app.js → h5/vendor/faceapi/face-api.js
- `detectFrame()` --indirect_call--> `d()` [INFERRED]
h5/js/app.js → h5/vendor/faceapi/face-api.js
## Import Cycles
- None detected.
## Communities (108 total, 26 thin omitted)
## Communities (109 total, 22 thin omitted)
### Community 0 - "face-api.js"
Cohesion: 0.02
Nodes (51): bgrToRgbTensor(), cn(), computeReshapedDimensions(), createBrowserEnv(), createFaceDetectionNet(), createSsdMobilenetv1(), Dimensions(), dr() (+43 more)
Nodes (39): cn(), computeReshapedDimensions(), createBrowserEnv(), createFileSystem(), createNodejsEnv(), Dimensions(), dr(), en() (+31 more)
### Community 1 - "线上闭环测试(不含真实微信授权)"
Cohesion: 0.25
@@ -152,35 +153,35 @@ Nodes (7): 发现并修复, 可复现命令, 已通过的检查, 线上闭环测
### Community 2 - "app.js"
Cohesion: 0.05
Nodes (85): applyMirror(), armNudge(), beginDetect(), bindBackdropClose(), busyWithFlow(), captureFromVideo(), captureHaptic(), checkStill() (+77 more)
Nodes (90): applyMirror(), armNudge(), beginDetect(), bindBackdropClose(), busyWithFlow(), cancelSkinDrag(), captureFromVideo(), captureHaptic() (+82 more)
### Community 3 - "skin-score.js"
Cohesion: 0.11
Cohesion: 0.12
Nodes (45): acneGrade(), buildViewModel(), calcAcne(), calcContour(), calcSens(), calcSmooth(), calcTone(), calcWrinkles() (+37 more)
### Community 4 - "customer-feedback-second.js"
Cohesion: 0.17
Nodes (10): assert, context, face, fs, geometry, original, path, source (+2 more)
### Community 5 - "n"
### Community 5 - "f"
Cohesion: 0.12
Nodes (43): a(), ae(), ba(), bf(), cf(), d(), da(), drawContour() (+35 more)
Nodes (36): a(), b(), ba(), cf(), d(), da(), drawContour(), dt() (+28 more)
### Community 6 - "extractFaceTensors"
Cohesion: 0.16
Nodes (5): Box(), extendWithFaceLandmarks(), extractFaceTensors(), FaceLandmarks(), minBbox()
### Community 6 - "Box"
Cohesion: 0.11
Nodes (5): Box(), ComputeAllFaceDescriptorsTask(), ComputeSingleFaceDescriptorTask(), extendWithFaceDescriptor(), ObjectDetection()
### Community 7 - ".forwardInput"
Cohesion: 0.22
Nodes (9): boxPredictionLayer(), decodeBoxesLayer(), depthwiseConvLayer(), getCenterCoordinatesAndSizesLayer(), getStridesForLayerIdx(), mobileNetV1(), outputLayer(), pointwiseConvLayer() (+1 more)
### Community 7 - "test/profile-store.js"
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.12
Nodes (14): __awaiter(), bufferToImage(), ComposableTask(), extendWithFaceExpressions(), fetchImage(), fetchJson(), fetchNetWeights(), fetchOrThrow() (+6 more)
Cohesion: 0.13
Nodes (13): __awaiter(), ComposableTask(), extractSingleFaceAndComputeResult(), fetchImage(), fetchJson(), fetchNetWeights(), fetchOrThrow(), __generator() (+5 more)
### Community 10 - "wechat-browser.mjs"
Cohesion: 0.22
@@ -199,8 +200,8 @@ 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.24
Nodes (15): awaitMediaLoaded(), createCanvas(), createCanvasFromMedia(), extractFaces(), extractImagePatches(), getContext2dOrThrow(), getEnv(), getMediaDimensions() (+7 more)
Cohesion: 0.19
Nodes (18): awaitMediaLoaded(), bufferToImage(), createCanvas(), createCanvasFromMedia(), extractFaces(), extractImagePatches(), getContext2dOrThrow(), getEnv() (+10 more)
### Community 16 - "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范"
Cohesion: 0.15
@@ -223,8 +224,8 @@ 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.10
Nodes (24): disposeUnusedWeightTensors(), extractorsFactory$3(), extractorsFactory$5(), extractorsFactory$7(), extractorsFactory$9(), extractParamsFromWeigthMap(), extractParamsFromWeigthMap$1(), extractParamsFromWeigthMap$2() (+16 more)
Cohesion: 0.08
Nodes (25): disposeUnusedWeightTensors(), extractorsFactory$3(), extractorsFactory$5(), extractorsFactory$7(), extractorsFactory$9(), extractParamsFromWeigthMap(), extractParamsFromWeigthMap$1(), extractParamsFromWeigthMap$2() (+17 more)
### Community 23 - "facepp-skinanalyze(旷视皮肤分析专业版调用技能)"
Cohesion: 0.20
@@ -239,16 +240,16 @@ Cohesion: 0.20
Nodes (16): _arrayLikeToArray(), _arrayWithHoles(), _arrayWithoutHoles(), _classCallCheck(), _createClass(), _defineProperties(), h(), _iterableToArray() (+8 more)
### Community 26 - ".extractWeights"
Cohesion: 0.14
Nodes (14): createFaceRecognitionNet(), createMtcnn(), createTinyFaceDetector(), createTinyYolov2(), extractFCParamsFactory(), extractorsFactory$2(), extractorsFactory$4(), extractorsFactory$8() (+6 more)
Cohesion: 0.09
Nodes (24): createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractConvParamsFactory(), extractFCParamsFactory() (+16 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.09
Nodes (36): ao(), Bo(), C(), cd(), cl(), co(), Do(), e() (+28 more)
Cohesion: 0.08
Nodes (35): ao(), Bo(), C(), cd(), cl(), co(), Do(), eo() (+27 more)
### Community 29 - "face-api.js 本地化依赖(h5/vendor/faceapi/)"
Cohesion: 0.29
@@ -282,9 +283,9 @@ Nodes (8): PSD 来源, 前端参数及限制, 后台校准留痕, 已确认并
Cohesion: 0.18
Nodes (11): list, bounds(), layer_at(), main(), save_image(), composite(), main(), sha256() (+3 more)
### Community 43 - "normalize"
Cohesion: 0.07
Nodes (28): conv(), conv$1(), convDown(), convLayer(), convLayer$1(), convNoRelu(), convWithBatchNorm(), denseBlock3() (+20 more)
### Community 43 - "convLayer"
Cohesion: 0.23
Nodes (11): convLayer(), convWithBatchNorm(), depthwiseSeparableConv$1(), fullyConnectedLayer(), leaky(), ONet(), PNet(), prelu() (+3 more)
### Community 44 - "start-local.sh"
Cohesion: 0.50
@@ -306,9 +307,9 @@ Nodes (19): 5\.1 轮廓(紧塑力), 5\.2 皱纹(紧塑力), 5\.3 肤色
Cohesion: 0.11
Nodes (17): 1. 结论和依据, 2. 统一比较口径, 3. 逐页差异和调整内容, 4. 报告数据如何接到新 UI, 5. 登录与“我的报告”的真实能力缺口, 6. 原稿中需要整理的内容, 7. 实施顺序与交付物, 8. 验收定义 (+9 more)
### Community 55 - "pn"
Cohesion: 0.25
Nodes (8): bn(), fn(), ln(), pd(), pn(), un(), wn(), xn()
### Community 55 - "i"
Cohesion: 0.09
Nodes (26): bn(), ca(), dn(), ee(), et(), fa(), fn(), i() (+18 more)
### Community 56 - "report-ui.js"
Cohesion: 0.50
@@ -319,8 +320,12 @@ Cohesion: 0.60
Nodes (5): diffCtx(), loadJSON(), main(), okNum(), okStr()
### Community 58 - ".dispose"
Cohesion: 0.15
Nodes (9): FaceRecognitionNet(), NetInput(), nonMaxSuppression(), scale(), SsdMobilenetv1(), stage1(), stage2(), stage3() (+1 more)
Cohesion: 0.12
Nodes (13): bgrToRgbTensor(), DetectSingleFaceLandmarksTask(), extendWithFaceLandmarks(), extractBoundingBoxes(), FaceRecognitionNet(), getSizesForScale(), nonMaxSuppression(), normalize$1() (+5 more)
### Community 61 - "FaceProcessor"
Cohesion: 0.22
Nodes (3): extractParams$1(), FaceLandmark68TinyNet(), FaceProcessor()
### Community 62 - "提交前 Review(2026-10-11)"
Cohesion: 0.40
@@ -335,39 +340,39 @@ Cohesion: 0.80
Nodes (5): browser(), init(), read(), rememberKind(), request()
### Community 65 - "微信登录接入与公网部署说明"
Cohesion: 0.22
Cohesion: 0.29
Nodes (7): 公网服务器配置, 公网真机验收清单, 反向代理与后台保护, 已完成的链路, 微信登录接入与公网部署说明, 数据、迁移与备份, 本机开发环境限制(不代表线上状态)
### Community 66 - "FaceLandmarks68"
Cohesion: 0.10
Nodes (8): FaceLandmark68NetBase(), FaceLandmarks5(), FaceLandmarks68(), getCenterPoint(), Point(), pt(), vt(), wl()
### Community 66 - "FaceLandmarks"
Cohesion: 0.06
Nodes (16): conv(), denseBlock3(), denseBlock4(), depthwiseSeparableConv(), FaceFeatureExtractor(), FaceLandmark68NetBase(), FaceLandmarks(), FaceLandmarks5() (+8 more)
### Community 68 - "analyze-timeout.js"
Cohesion: 0.20
Nodes (7): assert, end, fs, path, source, start, vm
### Community 70 - "extractConvParamsFactory"
Cohesion: 0.20
Nodes (9): extractConvParamsFactory(), extractorsFactory(), extractorsFactory$1(), extractorsFactory$6(), extractParams(), extractParams$2(), extractParams$6(), extractParamsTiny() (+1 more)
### Community 70 - ".forwardInput"
Cohesion: 0.39
Nodes (7): conv$1(), convDown(), convLayer$1(), convNoRelu(), residual(), residualDown(), __spreadArrays()
### Community 71 - "extractAllFacesAndComputeResults"
Cohesion: 0.29
Nodes (5): extendWithAge(), extendWithFaceDescriptor(), extendWithGender(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult()
### Community 71 - ".predictAgeAndGender"
Cohesion: 0.28
Nodes (4): extendWithAge(), extendWithGender(), PredictAllAgeAndGenderTask(), PredictSingleAgeAndGenderTask()
### Community 76 - ".toString"
Cohesion: 0.33
Nodes (5): createFileSystem(), createNodejsEnv(), FaceMatch(), la(), pr()
### Community 76 - ".forwardInput"
Cohesion: 0.22
Nodes (9): boxPredictionLayer(), decodeBoxesLayer(), depthwiseConvLayer(), getCenterCoordinatesAndSizesLayer(), getStridesForLayerIdx(), mobileNetV1(), outputLayer(), pointwiseConvLayer() (+1 more)
### Community 77 - ".runAndExtendWithFaceDetections"
Cohesion: 0.18
Nodes (8): DetectAllFacesTask(), DrawBox(), drawDetections(), DrawFaceLandmarks(), extendWithFaceDetection(), isWithFaceDetection(), isWithFaceLandmarks(), resizeResults()
### Community 77 - "extractFaceTensors"
Cohesion: 0.12
Nodes (13): DetectAllFacesTask(), DrawBox(), drawDetections(), DrawFaceLandmarks(), extendWithFaceDetection(), extendWithFaceExpressions(), extractAllFacesAndComputeResults(), extractFaceTensors() (+5 more)
### Community 78 - "i"
Cohesion: 0.06
Nodes (45): b(), be(), bi(), ca(), ce(), de(), dn(), ee() (+37 more)
### Community 78 - "n"
Cohesion: 0.10
Nodes (39): ae(), be(), bf(), bi(), ce(), de(), e(), Ef() (+31 more)
### Community 79 - "登录反馈与加载优化(2026-10-12)"
Cohesion: 0.25
Cohesion: 0.33
Nodes (5): 体验变化, 公网部署后复测, 慢的原因与处理, 登录反馈与加载优化(2026-10-12), 验证
### Community 80 - "线上微信登录部署记录"
@@ -387,7 +392,7 @@ Cohesion: 0.29
Nodes (5): 10.09 新版 H5 实施与验收, 按确认项实现, 文档未定义部分的实施口径, 素材再生成, 验证命令
### Community 84 - "微信登录联调:2026-10-12"
Cohesion: 0.33
Cohesion: 0.22
Nodes (6): 后续真机确认与体验优化, 微信登录联调:2026-10-12, 本轮修正, 白名单与凭据, 真实微信验收与排查, 自动验证
### Community 85 - "test/accounts.js"
@@ -422,24 +427,40 @@ Nodes (5): 原稿与导出可追溯性, 已完成, 本地验证, 生产部署及
Cohesion: 0.50
Nodes (3): artifacts, errors, samples
### Community 100 - "age"
Cohesion: 0.43
Nodes (4): age(), clean(), create(), key()
### Community 101 - "drawFaceExpressions"
Cohesion: 0.50
Nodes (3): drawFaceExpressions(), FaceExpressions(), isWithFaceExpressions()
### Community 102 - ".detect"
Cohesion: 0.18
Nodes (5): iou(), NetInput(), nonMaxSuppression$1(), TinyYolov2(), TinyYolov2Base()
### Community 107 - "基础信息记忆与肤质拖动"
Cohesion: 0.33
Nodes (4): 回归命令, 基础信息记忆与肤质拖动, 肤质交互, 范围
### Community 108 - "profile-interaction.mjs"
Cohesion: 0.40
Nodes (3): dragBy(), h5, touch()
## Knowledge Gaps
- **288 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+283 more)
- **305 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+300 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **26 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **22 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 `v()` connect `n` to `report-ui.js`, `face-api.js`, `app.js`, `C`?**
_High betweenness centrality (0.068) - this node is a cross-community bridge._
- **Why does `k()` connect `n` to `face-api.js`, `app.js`, `lib/accounts.js`, `i`, `C`?**
_High betweenness centrality (0.062) - this node is a cross-community bridge._
- **Why does `fill()` connect `report-ui.js` to `n`?**
_High betweenness centrality (0.054) - this node is a cross-community bridge._
- **Why does `v()` connect `f` to `report-ui.js`, `face-api.js`, `app.js`, `C`?**
_High betweenness centrality (0.059) - this node is a cross-community bridge._
- **Why does `fill()` connect `report-ui.js` to `f`?**
_High betweenness centrality (0.047) - this node is a cross-community bridge._
- **Why does `k()` connect `f` to `face-api.js`, `app.js`, `lib/accounts.js`, `n`, `i`, `C`?**
_High betweenness centrality (0.042) - 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?**
@@ -447,4 +468,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?**
_288 weakly-connected nodes found - possible documentation gaps or missing edges._
_305 weakly-connected nodes found - possible documentation gaps or missing edges._
File diff suppressed because one or more lines are too long
+1703 -759
View File
File diff suppressed because it is too large Load Diff
+28 -8
View File
@@ -40,8 +40,8 @@
"semantic_hash": ""
},
"h5/js/app.js": {
"mtime": 1791801437.4952922,
"ast_hash": "1a79141b30421384ebb80a7c7c383ba9",
"mtime": 1791810527.8429196,
"ast_hash": "d2cbf922e0f250d270cb3f6880fa5d73",
"semantic_hash": ""
},
"h5/js/head-pose.js": {
@@ -135,13 +135,13 @@
"semantic_hash": ""
},
"h5/README.md": {
"mtime": 1791809197.5909035,
"ast_hash": "4e597d6be915f6cd0b7fbd4bf34e5a96",
"mtime": 1791810413.4149451,
"ast_hash": "596d308e73a48f388c67d820e8e073d8",
"semantic_hash": ""
},
"h5/index.html": {
"mtime": 1791809137.1269257,
"ast_hash": "505ec38f7f2120e7d4d1b61eb8328097",
"mtime": 1791810016.13091,
"ast_hash": "454d692d26dc6ed624d5641784c8e61d",
"semantic_hash": ""
},
"h5/vendor/faceapi/README.md": {
@@ -1885,8 +1885,28 @@
"semantic_hash": ""
},
"docs/\u62cd\u6444\u7ea6\u675f\u653e\u5bbd\u4e0e\u6807\u9898\u66f4\u65b0-2026-10-12.md": {
"mtime": 1791809197.5949035,
"ast_hash": "62516ef04c3cba2f54e74c522d4e0b29",
"mtime": 1791809270.7869155,
"ast_hash": "caf913d143a498bff9ecc2cb373f40c4",
"semantic_hash": ""
},
"h5/js/profile-store.js": {
"mtime": 1791810317.830946,
"ast_hash": "5723356fa753e9a66fdee448e617390e",
"semantic_hash": ""
},
"h5/test/profile-interaction.mjs": {
"mtime": 1791810527.8429196,
"ast_hash": "7a370108ec4c922412fa0f278ab59f6a",
"semantic_hash": ""
},
"h5/test/profile-store.js": {
"mtime": 1791810317.8349462,
"ast_hash": "66a0e7d6ddc5eee9cd69c097257746d1",
"semantic_hash": ""
},
"docs/\u57fa\u7840\u4fe1\u606f\u8bb0\u5fc6\u4e0e\u80a4\u8d28\u62d6\u52a8-2026-10-12.md": {
"mtime": 1791810413.4149451,
"ast_hash": "6159880c092b531b465257eec590092a",
"semantic_hash": ""
}
}
+1 -1
View File
@@ -79,7 +79,7 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
## 档案页与报告页
- **档案页**:出生年月 / 性别 / 所在地区三个滚轮选择器同一风格(`vendor/mobile-select`,MIT),出生年月自动换算年龄;地区为省–市两级联动,数据为 `js/region-data.js`(由 china-division 2.7.0 `pc.json` 生成,另含港澳台与「海外」)。
- **档案页**:出生年月 / 性别 / 所在地区三个滚轮选择器同一风格(`vendor/mobile-select`,MIT),出生年月自动换算年龄;地区为省–市两级联动,数据为 `js/region-data.js`(由 china-division 2.7.0 `pc.json` 生成,另含港澳台与「海外」)。当前账号在本机浏览器记住已确认的基础信息和日常肤质,再次进入自动回填,滚轮也定位到原值;年龄按出生年月重新计算。肤质支持跟手拖动、松手吸附、轻扫切换及边界回弹,纵向手势仍滚动页面。详见 [基础信息记忆与肤质拖动](../docs/基础信息记忆与肤质拖动-2026-10-12.md)。
- **报告页**:无返回按钮与页头标题;总分环形图 + 肌肤年龄与「比实际年龄年轻/大 N 岁」;三维度分数 + 进度条(最低分标红并挂「优先关注」);维度详情为原布局——一张卡内紧塑力/活力感/稳定力三个 Tab,每个 Tab 两条「竖排标题 + 左图右文」;点检测图全屏放大,再点或按返回键回到报告。
## 检测流程与评分渲染
+3 -2
View File
@@ -165,13 +165,14 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
.skin-arrow.next { right: calc(5 * var(--u)); }
.skin-arrow .chev { width: calc(9 * var(--u)); height: calc(9 * var(--u)); border-width: 0 1.5px 1.5px 0; }
.skin-arrow[disabled] { opacity: .2; }
.skin-carousel { overflow: hidden; touch-action: pan-y; /* 纵向滚动交给浏览器,横向留给拖拽 */ }
.skin-carousel { overflow: hidden; touch-action: pan-y; user-select: none; -webkit-user-select: none; /* 纵向滚动交给浏览器,横向留给拖拽 */ }
.skin-track { display: flex; transition: transform .32s cubic-bezier(.22,.61,.36,1); }
.skin-track.dragging { transition: none; will-change: transform; }
.skin-item {
flex: 0 0 100%;
display: flex; flex-direction: column; align-items: center;
}
.skin-item img { width: calc(109 * var(--u)); height: calc(109 * var(--u)); }
.skin-item img { width: calc(109 * var(--u)); height: calc(109 * var(--u)); -webkit-user-drag: none; }
.skin-name { font-size: 12.5px; font-weight: 700; color: #000; margin-top: calc(16 * var(--u)); }
.skin-desc {
font-size: 10.3px; color: var(--ink);
+2 -1
View File
@@ -90,7 +90,7 @@
</div>
<div class="skin-stage">
<button type="button" class="skin-arrow prev" data-skin-step="-1" aria-label="上一个"><i class="chev left"></i></button>
<div class="skin-carousel" id="skin-carousel">
<div class="skin-carousel" id="skin-carousel" role="group" tabindex="0" aria-label="日常肤质">
<div class="skin-track" id="skin-track"></div>
</div>
<button type="button" class="skin-arrow next" data-skin-step="1" aria-label="下一个"><i class="chev right"></i></button>
@@ -487,6 +487,7 @@ if (/[?&]mock=1/.test(location.search)) document.write('<script src="assets/mock
<script src="js/report-copy.js" defer></script>
<script src="js/report-ui.js" defer></script>
<script src="js/account-client.js" defer></script>
<script src="js/profile-store.js" defer></script>
<script src="js/camera-geometry.js" defer></script>
<script src="js/app.js" defer></script>
</body>
+94 -26
View File
@@ -96,6 +96,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-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();
document.querySelectorAll('.screen').forEach(function (s) {
@@ -265,7 +266,7 @@
var item = document.createElement('div');
item.className = 'skin-item';
item.innerHTML =
'<img src="' + s.img + '"' + (i === 0 ? '' : ' loading="lazy"') + ' alt="' + s.name + '">' +
'<img src="' + s.img + '" draggable="false"' + (i === 0 ? '' : ' loading="lazy"') + ' alt="' + s.name + '">' +
'<span class="skin-name">' + s.name + '</span>' +
'<p class="skin-desc">' + s.desc + '</p>';
track.appendChild(item);
@@ -275,67 +276,129 @@
dotsBox.appendChild(dot);
});
function goSkin(i) {
function goSkin(i, persist) {
skinIndex = Math.max(0, Math.min(SKINS.length - 1, i));
track.classList.remove('dragging');
track.style.transform = 'translateX(-' + skinIndex * 100 + '%)';
dotsBox.querySelectorAll('i').forEach(function (d, k) {
d.classList.toggle('on', k === skinIndex);
});
$('.skin-arrow.prev').disabled = skinIndex === 0;
$('.skin-arrow.next').disabled = skinIndex === SKINS.length - 1;
$('#skin-carousel').setAttribute('aria-label', '日常肤质:' + SKINS[skinIndex].name);
if (persist !== false) saveProfile();
}
document.querySelectorAll('[data-skin-step]').forEach(function (b) {
b.addEventListener('click', function () { goSkin(skinIndex + (+b.dataset.skinStep)); });
});
goSkin(0);
// 拖拽切换(Pointer Events 统一鼠标 + 触摸)
var drag = { id: null, startX: 0, startY: 0, horizontal: false, suppress: false };
// Follow the finger immediately, then snap with distance/flick intent.
// pan-y and direction locking preserve normal vertical page scrolling.
var drag = { id: null, horizontal: false, vertical: false, suppressUntil: 0 };
var carousel = $('#skin-carousel');
function cancelSkinDrag() {
var id = drag.id;
drag.id = null; drag.horizontal = false; drag.vertical = false;
try { if (id != null && carousel.hasPointerCapture(id)) carousel.releasePointerCapture(id); } catch (_) {}
goSkin(skinIndex, false);
}
carousel.addEventListener('pointerdown', function (e) {
if (e.cancelable) e.preventDefault(); // 阻止原生图片拖拽/文字选中引发 pointercancel(click 仍会正常触发)
if (drag.id != null || e.isPrimary === false || e.button !== 0 || !carousel.clientWidth) return;
try { carousel.setPointerCapture(e.pointerId); } catch (err) { /* 非活动指针时忽略,退化为普通监听 */ }
// 捕获指针:斜拖出卡片、卡外松手也能收到 pointerup
drag.id = e.pointerId;
drag.startX = e.clientX;
drag.startY = e.clientY;
drag.horizontal = false;
drag.suppress = false;
drag.offset = track.getBoundingClientRect().left - carousel.getBoundingClientRect().left;
drag.width = carousel.clientWidth;
drag.lastX = e.clientX; drag.lastAt = e.timeStamp; drag.velocity = 0;
drag.horizontal = false; drag.vertical = false;
});
carousel.addEventListener('pointermove', function (e) {
if (e.pointerId !== drag.id) return;
if (e.pointerId !== drag.id || drag.vertical) return;
var dx = e.clientX - drag.startX, dy = e.clientY - drag.startY;
// 横向位移明显大于纵向时才判定为拖拽,避免吞掉纵向滚动
if (!drag.horizontal && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) drag.horizontal = true;
if (drag.horizontal && e.cancelable) e.preventDefault();
if (!drag.horizontal) {
if (Math.abs(dy) > 8 && Math.abs(dy) >= Math.abs(dx)) { drag.vertical = true; return; }
if (Math.abs(dx) <= 8 || Math.abs(dx) <= Math.abs(dy)) return;
drag.horizontal = true; track.classList.add('dragging');
}
if (e.cancelable) e.preventDefault();
var elapsed = e.timeStamp - drag.lastAt;
if (elapsed > 0) drag.velocity = (e.clientX - drag.lastX) / elapsed;
drag.lastX = e.clientX; drag.lastAt = e.timeStamp;
var offset = drag.offset + dx, min = -(SKINS.length - 1) * drag.width;
if (offset > 0) offset *= .25;
else if (offset < min) offset = min + (offset - min) * .25;
track.style.transform = 'translate3d(' + offset + 'px,0,0)';
});
carousel.addEventListener('pointerup', function (e) {
if (e.pointerId !== drag.id) return;
var dx = e.clientX - drag.startX;
if (drag.horizontal) {
if (Math.abs(dx) > 30) goSkin(skinIndex + (dx < 0 ? 1 : -1));
drag.suppress = true; // 拖拽后吞掉紧随的 click,防止点击穿透
var start = Math.round(-drag.offset / drag.width), target = start;
var speed = e.timeStamp - drag.lastAt > 100 ? 0 : drag.velocity;
if (Math.abs(dx) >= Math.min(50, drag.width * .2) || (Math.abs(dx) > 12 && Math.abs(speed) > .4)) {
target = Math.round(-(drag.offset + dx) / drag.width);
if (target === start) target += (Math.abs(speed) > .4 ? speed : dx) < 0 ? 1 : -1;
}
goSkin(target);
drag.suppressUntil = Date.now() + 350;
}
drag.id = null;
drag.horizontal = false;
drag.horizontal = false; drag.vertical = false;
});
carousel.addEventListener('pointercancel', function (e) {
if (e.pointerId !== drag.id) return; // 其它指针的 cancel 不打断当前拖拽
drag.id = null; // 拖拽被系统取消(滚轮/新指针等):干净结束,不残留状态
drag.horizontal = false;
if (e.pointerId === drag.id) cancelSkinDrag();
});
carousel.addEventListener('lostpointercapture', function (e) {
if (e.pointerId === drag.id) cancelSkinDrag();
});
carousel.addEventListener('dragstart', function (e) { e.preventDefault(); });
carousel.addEventListener('click', function (e) {
if (drag.suppress) { e.preventDefault(); e.stopPropagation(); drag.suppress = false; }
if (Date.now() < drag.suppressUntil) { e.preventDefault(); e.stopPropagation(); }
}, true);
carousel.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); goSkin(skinIndex + (e.key === 'ArrowRight' ? 1 : -1)); }
});
window.addEventListener('resize', cancelSkinDrag);
/* ─── 档案选择控件:出生年月 / 性别 / 省份城市,统一用 mobile-select 底部滚轮(选中值写入同名 hidden input) ─── */
var NOW = new Date(), NOW_Y = NOW.getFullYear(), NOW_M = NOW.getMonth() + 1;
var NOW_Y = new Date().getFullYear();
var YEARS = [], MONTHS = [];
for (var yy = NOW_Y - 99; yy <= NOW_Y - 1; yy++) YEARS.push(yy + '年');
for (var mm = 1; mm <= 12; mm++) MONTHS.push(mm + '月');
var regionSel = $('#f-region');
var regionSel = $('#f-region'), regionSelection = null;
var profileOwner = null, profileStore = ProfileStore.create(function () { return window.localStorage; });
function setPick(sel, text) { $(sel + ' .pick-value').textContent = text; }
function ageFromBirth(y, m) { return NOW_Y - y - (NOW_M < m ? 1 : 0); } // 未过生日月份按少一岁
function ageFromBirth(y, m) { return ProfileStore.age(y + '-' + (m < 10 ? '0' : '') + m); }
function saveProfile() {
var user = AccountClient.user();
if (!profileStore || !profileOwner || profileOwner !== ProfileStore.key(user)) return;
profileStore.write(user, ProfileStore.clean({ birth: $('#f-birth').value, gender: $('#f-gender').value,
region: regionSelection, skinIndex: skinIndex }, window.REGION_DATA, SKINS.length));
}
function restoreProfile() {
var user = AccountClient.user();
profileOwner = ProfileStore.key(user);
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 = '';
$('#f-birth').value = saved.birth;
$('#f-age').value = saved.birth ? ProfileStore.age(saved.birth) : '';
$('#f-gender').value = saved.gender;
regionSelection = saved.region;
var region = saved.region, text = region ? region.province + (region.city === region.province ? '' : ' ' + region.city) : '';
regionSel.value = text;
setPick('#pick-birth', saved.birth ? y + '年' + m + '月' : '');
setPick('#pick-gender', saved.gender); setPick('#pick-region', text);
if (birthPicker) birthPicker.setValue(saved.birth ? [y + '年', m + '月'] : [(NOW_Y - 30) + '年', '1月']);
if (genderPicker) genderPicker.setValue([saved.gender || '女']);
var initial = (window.REGION_DATA || [])[0];
if (regionPicker && (region || initial)) regionPicker.setValue([
{ value: region ? region.province : initial[0] }, { value: region ? region.city : initial[1][0] }
]);
cancelSkinDrag(); goSkin(saved.skinIndex, false);
}
function makePicker(trigger, opts) {
if (typeof MobileSelect === 'undefined') return null;
return new MobileSelect(Object.assign({
@@ -343,7 +406,7 @@
ensureBtnText: '确定', cancelBtnText: '取消'
}, opts));
}
makePicker('#pick-birth', {
var birthPicker = makePicker('#pick-birth', {
title: '出生年月',
wheels: [{ data: YEARS }, { data: MONTHS }],
position: [YEARS.indexOf((NOW_Y - 30) + '年'), 0],
@@ -352,14 +415,15 @@
$('#f-birth').value = y + '-' + (m < 10 ? '0' : '') + m;
$('#f-age').value = ageFromBirth(y, m);
setPick('#pick-birth', y + '年' + m + '月');
saveProfile();
}
});
makePicker('#pick-gender', {
var genderPicker = makePicker('#pick-gender', {
title: '性别',
wheels: [{ data: ['女', '男'] }],
onChange: function (val) { $('#f-gender').value = val[0]; setPick('#pick-gender', val[0]); }
onChange: function (val) { $('#f-gender').value = val[0]; setPick('#pick-gender', val[0]); saveProfile(); }
});
makePicker('#pick-region', {
var regionPicker = makePicker('#pick-region', {
title: '所在地区',
wheels: [{ data: (window.REGION_DATA || []).map(function (p) {
return { id: p[0], value: p[0], childs: p[1].map(function (c) { return { id: c, value: c }; }) };
@@ -368,7 +432,9 @@
var prov = val[0] && val[0].value, city = val[1] && val[1].value;
var text = !city || city === prov ? prov : prov + ' ' + city; // 直辖市/港澳台只显示一级
regionSel.value = text;
regionSelection = { province: prov, city: city || prov };
setPick('#pick-region', text);
saveProfile();
}
});
@@ -1549,10 +1615,12 @@
showScreen('s-profile', 'push');
},
'submit-profile': function () {
$('#f-age').value = ProfileStore.age($('#f-birth').value) || '';
var age = +$('#f-age').value;
if (!$('#f-birth').value || !(age >= 1 && age <= 99)) { toast('请选择出生年月'); return; }
if (!$('#f-gender').value) { toast('请选择性别'); return; }
if (!regionSel.value) { toast('请选择所在地区'); return; }
saveProfile();
openOverlay('p-photoguide');
},
'close-photoguide': function () { closeOverlay('p-photoguide'); },
+49
View File
@@ -0,0 +1,49 @@
/* Account-scoped, local-only form preferences. No credentials or report data. */
(function(root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.ProfileStore = factory();
})(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
? 'juzi.profile.v1.' + user.kind + '.' + encodeURIComponent(user.id) : null;
}
function age(birth, now) {
if (typeof birth !== 'string' || !/^\d{4}-(0[1-9]|1[0-2])$/.test(birth)) return null;
now = now || new Date();
var y = Number(birth.slice(0, 4)), m = Number(birth.slice(5));
var value = now.getFullYear() - y - (now.getMonth() + 1 < m ? 1 : 0);
return value >= 1 && value <= 99 ? value : null;
}
function clean(input, regions, skinCount, now) {
input = input && typeof input === 'object' ? input : {};
var region = input.region, validRegion = null;
if (region && typeof region === 'object') (regions || []).some(function(p) {
if (p[0] !== region.province || p[1].indexOf(region.city) < 0) return false;
validRegion = { province: p[0], city: region.city }; return true;
});
return { birth: age(input.birth, now) == null ? '' : input.birth,
gender: ['女', '男'].indexOf(input.gender) < 0 ? '' : input.gender,
region: validRegion, skinIndex: Number.isInteger(input.skinIndex) && input.skinIndex >= 0 && input.skinIndex < skinCount ? input.skinIndex : 0 };
}
function create(getStorage) {
var memory = Object.create(null);
return {
read: function(user) {
var name = key(user), raw;
if (!name) return null;
if (Object.prototype.hasOwnProperty.call(memory, name)) return memory[name];
try { raw = getStorage().getItem(name); } catch (_) { /* Private/restricted storage. */ }
if (raw == null) return null;
try { return raw.length <= 4096 ? JSON.parse(raw) : null; } catch (_) { return null; }
},
write: function(user, value) {
var name = key(user);
if (!name) return;
memory[name] = value;
try { getStorage().setItem(name, JSON.stringify(value)); } catch (_) { /* Keep this page usable without persistence. */ }
}
};
}
return { key: key, age: age, clean: clean, create: create };
});
+145
View File
@@ -0,0 +1,145 @@
// Real app and mobile-select, synthetic accounts only. Safe against production:
// all auth is intercepted, fonts are skipped, and any non-GET request is blocked.
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));
let child, dataRoot, browser;
let base = process.env.H5_BASE_URL;
try {
if (!base) {
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-profile-test-'));
base = 'http://127.0.0.1:18112/';
child = spawn(process.execPath, ['server.js'], { cwd: h5, env: { ...process.env, PORT: '18112', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', SKIN_API_KEY: '', SKIN_API_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(Error('test startup timeout')), 10000);
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timeout); resolve(); } });
child.once('exit', code => { clearTimeout(timeout); reject(Error('server exited ' + code)); });
});
}
browser = await chromium.launch({ headless: true });
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' };
page.setDefaultTimeout(20000);
page.on('pageerror', e => errors.push(e.message));
await page.route('**/*', route => {
const req = route.request();
if (req.method() !== 'GET') { blockedWrites.push(new URL(req.url()).pathname); 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 } });
return route.continue();
});
// Expose the actual vendor instances for choosing non-default wheel positions;
// restoration/confirmation and carousel interactions still use the actual UI.
await page.route('**/mobile-select.iife.min.js', async route => {
const response = await route.fetch();
const script = await response.text();
await route.fulfill({ response, body: script + `
var OriginalMobileSelect=MobileSelect; window.__pickers={};
MobileSelect=function(config){var picker=new OriginalMobileSelect(config);window.__pickers[config.trigger]=picker;return picker;};
` });
});
async function enter(reload = true) {
if (reload) await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.click('[data-action="open-privacy"]');
if (!(await page.locator('#privacy-check').evaluate(el => el.classList.contains('checked')))) await page.click('#privacy-check');
await page.click('[data-action="agree-continue"]');
await page.waitForSelector('#s-profile.active');
await page.waitForTimeout(400);
}
const values = () => page.evaluate(() => ({ birth: document.querySelector('#f-birth').value, age: document.querySelector('#f-age').value,
gender: document.querySelector('#f-gender').value, region: document.querySelector('#f-region').value,
labels: ['birth', 'gender', 'region'].map(k => document.querySelector('#pick-' + k + ' .pick-value').textContent),
skin: Array.from(document.querySelectorAll('#skin-dots i')).findIndex(el => el.classList.contains('on')) }));
async function pick(trigger, positions) {
await page.click(trigger);
if (positions) await page.evaluate(({ trigger, positions }) => positions.forEach((index, column) => window.__pickers[trigger].locatePosition(column, index)), { trigger, positions });
await page.click('.ms-mobile-select.ms-show .ms-ensure');
await page.waitForTimeout(250);
}
await enter();
let blank = await values();
assert.equal(blank.birth, ''); assert.equal(blank.gender, ''); assert.equal(blank.region, ''); assert.equal(blank.skin, 0);
await pick('#pick-birth', [62, 10]); // current year minus 37, November
await pick('#pick-gender', [1]);
const regionPositions = await page.evaluate(() => {
const province = REGION_DATA.findIndex(p => p[0] === '浙江省');
return [province, REGION_DATA[province][1].indexOf('宁波市')];
});
assert(regionPositions.every(i => i >= 0));
await pick('#pick-region', regionPositions);
await page.click('.skin-arrow.next'); await page.waitForTimeout(400);
const original = await values();
assert.equal(original.gender, '男'); assert.equal(original.region, '浙江省 宁波市'); assert.equal(original.skin, 1);
assert.equal(original.age, String(await page.evaluate(birth => ProfileStore.age(birth), original.birth)));
await enter();
assert.deepEqual(await values(), original, 'reload brings back last confirmed selections, without submitting');
for (const trigger of ['#pick-birth', '#pick-gender', '#pick-region']) await pick(trigger);
assert.deepEqual(await values(), original, 'picker wheels themselves reopen at the remembered values');
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' };
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');
// Chromium's real touch dispatcher exercises touch-action/pointer capture, not
// just dispatched DOM events. Check the transform before the finger is lifted.
const cdp = await context.newCDPSession(page);
const geometry = () => page.locator('#skin-carousel').evaluate(el => {
const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + 60, width: r.width };
});
const offset = () => page.locator('#skin-track').evaluate(el => new DOMMatrix(getComputedStyle(el).transform).m41);
async function touch(type, x, y, timestamp) { await cdp.send('Input.dispatchTouchEvent', { type, ...(timestamp ? { timestamp } : {}), touchPoints: type === 'touchEnd' || type === 'touchCancel' ? [] : [{ x, y, id: 1 }] }); }
async function dragBy(dx, { pause = 0, cancel = false, dy = 0, flick = false } = {}) {
const p = await geometry();
const at = flick ? Date.now() / 1000 : null;
await touch('touchStart', p.x, p.y, at);
await touch('touchMove', p.x + dx / 2, p.y + dy / 2, at && at + .02);
await touch('touchMove', p.x + dx, p.y + dy, at && at + .04);
const during = await offset();
if (pause) await page.waitForTimeout(pause);
await touch(cancel ? 'touchCancel' : 'touchEnd', 0, 0, at && at + .05);
await page.waitForTimeout(400);
return during;
}
const p = await geometry(), before = await offset();
const during = await dragBy(-100);
assert(Math.abs(during - (before - 100)) < 3, 'track follows the finger BEFORE release');
assert.equal((await values()).skin, 2); assert(Math.abs(await offset() + 2 * p.width) < 2, 'snaps precisely to the adjacent item');
await dragBy(25, { pause: 160 }); assert.equal((await values()).skin, 2, 'short slow drag snaps back');
await dragBy(-40, { flick: true }); assert.equal((await values()).skin, 3, 'short fast flick advances');
await dragBy(100, { cancel: true }); assert.equal((await values()).skin, 3, 'system cancellation snaps back');
assert.equal(await page.locator('#skin-track.dragging').count(), 0);
await dragBy(2, { dy: -90 }); assert.equal((await values()).skin, 3, 'vertical scrolling never switches skin');
await page.locator('#skin-dots i').first().click(); await page.waitForTimeout(400);
const edge = await dragBy(100);
assert(edge > 0 && edge < 40, 'first item has elastic resistance');
assert.equal((await values()).skin, 0); assert(Math.abs(await offset()) < 2);
await page.locator('#skin-dots i').last().click(); await page.waitForTimeout(400);
await dragBy(-100); assert.equal((await values()).skin, 4, 'last item cannot overshoot');
await page.locator('#skin-carousel').focus(); await page.keyboard.press('ArrowLeft');
await page.waitForTimeout(400); assert.equal((await values()).skin, 3, 'keyboard still works');
for (const width of [320, 390, 480]) {
await page.setViewportSize({ width, height: 726 }); await page.waitForTimeout(400);
const size = await geometry(); assert(Math.abs(await offset() + 3 * size.width) < 2, 'resize keeps selection aligned at ' + width);
assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'no horizontal page overflow');
}
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');
assert.deepEqual(errors, []); assert.deepEqual(blockedWrites, [], 'no backend writes or paid requests');
console.log('PASS: actual profile defaults/wheels/reload/account isolation; real mobile touch follows finger/snap/flick/edges/cancel/vertical scroll; arrows/dots/keyboard; three responsive widths; submission; no JS errors/backend writes');
} finally {
if (browser) await browser.close();
if (child && child.exitCode == null) { const stopped = once(child, 'exit'); child.kill(); await stopped; }
if (dataRoot) fs.rmSync(dataRoot, { recursive: true, force: true });
}
+46
View File
@@ -0,0 +1,46 @@
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 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);
assert.equal(Profile.age(valid.birth, new Date(2026, 10, 1)), 35);
assert.equal(Profile.age(valid.birth, new Date(2027, 9, 1)), 35);
for (const birth of [null, '', '1991-1', '1991-00', '1991-13', '1991-11-01', '2026-01', '2027-01', '1926-01']) {
assert.equal(Profile.age(birth, now), null, String(birth));
}
assert.equal(Profile.age('2025-10', now), 1);
assert.equal(Profile.age('1926-11', now), 99);
assert.deepEqual(Profile.clean(null, regions, 5, now), empty);
assert.deepEqual(Profile.clean({ ...valid, token: 'not-persisted', report: {} }, regions, 5, now), valid);
assert.deepEqual(Profile.clean({ birth: 'bad', gender: '<script>', region: { province: '北京市', city: '宁波市' }, skinIndex: 5 }, regions, 5, now), empty);
for (const skinIndex of [-1, 1.5, '2', NaN]) assert.equal(Profile.clean({ skinIndex }, regions, 5, now).skinIndex, 0);
assert.equal(Profile.clean({ gender: '女' }, regions, 5, now).gender, '女', 'partial selections can be saved');
assert.equal(Profile.key(null), null);
assert.equal(Profile.key({ id: '', kind: 'browser' }), null);
assert.equal(Profile.key({ id: 'x', kind: 'unknown' }), null);
assert.equal(Profile.key({ id: 'x'.repeat(129), kind: 'browser' }), null);
assert.equal(Profile.key({ id: 'a/b', kind: 'wechat' }), 'juzi.profile.v1.wechat.a%2Fb');
const data = new Map();
const storage = { getItem: key => data.get(key) ?? null, setItem: (key, value) => data.set(key, value) };
const store = Profile.create(() => storage);
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');
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)]) {
data.set(Profile.key(user), raw);
assert.equal(Profile.create(() => storage).read(user), null, 'corrupt/oversized data is harmless');
}
const restricted = Profile.create(() => { throw Error('SecurityError'); });
assert.equal(restricted.read(user), null);
restricted.write(user, valid); assert.deepEqual(restricted.read(user), valid);
assert.equal(restricted.read(other), null);
const quota = Profile.create(() => ({ getItem: () => JSON.stringify(empty), setItem: () => { throw Error('QuotaExceededError'); } }));
quota.write(user, valid); assert.deepEqual(quota.read(user), valid, 'stale disk data cannot overwrite latest in-page selections');
console.log('PASS: profile validation, dynamic age, reload persistence, account isolation, corrupt/restricted/quota-limited storage, no credentials/report fields');