Compare commits

..
11 Commits
Author SHA1 Message Date
xsl 2a7593b736 save 2026-10-10 14:23:43 +08:00
root 658b232a39 save 2026-10-09 23:22:28 +08:00
Ubuntu f7902b86c8 save 2026-10-09 22:29:02 +08:00
xsl 55f26d91af 修改整理反馈需求 2026-10-09 22:12:33 +08:00
xsl 499be22771 第一版反馈 2026-10-09 21:08:12 +08:00
Ubuntu c06d15a2d4 chore+perf: 密钥出库 + 后台详情接口裁剪 face_maps
安全:
- reference/facepp-skinanalyze/credentials.env 内含 Face++ 正式 key/secret
  且被 git 跟踪,移出 index(文件保留本地,.gitignore 已覆盖 *.env)。
  ⚠️ 密钥已存在于 git 历史,若 origin 曾被 push 即视为泄漏,
  请在旷视控制台重置 API secret,改用环境变量/apikey.bash 注入。

性能(后台详情页卡顿):
- 详情接口将 raw_json.result.face_maps 的 base64 图层(单条约 3MB)
  替换为占位说明,响应 3.2MB→163KB;新增
  GET /api/admin/records/:id/raw 供下载完整原始 JSON(走 Basic Auth)
- admin 列表/详情图片加 loading=lazy + decoding=async
2026-09-28 22:46:08 +08:00
Ubuntu 3de730a127 save 2026-09-28 15:27:31 +08:00
xsl 3d32434d6f 添加后端文档 2026-09-28 10:49:09 +08:00
xslandCursor 67b6d86fce chore: 移除 graphify 日期快照目录
历史快照与根目录图重复,加入 ignore 防止再入库。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 09:05:25 +08:00
xslandCursor ecb4bb2515 chore: 将 graphify cache 移出版本库
cache 可本地重建,加入 .gitignore 避免再次误提交。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 09:04:27 +08:00
xsl b6ba6b65c5 save 2026-09-28 09:02:32 +08:00
57 changed files with 39743 additions and 813 deletions
+6
View File
@@ -13,3 +13,9 @@ __pycache__/
aipkey.bash
h5/data/
temp/
# graphify 本地缓存与日期快照(可重建,无需入库)
graphify-out/cache/
graphify-out/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/
graphify-out/.rebuild.lock
graphify-out/..graph.tmp.*
+1
View File
@@ -0,0 +1 @@
kpx234XimQGtFPo6
@@ -0,0 +1,58 @@
# 1meAI测肤旷视UI问题反馈
## UI交互界面调整
##### 交互
1. 年龄调整为滚轮,消费者选择出生年份月份,系统自动计算年龄(下拉设计)。
![image\.png](图片和附件/image%204.png)
1. 地区做成滚动形式,省份城市形式(下拉设计)。 找现存的插件,提供选择省份和城市。
![image\.png](图片和附件/image%205.png)
1. 拍摄方式修改为**动态捕捉方式**,再加入 远一点,近一点 请向左转头,请略微抬头等的动态提示。同时,如果消费者的角度光线等必要条件合适,立刻拍摄,不再倒计时拍摄, 不要手动拍摄的按钮。
![image\.png](图片和附件/image%206.png)
1. 拍摄的时候画面缩小 15%, 使得摄像头离面部近一点也能正常拍摄。 (当前拍摄,需要手机离面部很远才符合要求)
2. 正脸,两个侧脸的拍摄,必须安装顺序完成,拍摄完成后才能拍摄下一张。
![image\.png](图片和附件/image.png)
##### **设计**
具体设计问题:
1 主页面 参考下面图片, 左边的是当前的状态,右边的是要达到效果的标准,
- Life in Full 为更好的生命表现 字号,位置,粗细 要和图片一致。
- 开始检测的按钮不要圆角,要长方形带一点倒角。
![截屏2026\-09\-28 13\.38\.51\.png](图片和附件/截屏2026-09-28%2013.38.51.png)
2 设置的页面
- 不再要返回的按钮
- “您的年龄“, “您的性别”, “您所在的地区”,需要居中,不是左对齐
- 选择年龄,性别,和地区的选择控件风格要一致
- 提交按钮 同样 要长方形带一点倒角
![截屏2026\-09\-28 13\.43\.25\.png](图片和附件/截屏2026-09-28%2013.43.25.png)
拍照页面,参考图片右边的标准
- 弹出“请拍摄正面及左右侧面照片” 这个对话框的时候, 对话框大小需要和右边的标准一致,背景要把选择年龄,性别,地区等UI元素去掉。 “开始” 按钮要长方形带一点倒角
![截屏2026\-09\-28 13\.44\.10\.png](图片和附件/截屏2026-09-28%2013.44.10.png)
报告页面
- 去掉返回按钮和 "1'me 在线肌肤测试" 的文字
- “您的肌肤状态表现”,这个部分字体有点小,需要整体优化一下
- 再往下的图片和说明部分,也是字体有点小,同样需要整体优化一下
- 点击图片需要放大,再次点击返回
![截屏2026\-09\-28 13\.56\.32\.png](图片和附件/截屏2026-09-28%2013.56.32.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+69
View File
@@ -0,0 +1,69 @@
{
"0": "face-api.js",
"1": "n",
"2": "app.js",
"3": "skin-score.js",
"4": "isTensor",
"5": ".toBatchTensor",
"6": "getEnv",
"7": "FaceLandmarks",
"8": "disposeUnusedWeightTensors",
"9": "__awaiter",
"10": "extractWeightEntryFactory",
"11": "FaceProcessor",
"12": "server.js",
"13": "files",
"14": "extractAllFacesAndComputeResults",
"15": ".runAndExtendWithFaceDetection",
"16": "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范",
"17": "extractFaces",
"18": "1'me 在线肌肤测试 H5",
"19": "head-pose.js",
"20": "build_fixtures.py",
"21": ".forwardInput",
"22": ".loadFromDisk",
"23": "facepp-skinanalyze(旷视皮肤分析专业版调用技能)",
"24": "巨子测肤 · 1'me 语音播报内容设计文档 V1.0",
"25": "extractParamsFromWeigthMap$1",
"26": "wi",
"27": "gen_voice.py",
"28": "initialize",
"29": "face-api.js 本地化依赖(h5/vendor/faceapi/)",
"30": "regression.js",
"31": "isValidNumber",
"32": "FaceMatcher",
"33": "build-fixture.py",
"34": "smoke-test.js",
"35": "DetectSingleFaceLandmarksTask",
"36": "so",
"37": "tt",
"38": "TinyYolov2",
"39": "cn",
"40": ".computeFaceDescriptor",
"41": "SsdMobilenetv1",
"42": "dr",
"43": "FaceLandmark68Net",
"44": "rescaleAndNormalize",
"45": "PredictAllAgeAndGenderWithFaceAlignmentTask",
"46": "PredictAllFaceExpressionsWithFaceAlignmentTask",
"47": "PredictSingleAgeAndGenderWithFaceAlignmentTask",
"48": "PredictSingleFaceExpressionsWithFaceAlignmentTask",
"49": "Yo",
"50": "call_skinanalyze_3view.sh",
"51": "TinyXception",
"52": "Vc",
"53": "FaceDetection",
"54": "ff",
"55": "ge",
"56": "sa",
"57": "po",
"58": ".dispose",
"59": "call_skinanalyze.sh",
"60": "report-fixture.js",
"61": ".extractWeights",
"62": "NeuralNetwork",
"63": "e2e-browser.mjs",
"64": "AgeGenderNet",
"66": "FaceExpressionNet",
"71": "PredictSingleFaceExpressionsTask"
}
+1
View File
@@ -0,0 +1 @@
{"0": "084975bb68797cf4", "1": "6c6ce24229e04595", "2": "64f07a78d61df933", "3": "8ac814cdb72a1a7d", "4": "c18a4a8fab272cc6", "5": "7f62d3cc90a92195", "6": "865611558d18274c", "7": "e7b6c52cb9523ba1", "8": "934b465b2e687d3d", "9": "31a3a505422c0528", "10": "f943c63a37873e9e", "11": "9ed244ba3453a0c1", "12": "8d1af962d46ec19f", "13": "7e3b558a587a4ebe", "14": "2681c1b3f4f3539e", "15": "8bd61518dd267641", "16": "95add6a975c0cb72", "17": "d5ee72467704e6f0", "18": "fd0841b607871dc7", "19": "0a373ad2197fd1a6", "20": "aecf2fce44ba49c5", "21": "880a74c6d255e5cc", "22": "1c67ad3d97e5c622", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "79d2843a716971c8", "26": "83b0fd3b14363e4d", "27": "eab427ce9a5219e9", "28": "eb3733e409dc7411", "29": "c608f1c6492e1cb1", "30": "f2a40b5f1b29f669", "31": "8f647d3ce08ecd2b", "32": "f6bcf56c4589316b", "33": "75f750c5b7d9f63d", "34": "f024ff7942935690", "35": "23f1d406f6c779ae", "36": "c55f872b3e16ab6f", "37": "3088ae48043ee39d", "38": "c96276581a8c98d4", "39": "932f010dc6115fb4", "40": "444ef0744645f5ac", "41": "386a184c6bd5a40e", "42": "2bfc96d4090afd84", "43": "9a25b1230b5fe649", "44": "8d5f4e992bac9095", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "47": "01f68dfc34a29d32", "48": "8c471c7baeaaca54", "49": "a54008a71fdd44fa", "50": "bbb2b57d72922b16", "51": "0985ce7473fdd021", "52": "c5a22f1501fe85b7", "53": "287d60115d9acda8", "54": "1331494302f6c136", "55": "f42d242a020b2f05", "56": "2537fe1309d3ab9a", "57": "7862e3ee5a214160", "58": "440ae90e84499b6f", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "151209871c761759", "62": "d96493a5f110bd4c", "63": "c256b43f75d7809e", "64": "512a2535c88c4284", "66": "e858d75e995c92e5", "71": "cb5ac3401283c378"}
+1
View File
@@ -0,0 +1 @@
.
+310
View File
@@ -0,0 +1,310 @@
# Graph Report - juzi (2026-09-28)
## Corpus Check
- 31 files · ~1,845,638 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 900 nodes · 2117 edges · 67 communities (46 shown, 21 thin omitted)
- Extraction: 92% EXTRACTED · 8% INFERRED · 0% AMBIGUOUS · INFERRED: 168 edges (avg confidence: 0.52)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `4e3d915b`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation)
- face-api.js
- n
- app.js
- skin-score.js
- isTensor
- .toBatchTensor
- getEnv
- FaceLandmarks
- disposeUnusedWeightTensors
- __awaiter
- extractWeightEntryFactory
- FaceProcessor
- server.js
- files
- extractAllFacesAndComputeResults
- .runAndExtendWithFaceDetection
- 巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范
- extractFaces
- 1'me 在线肌肤测试 H5
- head-pose.js
- build_fixtures.py
- .forwardInput
- .loadFromDisk
- facepp-skinanalyze(旷视皮肤分析专业版调用技能)
- 巨子测肤 · 1'me 语音播报内容设计文档 V1.0
- extractParamsFromWeigthMap$1
- wi
- gen_voice.py
- initialize
- face-api.js 本地化依赖(h5/vendor/faceapi/)
- regression.js
- isValidNumber
- FaceMatcher
- build-fixture.py
- DetectSingleFaceLandmarksTask
- so
- tt
- TinyYolov2
- cn
- .computeFaceDescriptor
- SsdMobilenetv1
- dr
- FaceLandmark68Net
- rescaleAndNormalize
- PredictAllAgeAndGenderWithFaceAlignmentTask
- PredictAllFaceExpressionsWithFaceAlignmentTask
- PredictSingleAgeAndGenderWithFaceAlignmentTask
- PredictSingleFaceExpressionsWithFaceAlignmentTask
- Yo
- call_skinanalyze_3view.sh
- TinyXception
- Vc
- FaceDetection
- ff
- ge
- sa
- po
- .dispose
- call_skinanalyze.sh
- .extractWeights
- NeuralNetwork
- e2e-browser.mjs
- AgeGenderNet
- FaceExpressionNet
- PredictSingleFaceExpressionsTask
## God Nodes (most connected - your core abstractions)
1. `__awaiter()` - 59 edges
2. `__generator()` - 59 edges
3. `n()` - 36 edges
4. `t()` - 33 edges
5. `i()` - 33 edges
6. `r()` - 32 edges
7. `C()` - 32 edges
8. `toNetInput()` - 27 edges
9. `e()` - 22 edges
10. `f()` - 20 edges
## Surprising Connections (you probably didn't know these)
- `detectFrame()` --indirect_call--> `d()` [INFERRED]
h5/js/app.js → h5/vendor/faceapi/face-api.js
- `vizCompose()` --indirect_call--> `h()` [INFERRED]
h5/js/app.js → h5/vendor/faceapi/face-api.js
- `vizCompose()` --indirect_call--> `rh()` [INFERRED]
h5/js/app.js → h5/vendor/faceapi/face-api.js
- `vizCompose()` --indirect_call--> `w()` [INFERRED]
h5/js/app.js → h5/vendor/faceapi/face-api.js
- `renderReport()` --indirect_call--> `t()` [INFERRED]
h5/js/app.js → h5/vendor/faceapi/face-api.js
## Import Cycles
- None detected.
## Communities (67 total, 21 thin omitted)
### Community 0 - "face-api.js"
Cohesion: 0.02
Nodes (38): bn(), ca(), cd(), ce(), dn(), ee(), er(), et() (+30 more)
### Community 1 - "n"
Cohesion: 0.07
Nodes (85): a(), ae(), ao(), b(), ba(), be(), bf(), bi() (+77 more)
### Community 2 - "app.js"
Cohesion: 0.06
Nodes (71): afterCurrent(), applyMirror(), armNudge(), beginCountdown(), beginDetect(), bindBackdropClose(), busyWithFlow(), cancelCountdown() (+63 more)
### Community 3 - "skin-score.js"
Cohesion: 0.12
Nodes (44): acneGrade(), buildViewModel(), calcAcne(), calcContour(), calcSens(), calcSmooth(), calcTone(), calcWrinkles() (+36 more)
### Community 4 - "isTensor"
Cohesion: 0.33
Nodes (5): extractorsFactory$3(), extractParamsFromWeigthMap$4(), isTensor(), isTensor1D(), isTensor2D()
### Community 5 - ".toBatchTensor"
Cohesion: 0.08
Nodes (30): conv(), conv$1(), convDown(), convLayer(), convLayer$1(), convNoRelu(), convWithBatchNorm(), denseBlock3() (+22 more)
### Community 6 - "getEnv"
Cohesion: 0.09
Nodes (26): awaitMediaLoaded(), bufferToImage(), createCanvas(), createCanvasFromMedia(), DetectAllFacesTask(), DrawBox(), drawDetections(), drawFaceExpressions() (+18 more)
### Community 7 - "FaceLandmarks"
Cohesion: 0.06
Nodes (16): bgrToRgbTensor(), extendWithFaceLandmarks(), FaceLandmark68NetBase(), FaceLandmarks(), FaceLandmarks5(), FaceLandmarks68(), getCenterPoint(), isDimensions() (+8 more)
### Community 8 - "disposeUnusedWeightTensors"
Cohesion: 0.22
Nodes (6): disposeUnusedWeightTensors(), extractorsFactory$9(), extractParamsFromWeigthMap(), extractParamsFromWeigthMap$6(), extractParamsFromWeigthMap$7(), extractParamsFromWeigthMapTiny()
### Community 9 - "__awaiter"
Cohesion: 0.22
Nodes (8): __awaiter(), fetchImage(), fetchJson(), fetchNetWeights(), fetchOrThrow(), __generator(), Mtcnn(), toNetInput()
### Community 10 - "extractWeightEntryFactory"
Cohesion: 0.53
Nodes (6): extractorsFactory$7(), extractWeightEntryFactory(), loadConvParamsFactory(), loadParamsFactory(), loadParamsFactory$1(), loadSeparableConvParamsFactory()
### Community 11 - "FaceProcessor"
Cohesion: 0.24
Nodes (3): extractParams$1(), FaceLandmark68TinyNet(), FaceProcessor()
### Community 12 - "server.js"
Cohesion: 0.14
Nodes (16): callFacepp(), CRED, DATA_DIR, ERR_MSG, fs, handleAnalyze(), http, isUsableB64() (+8 more)
### Community 13 - "files"
Cohesion: 0.12
Nodes (15): files, analyze-fail, analyzing, countdown-321, hint-far, hint-front, hint-hold, hint-near (+7 more)
### Community 14 - "extractAllFacesAndComputeResults"
Cohesion: 0.15
Nodes (8): extendWithAge(), extendWithFaceExpressions(), extendWithGender(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult(), PredictAllAgeAndGenderTask(), PredictAllFaceExpressionsTask(), PredictSingleAgeAndGenderTask()
### Community 15 - ".runAndExtendWithFaceDetection"
Cohesion: 0.23
Nodes (6): allFacesMtcnn(), allFacesSsdMobilenetv1(), allFacesTinyYolov2(), DetectAllFaceLandmarksTask(), detectAllFaces(), DetectSingleFaceTask()
### Community 16 - "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范"
Cohesion: 0.15
Nodes (12): 1. 基准与尺寸换算, 2. 色彩规范, 3. 字体规范, 4. 组件切图(assets/svg/), 5. 图片素材(assets/images/,共 15 张), 6. 页面清单(design/ 下有整屏 PNG 预览), 7. 目录结构, 8. 还原建议 (+4 more)
### Community 17 - "extractFaces"
Cohesion: 0.22
Nodes (6): Box(), extractFaces(), extractFaceTensors(), imageTensorToCanvas(), isTensor3D(), isTensor4D()
### Community 18 - "1'me 在线肌肤测试 H5"
Cohesion: 0.18
Nodes (10): 1'me 在线肌肤测试 H5, Windows 本机部署(提测用), 密钥配置, 已知占位清单, 技术说明, 拍照交互(引导式自动三连拍), 本地运行, 检测流程与评分渲染 (+2 more)
### Community 19 - "head-pose.js"
Cohesion: 0.33
Nodes (8): assertPoints(), check(), clamp(), dist(), estimate(), makeFrontal(), makeSide(), midpoint()
### 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 - ".forwardInput"
Cohesion: 0.22
Nodes (9): boxPredictionLayer(), decodeBoxesLayer(), depthwiseConvLayer(), getCenterCoordinatesAndSizesLayer(), getStridesForLayerIdx(), mobileNetV1(), outputLayer(), pointwiseConvLayer() (+1 more)
### Community 22 - ".loadFromDisk"
Cohesion: 0.19
Nodes (5): ComposableTask(), getModelUris(), Gf(), loadWeightMap(), TinyFaceDetector()
### Community 23 - "facepp-skinanalyze(旷视皮肤分析专业版调用技能)"
Cohesion: 0.20
Nodes (9): 1. 凭据(统一管理), 2. 标准调用模板(全可选参数,每次都带), 2b. 三视图调用模板(正脸 + 双侧脸 → 下颌线), 3.1 本轮踩过的坑(务必避复), 3. 三维度 × 6 分类 P/S 计算(2026-09-26 生效,取代旧六维), 4. 产品分组公式, 5. Excel 报告铁律(必须遵守), 6. 其他 Agent 调用约定 (+1 more)
### Community 24 - "巨子测肤 · 1'me 语音播报内容设计文档 V1.0"
Cohesion: 0.22
Nodes (8): 1. 音频格式约定(已定), 2. 播报清单(14 条), 3. 播放策略(前端已实现侧约定), 4. 生成验收, A. 步骤引导(进入每个拍照步骤时播,与屏幕步骤文案一致), B. 姿态提示(实时检测不通过时播,同一提示 4 秒内不重复), C. 拍摄与流程, 巨子测肤 · 1'me 语音播报内容设计文档 V1.0
### Community 25 - "extractParamsFromWeigthMap$1"
Cohesion: 0.40
Nodes (3): extractParamsFromWeigthMap$1(), extractParamsFromWeigthMap$3(), seperateWeightMaps()
### Community 26 - "wi"
Cohesion: 0.28
Nodes (9): Ci(), Ei(), Fi(), Ii(), ki(), pe(), ri(), wi() (+1 more)
### Community 27 - "gen_voice.py"
Cohesion: 0.30
Nodes (11): ndarray, Path, gen_countdown(), main(), normalize_clip(), 倒计时单独按字生成再拼接,避免「三、二、一」整句被读成播报腔、与提示音气质漂移。 与提示音共用同一 speaker + instruct;句间留约…, 裁掉过长首尾静音,保留 ≤150ms 边沿,避免提示显迟钝。, 响度归一 −16 LUFS + 转 MP3 48k CBR mono 22050Hz。 (+3 more)
### Community 28 - "initialize"
Cohesion: 0.29
Nodes (7): createBrowserEnv(), createFileSystem(), createNodejsEnv(), initialize(), isBrowser(), monkeyPatch(), setEnv()
### Community 29 - "face-api.js 本地化依赖(h5/vendor/faceapi/)"
Cohesion: 0.29
Nodes (6): face-api.js 本地化依赖(h5/vendor/faceapi/), 为什么本地化, 文件清单与体积, 来源与版本, 用法, 许可证
### Community 30 - "regression.js"
Cohesion: 0.60
Nodes (5): diffCtx(), loadJSON(), main(), okNum(), okStr()
### Community 31 - "isValidNumber"
Cohesion: 0.50
Nodes (4): Dimensions(), isValidNumber(), isValidProbablitiy(), isWithGender()
### Community 33 - "build-fixture.py"
Cohesion: 0.60
Nodes (4): main(), 递归缩放 result 内全部坐标:{left,top,width,height} 矩形与 {x,y} 点,与原数据同为整数。, scale_coords(), to_data_url()
### Community 36 - "so"
Cohesion: 0.50
Nodes (4): Do(), io(), so(), To()
### Community 37 - "tt"
Cohesion: 0.50
Nodes (4): kn(), tt(), wo(), z()
### Community 39 - "cn"
Cohesion: 0.67
Nodes (3): cn(), en(), yn()
### Community 40 - ".computeFaceDescriptor"
Cohesion: 0.18
Nodes (4): ComputeAllFaceDescriptorsTask(), ComputeSingleFaceDescriptorTask(), extendWithFaceDescriptor(), FaceRecognitionNet()
### Community 41 - "SsdMobilenetv1"
Cohesion: 0.40
Nodes (3): extractorsFactory$5(), extractParamsFromWeigthMap$5(), SsdMobilenetv1()
### Community 42 - "dr"
Cohesion: 0.67
Nodes (3): dr(), na(), ta()
### Community 44 - "rescaleAndNormalize"
Cohesion: 0.67
Nodes (3): getSizesForScale(), normalize$1(), rescaleAndNormalize()
### Community 49 - "Yo"
Cohesion: 0.67
Nodes (3): qo(), Xo(), Yo()
### Community 58 - ".dispose"
Cohesion: 0.14
Nodes (13): computeReshapedDimensions(), extractBoundingBoxes(), iou(), isEven(), nonMaxSuppression(), nonMaxSuppression$1(), pyramidDown(), scale() (+5 more)
### Community 61 - ".extractWeights"
Cohesion: 0.08
Nodes (25): createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractConvParamsFactory(), extractFCParamsFactory() (+17 more)
### Community 63 - "e2e-browser.mjs"
Cohesion: 0.29
Nodes (4): EXPECTED, fails, pageErrors, pwRoot
## Knowledge Gaps
- **63 isolated node(s):** `step1-front`, `step2-rightface`, `step3-leftface`, `hint-noface`, `hint-near` (+58 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **21 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `val()` connect `skin-score.js` to `extractFaces`?**
_High betweenness centrality (0.083) - this node is a cross-community bridge._
- **Why does `vizCompose()` connect `app.js` to `n`?**
_High betweenness centrality (0.058) - this node is a cross-community bridge._
- **Why does `Box()` connect `extractFaces` to `face-api.js`, `.dispose`, `isValidNumber`, `FaceLandmarks`?**
_High betweenness centrality (0.058) - this node is a cross-community bridge._
- **Are the 23 inferred relationships involving `n()` (e.g. with `ae()` and `be()`) actually correct?**
_`n()` has 23 INFERRED edges - model-reasoned connections that need verification._
- **Are the 7 inferred relationships involving `t()` (e.g. with `renderReport()` and `e()`) actually correct?**
_`t()` has 7 INFERRED edges - model-reasoned connections that need verification._
- **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 `step1-front`, `step2-rightface`, `step3-leftface` to the rest of the system?**
_63 weakly-connected nodes found - possible documentation gaps or missing edges._
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+812
View File
@@ -0,0 +1,812 @@
{
"design/file-tree.json": {
"mtime": 1790000113.2956002,
"ast_hash": "88c90bbcbe3229fa637280968a08a0dd",
"semantic_hash": ""
},
"design/full.json": {
"mtime": 1790000315.5846467,
"ast_hash": "2de4a512da615f6d73cd4b8651e6a419",
"semantic_hash": ""
},
"design/imgusage.json": {
"mtime": 1790000370.6153483,
"ast_hash": "00b2a8d870c5324489f326969b1b4c85",
"semantic_hash": ""
},
"design/node-4-48.json": {
"mtime": 1790000094.0240362,
"ast_hash": "efb5bc302a86c3500cca0656d4710527",
"semantic_hash": ""
},
"h5/assets/mock/build-fixture.py": {
"mtime": 1790528158.658554,
"ast_hash": "759c1402050f8d09ac713c0ec45dd7ae",
"semantic_hash": ""
},
"h5/assets/mock/report-fixture.js": {
"mtime": 1790528158.6625543,
"ast_hash": "a29ac0b23b262206470345e32041a0c8",
"semantic_hash": ""
},
"h5/assets/mock/smoke-test.js": {
"mtime": 1790438217.8753266,
"ast_hash": "4aa15834481b394b873c32f38c782bb0",
"semantic_hash": ""
},
"h5/assets/voice/manifest.json": {
"mtime": 1790531330.970398,
"ast_hash": "bd691605fe7c997dea60869a892624c3",
"semantic_hash": ""
},
"h5/js/app.js": {
"mtime": 1790531707.8223634,
"ast_hash": "7c3652449c905d9f19ca37a66089766f",
"semantic_hash": ""
},
"h5/js/head-pose.js": {
"mtime": 1790528422.8265557,
"ast_hash": "165cc2ae73d0a9526db0755ec62ffaac",
"semantic_hash": ""
},
"h5/js/skin-score.js": {
"mtime": 1790528158.6665542,
"ast_hash": "61d27784c13fc9fd5ea3b4e2f6187756",
"semantic_hash": ""
},
"h5/server.js": {
"mtime": 1790528158.6705542,
"ast_hash": "e23a541e2ce85e0815616071820b46ef",
"semantic_hash": ""
},
"h5/test/build_fixtures.py": {
"mtime": 1790438217.95594,
"ast_hash": "71c960e0089ed6e10a26789f8aee17d3",
"semantic_hash": ""
},
"h5/test/fixtures/case1.json": {
"mtime": 1790438217.95594,
"ast_hash": "879ad7b59bd68ab233e20f90b76e3a9f",
"semantic_hash": ""
},
"h5/test/fixtures/case2.json": {
"mtime": 1790438217.95594,
"ast_hash": "66c1aaebec257b6d7ba237d7d4bbe3da",
"semantic_hash": ""
},
"h5/test/fixtures/truth.json": {
"mtime": 1790438217.95594,
"ast_hash": "c85c9f5317c0603afd0a04f1e92361bc",
"semantic_hash": ""
},
"h5/test/regression.js": {
"mtime": 1790528158.6705542,
"ast_hash": "d83fc0a378d1117fc2203569d325ca24",
"semantic_hash": ""
},
"h5/vendor/faceapi/face-api.js": {
"mtime": 1790438217.9597785,
"ast_hash": "6939d12050455a60cdc7871d56e16d37",
"semantic_hash": ""
},
"h5/vendor/faceapi/models/face_landmark_68_model-weights_manifest.json": {
"mtime": 1790438217.9597785,
"ast_hash": "1d4029763003335bc6921aadeb58706a",
"semantic_hash": ""
},
"h5/vendor/faceapi/models/tiny_face_detector_model-weights_manifest.json": {
"mtime": 1790438217.9597785,
"ast_hash": "5bab50532388f5da9b4cd85b15adc11c",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/examples/call_skinanalyze.sh": {
"mtime": 1790395590.0,
"ast_hash": "14041c2f7ede800a3d661e6d6dd31568",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/examples/call_skinanalyze_3view.sh": {
"mtime": 1790407434.0,
"ast_hash": "f024f01d1869662aa896a69e5c7efd3b",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/examples/last_3view_result.json": {
"mtime": 1790438217.9636173,
"ast_hash": "8097ab0b741dae75ea7aa07e506755f0",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/examples/ref_3view_raw.json": {
"mtime": 1790438217.9904885,
"ast_hash": "bbd2255eecada059f2253e7adde0527a",
"semantic_hash": ""
},
"tools/gen_voice.py": {
"mtime": 1790531338.7343898,
"ast_hash": "00e33f0da3f6fbe8357e60d89ad27879",
"semantic_hash": ""
},
"design/\u8bbe\u8ba1\u89c4\u8303.md": {
"mtime": 1790049369.1868372,
"ast_hash": "cdddd8fbd14bcd79b66ec6c4b707d309",
"semantic_hash": ""
},
"docs/\u8bed\u97f3\u64ad\u62a5\u5185\u5bb9\u8bbe\u8ba1\u6587\u6863_V1.0.md": {
"mtime": 1790531341.690387,
"ast_hash": "93c71559618d0f53a4e02ab763a20333",
"semantic_hash": ""
},
"h5/README.md": {
"mtime": 1790531597.4743752,
"ast_hash": "26db3c846af2efc0a4bfb442985e2922",
"semantic_hash": ""
},
"h5/index.html": {
"mtime": 1790531666.4743714,
"ast_hash": "66cb0b0c306fcc9adb4dbed358651d13",
"semantic_hash": ""
},
"h5/vendor/faceapi/README.md": {
"mtime": 1790438217.95594,
"ast_hash": "dac9995fc002041506663986698a82ae",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/SKILL.md": {
"mtime": 1790407478.0,
"ast_hash": "3a0039ccbaf7e2aa0f830486bcbd5391",
"semantic_hash": ""
},
"design/01-\u542f\u52a8\u9875.png": {
"mtime": 1790000154.6901708,
"ast_hash": "d2ca3923a7c401966a73e44d438ba4e7",
"semantic_hash": ""
},
"design/02-\u542f\u52a8\u9875\u5f39\u7a97.png": {
"mtime": 1790000158.6925905,
"ast_hash": "7f19630b71855608f9679132461d55ae",
"semantic_hash": ""
},
"design/03-\u6863\u6848\u586b\u5199.png": {
"mtime": 1790000162.200167,
"ast_hash": "9807a1be1ebe6e9f1c502571cbe54d78",
"semantic_hash": ""
},
"design/04-\u62cd\u7167\u5f39\u7a97.png": {
"mtime": 1790000166.471339,
"ast_hash": "f4a252af1137ed2187898e20a380a88f",
"semantic_hash": ""
},
"design/05-\u6b63\u9762.png": {
"mtime": 1790000168.8546982,
"ast_hash": "e4b3fa148783b4362d26347fd703c71b",
"semantic_hash": ""
},
"design/06-\u5de6\u9762.png": {
"mtime": 1790000173.1124754,
"ast_hash": "e637771b374a3ddd58324e8d97e3d4a2",
"semantic_hash": ""
},
"design/07-\u53f3\u9762.png": {
"mtime": 1790000175.8311493,
"ast_hash": "1a7b7e8c334a9209101e572103fa67c2",
"semantic_hash": ""
},
"design/08-\u62a5\u544a\u9875.png": {
"mtime": 1790000180.7451975,
"ast_hash": "4ff22bccae9a45c44df7c86fe6cb981f",
"semantic_hash": ""
},
"design/09-\u80a4\u8d28\u9009\u62e9.png": {
"mtime": 1790000513.5305562,
"ast_hash": "6d1e321fde605e85357a5be744c4def5",
"semantic_hash": ""
},
"design/assets/images/\u4ea7\u54c1\u56fe-1.png": {
"mtime": 1790000430.1019387,
"ast_hash": "c6df1a56cf0e33713030e3eaa198a5e0",
"semantic_hash": ""
},
"design/assets/images/\u4ea7\u54c1\u56fe-2.png": {
"mtime": 1790000404.3295372,
"ast_hash": "cf579c739843a70967ff2c422d0c9325",
"semantic_hash": ""
},
"design/assets/images/\u4ea7\u54c1\u56fe-3.png": {
"mtime": 1790000421.2071846,
"ast_hash": "6936c50c6e4b85282ddbbda9577f8b9c",
"semantic_hash": ""
},
"design/assets/images/\u4ea7\u54c1\u56fe-4.png": {
"mtime": 1790000399.6728995,
"ast_hash": "9e23a07171a70733d27aa782381d97e6",
"semantic_hash": ""
},
"design/assets/images/\u53f3\u9762-\u62cd\u7167\u793a\u610f.png": {
"mtime": 1790000437.9018357,
"ast_hash": "b3e2b5821568a915996819c1cfc7a904",
"semantic_hash": ""
},
"design/assets/images/\u542f\u52a8\u9875-\u80cc\u666f.png": {
"mtime": 1790000413.581229,
"ast_hash": "bf422f0e71871971b2c80fe42be6b64b",
"semantic_hash": ""
},
"design/assets/images/\u542f\u52a8\u9875-\u9876\u90e8\u88c5\u9970.png": {
"mtime": 1790000426.6591818,
"ast_hash": "b8efcb504f76bd344db8d6e3033b6c3a",
"semantic_hash": ""
},
"design/assets/images/\u5de6\u9762-\u62cd\u7167\u793a\u610f.png": {
"mtime": 1790000416.8443816,
"ast_hash": "77648573f074abec346a3d37e08bcf0a",
"semantic_hash": ""
},
"design/assets/images/\u62a5\u544a\u9875-\u80cc\u666f.png": {
"mtime": 1790000396.365109,
"ast_hash": "bae154cd0079887afd54061a9f82289d",
"semantic_hash": ""
},
"design/assets/images/\u6863\u6848\u586b\u5199-\u80cc\u666f.png": {
"mtime": 1790000418.3826458,
"ast_hash": "43803b9afed439c0a76644a3aedd9c43",
"semantic_hash": ""
},
"design/assets/images/\u68c0\u6d4b\u56fe-1.png": {
"mtime": 1790000408.6502259,
"ast_hash": "974334dd6b09a2d433b1c332b078c552",
"semantic_hash": ""
},
"design/assets/images/\u68c0\u6d4b\u56fe-2.png": {
"mtime": 1790000435.4466016,
"ast_hash": "0183da8dafa710b60f5bd050be660b4f",
"semantic_hash": ""
},
"design/assets/images/\u68c0\u6d4b\u56fe-3.png": {
"mtime": 1790000432.671956,
"ast_hash": "bb835cb62b9d93edd9be15c00b08321e",
"semantic_hash": ""
},
"design/assets/images/\u68c0\u6d4b\u56fe-4.png": {
"mtime": 1790000424.6448433,
"ast_hash": "8b58cd531d67b6cec999af5f70507d93",
"semantic_hash": ""
},
"design/assets/images/\u6b63\u9762-\u62cd\u7167\u793a\u610f.png": {
"mtime": 1790000393.3901157,
"ast_hash": "16c0b62b53b41b1b0b9478b256bc4844",
"semantic_hash": ""
},
"design/assets/svg/\u54c1\u724c-1me.svg": {
"mtime": 1790000462.5893614,
"ast_hash": "9e2d6467c9652c0bf993a63a1cb4b86c",
"semantic_hash": ""
},
"design/assets/svg/\u56fe\u6807-\u4e0b\u62c9\u4e0e\u8fd4\u56de.svg": {
"mtime": 1790000457.297922,
"ast_hash": "969224abe7e91e6c791c6d568a9f520c",
"semantic_hash": ""
},
"design/assets/svg/\u56fe\u6807-\u5173\u95ed.svg": {
"mtime": 1790000455.4710763,
"ast_hash": "b5305a8fd62af5f2b572883261b6d01d",
"semantic_hash": ""
},
"design/assets/svg/\u56fe\u6807-\u76f8\u518c.svg": {
"mtime": 1790000459.09632,
"ast_hash": "b88e51bfc2badd09969127d262692196",
"semantic_hash": ""
},
"design/assets/svg/\u56fe\u6807-\u91cd\u62cd.svg": {
"mtime": 1790000460.9408302,
"ast_hash": "5851ef2c4d9d327e0d2ee5e75457c7f2",
"semantic_hash": ""
},
"design/assets/svg/\u62cd\u7167-\u5feb\u95e8.svg": {
"mtime": 1790000453.87119,
"ast_hash": "cf41a6fa1e677b1fb3e920bc8329a8bf",
"semantic_hash": ""
},
"design/assets/svg/\u6309\u94ae-\u4e3b\u6309\u94ae.svg": {
"mtime": 1790000452.1367342,
"ast_hash": "413cd4ae4a09fe4714e0ca11c12d0f23",
"semantic_hash": ""
},
"design/assets/svg/\u80a4\u8d28-\u9762\u90e8\u63d2\u753b.svg": {
"mtime": 1790000464.5260975,
"ast_hash": "d5196714a0b74dd1776c814b70fd34ca",
"semantic_hash": ""
},
"h5/assets/favicon.svg": {
"mtime": 1790049363.6837668,
"ast_hash": "c607b5042971f9c893c72c50ccabfbd2",
"semantic_hash": ""
},
"h5/assets/img/bg-splash.jpg": {
"mtime": 1790438217.8714879,
"ast_hash": "e1a17b5eca9b820d00e1f1f4ade26dd4",
"semantic_hash": ""
},
"h5/assets/img/cam-front.jpg": {
"mtime": 1790002769.368461,
"ast_hash": "c6fc84aa9669a62e93827a8afc8080c5",
"semantic_hash": ""
},
"h5/assets/img/cam-left.jpg": {
"mtime": 1790528422.8225558,
"ast_hash": "778113ed834bf0d7524f285a4796d0c6",
"semantic_hash": ""
},
"h5/assets/img/cam-right.jpg": {
"mtime": 1790528422.8225558,
"ast_hash": "0204aaa790b6ec96458fe15cafc8c09f",
"semantic_hash": ""
},
"h5/assets/img/det-1.jpg": {
"mtime": 1790002770.475919,
"ast_hash": "a40b32001b6eec4e540cf8c59ce6cfff",
"semantic_hash": ""
},
"h5/assets/img/det-2.jpg": {
"mtime": 1790002770.4835565,
"ast_hash": "32958edf9582ef0e76aae6ffbb234e88",
"semantic_hash": ""
},
"h5/assets/img/prod-1.jpg": {
"mtime": 1790002770.4491873,
"ast_hash": "f4785fb93b92b943bf82e33383bf2989",
"semantic_hash": ""
},
"h5/assets/img/skin-0-dry.png": {
"mtime": 1790002750.3837938,
"ast_hash": "d29aa6f0991f1d323c2070f8d176f29a",
"semantic_hash": ""
},
"h5/assets/img/skin-1-mixeddry.png": {
"mtime": 1790002752.342788,
"ast_hash": "bb292677c581ce480c1a46619c1a3875",
"semantic_hash": ""
},
"h5/assets/img/skin-2-neutral.png": {
"mtime": 1790002753.958099,
"ast_hash": "f355bd6e48f75ba4b3e15a8099e540f7",
"semantic_hash": ""
},
"h5/assets/img/skin-3-oily.png": {
"mtime": 1790002755.8330815,
"ast_hash": "be8f518b42e53cf97f57451c1270ac29",
"semantic_hash": ""
},
"h5/assets/img/skin-4-mixedoily.png": {
"mtime": 1790002759.3910704,
"ast_hash": "8aa99ca6593dcb550492ce483940807a",
"semantic_hash": ""
},
"h5/assets/svg/\u54c1\u724c-1me.svg": {
"mtime": 1790002759.3987067,
"ast_hash": "9e2d6467c9652c0bf993a63a1cb4b86c",
"semantic_hash": ""
},
"h5/assets/svg/\u56fe\u6807-\u4e0b\u62c9\u4e0e\u8fd4\u56de.svg": {
"mtime": 1790002759.3987067,
"ast_hash": "969224abe7e91e6c791c6d568a9f520c",
"semantic_hash": ""
},
"h5/assets/svg/\u56fe\u6807-\u5173\u95ed.svg": {
"mtime": 1790002759.3987067,
"ast_hash": "b5305a8fd62af5f2b572883261b6d01d",
"semantic_hash": ""
},
"h5/assets/svg/\u56fe\u6807-\u76f8\u518c.svg": {
"mtime": 1790002759.3987067,
"ast_hash": "b88e51bfc2badd09969127d262692196",
"semantic_hash": ""
},
"h5/assets/svg/\u56fe\u6807-\u91cd\u62cd.svg": {
"mtime": 1790002759.3987067,
"ast_hash": "5851ef2c4d9d327e0d2ee5e75457c7f2",
"semantic_hash": ""
},
"h5/assets/svg/\u62cd\u7167-\u5feb\u95e8.svg": {
"mtime": 1790002759.3987067,
"ast_hash": "cf41a6fa1e677b1fb3e920bc8329a8bf",
"semantic_hash": ""
},
"h5/preview/01-splash.png": {
"mtime": 1790050131.6772592,
"ast_hash": "c715cc3da1bad38ce7bf023746e8081e",
"semantic_hash": ""
},
"h5/preview/02-privacy.png": {
"mtime": 1790050131.6772592,
"ast_hash": "d04922d16ce5b9cdd7e7f2ac4c4b09a3",
"semantic_hash": ""
},
"h5/preview/03-policy.png": {
"mtime": 1790050131.6772592,
"ast_hash": "e8913d2d0d9aa11fb6c57f5c9bc774df",
"semantic_hash": ""
},
"h5/preview/04-profile.png": {
"mtime": 1790050131.6772592,
"ast_hash": "f0a6894d702f773ffb1bedd7b49812a2",
"semantic_hash": ""
},
"h5/preview/05-profile-skin1.png": {
"mtime": 1790050131.6772592,
"ast_hash": "1d47579cd47578c383e244e9aa6b9282",
"semantic_hash": ""
},
"h5/preview/06-profile-skin3.png": {
"mtime": 1790050131.6772592,
"ast_hash": "3974c06483739676578d320e485a7e8c",
"semantic_hash": ""
},
"h5/preview/07-photoguide.png": {
"mtime": 1790050131.6772592,
"ast_hash": "0d90b2f73832739a7f52c9a236114a60",
"semantic_hash": ""
},
"h5/preview/08-cam-front.png": {
"mtime": 1790528422.8265557,
"ast_hash": "6a91a1ad636b7dfc6894b24dbf56a28b",
"semantic_hash": ""
},
"h5/preview/09-cam-left.png": {
"mtime": 1790528422.8265557,
"ast_hash": "b5112fc38a23d5a56705a54bc4459120",
"semantic_hash": ""
},
"h5/preview/10-cam-right.png": {
"mtime": 1790528422.8265557,
"ast_hash": "336a83daf1bb236f1eba0938cffba08d",
"semantic_hash": ""
},
"h5/preview/11-report-top.png": {
"mtime": 1790050131.6772592,
"ast_hash": "c02509ee4f78ef5120a151fce6698d51",
"semantic_hash": ""
},
"h5/preview/12-report-mid.png": {
"mtime": 1790050131.6772592,
"ast_hash": "664b00f9c7123cd19aa98b9297d91552",
"semantic_hash": ""
},
"h5/preview/13-report-bottom.png": {
"mtime": 1790050131.6772592,
"ast_hash": "664b00f9c7123cd19aa98b9297d91552",
"semantic_hash": ""
},
"h5/preview/14-report-vm-viewport.png": {
"mtime": 1790438217.8791654,
"ast_hash": "180ce2cde0f2f40000cc7f8d6fe955c3",
"semantic_hash": ""
},
"h5/preview/15-report-vm-full.png": {
"mtime": 1790438217.8791654,
"ast_hash": "280932f0bc053e918c314cb745e851ae",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/01-splash.png": {
"mtime": 1790438217.8945203,
"ast_hash": "8e2627a1b5bc4bc112c89d0fc9ca0b15",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/02-privacy.png": {
"mtime": 1790438217.898359,
"ast_hash": "32bb6862f6706b46627d4ce93732fd74",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/03-profile.png": {
"mtime": 1790438217.898359,
"ast_hash": "50da58d92e1a70467cc79a370f0c0d4b",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/04-photoguide.png": {
"mtime": 1790438217.9021978,
"ast_hash": "dba989b00270fc733b2478ca4bc06bae",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/05-cam-step1.png": {
"mtime": 1790438217.9021978,
"ast_hash": "d0bf44295ec0eead4db79a8513e7cee6",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/06-mock-report-bottom.png": {
"mtime": 1790438217.9060364,
"ast_hash": "33cb9b3ab0982b2c8d9ddab060153502",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/06-mock-report-tab0.png": {
"mtime": 1790438217.9060364,
"ast_hash": "e0f8cb949cd37fe5fbec206ab9700d36",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/06-mock-report-tab1.png": {
"mtime": 1790438217.9098752,
"ast_hash": "e693fbe6914b8f56cb186534493c7b83",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/06-mock-report-tab2.png": {
"mtime": 1790438217.9098752,
"ast_hash": "f864c88ef9088ff1b130dd35372d150c",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/08-real-report-bottom.png": {
"mtime": 1790438217.9098752,
"ast_hash": "a458d0674304fb3b1312caa915c8e515",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/08-real-report-tab0.png": {
"mtime": 1790438217.913714,
"ast_hash": "923315723540ffd1e8759ffd6b29ffb8",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/08-real-report-tab1.png": {
"mtime": 1790438217.913714,
"ast_hash": "9c39170a3937002e8dc2a2a494c654f4",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/08-real-report-tab2.png": {
"mtime": 1790438217.9175527,
"ast_hash": "30a60c22a71b98c1c4ff8a2f065bae7e",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/09-policy-end.png": {
"mtime": 1790438217.9175527,
"ast_hash": "0c46468e37116cdbc4380cf226630c70",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/09-policy-mid.png": {
"mtime": 1790438217.9213915,
"ast_hash": "16ed812b52d452db5153bff5a8f3f964",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/09-policy-top.png": {
"mtime": 1790438217.9213915,
"ast_hash": "29b3c9f9083afe6810a0c75e7b3a845b",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/vp352x736-splash.png": {
"mtime": 1790438217.9290688,
"ast_hash": "cc4e8f45d3a6aca94710b7c099cb895d",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/vp375x812-splash.png": {
"mtime": 1790438217.9329076,
"ast_hash": "8e2627a1b5bc4bc112c89d0fc9ca0b15",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/vp390x844-splash.png": {
"mtime": 1790438217.940585,
"ast_hash": "9a5d05c6534f65ba6cd244b2d2fc55c1",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/vp414x896-splash.png": {
"mtime": 1790438217.9521012,
"ast_hash": "09350489e9692fc8b3b7b70c1a0de047",
"semantic_hash": ""
},
"h5/preview/verify-report.png": {
"mtime": 1790438217.9521012,
"ast_hash": "65029ee2e9ee620e2513c999ad5650cb",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/examples/images/C3_-135.jpg": {
"mtime": 1790407491.0,
"ast_hash": "65e145520cf438df78b985ddddebe6fd",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/examples/images/C3_135.jpg": {
"mtime": 1790407491.0,
"ast_hash": "af8c5642cfa8b4a4af1f48e4bb02f602",
"semantic_hash": ""
},
"reference/facepp-skinanalyze/examples/images/C3_180.jpg": {
"mtime": 1790407491.0,
"ast_hash": "3b3ba2bbe11175c76f25ea63730106bd",
"semantic_hash": ""
},
"h5/assets/voice/analyze-fail.mp3": {
"mtime": 1790438527.0770879,
"ast_hash": "d1c9babebdf8ce4a4b84cbcc91ae75a8",
"semantic_hash": ""
},
"h5/assets/voice/analyzing.mp3": {
"mtime": 1790438526.9699075,
"ast_hash": "215590766d8155ba6a31623ad124990f",
"semantic_hash": ""
},
"h5/assets/voice/countdown-321.mp3": {
"mtime": 1790476628.9047492,
"ast_hash": "2d70857eb145ff43c48070e3b1dc68e7",
"semantic_hash": ""
},
"h5/assets/voice/hint-far.mp3": {
"mtime": 1790438526.610088,
"ast_hash": "5909ae510464763f60d0c0d22a8253e7",
"semantic_hash": ""
},
"h5/assets/voice/hint-front.mp3": {
"mtime": 1790438526.6636782,
"ast_hash": "fb3d866925dcde339e99a46a83c41598",
"semantic_hash": ""
},
"h5/assets/voice/hint-hold.mp3": {
"mtime": 1790438526.7938256,
"ast_hash": "a12c24dff61679831b24d9dfad121ad1",
"semantic_hash": ""
},
"h5/assets/voice/hint-near.mp3": {
"mtime": 1790438526.5718093,
"ast_hash": "eb3ad138d3d9bf45cefd82c8b1c2013f",
"semantic_hash": ""
},
"h5/assets/voice/hint-noface.mp3": {
"mtime": 1790438526.5105634,
"ast_hash": "c3ef6d710e1cacb9a03f00bf1a08fd45",
"semantic_hash": ""
},
"h5/assets/voice/hint-turn-left.mp3": {
"mtime": 1790438526.7057848,
"ast_hash": "03596c1fcd62f0656c0c674a67ac0649",
"semantic_hash": ""
},
"h5/assets/voice/hint-turn-right.mp3": {
"mtime": 1790438526.755547,
"ast_hash": "7a73db280cb86b3c513f044e6cceeaa2",
"semantic_hash": ""
},
"h5/assets/voice/shot-done.mp3": {
"mtime": 1790438526.897178,
"ast_hash": "09eaefdee08f5f5edbe75c58fe57f1ff",
"semantic_hash": ""
},
"h5/assets/voice/step1-front.mp3": {
"mtime": 1790438526.2005062,
"ast_hash": "8b19ad0d6dd7692bc6a10fcab5fd6ee7",
"semantic_hash": ""
},
"tools/voice_wav_tmp/analyze-fail.wav": {
"mtime": 1790438526.9737353,
"ast_hash": "65abad5af593c912e19c245c6f14483c",
"semantic_hash": ""
},
"tools/voice_wav_tmp/analyzing.wav": {
"mtime": 1790438526.897178,
"ast_hash": "08ffd1d47f4c9d83f17120a938ef1839",
"semantic_hash": ""
},
"tools/voice_wav_tmp/countdown-321.wav": {
"mtime": 1790476628.7545965,
"ast_hash": "3b8e9ffcab556fd7adf8ec393bc39a82",
"semantic_hash": ""
},
"tools/voice_wav_tmp/hint-far.wav": {
"mtime": 1790438526.5718093,
"ast_hash": "4b7b0555fc3a826bcb18d251247b6aea",
"semantic_hash": ""
},
"tools/voice_wav_tmp/hint-front.wav": {
"mtime": 1790438526.613916,
"ast_hash": "64e7c24d89c5ded6bfcd7fabc8b9f314",
"semantic_hash": ""
},
"tools/voice_wav_tmp/hint-hold.wav": {
"mtime": 1790438526.7593749,
"ast_hash": "406f91fa180fe2eb6502e26ab8d30170",
"semantic_hash": ""
},
"tools/voice_wav_tmp/hint-near.wav": {
"mtime": 1790438526.5143914,
"ast_hash": "44baaf9c5b68bdcab51893d0464c950e",
"semantic_hash": ""
},
"tools/voice_wav_tmp/hint-noface.wav": {
"mtime": 1790438526.3957274,
"ast_hash": "5289e30144bda0019352fcae944e253a",
"semantic_hash": ""
},
"tools/voice_wav_tmp/hint-turn-left.wav": {
"mtime": 1790438526.667506,
"ast_hash": "cb4b8752c0d918e0a7d0f10eeeb510a8",
"semantic_hash": ""
},
"tools/voice_wav_tmp/hint-turn-right.wav": {
"mtime": 1790438526.7096126,
"ast_hash": "61513064e4a0b0727e0e68ac5d41ed43",
"semantic_hash": ""
},
"tools/voice_wav_tmp/shot-done.wav": {
"mtime": 1790438526.866555,
"ast_hash": "b7053fef4fb194acb8646d105970fdaf",
"semantic_hash": ""
},
"tools/voice_wav_tmp/step1-front.wav": {
"mtime": 1790438526.0703588,
"ast_hash": "f528486fa595066fb8c89ba68f82987f",
"semantic_hash": ""
},
"tools/voice_wav_tmp/step2-rightface.wav": {
"mtime": 1790438526.204334,
"ast_hash": "5b4d9391087ec0f7aeaff7efdbacd39b",
"semantic_hash": ""
},
"tools/voice_wav_tmp/step3-leftface.wav": {
"mtime": 1790438526.322998,
"ast_hash": "890de5970d83b76604a1823f47faa567",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/10-history-list.png": {
"mtime": 1790476530.4511077,
"ast_hash": "98aac9734d0734ab786479ea4b46db55",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/11-auto3shot-report.png": {
"mtime": 1790476530.4548612,
"ast_hash": "010ce5731435048085fe4a2ae4edd558",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/debug-cam-live.png": {
"mtime": 1790476530.4548612,
"ast_hash": "6519b24c8da157b893ec654fec5c6457",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r6-region.png": {
"mtime": 1790476530.4586143,
"ast_hash": "641d1de74e43e17f59c93083eb449a87",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r6-report.png": {
"mtime": 1790476530.4586143,
"ast_hash": "411861be4980af6506712519d63fb5e1",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r7-policy.png": {
"mtime": 1790476530.4586143,
"ast_hash": "b18e55a76b10c77f68934c9655d323aa",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r7-tab1.png": {
"mtime": 1790476530.4586143,
"ast_hash": "731fac1d0ce5efb0d8914b18b51c2177",
"semantic_hash": ""
},
"h5/test/e2e-browser.mjs": {
"mtime": 1790531366.4343376,
"ast_hash": "3ae597baedac698f02f27c5662784348",
"semantic_hash": ""
},
"h5/assets/svg/prod-badge.svg": {
"mtime": 1790528422.8225558,
"ast_hash": "672628fa098279e7ff6af11ac46f0d40",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r8-cam-step2.png": {
"mtime": 1790528158.6665542,
"ast_hash": "426f78d3a7707a6055de143e1b7db62f",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r8-report-mid.png": {
"mtime": 1790528158.6665542,
"ast_hash": "fa33c1814681a8875c059535725cc3fb",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r9-hint.png": {
"mtime": 1790528158.6705542,
"ast_hash": "12e66e2c4dd2a2eb3868e9ebf3422823",
"semantic_hash": ""
},
"h5/preview/e2e-20260926/r9-tab.png": {
"mtime": 1790528158.6705542,
"ast_hash": "5844e3cdce417de3c7cd448cc226688b",
"semantic_hash": ""
},
"h5/assets/voice/step2-rightface.mp3": {
"mtime": 1790528422.8225558,
"ast_hash": "8c0a0adf17ae494cc10d61d16c2bde90",
"semantic_hash": ""
},
"h5/assets/voice/step3-leftface.mp3": {
"mtime": 1790528422.8225558,
"ast_hash": "9f7ec9320aad81e86fbb1677228e6483",
"semantic_hash": ""
}
}
+80 -22
View File
@@ -10,10 +10,18 @@
```
h5/
├── index.html 页面结构(启动 / 档案 / 拍照 / 报告 + 弹层 + 内联 SVG 取景蒙版)
├── server.js 后端:静态托管 + POST /api/skin-analyze(Face++ skinanalyze_pro 三视图中转)
├── css/style.css 全部样式(375px 基准)
├── admin.html 后台查看页(记录列表 / 失败与异常 / 统计概览,零依赖,见下文「后台查看」)
├── server.js 后端:静态托管 + POST /api/skin-analyze(Face++ skinanalyze_pro 三视图中转)+ /api/admin/*(记录查询/重试/统计/导出)
├── css/style.css C 端全部样式(375px 基准)
├── css/admin.css 后台查看页样式
├── js/app.js 全部交互(屏幕切换、肤质轮播、引导式三连拍、报告渲染)
├── js/skin-score.js 三维度×6分类评分引擎(纯逻辑 UMD,window.SkinScore,可 node 自测)
├── js/admin.js 后台查看页脚本(纯原生 ES5)
├── js/skin-score.js 三维度×6分类评分引擎(纯逻辑 UMD,window.SkinScore,可 node 自测;服务端评分复用同一份)
├── js/head-pose.js 头部姿态估算(纯逻辑 UMD,window.HeadPose,可 node 自测)
├── data/records.jsonl 检测记录库(一行一条 JSON,零依赖嵌入式存储,已 .gitignore)
├── data/photos/ 检测照片 data/photos/<record_id>/{front,left,right}.jpg(本地磁盘代替对象存储)
├── data/seed.js 演示种子数据(一次性:node h5/data/seed.js,覆盖重写 records.jsonl)
├── data/results/ 每次检测的 Face++ 原始返回落盘(复核用,已 .gitignore)
├── js/head-pose.js 头部姿态估算(纯逻辑 UMD,window.HeadPose,可 node 自测)
├── test/regression.js 真值回归(node h5/test/regression.js,对照 docs xlsx 两组三视图真值)
├── vendor/faceapi/ face-api.js 0.22.2 + 模型(本地化,见该目录 README)
@@ -46,29 +54,34 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
调试:
- `http://127.0.0.1:8080/?mock=1` 使用内置样本(`SkinScore.MOCK_RESULT`/`MOCK_SIDE`)跳过真实检测;不加载 face-api、不跑姿态检测,快门手动逐步推进(离线/自动化验证用)。
- `http://127.0.0.1:8080/?mock=1` 使用内置样本(`SkinScore.MOCK_RESULT`/`MOCK_SIDE`)跳过真实检测;不加载 face-api、不跑姿态检测,显示快门手动逐步推进(离线/自动化验证用)。
- `http://127.0.0.1:8080/?mock=1&screen=report` 直渲 mock 报告页(**仅调试用**,供无头浏览器截图验证布局;档案缺省按 28 岁/干性肌肤);mock 数据源为 `assets/mock/report-fixture.js`(真实案例1三视图检测返回,含 face_maps 图层 data URL 与 marks 坐标),改该文件即可换样本。
## 拍照交互(引导式自动三连拍)
## 拍照交互(动态捕捉自动三连拍)
三步(顺序对齐 Figma,左右一律以用户本人为准):1/3 正脸(target `front`)→ 2/3 右脸出镜(用户头向自己的左侧转,target `right`,产物作 `right_image`)→ 3/3 左脸出镜(头向自己的右侧转,target `left`,产物作 `left_image`)。送检前 `photosByView` 按 target 重排为 [正, 左, 右]。
三步严格按顺序,本步拍到合格照片才进入下一步(左右一律以用户本人为准):1/3 正对屏幕(target `front`)→ 2/3 右脸出镜(用户头向自己的左侧转,target `right`,产物作 `right_image`)→ 3/3 左脸出镜(头向自己的右侧转,target `left`,产物作 `left_image`)。送检前 `photosByView` 按 target 重排为 [正, 左, 右]。页面内没有返回按钮,微信/浏览器返回键经 History API 回到档案页并释放摄像头。
- **步骤圆点**:顶部三点,当前步高亮、完成步绿色打勾。
- **取景蒙版**:内联 SVG(`#cam-mask`)按步骤切换轮廓——正脸步为正脸剪影(含耳部),侧脸步为对应方向的侧脸剪影(鼻朝向与转头方向一致);**轮廓路径由 Figma 拍照示意图(`design/assets/images/*-拍照示意.png` 的 alpha 镂空区)逐像素描摹生成**,坐标为 Figma 375×812 整帧,viewBox 只取 y 86–812(帧顶 86px 是微信导航栏,H5 视口从其下方开始;步骤文案/引导等纵向百分比也以 726 高为基准);框外 85% 黑(SVG mask 镂空,对齐 Figma 图层不透明度),轮廓线 2.5px 半透明白。未检测到人脸时轮廓呼吸动画(1.8s);姿态 ok 时轮廓变绿并停止动效。
- **正脸步提示口径**:正脸步角度不合格一律只提示「请正对屏幕」(不再提示左右转头/抬头低头),阈值 |yaw|≤15°、|pitch|≤18°(2026-09-28 产品要求放宽);侧脸步仍按方向提示「请向左/右转头」。
- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`)。进入拍照页即开始加载模型(状态提示"检测引擎加载中…"),加载完成前快门为手动模式。检测循环 ~150ms 一帧,`detectSingleFace(video, TinyFaceDetectorOptions({inputSize:416, scoreThreshold:0.3})).withFaceLandmarks()` 后拿 68 特征点调 `HeadPose.check(pos, target, mirrored)`——特征点取自原始帧(预览 CSS 镜像不影响),侧脸步 mirrored 恒 false(个别前置帧已镜像的机型由侧脸自校准翻转 `sideYawFlip`,只动判定不动画面),正脸步只看 |yaw|;连续漏检 ≥3 帧才报"未检测到人脸"(容忍单帧抖动);人脸框高度占比 <26% 提示"请靠近一点"、>58% 提示"请远一点"(以取景框≈42% 屏高标定)。提示一次一条,优先级:无人脸 > 距离 > 角度。
- **倒计时自动拍**:姿态 ok 连续维持 ≥600ms 后,先等「保持不动」等在播提示音自然收尾(画面保持绿框+保持不动),再进 3s 倒计时(中央大数字,轮廓保持绿色,数字与「三、二、一」语音同步起播,不再拦腰切断);倒计时中任一帧不 ok 立即取消并回到对应提示。倒计时结束进入白屏补光。
- **镜像策略**:前置镜头三步**预览**都镜像(照镜子习惯;Figma 侧脸轮廓即按镜像画面绘制:右脸出镜耳在画面右、左脸出镜耳在画面左);后置镜头预览不镜像,侧脸轮廓随之水平翻转(`.cam-mask.flip`)。**抓帧一律不镜像**:三张照片都是摄像头原始视角,Face++ 左右分区文案(左嘴角纹/左右脸颊)对应用户真实左右,与真值样本同口径。
- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(原始视角 + 最长边 1280 JPEG),保持 ~300ms 再淡出(总 600–900ms),同步 `navigator.vibrate(30)`。
- **手动兜底**:快门任何时刻可拍当前步(同样走白屏补光),不依赖检测;face-api/模型加载失败 → toast"自动识别不可用,已切换手动拍摄",全程手动。系统相机降级模式(getUserMedia 不可用/被拒)维持原样:无检测、无补光。
- **相册选图**:相机页「相册」按钮可选当前步的图片(任何模式下可用),与实拍同管线(压 1280 → 推进流程)。
- **语音播报**:步骤引导/姿态提示/倒计时/拍摄完成/分析中/失败共 14 条,文案与文件名契约见 `../docs/语音播报内容设计文档_V1.0.md`。WebAudio 单通道、姿态提示同条 4s 去重、解锁后预加载全部条目;流程音(步骤/拍摄完成/倒计时/分析中)排队顺序播放互不截断,姿态提示不截断流程音(期间只挂起最新一条,收尾后补播),失败提示可打断「分析中」。`assets/voice/manifest.json` 清单制(14 条已生成),清单缺失的条目静默跳过。
- **无进展提示**:单步 12s 未触发倒计时,底部浮出"识别不到?可点下方快门手动拍摄"(不阻塞流程)。
- **资源管理**:离开拍照页(cam-back/完成)停止检测循环、清全部定时器、释放摄像头;`camSeq`(流程)/`camSeqCam`(相机)双令牌防竞态;face-api 模型只加载一次(缓存 promise,失败后下次进入重试)。
- **取景蒙版**:内联 SVG(`#cam-mask`)按步骤切换轮廓——正脸步为正脸剪影(含耳部),侧脸步为对应方向的侧脸剪影;**轮廓路径由 Figma 拍照示意图(`design/assets/images/*-拍照示意.png` 的 alpha 镂空区)逐像素描摹生成**,坐标为 Figma 375×812 整帧,viewBox 只取 y 86–812;框外 85% 黑,轮廓线 2.5px 半透明白。检测运行时轮廓内有自上而下的扫描带(`#cam-clip` 由 app.js 复制轮廓生成);未检测到人脸时轮廓呼吸动画;条件合格时轮廓变绿。
- **预览缩小 15%**:`#cam-video` 按 `scale(.85)` 显示。距离判定使用 object-fit:cover 和缩放后的屏幕占比(人脸高度占屏高 <26% 提示靠近、>58% 提示远一点),避免横竖视频比例不同导致距离误判。
- **动态提示**(一次一条,优先级:无人脸 > 距离 > 居中 > 角度 > 光线):「未检测到人脸」「请靠近一点 / 请远一点」「请将脸部移入取景框」(人脸中心偏离中线 >18% 屏宽)、角度类「请向左转头 / 请向右转头 / 请略微抬头 / 请略微低头」(正脸阈值 |yaw|≤15°、|pitch|≤18°,超出时按超出比例更大的一项给方向;侧脸 yaw 28°–75°、|pitch|≤20°)、光线类「请到光线充足处 / 光线过强,请避开强光」(脸部平均亮度 <65 或 >235)。
- **合格即拍**:单人、距离、横纵居中、姿态和光线连续合格 450ms 后立即抓取当前帧,再闪白 180ms 给出反馈。无倒计时;任何丢帧或检测异常都会清空合格时长,防止误拍。
- **实时检测**:本地 face-api 模型,约每 150ms 用 detectAllFaces + landmarks 判断单人及姿态。连续漏检 3 帧才更换文案,但每次漏检均中断可拍摄状态。按原始帧判断真实左右,持续转错方向也不会自动交换左右照片。切换镜头及退出页面作废旧的检测回调。
- **镜像策略**:前置镜头预览镜像(照镜子习惯);后置预览不镜像,侧脸轮廓随之翻转(`.cam-mask.flip`)。**抓帧一律不镜像**,三张照片都是摄像头原始视角。
- **抓拍反馈**:先抓取已校验的当前帧(原始视角、最长边 1280 JPEG),再显示 180ms 闪白和短振动;不在延迟后抓取可能已经偏离姿态的画面。
- **正式页面无手动快门**:相机权限或模型加载失败时显示重试按钮;仅 `?mock=1` 调试模式保留手动快门。拍摄进度明确显示正脸→右脸→左脸,完成三张后只送检一次。
- **相册选图**:按当前步骤选择照片,压缩后等待模型加载并校验单人、左右朝向和光线;不合格或检查失败均停留本步,不再跳过校验。读取、校验和抓拍期间锁定操作,离开页面使未完成读取失效。
- **语音播报**:步骤引导/姿态提示/拍摄完成/分析中/失败,文案与文件名契约见 `../docs/语音播报内容设计文档_V1.0.md`。新增的提示(居中、抬头/低头、光线)暂无音频,清单缺失的条目静默跳过,只显示文字。
- **无进展提示**:单步 12s 仍未拍到,底部浮出「识别不到?请正对镜头,并到光线充足处」(不阻塞流程)。
- **资源管理**:离开拍照页停止检测循环、清全部定时器、释放摄像头;`camSeq`(流程)/`camSeqCam`(相机)双令牌防竞态;face-api 模型只加载一次。
## 档案页与报告页
- **档案页**:出生年月、性别、省市用统一 mobile-select 滚轮,提供占位提示;按出生月份计算并显示年龄,限制选择结果为 1–99 岁,提交时重新计算以适应跨月。省市联动数据见 `js/region-data.js`;插件行点击已适配 44px 行高与安全区,见 vendor README。
- **报告页**:无返回按钮与页头标题;总分环形图 + 肌肤年龄与「比实际年龄年轻/大 N 岁」;三维度分数 + 进度条(最低分标红并挂「优先关注」);紧塑力/活力感/稳定力各一张卡,卡内子 Tab 切换;点检测图全屏放大,再点或按返回键回到报告。
## 检测流程与评分渲染
1. 三视图送检:POST `/api/skin-analyze`,请求 `{ front_image, left_image, right_image }`(base64 可带 dataURL 前缀;正脸=photos[0],左=photos[2],右=photos[1])。
1. 三视图送检:POST `/api/skin-analyze`,请求 `{ front_image, left_image, right_image, profile? }`(base64 可带 dataURL 前缀;正脸=photos[0],左=photos[2],右=photos[1];`profile` 为可选档案 `{real_age,gender,region,daily_skin_type}`,后台记录用)。
2. 服务端转发 Face++ **专业版 `skinanalyze_pro`**(三视图必须走 `image_file` 文件形式),请求 `return_side_results=jawline_info` 取左右下颌线角度,`return_maps`/`return_marks` 取叠加图与标注;响应 `{ ok:true, result, face_rectangle, side:{ left, right } }`,失败 `{ ok:false, code, message }`(message 已是用户可读文案)。`result.face_maps` 为对象,含报告页所需图层的完整 data URL(`red_area`/`brown_area`/`texture_enhanced_lines`/`texture_enhanced_pores`/`texture_enhanced_blackheads`,由 server.js 的 `REPORT_MAPS` 常量控制);`marks` 坐标(`acne`/`brown_spot`/`mole`/`closed_comedones`/`acne_pustule`/`acne_nodule`/`wrinkle_mark`/`melanin_mark`/`dark_circle_mark`)随 `result` 原样透传,与上传图片同一像素空间。
3. 评分由 `js/skin-score.js` 引擎完成,公式/锁值/话术/产品推荐以 `docs/三维度评分与话术生成需求文档_V2.3.docx` 为最高契约(V2.3 口径,含 §8.5 护理建议五段):
- 6 分类:轮廓 = min(眼袋映射分, 下颌线角度分);皱纹(12 分区);光滑(毛孔/黑头/纹理);肤色水润(色素×水润);敏感(泛红面积取 `red_spot.red_spot_area`/红区分);痘痘(分级映射×丘疹 `acne.count` 计数扣分);分组按实际年龄;
@@ -86,6 +99,27 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
8. **原始返回落盘**:服务端每次检测成功后把 Face++ 完整原始返回写入 `h5/data/results/<时间戳>-<request_id>.json`(复核/真值用,已 .gitignore;写盘失败不影响响应)。
9. 检测失败按服务端 message 提示并退回档案页。
## 后台查看(admin.html)
本地查看版后台,浏览器打开 **`http://127.0.0.1:8080/admin.html`**(无登录鉴权,仅内网/本地使用;上线前需加网关或鉴权)。请求体除三张照片外还带可选档案 `profile:{real_age,gender,region,daily_skin_type}`(前端 `js/app.js` 自动附带,年龄非法按 null),服务端据此落库并在检测成功时用 `SkinScore.buildViewModel` 现场评分(与 C 端同一份公式)。
- **数据落库**:每次有效调用(正脸照片校验通过起)生成 `record_id`,照片写 `data/photos/<record_id>/`,记录(档案/照片地址/拍摄与上传状态/检测状态与失败原因/评分/文案/精华/耗时/raw 文件名/重试关系)追加到 `data/records.jsonl`。C 端响应结构不变。
- **记录列表**:多条件筛选(时间区间/检测状态/失败原因/年龄/产品组别/维度+档位/记录ID),行点击开详情抽屉(照片大图与下载、状态、六分类与三维度评分表、文案全文、护理建议、精华与触发判据、计算备注、接口原始 JSON 折叠查看、手动重试)。
- **失败与异常**:失败原因分布条 + 失败列表(每条可一键重试:用原照片重新检测,生成新记录并挂版本链)。
- **统计概览**:今日/累计/成功率/平均耗时卡片,三维度档位分布,产品组别分布(纯 CSS 饼图),近 14 天每日检测量柱状图,按日期区间导出 CSV(UTF-8 BOM,不含 raw_json)。
- **演示数据**:`node h5/data/seed.js` 生成约 10 条演示记录(覆盖重写 records.jsonl,照片复制自 `reference/facepp-skinanalyze/examples/images/` 真实三视图)。
### Admin API(统一返回 `{ok:false,message}` 错误)
| 接口 | 说明 |
| --- | --- |
| `GET /api/admin/records` | 分页查询。参数:`from/to`(ISO 日期)、`detect_status`、`fail_code`、`age_min/age_max`、`group`、`dim`(firm/vita/stab)+`dim_level`、`q`(record_id 前缀)、`page`、`page_size`(默认 20)、`only_fail=1`。返回 `{total,page,page_size,items}`(倒序,含照片 URL,不含 raw_json 与文案全文) |
| `GET /api/admin/records/:id` | 完整记录 + `raw_json` 全文(读失败为 null)+ `photos` URL + `versions`(版本链:父记录与重试子记录) |
| `POST /api/admin/records/:id/retry` | 手动重试:原照片重新检测,生成新记录(`retry_of`=原 id),原记录 `retry_count+1`,返回新记录 |
| `GET /api/admin/photos/:id/:which` | 照片回显(`which`=front/left/right),`image/jpeg` inline;文件不存在 404 |
| `GET /api/admin/stats` | 今日/累计检测量、成功率、平均耗时、失败原因分布、三维度档位分布、产品组别分布、近 14 天每日检测量/成功数 |
| `GET /api/admin/export?from=&to=` | CSV 导出(UTF-8 BOM,Content-Disposition attachment 带日期区间文件名);禁止 raw_json 进单元格(Excel 单格 32767 字符截断) |
## 部署
需要 Node ≥ 18 运行 `server.js`(负责静态与 API 中转),**不能用纯静态托管**(密钥不能进前端)。
@@ -94,6 +128,24 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
PORT=8080 node server.js # 建议 pm2 / systemd 常驻
```
### 线上部署(juzi.xiangsilian.com)
- Node 只绑回环(`HOST` 默认 `127.0.0.1`,公网入口仅 nginx;需局域网调试才 `HOST=0.0.0.0`),nginx 反代 + certbot HTTPS,见 `/etc/nginx/sites-available/juzi`。
- **后台 Basic Auth**:nginx 对 `admin.html`、`css/admin.css`、`js/admin.js`、`/api/admin/` 做 `auth_basic`(账密 `/etc/nginx/.htpasswd-juzi`),`/data/`(照片/原始 JSON/记录库)由 nginx 404 + server.js 静态层 403 双重封锁,照片只能走鉴权后的 `/api/admin/photos`。**上线正式版前应替换为文档要求的后台账号体系。**
- 服务常驻建议 systemd:
```ini
[Unit]
Description=juzi skin h5
After=network.target
[Service]
WorkingDirectory=/home/ubuntu/juzi/h5
ExecStart=/usr/bin/node server.js
Restart=always
[Install]
WantedBy=multi-user.target
```
### Windows 本机部署(提测用)
```bat
@@ -107,7 +159,7 @@ schtasks /create /tn "JuziSkinH5" /tr "\"C:\Windows\System32\cmd.exe\" /c \"D:\j
:: 移除: schtasks /delete /tn "JuziSkinH5" /f
```
局域网提测访问 `http://<本机局域网IP>:8088/`。注意:① 首次监听若弹 Windows 防火墙提示需允许 node.exe;② **手机走局域网 HTTP 时 getUserMedia 不可用**,会自动降级为系统相机手动三连拍(分析链路完整),要测引导式自动拍照需在本机浏览器访问 `http://127.0.0.1:8088`,或上 HTTPS。
局域网提测需设置 HOST=0.0.0.0 并允许对应防火墙端口。手机通过局域网 HTTP 访问时不能使用 getUserMedia,需使用 HTTPS 才能测试动态自动拍照;相册选图入口仍可用且逐步校验。
nginx 反代示例(静态与 API 都交给 server.js,nginx 只做 HTTPS 终结):
@@ -125,7 +177,7 @@ server {
}
```
> **HTTPS 必需**:getUserMedia 相机权限仅在安全上下文可用(localhost 除外)。正式域名必须上 HTTPS,否则前端自动降级为系统相机拍照。
> **HTTPS 必需**:手机自动拍摄要求安全上下文,正式域名必须使用 HTTPS;本机 localhost 例外。相机不可用时页面显示重试与相册入口。
## 已知占位清单
@@ -133,7 +185,7 @@ server {
- 语音 14 条已由 `tools/gen_voice.py` 生成;改文案后重跑脚本并同步 `manifest.json`。
- 「了解更多」为开发中占位(Toast 提示);拍照页右下按钮为切换前后镜头(系统相机降级模式下提示在系统相机内切换)。
- 产品推荐卡(COREⅡ 双仓精华液)为设计稿固定素材,不随精华推荐档位(25+/35+/50+)变化,待产品提供三档对应素材。
- **姿态与距离参数已用模拟摄像头校准**(2026-09-27):以 C3 三视图(+45° 侧脸)生成 Y4M 模拟摄像头视频,闭环修正了 yaw 增益(2.2→3.8)、pitch 基线(0.32→0.45)、check() 双重镜像符号 bug、距离阈值(0.35/0.8→0.26/0.58)、检测参数(320/0.4→416/0.3)与漏检 3 帧容忍;自动三连拍在无头浏览器全程通过(e2e E7)。**真机仍建议复核一次**:不同机型前置镜像策略/取景比例可能微调体验(校准接口:调用方 `mirrored` 标志与 `HeadPose.config`)。
- **真机复核**:头部姿态系数沿用既有校准,新增多人、屏幕坐标距离/居中与亮度闸门已通过可控浏览器输入测试。前后镜头、不同手机曝光及微信相机权限仍需用真实设备复核;自动化测试不替代硬件实拍。
## 技术说明
@@ -141,3 +193,9 @@ server {
- 图片性能:首屏背景 preload;首屏外 `loading="lazy"`。
- 快门防连点/双流程令牌(camSeq/camSeqCam)防竞态;拍照页取景框外纯黑(对齐 Figma),白字直接落在黑色上。
- face-api.js 0.22.2 与模型全部本地化(`vendor/faceapi/`,约 1.4MB),运行时零外部请求,微信 webview / iOS Safari 弱网与内网部署可用;许可证(MIT / Apache-2.0)见 `vendor/faceapi/README.md`。
## UI 反馈验收
`node h5/test/e2e-feedback.mjs` 验证出生年月、省市联动、自动三连拍的质量闸门与顺序、模型重试、相册校验、图片放大及 320/375/430px 布局。测试拦截分析请求,不调用 Face++。
两个浏览器测试都支持 `BASE_URL`(例如 http://127.0.0.1:8088)、`PLAYWRIGHT_MODULE`(本机 Playwright 的 index.mjs 绝对路径)和 `E2E_OUTPUT_DIR`(截图目录),Windows 与 Linux 均可执行。未指定 Playwright 路径时使用全局安装。
+154
View File
@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>巨子测肤 · 后台查看</title>
<link rel="stylesheet" href="css/admin.css">
</head>
<body>
<header class="topbar">
<div class="brand">巨子测肤<span>后台查看</span></div>
<nav class="tabs">
<button type="button" class="tab on" data-tab="records">记录列表</button>
<button type="button" class="tab" data-tab="fails">失败与异常</button>
<button type="button" class="tab" data-tab="stats">统计概览</button>
</nav>
</header>
<main>
<!-- ─── 记录列表(文档 4.1/4.2) ─── -->
<section id="tab-records" class="panel on">
<div class="filters">
<label>创建时间 <input type="date" id="f-from"> ~ <input type="date" id="f-to"></label>
<label>检测状态
<select id="f-status">
<option value="">全部</option>
<option>待检测</option><option>检测中</option><option>成功</option><option>失败</option>
</select>
</label>
<label>失败原因
<select id="f-failcode">
<option value="">全部</option>
<option>NO_FACE_FOUND</option>
<option>INVALID_IMAGE_FACE</option>
<option>INVALID_FACE_SIZE</option>
<option>INVALID_IMAGE_SIZE</option>
<option>IMAGE_FILE_TOO_LARGE</option>
<option>IMAGE_ERROR_UNSUPPORTED_FORMAT</option>
<option>AUTHENTICATION_ERROR</option>
<option>AUTHORIZATION_ERROR</option>
<option>CONCURRENCY_LIMIT_EXCEEDED</option>
<option>INTERNAL_ERROR</option>
<option>TIMEOUT</option>
<option>NETWORK_ERROR</option>
</select>
</label>
<label>年龄 <input type="number" id="f-agemin" min="1" max="120" placeholder="最小"> ~ <input type="number" id="f-agemax" min="1" max="120" placeholder="最大"></label>
<label>产品组别
<select id="f-group">
<option value="">全部</option>
<option>25+组</option><option>35+组</option><option>50+组</option>
</select>
</label>
<label>维度
<select id="f-dim">
<option value="">不限</option>
<option value="firm">紧塑力</option>
<option value="vita">活力感</option>
<option value="stab">稳定力</option>
</select>
<select id="f-dimlevel">
<option value="">不限档位</option>
<option>预警</option><option>改善</option><option>良好</option>
</select>
</label>
<label>记录ID <input type="text" id="f-q" placeholder="精确/前缀匹配"></label>
<button type="button" class="btn primary" id="btn-search">查询</button>
<button type="button" class="btn" id="btn-reset">重置</button>
</div>
<div class="table-wrap">
<table id="tbl-records">
<thead>
<tr>
<th>照片</th><th>记录ID</th><th>年龄/性别/地区/肤质</th><th>拍摄状态</th><th>上传状态</th>
<th>检测状态</th><th>总分</th><th>三维度档位</th><th>推荐精华</th><th>耗时</th><th>创建时间</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="pager">
<button type="button" class="btn" id="btn-prev">上一页</button>
<span id="page-info">-</span>
<button type="button" class="btn" id="btn-next">下一页</button>
</div>
</section>
<!-- ─── 失败与异常(文档 4.4) ─── -->
<section id="tab-fails" class="panel">
<div id="fail-dist" class="fail-dist"></div>
<div class="table-wrap">
<table id="tbl-fails">
<thead>
<tr>
<th>记录ID</th><th>失败环节</th><th>失败码</th><th>失败信息</th><th>年龄</th><th>拍摄状态</th><th>创建时间</th><th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- ─── 统计概览(文档 4.5) ─── -->
<section id="tab-stats" class="panel">
<div class="cards" id="stat-cards"></div>
<div class="grid2">
<div class="block">
<h3>三维度档位分布</h3>
<div id="dim-dist"></div>
</div>
<div class="block">
<h3>产品组别分布</h3>
<div id="group-dist" class="pie-wrap"></div>
</div>
</div>
<div class="block">
<h3>近 14 天每日检测量</h3>
<div id="daily-chart" class="daily-chart"></div>
</div>
<div class="block">
<h3>导出 CSV</h3>
<div class="export-bar">
<label>起始日期 <input type="date" id="exp-from"></label>
<label>结束日期 <input type="date" id="exp-to"></label>
<button type="button" class="btn primary" id="btn-export">导出 CSV</button>
<span class="hint">UTF-8 BOM,Excel 直接打开;不含接口原始 JSON</span>
</div>
</div>
</section>
</main>
<!-- ─── 记录详情(抽屉) ─── -->
<div id="drawer-mask" class="drawer-mask hidden"></div>
<aside id="drawer" class="drawer hidden">
<div class="drawer-head">
<h2 id="d-title">记录详情</h2>
<div>
<button type="button" class="btn primary" id="btn-retry">重试</button>
<button type="button" class="btn" id="btn-close">关闭</button>
</div>
</div>
<div class="drawer-body" id="d-body"></div>
</aside>
<!-- 图片查看 -->
<div id="lightbox" class="lightbox hidden">
<img id="lightbox-img" alt="">
</div>
<div id="toast" class="toast hidden"></div>
<script src="js/admin.js"></script>
</body>
</html>
+170
View File
@@ -0,0 +1,170 @@
/* 巨子测肤 · 后台查看页样式(零依赖) */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f3f5f8; color: #2b3445; font-size: 14px;
}
.hidden { display: none !important; }
/* ─── 顶栏 ─── */
.topbar {
display: flex; align-items: center; gap: 32px;
background: #fff; border-bottom: 1px solid #e4e8ef;
padding: 0 24px; height: 56px; position: sticky; top: 0; z-index: 20;
}
.brand { font-size: 17px; font-weight: 700; color: #1f2733; }
.brand span { font-weight: 400; color: #8a94a6; margin-left: 8px; font-size: 13px; }
.tabs { display: flex; gap: 4px; height: 56px; }
.tab {
border: none; background: none; cursor: pointer; padding: 0 18px;
font-size: 14px; color: #5a6578; border-bottom: 2px solid transparent;
}
.tab:hover { color: #2563eb; }
.tab.on { color: #2563eb; border-bottom-color: #2563eb; font-weight: 600; }
main { padding: 20px 24px; }
.panel { display: none; }
.panel.on { display: block; }
/* ─── 筛选栏 ─── */
.filters {
background: #fff; border: 1px solid #e4e8ef; border-radius: 8px;
padding: 12px 16px; margin-bottom: 16px;
display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center;
}
.filters label { color: #5a6578; font-size: 13px; display: inline-flex; align-items: center; gap: 6px; }
input, select {
border: 1px solid #d4dae3; border-radius: 6px; padding: 6px 8px; font-size: 13px; color: #2b3445;
background: #fff; outline: none;
}
input:focus, select:focus { border-color: #2563eb; }
input[type="number"] { width: 64px; }
#f-q { width: 170px; }
.btn {
border: 1px solid #d4dae3; background: #fff; border-radius: 6px;
padding: 6px 14px; font-size: 13px; cursor: pointer; color: #2b3445;
}
.btn:hover { border-color: #2563eb; color: #2563eb; }
.btn.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
.btn.primary:hover { background: #1d4fd7; color: #fff; }
.btn.small { padding: 3px 10px; font-size: 12px; }
/* ─── 表格 ─── */
.table-wrap { background: #fff; border: 1px solid #e4e8ef; border-radius: 8px; overflow: auto; }
table { width: 100%; border-collapse: collapse; min-width: 1080px; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #eef1f6; white-space: nowrap; }
th { background: #f8fafc; color: #5a6578; font-weight: 600; font-size: 13px; }
tbody tr { cursor: pointer; }
tbody tr:hover { background: #f5f8ff; }
tbody tr:last-child td { border-bottom: none; }
.thumb { width: 44px; height: 44px; object-fit: cover; border-radius: 4px; border: 1px solid #e4e8ef; cursor: zoom-in; display: block; }
.mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: #4a5568; }
.sub { color: #8a94a6; font-size: 12px; }
/* 档位标签:预警=红 / 改善=橙黄 / 良好=绿 */
.lv {
display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; margin: 1px 2px;
}
.lv-bad { background: #fdecec; color: #d93026; }
.lv-warn { background: #fdf3e0; color: #d4880f; }
.lv-good { background: #e6f6ec; color: #1e9e50; }
.st { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
.st-ok { background: #e6f6ec; color: #1e9e50; }
.st-fail { background: #fdecec; color: #d93026; }
.st-ing { background: #e8f0fe; color: #2563eb; }
.st-wait { background: #f0f2f5; color: #8a94a6; }
.pager { display: flex; align-items: center; gap: 12px; justify-content: flex-end; margin-top: 12px; color: #5a6578; font-size: 13px; }
/* ─── 失败分布条 ─── */
.fail-dist { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
.fail-chip {
background: #fff; border: 1px solid #e4e8ef; border-radius: 8px;
padding: 10px 16px; display: flex; flex-direction: column; gap: 2px; min-width: 150px;
}
.fail-chip .code { font-weight: 600; color: #d93026; font-size: 13px; }
.fail-chip .pct { color: #5a6578; font-size: 12px; }
.fail-chip .bar { height: 6px; background: #fdecec; border-radius: 3px; margin-top: 4px; overflow: hidden; }
.fail-chip .bar i { display: block; height: 100%; background: #d93026; }
/* ─── 统计卡片与图表 ─── */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 16px; }
.card { background: #fff; border: 1px solid #e4e8ef; border-radius: 8px; padding: 16px 18px; }
.card .num { font-size: 26px; font-weight: 700; color: #1f2733; margin-top: 4px; }
.card .lab { color: #8a94a6; font-size: 13px; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; }
@media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; } }
.block { background: #fff; border: 1px solid #e4e8ef; border-radius: 8px; padding: 16px 18px; margin-bottom: 14px; }
.block h3 { font-size: 14px; color: #1f2733; margin-bottom: 14px; }
.dim-row { margin-bottom: 12px; }
.dim-row .dim-name { font-weight: 600; margin-bottom: 4px; }
.dim-bar { display: flex; height: 18px; border-radius: 4px; overflow: hidden; gap: 2px; }
.dim-bar i { display: block; height: 100%; }
.dim-bar .b-bad { background: #d93026; }
.dim-bar .b-warn { background: #f0a020; }
.dim-bar .b-good { background: #2fa84f; }
.dim-legend { color: #8a94a6; font-size: 12px; margin-top: 4px; }
.pie-wrap { display: flex; align-items: center; gap: 24px; }
.pie { width: 140px; height: 140px; border-radius: 50%; flex: none; }
.pie-legend { font-size: 13px; color: #5a6578; }
.pie-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; }
.pie-empty { color: #8a94a6; }
.daily-chart { display: flex; align-items: flex-end; gap: 6px; height: 160px; padding-top: 8px; }
.daily-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; height: 100%; justify-content: flex-end; }
.daily-bar { width: 70%; background: #2563eb; border-radius: 3px 3px 0 0; position: relative; min-height: 2px; }
.daily-bar.ok { background: #2fa84f; position: absolute; bottom: 0; left: 15%; opacity: .55; }
.daily-val { font-size: 11px; color: #5a6578; }
.daily-date { font-size: 11px; color: #8a94a6; transform: rotate(-38deg); white-space: nowrap; }
.export-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.hint { color: #8a94a6; font-size: 12px; }
/* ─── 详情抽屉 ─── */
.drawer-mask { position: fixed; inset: 0; background: rgba(20, 28, 45, .45); z-index: 30; }
.drawer {
position: fixed; top: 0; right: 0; bottom: 0; width: min(860px, 94vw);
background: #fff; z-index: 31; box-shadow: -6px 0 24px rgba(20, 28, 45, .18);
display: flex; flex-direction: column;
}
.drawer-head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-bottom: 1px solid #e4e8ef; flex: none;
}
.drawer-head h2 { font-size: 16px; }
.drawer-head > div { display: flex; gap: 8px; }
.drawer-body { overflow: auto; padding: 18px 20px 40px; }
.d-sec { margin-bottom: 20px; }
.d-sec h3 { font-size: 14px; color: #1f2733; margin-bottom: 10px; border-left: 3px solid #2563eb; padding-left: 8px; }
.d-photos { display: flex; gap: 14px; flex-wrap: wrap; }
.d-photo { text-align: center; }
.d-photo img { width: 150px; height: 150px; object-fit: cover; border-radius: 6px; border: 1px solid #e4e8ef; cursor: zoom-in; display: block; }
.d-photo a { font-size: 12px; color: #2563eb; text-decoration: none; }
.d-kv { display: flex; flex-wrap: wrap; gap: 6px 22px; color: #4a5568; font-size: 13px; }
.d-kv b { color: #1f2733; }
.d-table { width: 100%; min-width: 0; border: 1px solid #e4e8ef; border-radius: 6px; overflow: hidden; }
.d-table th, .d-table td { white-space: normal; }
.d-copy { background: #f8fafc; border: 1px solid #e4e8ef; border-radius: 6px; padding: 10px 12px; margin-bottom: 8px; font-size: 13px; line-height: 1.7; color: #4a5568; }
.d-copy b { color: #1f2733; }
.d-notes li { color: #8a6d1a; font-size: 13px; margin: 4px 0 4px 18px; }
.d-versions a { color: #2563eb; cursor: pointer; text-decoration: none; margin-right: 14px; font-size: 13px; }
details pre {
background: #1f2733; color: #d7e1f0; border-radius: 6px; padding: 12px;
font-size: 12px; overflow: auto; max-height: 420px; white-space: pre-wrap; word-break: break-all;
}
summary { cursor: pointer; color: #2563eb; font-size: 13px; margin-bottom: 8px; }
/* ─── 图片查看 / 提示 ─── */
.lightbox {
position: fixed; inset: 0; background: rgba(10, 14, 22, .85); z-index: 40;
display: flex; align-items: center; justify-content: center; cursor: zoom-out;
}
.lightbox img { max-width: 92vw; max-height: 92vh; border-radius: 6px; }
.toast {
position: fixed; top: 70px; left: 50%; transform: translateX(-50%);
background: #1f2733; color: #fff; padding: 9px 20px; border-radius: 6px;
font-size: 13px; z-index: 50;
}
+339 -390
View File
@@ -1,6 +1,6 @@
/* ═══════════════════════════════════════════
巨子测肤 · 1'me 在线肌肤测试 — H5 样式
设计稿 1080px 基准 → 此处 375px(÷2.88)
设计稿 1080px 基准 → 375px(÷2.88);尺寸写成 N × --u,不同宽度手机等比缩放、视觉一致
主色 #F5F078 · 文字 #282828 · 底 #EFEFEF
═══════════════════════════════════════════ */
:root {
@@ -13,28 +13,29 @@
--para: #4A4A4A;
--bg: #EFEFEF;
--line: #E5E5E5;
--dry: #EAE6E1;
--neutral: #DBF6D6;
--bad: #D30C0C;
--nav-h: 44px;
--u: calc(min(100vw, 480px) / 375); /* 1 设计像素(375 宽基准) */
--font: "Noto Sans SC","PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; }
html { overflow: hidden; }
body {
font-family: var(--font);
color: var(--ink);
background: #000;
overflow: hidden;
overflow: clip; /* 滚轮在屏外准备时不能把整个页面程序化滚走 */
-webkit-font-smoothing: antialiased;
user-select: none;
max-width: 480px; /* 桌面预览时居中显示 */
margin: 0 auto;
position: relative;
position: fixed; inset: 0; width: 100%;
}
img { display: block; }
button { font-family: inherit; border: 0; background: none; cursor: pointer; }
button { font-family: inherit; border: 0; background: none; cursor: pointer; color: inherit; }
/* ─── 屏幕容器 ─── */
.screen {
@@ -47,15 +48,15 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.screen.active { display: block; animation: screenIn .35s ease both; }
@keyframes screenIn { from { opacity: 0; transform: translateX(14px); } to { opacity: 1; transform: none; } }
/* ─── 通用按钮 ─── */
/* ─── 主按钮:长方形 + 小倒角(设计稿 181×37) ─── */
.btn-primary {
display: block;
width: 182px; height: 39px;
line-height: 39px;
width: calc(181 * var(--u)); height: calc(37 * var(--u));
line-height: calc(37 * var(--u));
background: var(--yellow);
border-radius: 20px;
border-radius: calc(5 * var(--u));
color: var(--ink);
font-size: 14px; font-weight: 500;
font-size: calc(14.5 * var(--u)); font-weight: 500; letter-spacing: calc(.5 * var(--u));
text-align: center;
transition: transform .12s ease;
}
@@ -68,8 +69,16 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
display: inline-block; padding: 2px;
}
.chev.left { transform: rotate(135deg); }
.chev.right { transform: rotate(-45deg); }
.chev.down { transform: rotate(45deg); }
.nav-back {
width: 27px; height: 27px;
display: flex; align-items: center; justify-content: center;
border: 1px solid var(--line); border-radius: 50%;
background: #fff;
}
/* ═══════════ 01 启动页 ═══════════ */
#s-splash { background: #101823; }
.splash-bg {
@@ -77,194 +86,188 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
background: url(../assets/img/bg-splash.jpg) center/cover no-repeat;
}
.splash-slogan {
position: absolute; left: 0; right: 0; top: 20%;
position: absolute; left: 0; right: 0; top: 10.7%;
text-align: center;
color: #fff; font-size: 23px; font-weight: 500;
line-height: 1.38;
letter-spacing: 1px;
text-shadow: 0 1px 8px rgba(0,0,0,.18);
color: #fff; font-size: calc(23 * var(--u)); font-weight: 500;
line-height: 1.38; letter-spacing: calc(.6 * var(--u));
text-shadow: 0 1px 8px rgba(0,0,0,.15);
}
.splash-bottom {
position: absolute; left: 0; right: 0; bottom: 11%;
display: flex; flex-direction: column; align-items: center; gap: 18px;
position: absolute; left: 0; right: 0; bottom: 12.4%;
display: flex; flex-direction: column; align-items: center; gap: calc(16 * var(--u));
padding-bottom: env(safe-area-inset-bottom);
}
.brand-row { display: flex; align-items: center; gap: 7px; }
.brand-logo { width: 46px; height: auto; filter: brightness(0) invert(1); }
.brand-text { color: #fff; font-size: 21px; font-weight: 500; letter-spacing: 1px; }
/* ═══════════ 通用导航 ═══════════ */
.nav {
position: sticky; top: 0; z-index: 20;
height: var(--nav-h);
display: flex; align-items: center;
padding: 0 6px;
background: rgba(255,255,255,.94);
-webkit-backdrop-filter: blur(8px); /* iOS<15.4 需要前缀 */
backdrop-filter: blur(8px);
}
.nav-dark { background: rgba(20,20,20,.55); }
.nav-dark .nav-title { color: #fff; }
.nav-dark .chev { border-color: #fff; }
.nav-back {
width: 27px; height: 27px;
display: flex; align-items: center; justify-content: center;
border: 1px solid #E5E5E5; border-radius: 50%;
background: #fff;
}
/* 深色导航(拍照页):半透明白底盘 + 浅白描边 */
.nav-dark .nav-back { background: rgba(255,255,255,.18); border-color: rgba(255,255,255,.4); }
.nav-title {
position: absolute; left: 50%; transform: translateX(-50%);
font-size: 14px; font-weight: 500; color: #000;
max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.brand-row { display: flex; align-items: center; gap: calc(7 * var(--u)); }
.brand-logo { width: calc(46 * var(--u)); height: auto; filter: brightness(0) invert(1); }
.brand-text { color: #fff; font-size: calc(21.5 * var(--u)); font-weight: 500; letter-spacing: calc(1 * var(--u)); }
/* ═══════════ 03 档案填写 ═══════════ */
#s-profile { background: #0e0f18; }
.profile-bg {
position: absolute; inset: 0;
/* 设计稿:启动页照片 + 50% 正片叠底黑遮罩(矩形3) */
/* 启动页照片 + 55% 黑遮罩 */
background:
linear-gradient(rgba(0,0,0,.55), rgba(0,0,0,.55)),
url(../assets/img/bg-splash.jpg) center/cover no-repeat;
}
#s-profile .nav { position: fixed; left: 0; right: 0; }
.profile-body {
position: relative; z-index: 1;
min-height: 100%;
/* 设计:标题在导航下 23px;按钮随内容流(卡底→按钮间距按设计节奏约 22px) */
padding: calc(var(--nav-h) + 23px) 64px clamp(48px, 8vh, 121px);
display: flex; flex-direction: column; justify-content: flex-start;
gap: 16px;
padding: calc(34 * var(--u)) calc(64 * var(--u)) calc(40 * var(--u));
}
.page-title {
color: #fff; font-size: 21px; font-weight: 700;
letter-spacing: 1px; margin-bottom: 2px;
color: #fff; font-size: calc(21.5 * var(--u)); font-weight: 700;
letter-spacing: calc(1 * var(--u)); text-align: center;
margin-bottom: calc(22 * var(--u));
}
.field { margin-bottom: calc(19 * var(--u)); }
.field-label {
display: block;
color: #fff; font-size: 14px; font-weight: 400;
margin-bottom: 6px; letter-spacing: .5px;
display: block; text-align: center;
color: #fff; font-size: calc(14.5 * var(--u)); font-weight: 400;
margin-bottom: calc(9 * var(--u)); letter-spacing: calc(.5 * var(--u));
}
.form-row.two { display: flex; gap: 22px; }
.form-row.two .field { flex: 1; }
.field-input {
display: block; width: 100%; height: 38px;
background: #fff; border-radius: 6px; border: 0; outline: none;
padding: 0 12px;
font-family: inherit; font-size: 13px; color: var(--ink);
appearance: none; -webkit-appearance: none;
}
.field-input::placeholder { color: #bbb; }
.field-select-wrap { position: relative; }
.field-select-wrap .chev { position: absolute; right: 12px; top: 15px; width: 7px; height: 7px; pointer-events: none; }
.form-row.two { display: flex; gap: calc(14 * var(--u)); }
.form-row.two .field { flex: 1; min-width: 0; }
.form-row.two .field:first-child { flex: 1.25; }
/* 肤质卡片 */
/* 选择控件(出生年月/性别/地区同一样式) */
.pick {
display: flex; align-items: center; justify-content: space-between; gap: 6px;
width: 100%; height: calc(37 * var(--u));
padding: 0 calc(13 * var(--u)) 0 calc(12 * var(--u));
background: #fff; border-radius: calc(5 * var(--u));
font-size: calc(13.5 * var(--u)); color: var(--ink); text-align: left;
}
.pick-value { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pick-value.placeholder { color: #777; }
.profile-age { color: #eee; font-size: max(12px, calc(12 * var(--u))); text-align: center; margin: calc(-9 * var(--u)) 0 calc(18 * var(--u)); }
.pick:focus-visible, .btn:focus-visible, .ditem-img:focus-visible, .zoom-close:focus-visible { outline: 3px solid #D3BF29; outline-offset: 3px; }
.pick .chev { flex: none; width: calc(8 * var(--u)); height: calc(8 * var(--u)); margin-top: calc(-4 * var(--u)); border-width: 0 1.5px 1.5px 0; }
.pick:active { background: #F7F7F7; }
/* 肤质卡片(设计稿 246×278) */
.skin-card {
background: #fff; border-radius: 6px;
padding: 24px 10px 26px;
background: #fff; border-radius: calc(5 * var(--u));
padding: calc(21 * var(--u)) 0 calc(24 * var(--u));
}
.skin-axis {
display: flex; justify-content: center; gap: 8px;
margin: 0 0 14px;
display: flex; justify-content: center; gap: calc(12 * var(--u));
margin-bottom: calc(18 * var(--u));
}
.axis-item {
display: inline-flex; align-items: center; gap: 4px;
font-size: 9px; color: var(--ink);
display: inline-flex; align-items: center; gap: calc(5 * var(--u));
font-size: calc(9 * var(--u)); color: var(--ink);
}
.chip { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
.chip { width: calc(8 * var(--u)); height: calc(8 * var(--u)); border-radius: 50%; display: inline-block; }
.skin-stage { position: relative; padding: 0 calc(28 * var(--u)); }
.skin-arrow {
position: absolute; top: calc(42 * var(--u)); z-index: 1;
width: calc(26 * var(--u)); height: calc(26 * var(--u));
display: flex; align-items: center; justify-content: center;
}
.skin-arrow.prev { left: calc(5 * var(--u)); }
.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-track { display: flex; transition: transform .32s cubic-bezier(.22,.61,.36,1); }
.skin-item {
flex: 0 0 100%;
display: flex; flex-direction: column; align-items: center;
}
.skin-item img { width: 109px; height: 109px; }
.skin-name { font-size: 13px; font-weight: 500; color: #000; margin-top: 12px; }
.skin-item img { width: calc(109 * var(--u)); height: calc(109 * var(--u)); }
.skin-name { font-size: calc(12.5 * var(--u)); font-weight: 700; color: #000; margin-top: calc(16 * var(--u)); }
.skin-desc {
font-size: 10px; color: var(--ink); font-weight: 400;
line-height: 1.5; text-align: center;
margin-top: 5px; padding: 0 26px;
font-size: calc(10.3 * var(--u)); color: var(--ink);
line-height: 1.55; text-align: center;
margin-top: calc(5 * var(--u)); max-width: calc(136 * var(--u));
}
.skin-dots { display: flex; justify-content: center; gap: 7px; margin-top: 16px; }
.skin-dots { display: flex; justify-content: center; gap: calc(5 * var(--u)); margin-top: calc(22 * var(--u)); }
.skin-dots i {
width: 8px; height: 8px; border-radius: 50%;
width: calc(8 * var(--u)); height: calc(8 * var(--u)); border-radius: 50%;
background: #D6D6D6; transition: background .25s;
}
.skin-dots i.on { background: #000; }
/* 按钮随内容流:卡底→按钮间距 22px(= flex gap 16 + 此处 6),两侧 auto 居中 */
.btn-submit { margin: 6px auto 0; }
.btn-submit { margin: calc(25 * var(--u)) auto 0; }
/* ─── 底部滚轮选择器(vendor/mobile-select)外观统一 ─── */
.ms-mobile-select .ms-gray-layer { background: rgba(0,0,0,.55); }
.ms-mobile-select .ms-content {
bottom: -400px;
border-radius: 12px 12px 0 0; overflow: hidden;
padding-bottom: env(safe-area-inset-bottom);
font-family: var(--font);
}
.ms-mobile-select .ms-btn-bar { height: 50px; line-height: 50px; font-size: 15px; border-bottom-color: #F0F0F0; }
.ms-mobile-select .ms-cancel, .ms-mobile-select .ms-ensure { height: 50px; width: 64px; }
.ms-mobile-select .ms-cancel { color: var(--grey-2) !important; }
.ms-mobile-select .ms-ensure { color: var(--ink) !important; font-weight: 600; }
.ms-mobile-select .ms-title { font-size: 15px; font-weight: 600; color: var(--ink) !important; }
.ms-mobile-select .ms-wheels, .ms-mobile-select .ms-wheel, .ms-mobile-select .ms-shadow-mask { height: 220px; }
.ms-mobile-select .ms-select-container li { font-size: 16px; color: var(--ink); height: 44px; line-height: 44px; }
.ms-mobile-select .ms-select-line { height: 44px; top: 88px; border-color: #EFEFEF; background: rgba(245,240,120,.18); }
/* ═══════════ 05-07 拍照页 ═══════════ */
#s-cam { background: #000; }
/* 相机未就绪/降级时的静态态:白底经蒙版 85% 压暗 → 框外深灰、轮廓内白,即 Figma 05-07 静态稿 */
/* 相机未就绪/降级时的静态态:白底经蒙版 85% 压暗 → 框外深灰、轮廓内白 */
.cam-bg { position: absolute; inset: 0; background: #fff; }
/* 实时相机画面(getUserMedia 成功后显示,盖住静态示意图) */
/* 实时画面整体缩小 15%:脸离镜头近一点也能装进取景轮廓(检测距离阈值按同一比例放宽) */
#cam-video {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
transform: scaleX(-1); /* 自拍镜面预览 */
transform: scale(.85);
display: none; z-index: 0;
}
#cam-video.mirror { transform: scale(-.85, .85); } /* 前置镜头镜像预览 */
#s-cam.cam-live #cam-video { display: block; }
#s-cam.cam-live .cam-bg { display: none; }
/* 设计稿(Figma 05-07):压暗只在取景蒙版框外(.cam-dim-rect),不再整屏压暗 */
#s-cam::before { content: none; }
#s-cam .nav { position: fixed; left: 0; right: 0; }
/* 拍照页纵向定位按 WebView 口径:Figma 帧 812 高扣掉顶部 86px 微信导航栏 = 726 高视口,百分比均以 726 为基准 */
/* 纵向定位按 WebView 口径:Figma 帧 812 高扣掉顶部 86px 微信导航栏 = 726 高视口 */
.cam-step {
position: absolute; left: 0; right: 0; top: 6.4%;
position: absolute; left: 0; right: 0; top: 7.1%;
text-align: center;
color: #fff; font-size: 21px; font-weight: 500;
line-height: 1.5; letter-spacing: 1px;
color: #fff; font-size: calc(21.5 * var(--u)); font-weight: 500;
line-height: 1.5; letter-spacing: calc(1 * var(--u));
text-shadow: 0 1px 10px rgba(0,0,0,.4);
z-index: 2;
}
.cam-guide {
position: absolute; left: 0; right: 0; bottom: 22.4%;
display: flex; justify-content: center; align-items: center; gap: 18px;
display: flex; justify-content: center; align-items: center; gap: calc(26 * var(--u));
z-index: 2;
}
.cam-guide span { color: #fff; font-size: 14px; font-weight: 400; opacity: .92; }
.cam-guide i { width: 0; height: 15px; border-left: 1px solid #fff; }
.cam-guide span { color: #fff; font-size: calc(14.5 * var(--u)); font-weight: 400; }
.cam-guide i { width: 0; height: calc(15 * var(--u)); border-left: 1px solid rgba(255,255,255,.7); }
.cam-controls {
position: absolute; left: 0; right: 0; bottom: 5.2%;
height: calc(62 * var(--u));
display: flex; align-items: center; justify-content: space-between;
padding: 0 55px;
padding-bottom: env(safe-area-inset-bottom);
padding: 0 calc(56 * var(--u));
margin-bottom: env(safe-area-inset-bottom);
z-index: 2;
}
.cam-side img { width: 31px; height: 31px; }
.cam-shutter img { width: 62px; height: 62px; }
.cam-side img { width: calc(30 * var(--u)); height: calc(30 * var(--u)); }
.cam-shutter { visibility: hidden; }
.cam-shutter img { width: calc(62 * var(--u)); height: calc(62 * var(--u)); }
.cam-shutter:active { transform: scale(.92); }
/* 白屏补光:瞬间点亮(.on 无过渡),移除后淡出(总时长 600–900ms) */
#s-cam.cam-manual .cam-shutter { visibility: visible; }
.cam-controls button:disabled { opacity: .35; pointer-events: none; }
.cam-retry { position: absolute; bottom: 14%; left: 50%; transform: translateX(-50%); z-index: 3; padding: 10px 18px; border: 1px solid #fff; border-radius: 5px; color: #fff; background: #282828; font-size: 14px; white-space: nowrap; }
.cam-retry[hidden] { display: none; }
.cam-progress { position: absolute; top: 21%; left: 0; right: 0; display: flex; justify-content: center; gap: 22px; z-index: 2; color: #bbb; font-size: max(12px, calc(12 * var(--u))); }
.cam-progress span::before { content: ''; display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: #777; vertical-align: middle; }
.cam-progress .current { color: var(--yellow); }
.cam-progress .current::before { background: var(--yellow); }
.cam-progress .done { color: #70E28A; }
.cam-progress .done::before { background: #70E28A; }
/* 白屏补光:瞬间点亮(.on 无过渡),移除后淡出 */
.cam-fill {
position: absolute; inset: 0; background: #fff;
opacity: 0; pointer-events: none; z-index: 30; /* 盖住导航(z 20) */
opacity: 0; pointer-events: none; z-index: 30;
transition: opacity .25s ease;
}
.cam-fill.on { opacity: 1; transition: none; }
/* 步骤圆点:当前步高亮、完成步绿色打勾(Figma 无此元素,放在步骤文案与取景轮廓之间的空隙) */
.cam-dots {
position: absolute; top: 16.2%; left: 0; right: 0;
display: flex; justify-content: center; gap: 12px;
z-index: 2;
}
.cam-dots i {
width: 9px; height: 9px; border-radius: 50%;
background: rgba(255,255,255,.35);
position: relative; transition: background .25s;
}
.cam-dots i.on { background: #fff; box-shadow: 0 0 0 3px rgba(255,255,255,.25); }
.cam-dots i.done { background: #34C759; }
.cam-dots i.done::after {
content: "✓"; position: absolute; left: 0; right: 0; top: -1px;
text-align: center; font-size: 8px; line-height: 10px; color: #fff;
}
/* 取景蒙版:SVG mask 镂空 + 轮廓线框,按 data-step 切换轮廓;框外 85% 黑 */
.cam-mask {
position: absolute; inset: 0;
@@ -276,53 +279,46 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.cam-mask[data-step="1"] [data-shape="1"],
.cam-mask[data-step="2"] [data-shape="2"] { display: block; }
.cam-mask.flip { transform: scaleX(-1); } /* 后置镜头预览不镜像:侧脸轮廓随之翻转 */
.cam-dim-rect { fill: #000; fill-opacity: .85; } /* Figma 05-07 框外为 85% 黑(alpha 217):环境隐约可见,人脸只在轮廓内清晰 */
.cam-dim-rect { fill: #000; fill-opacity: .85; }
.cam-line path { fill: none; stroke: rgba(255,255,255,.85); stroke-width: 2.5; }
/* 未检测到人脸:轮廓呼吸动画(~1.8s);姿态 ok:品牌绿定格 */
@keyframes maskBreath { 0%,100% { stroke-opacity: .3; } 50% { stroke-opacity: .95; } }
.cam-mask.no-face .cam-line path { animation: maskBreath 1.8s ease-in-out infinite; }
.cam-mask.ok .cam-line path { stroke: #34C759; stroke-opacity: 1; animation: none; }
/* 动态捕捉扫描带:自上而下扫过轮廓内 */
.cam-scan { display: none; }
.cam-mask.scanning .cam-scan { display: block; }
.cam-scan-band { animation: camScan 2.2s cubic-bezier(.45,.05,.55,.95) infinite; }
@keyframes camScan { from { transform: translateY(0); } to { transform: translateY(440px); } }
/* 检测引擎加载状态(小字胶囊) */
.cam-status {
position: absolute; top: 17px; left: 50%; /* 与悬浮返回键同一行,不压步骤文案 */
position: absolute; top: 17px; left: 50%;
transform: translateX(-50%);
font-size: 10px; color: rgba(255,255,255,.85);
font-size: calc(11 * var(--u)); color: rgba(255,255,255,.85);
background: rgba(0,0,0,.35);
padding: 4px 12px; border-radius: 11px;
z-index: 2; opacity: 0; transition: opacity .25s; white-space: nowrap;
}
.cam-status.show { opacity: 1; }
/* 引导提示:一次只显示一条,触发式出现/消失 */
/* 动态提示:取景框下方、引导短句上方,一次一条 */
.cam-hint {
position: absolute; left: 50%; bottom: 29%;
position: absolute; left: 50%; bottom: 27%;
transform: translateX(-50%) translateY(6px);
font-size: 13px; color: #fff;
background: rgba(0,0,0,.45);
padding: 7px 16px; border-radius: 17px;
font-size: calc(17 * var(--u)); font-weight: 500; color: #fff;
text-shadow: 0 1px 8px rgba(0,0,0,.65);
z-index: 2; opacity: 0; pointer-events: none;
transition: opacity .2s ease, transform .2s ease;
white-space: nowrap;
}
.cam-hint.show { opacity: 1; transform: translateX(-50%); }
.cam-hint.ok { background: rgba(52,199,89,.82); }
/* 倒计时大数字(取景中央 3→2→1) */
.cam-count {
position: absolute; left: 0; right: 0; top: 40%;
text-align: center;
font-size: 76px; font-weight: 600; color: #fff;
text-shadow: 0 2px 18px rgba(0,0,0,.55);
z-index: 3; opacity: 0; transition: opacity .15s; pointer-events: none;
}
.cam-count.show { opacity: 1; }
.cam-hint.ok { color: #34C759; }
/* 长时间无进展轻提示(不阻塞流程) */
.cam-nudge {
position: absolute; left: 50%; bottom: 13%;
position: absolute; left: 50%; bottom: 16.5%;
transform: translateX(-50%) translateY(6px);
font-size: 10px; color: #fff;
font-size: calc(11.5 * var(--u)); color: #fff;
background: rgba(0,0,0,.55);
padding: 6px 14px; border-radius: 14px;
z-index: 2; opacity: 0; pointer-events: none;
@@ -331,131 +327,173 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
}
.cam-nudge.show { opacity: 1; transform: translateX(-50%); }
/* ═══════════ 08 报告页(Figma 2:9 严格还原:设计稿 1080 基准 ÷2.88) ═══════════ */
.report-body { padding: 0 38px 20px; }
/* 顶栏:白底通栏横带,左返回圆钮 + 居中黑标题(设计 255px 高通栏含手机状态栏,H5 只保留标题栏) */
.report-nav {
position: relative;
margin: 0 -38px; /* 通栏:抵消 body 侧 padding */
height: calc(44px + env(safe-area-inset-top));
padding-top: env(safe-area-inset-top);
background: #fff;
display: flex; align-items: center; justify-content: center;
}
.report-nav .nav-back {
position: absolute; left: 8px;
top: calc(50% + env(safe-area-inset-top) / 2); transform: translateY(-50%);
}
.report-nav-title { font-size: 14px; font-weight: 500; color: #000; letter-spacing: .5px; }
/* ═══════════ 08 报告页 ═══════════ */
#s-report { background: var(--bg); }
.report-body { padding: 0 calc(18 * var(--u)) calc(28 * var(--u)); }
.report-title {
font-size: 20.6px; font-weight: 700; color: var(--ink);
text-align: center; margin: 20px 0 13px;
letter-spacing: .5px;
font-size: calc(23 * var(--u)); font-weight: 700; color: var(--ink);
text-align: center; letter-spacing: calc(.5 * var(--u));
padding: calc(32 * var(--u)) 0 calc(20 * var(--u));
}
.card {
background: #fff; border-radius: 10px;
padding: 14px 18px;
margin-bottom: 10px;
background: #fff; border-radius: calc(8 * var(--u));
margin-bottom: calc(12 * var(--u));
}
/* 总分:两张独立白卡(设计 420+420,间距 17px→6px) */
.stats-row { display: flex; gap: 6px; margin-bottom: 10px; }
.stats-row .card-stat { flex: 1; margin-bottom: 0; }
.card-stat {
display: flex; flex-direction: column; align-items: center;
padding: 12px 10px 13px;
.stat-label { font-size: max(13px, calc(14 * var(--u))); color: var(--ink); }
.stat-value { color: var(--ink); line-height: 1; white-space: nowrap; }
.stat-value b { font-weight: 500; letter-spacing: calc(.5 * var(--u)); }
.stat-value i { font-style: normal; font-weight: 500; }
/* 总分卡:环形综合评分 | 肌肤年龄 */
.card-summary { display: flex; align-items: center; padding: calc(16 * var(--u)) 0; }
.sum-score {
position: relative; flex: 0 0 52%;
display: flex; justify-content: center;
border-right: 1px solid #EFEFEF;
}
.stat-label { font-size: 8.7px; font-weight: 500; color: var(--grey); }
/* label 下短横线(设计 99px→34px 居中) */
.stat-label::after {
content: ""; display: block;
width: 34px; height: 1px; background: #EFEFEF;
margin: 4px auto 0;
.ring { width: calc(108 * var(--u)); height: calc(108 * var(--u)); transform: rotate(-90deg); }
.ring circle { fill: none; stroke-width: 9; }
.ring-bg { stroke: #EFEFEF; }
.ring-fg { stroke: var(--yellow); stroke-linecap: round; stroke-dasharray: 326.73; stroke-dashoffset: 326.73; transition: stroke-dashoffset .9s ease; }
.ring-center {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
}
/* 大数字 30px/400 + 单位 15px/500(设计 87px 与 43.7px) */
.stat-value { margin-top: 2px; line-height: 40px; color: var(--ink); }
.stat-value b { font-size: 30px; font-weight: 400; letter-spacing: 1px; }
.stat-value i { font-style: normal; font-size: 15px; font-weight: 500; }
/* 三维度卡(设计:标签 8.7px 灰 / 分数 28.9px lh50 / 竖向短分隔线 21px / 最低分标红) */
.card-dims { padding: 17px 19px 15px; }
.ring-center .stat-value b { font-size: calc(40 * var(--u)); }
.ring-center .stat-value i { font-size: calc(14 * var(--u)); }
.ring-center .stat-label { margin-top: calc(3 * var(--u)); font-size: max(12px, calc(13 * var(--u))); color: var(--para); }
.sum-age {
flex: 1; min-width: 0;
display: flex; flex-direction: column; align-items: flex-start;
padding-left: calc(26 * var(--u));
}
.stat-age { margin-top: calc(6 * var(--u)); }
.stat-age b { font-size: calc(58 * var(--u)); }
.stat-age i { font-size: calc(19 * var(--u)); margin-left: 2px; }
.age-pill {
margin-top: calc(7 * var(--u));
font-size: max(11px, calc(11 * var(--u))); color: var(--grey-2);
background: #F2F2F2; border-radius: calc(9 * var(--u));
padding: calc(2 * var(--u)) calc(10 * var(--u));
white-space: nowrap;
}
.age-pill[hidden] { display: none; }
/* 三维度卡:分数 + 进度条;最低分标红并挂「优先关注」 */
.card-dims { padding: calc(18 * var(--u)) calc(7 * var(--u)) calc(7 * var(--u)); }
.dims-row { display: flex; }
.dim { flex: 1; position: relative; display: flex; flex-direction: column; align-items: center; }
.dim + .dim::before {
content: ""; position: absolute; left: 0; top: 37px;
width: 1px; height: 21px; margin-top: -10.5px; background: #EFEFEF;
.dim {
flex: 1; min-width: 0; position: relative;
display: flex; flex-direction: column; align-items: flex-start;
padding: 0 0 calc(4 * var(--u)) calc(14 * var(--u));
}
.dim + .dim { border-left: 1px solid #EFEFEF; }
.dim-name { font-size: max(13px, calc(14 * var(--u))); color: var(--ink); }
.dim-score { margin-top: calc(4 * var(--u)); font-size: calc(30 * var(--u)); font-weight: 500; color: var(--ink); line-height: 1.1; }
.dim-score b { font-size: calc(42 * var(--u)); font-weight: 500; }
.dim-score.dim-bad { color: var(--bad); }
.dim-bar {
display: block; margin-top: calc(6 * var(--u));
width: calc(69 * var(--u)); height: calc(6 * var(--u));
border-radius: calc(3 * var(--u)); background: #EFEFEF; overflow: hidden;
}
.dim-bar i { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--yellow); transition: width .8s ease; }
.dim.best .dim-bar i { background: #D7EE6A; }
.dim.worst .dim-bar i { background: #F4B04F; }
.dim-flag {
visibility: hidden;
margin-top: calc(6 * var(--u));
font-style: normal; font-size: calc(9 * var(--u)); color: var(--ink);
background: #F8F4A6; border-radius: calc(8 * var(--u));
padding: calc(1 * var(--u)) calc(8 * var(--u));
}
.dim.worst .dim-flag { visibility: visible; }
.dims-note {
position: relative; margin-top: calc(12 * var(--u));
background: #F2F2F2; border-radius: calc(6 * var(--u));
padding: calc(11 * var(--u)) calc(14 * var(--u)) calc(11 * var(--u)) calc(26 * var(--u));
}
.dims-note::before {
content: ""; position: absolute; left: calc(14 * var(--u)); top: calc(12 * var(--u)); bottom: calc(12 * var(--u));
width: calc(3 * var(--u)); border-radius: 2px; background: var(--yellow);
}
.dims-note-l1 { font-size: max(14px, calc(14 * var(--u))); color: var(--para); }
.dims-note-l1 b { font-weight: 500; color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
.dims-note-l2 { margin-top: calc(6 * var(--u)); font-size: max(13px, calc(13 * var(--u))); color: var(--grey-2); line-height: 1.6; }
/* 维度区块:大标题 + 卡内子 Tab + 大图 + 说明 */
.sec-title {
font-size: calc(22 * var(--u)); font-weight: 700; color: var(--ink);
text-align: center; letter-spacing: calc(1 * var(--u));
margin: calc(26 * var(--u)) 0 calc(16 * var(--u));
}
.card-detail { padding: calc(14 * var(--u)) calc(20 * var(--u)) calc(22 * var(--u)); }
.detail-tabs { display: flex; justify-content: space-between; border-bottom: 1px solid #EFEFEF; }
.dtab {
position: relative;
min-width: calc(80 * var(--u));
padding: calc(6 * var(--u)) calc(4 * var(--u)) calc(11 * var(--u));
font-size: calc(14.5 * var(--u)); font-weight: 500; color: var(--ink);
}
.dim-name { font-size: 8.7px; font-weight: 500; color: var(--grey); }
.dim-score { font-size: 28.9px; font-weight: 500; color: var(--ink); line-height: 50px; }
.dim-score.dim-bad { color: var(--bad); } /* 最低分标红 */
/* 关注行(设计 fs25→8.7px:前缀灰 + 维度名深色大一号;下行深色口号) */
.dims-note { margin-top: 2px; text-align: center; line-height: 1.5; }
.dims-note-l1 { font-size: 8.7px; font-weight: 500; color: #858585; }
.dims-note-l1 b { font-size: 10.1px; font-weight: 500; color: var(--ink); }
.dims-note-l2 { font-size: 8.7px; font-weight: 500; color: var(--ink); }
/* 具体描述(Tab 10.1px,选中 700 + 78×7 黄条;竖排条目标题在图左;图文垂直居中) */
.card-detail { padding: 0 19px 19px; }
.detail-tabs { display: flex; border-bottom: 1px solid #F0F0F0; }
.dtab { flex: 1; padding: 13px 0 0; font-weight: 500; color: var(--ink); }
.dtab-tx { position: relative; display: inline-block; font-size: 10.1px; padding: 0 5px 7px; }
.dtab.active { font-weight: 700; }
.dtab.active .dtab-tx::after {
content: ""; position: absolute; left: 50%; transform: translateX(-50%); bottom: -1px;
width: 78px; height: 7px; border-radius: 3.5px; background: var(--yellow);
.dtab.active::after {
content: ""; position: absolute; left: 0; right: 0; bottom: calc(-3 * var(--u));
height: calc(6 * var(--u)); border-radius: calc(3 * var(--u)); background: var(--yellow);
}
.dpanel { display: none; }
.dpanel.active { display: block; animation: fade .25s ease; padding-top: 19px; }
.dpanel.active { display: block; animation: fade .25s ease; padding-top: calc(14 * var(--u)); }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.ditem { display: flex; gap: 9px; align-items: center; }
.ditem + .ditem { margin-top: 18px; padding-top: 18px; border-top: 1px solid #F0F0F0; }
.ditem-img { width: 92px; height: 96px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.ditem-text { flex: 1; min-width: 0; }
/* 检测条目标题:竖排,位于图左(设计稿如此) */
.ditem-title {
writing-mode: vertical-rl;
letter-spacing: 4px;
font-size: 11.5px; font-weight: 500; color: var(--ink);
margin-right: 3.5px; flex-shrink: 0;
.ditem-img {
width: 100%; height: auto; max-height: calc(360 * var(--u));
margin: 0 auto calc(14 * var(--u));
object-fit: contain; border-radius: calc(6 * var(--u));
background: #F5F5F5; cursor: zoom-in;
}
.ditem-para { font-size: 10.1px; font-weight: 400; color: var(--para); line-height: 1.41; overflow-wrap: anywhere; }
/* 护理建议(标题 13px/700/#000;条目 10.1px #4A4A4A lh1.43;收尾句 11.1px/500/#000) */
.card-care, .card-essence { padding: 18px 20px 16px 19px; }
.card-heading { font-size: 13px; font-weight: 700; color: #000; margin-bottom: 4px; }
.care-item { font-size: 10.1px; color: var(--para); line-height: 1.43; margin-bottom: 7px; overflow-wrap: anywhere; }
.care-summary { font-size: 11.1px; font-weight: 500; color: #000; margin-top: 5px; }
/* 精华推荐(Figma 无此卡,按业务文档 V2.3 §6.4 保留,样式沿用护理建议卡) */
.essence-name { font-size: 12px; font-weight: 700; color: var(--ink); margin-bottom: 5px; }
.essence-triggers { font-size: 7px; color: var(--grey-3); margin-top: 6px; }
/* 产品推荐:大标题独立落在灰底;白卡左文案右图(图 151×174 撑满卡高,COREⅡ 为 Figma 导出字标 SVG) */
.ditem-img[hidden] { display: none; }
.ditem-para {
padding-top: calc(12 * var(--u)); border-top: 1px solid #EFEFEF;
font-size: max(14px, calc(15 * var(--u))); color: var(--para); line-height: 1.8;
text-align: justify; overflow-wrap: anywhere;
}
.vm-notes { font-size: calc(10 * var(--u)); color: var(--grey-3); line-height: 1.7; margin: calc(14 * var(--u)) calc(6 * var(--u)) 0; }
.report-disclaimer {
font-size: calc(10 * var(--u)); color: var(--para);
line-height: 1.7; margin: calc(18 * var(--u)) calc(6 * var(--u)) calc(14 * var(--u));
}
/* 护理建议 / 精华推荐 */
.card-care, .card-essence { padding: calc(20 * var(--u)) calc(22 * var(--u)) calc(18 * var(--u)); }
.card-heading { font-size: calc(17 * var(--u)); font-weight: 700; color: #000; margin-bottom: calc(10 * var(--u)); }
.care-item { font-size: max(14px, calc(15 * var(--u))); color: var(--para); line-height: 1.8; margin-bottom: calc(14 * var(--u)); overflow-wrap: anywhere; }
.care-summary { font-size: calc(13.5 * var(--u)); font-weight: 500; color: #000; margin-top: calc(4 * var(--u)); }
.essence-name { font-size: calc(16 * var(--u)); font-weight: 700; color: var(--ink); }
.essence-triggers { font-size: max(12px, calc(12 * var(--u))); color: var(--grey-3); margin-top: calc(8 * var(--u)); line-height: 1.7; }
/* 产品推荐:大标题落在灰底;白卡左文案右图(各占一半) */
.prod-title {
font-size: 21.6px; font-weight: 700; color: var(--ink);
line-height: 1.5; text-align: center; margin: 15px 0 16px;
font-size: calc(23 * var(--u)); font-weight: 700; color: var(--ink);
line-height: 1.45; text-align: center; letter-spacing: calc(1 * var(--u));
margin: calc(30 * var(--u)) 0 calc(18 * var(--u));
}
.card-product { padding: 0; overflow: hidden; }
.prod-card { display: flex; align-items: stretch; }
.card-product { overflow: hidden; }
.prod-card { display: flex; align-items: stretch; min-height: calc(176 * var(--u)); }
.prod-info {
flex: 1; min-width: 0;
padding: 44px 10px 16px 16px;
padding: calc(40 * var(--u)) calc(10 * var(--u)) calc(16 * var(--u)) calc(18 * var(--u));
display: flex; flex-direction: column; align-items: flex-start;
}
.prod-badge { width: 39px; height: auto; }
.prod-name { font-size: 13px; font-weight: 700; color: var(--ink); line-height: 1.34; margin-top: 9px; overflow-wrap: anywhere; }
.prod-sub { font-size: 7.3px; font-weight: 500; color: var(--grey-3); margin-top: auto; padding-top: 10px; }
.prod-scroll {
flex-shrink: 0; margin: 0; padding: 0;
display: flex; overflow: hidden;
scrollbar-width: none;
}
.prod-scroll::-webkit-scrollbar { display: none; }
.prod-scroll img {
width: 151px; height: 174px;
object-fit: cover; flex-shrink: 0;
}
.btn-more { margin: 20px auto 0; }
/* 免责小字:灰底独立文字,左对齐到卡片文案位(设计 x=166→57.6px;fw300 #000) */
.report-disclaimer {
font-size: 7.3px; font-weight: 300; color: #000;
line-height: 1.44; text-align: left;
margin: 13px 19px 11px;
}
.prod-badge { width: calc(44 * var(--u)); height: auto; }
.prod-name { font-size: calc(14.5 * var(--u)); font-weight: 700; color: var(--ink); line-height: 1.45; margin-top: calc(10 * var(--u)); }
.prod-sub { font-size: calc(9.5 * var(--u)); color: var(--grey-3); margin-top: auto; padding-top: calc(10 * var(--u)); }
.prod-scroll { flex: 0 0 51%; display: flex; overflow: hidden; }
.prod-scroll img { width: 100%; height: 100%; object-fit: cover; }
.btn-more { margin: calc(26 * var(--u)) auto 0; }
/* 档位配色(引擎档位 → 文字色) */
.lv-good { color: #2FA84F; }
.lv-warn { color: #E8A13D; }
.lv-bad { color: var(--bad); }
/* ═══════════ 弹窗 ═══════════ */
.overlay {
@@ -467,37 +505,31 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.overlay.show { display: flex; }
.overlay .popup { animation: popIn .3s cubic-bezier(.34,1.4,.64,1) both; }
@keyframes popIn { from { opacity: 0; transform: translateY(26px) scale(.96); } to { opacity: 1; transform: none; } }
/* 弹窗 308×312(设计稿 02/04) */
.popup {
position: relative;
width: 307px;
background: #fff; border-radius: 10px;
padding: 34px 26px 33px;
width: calc(308 * var(--u)); min-height: calc(312 * var(--u));
background: #fff; border-radius: calc(10 * var(--u));
padding: calc(40 * var(--u)) calc(32 * var(--u)) calc(35 * var(--u));
text-align: left;
display: flex; flex-direction: column;
}
.popup-close {
position: absolute; top: 12px; right: 12px;
width: 31px; height: 31px;
position: absolute; top: calc(10 * var(--u)); right: calc(10 * var(--u));
width: calc(32 * var(--u)); height: calc(32 * var(--u));
display: flex; align-items: center; justify-content: center;
}
.popup-close img { width: 17px; height: 17px; opacity: .8; }
.popup-close img { width: calc(19 * var(--u)); height: calc(19 * var(--u)); }
.popup-title {
font-size: 23px; font-weight: 500; color: var(--ink);
line-height: 1.35; letter-spacing: .5px;
margin-bottom: 15px;
font-size: calc(23 * var(--u)); font-weight: 500; color: var(--ink);
line-height: 1.38; letter-spacing: calc(.5 * var(--u));
margin-bottom: calc(14 * var(--u));
}
.popup-title-center { text-align: center; margin: 18px 0 24px; }
/* 拍照引导弹窗:近方形比例,标题垂直居中偏上,按钮贴底 */
#p-photoguide .popup {
min-height: 312px;
display: flex; flex-direction: column;
padding: 34px 26px 52px;
}
#p-photoguide .popup-title-center { margin: 56px 0 auto; }
.popup-body { font-size: 11.5px; color: var(--ink); line-height: 1.55; margin-bottom: 20px; }
.privacy-row { display: flex; align-items: center; gap: 8px; margin-bottom: 40px; cursor: pointer; }
.popup-body { font-size: calc(11.6 * var(--u)); color: var(--ink); line-height: 1.6; margin-bottom: calc(26 * var(--u)); }
.privacy-row { display: flex; align-items: center; gap: calc(9 * var(--u)); cursor: pointer; }
.checkbox {
width: 11px; height: 11px; border-radius: 50%;
border: 1px solid #282828; background: #fff;
width: calc(12 * var(--u)); height: calc(12 * var(--u)); border-radius: 50%;
border: 1px solid var(--ink); background: #fff;
flex-shrink: 0; position: relative;
}
.checkbox.checked::after {
@@ -505,17 +537,35 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
inset: 2px; border-radius: 50%;
background: var(--ink-2);
}
.privacy-text { font-size: 10px; color: var(--grey-2); }
.popup-btn { margin: 0 auto; }
.privacy-text { font-size: calc(10.3 * var(--u)); color: var(--grey-2); }
.privacy-link { color: var(--ink); font-weight: 500; text-decoration: underline; cursor: pointer; }
.privacy-link:active { opacity: .7; }
.popup-btn { margin: auto auto 0; }
#p-privacy .popup-btn { margin-top: calc(40 * var(--u)); }
/* 拍照引导:背景只留启动页底图 + Slogan,标题居中 */
.overlay-guide { background: #101823; }
.guide-bg { position: absolute; inset: 0; background: url(../assets/img/bg-splash.jpg) center/cover no-repeat; }
.guide-bg::after { content: ""; position: absolute; inset: 0; background: rgba(0,0,0,.5); }
#p-photoguide .popup { padding: 0 calc(24 * var(--u)) calc(55 * var(--u)); }
.popup-title-center { text-align: center; margin: calc(88 * var(--u)) 0 calc(40 * var(--u)); }
/* 报告检测图放大 */
.overlay-zoom { background: rgba(0,0,0,.92); z-index: 80; cursor: zoom-out; }
.overlay-zoom img { max-width: 100%; max-height: 100%; object-fit: contain; animation: zoomIn .22s ease both; }
.overlay-zoom img { max-height: calc(100% - 110px); }
.zoom-close { position: absolute; top: max(16px, env(safe-area-inset-top)); right: 14px; width: 44px; height: 44px; font-size: 34px; color: #fff; border-radius: 50%; background: rgba(255,255,255,.12); }
.zoom-tip { position: absolute; bottom: max(24px, env(safe-area-inset-bottom)); color: #eee; font-size: 14px; }
@keyframes zoomIn { from { opacity: 0; transform: scale(.88); } to { opacity: 1; transform: none; } }
/* 分析中 */
.overlay-dark { background: rgba(16,16,16,.82); }
.analyzing {
color: #fff; text-align: center;
font-size: 13px; letter-spacing: 1px;
font-size: calc(14 * var(--u)); letter-spacing: 1px;
display: flex; flex-direction: column; align-items: center; gap: 16px;
}
.analyzing-tip { font-size: 10px; color: rgba(255,255,255,.45); letter-spacing: 0; }
.analyzing-tip { font-size: calc(11 * var(--u)); color: rgba(255,255,255,.45); letter-spacing: 0; }
.spinner {
width: 34px; height: 34px;
border: 3px solid rgba(255,255,255,.25);
@@ -529,59 +579,32 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.toast {
position: absolute; left: 50%; bottom: 15%;
transform: translateX(-50%) translateY(8px);
max-width: 86%;
background: rgba(20,20,20,.85); color: #fff;
font-size: 12px;
font-size: calc(13 * var(--u)); line-height: 1.5; text-align: center;
padding: 9px 18px; border-radius: 18px;
opacity: 0; pointer-events: none;
transition: all .25s ease;
z-index: 99; white-space: nowrap;
z-index: 99;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ═══════════ 第3轮上线打磨新增(以下均为新增规则,不动以上已验收样式) ═══════════ */
/* 隐私协议链接:下划线、色值不变,点击开占位弹层 */
.privacy-link { color: var(--grey-2); text-decoration: underline; cursor: pointer; }
.privacy-link:active { opacity: .7; }
/* 隐私协议占位弹层(轻量,复用 .overlay/.popup 骨架) */
#p-policy .popup { padding: 30px 26px 34px; }
.popup-title-policy { font-size: 18px; margin-bottom: 12px; }
/* ═══════════ 第4轮:引导式自动三连拍 + SkinScore 报告接入(新增,不动以上已验收样式) ═══════════ */
/* 档位配色:良好=绿 / 改善=琥珀 / 预警=红(文字场景直接上色) */
.lv-good { color: #2FA84F; }
.lv-warn { color: #E8A13D; }
.lv-bad { color: var(--bad); }
/* 肤龄钳制留痕(小字灰) */
.stat-sub { font-size: 8px; color: var(--grey-3); min-height: 10px; line-height: 10px; }
/* 分类条目:S 值 + 档位徽章 */
.cat-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.cat-s { font-size: 15px; font-weight: 600; color: var(--ink); }
.cat-s.cat-na { font-size: 9px; font-weight: 400; color: var(--grey-2); }
.cat-badge { font-style: normal; font-size: 8px; font-weight: 500; color: #fff; padding: 2px 7px; border-radius: 8px; }
.cat-badge.lv-good { background: #2FA84F; color: #fff; }
.cat-badge.lv-warn { background: #E8A13D; color: #fff; }
.cat-badge.lv-bad { background: var(--bad); color: #fff; }
/* 降级标注(下颌线未参与评分等,小字灰) */
.vm-notes { font-size: 7px; font-weight: 400; color: var(--grey-3); line-height: 1.7; margin: 0 2px 9px; }
/* 精华推荐卡 */
.essence-name { font-size: 15px; font-weight: 700; color: var(--ink); margin-bottom: 6px; }
.essence-triggers { font-size: 8.7px; color: var(--grey-3); margin-top: 6px; } /* 设计规范 Caption 级:验收要求展示命中判据,需可读 */
/* ═══════════ 第5轮:报告页检测可视化(face_maps 图层 / marks 叠加,新增,不动以上已验收样式) ═══════════ */
/* 分类卡检测图复用 .ditem-img(左图右文、92×96 裁切);无检测图时(hidden)卡片退回文字占位 */
.ditem-img[hidden] { display: none; }
/* ═══════════ 隐私政策与服务协议(全文页) ═══════════ */
.overlay-policy { background: #F5F5F5; }
.policy-page { width: 100%; height: 100%; display: flex; flex-direction: column; background: #fff; }
.policy-page { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; background: #fff; }
.policy-float-back {
position: absolute; top: 12px; left: 12px; z-index: 2;
background: #fff; box-shadow: 0 1px 6px rgba(0,0,0,.12);
}
.policy-header {
position: relative; flex: none; height: var(--nav-h);
display: flex; align-items: center; justify-content: center;
background: #fff; border-bottom: 1px solid var(--line);
}
.policy-header .nav-back { position: absolute; left: 4px; top: 0; width: 40px; height: 100%; display: flex; align-items: center; justify-content: center; }
.policy-header .nav-back { position: absolute; left: 4px; top: 0; width: 40px; height: 100%; border: 0; }
.policy-header-title { font-size: 14px; font-weight: 500; color: #000; }
.policy-body { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 22px 20px 48px; }
#p-policy .policy-body { padding-top: 52px; }
.policy-title { font-size: 19px; font-weight: 700; color: var(--ink); line-height: 1.4; margin-bottom: 16px; }
.policy-h2 {
font-size: 15px; font-weight: 600; color: var(--ink);
@@ -598,10 +621,6 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.policy-end { text-align: center; color: var(--grey); margin-top: 28px; }
/* ═══════════ 历史报告 ═══════════ */
.nav-history {
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
font-size: 12px; color: var(--grey-2); padding: 4px 8px;
}
.policy-header .history-clear {
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
font-size: 12px; color: var(--grey-2); padding: 4px 8px;
@@ -621,76 +640,6 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.his-score span { font-size: 11px; color: var(--grey); margin-left: 2px; }
.his-del { flex: none; font-size: 11px; color: var(--grey-2); padding: 6px 4px; }
/* ═══════════ 第6轮:真机反馈 13 项 ═══════════ */
/* #2 姿态提示浮层:移到取景框下方(原 29% 与脸部轮廓/下巴重叠) */
.cam-hint { bottom: 21%; }
/* #10 无进展提示与快门重叠:上移 */
.cam-nudge { bottom: 16%; }
/* #4/#10 拍照页顶栏取消,返回键改悬浮白圆(对齐设计稿) */
.cam-back-float {
position: absolute; top: 12px; left: 12px; z-index: 3;
background: rgba(255,255,255,.9); border-color: transparent;
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
/* #12 预览镜像改为类切换(前置镜像/后置不镜像) */
#cam-video { transform: none; }
#cam-video.mirror { transform: scaleX(-1); }
/* #1 城市自定义下拉(替代原生 select) */
.rs { position: relative; }
.rs-current {
width: 100%; display: flex; align-items: center; justify-content: space-between;
background: #fff; border: 1px solid var(--line); border-radius: 8px;
padding: 12px 14px; font-size: 13px; color: var(--ink); text-align: left;
}
.rs-current .chev { transition: transform .2s; }
.rs.open .rs-current .chev { transform: rotate(225deg); }
.rs-value.rs-placeholder { color: #B3B3B3; }
.rs-list {
position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 30;
max-height: 264px; overflow-y: auto; -webkit-overflow-scrolling: touch;
background: #fff; border: 1px solid var(--line); border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.08);
}
.rs-list li { padding: 12px 14px; font-size: 13px; color: var(--ink); }
.rs-list li + li { border-top: 1px solid #F3F3F3; }
.rs-list li:active { background: #F7F7F7; }
/* #5 隐私页去头部条,返回键悬浮 */
.policy-float-back {
position: absolute; top: 12px; left: 12px; z-index: 2;
background: #fff; box-shadow: 0 1px 6px rgba(0,0,0,.12);
}
.overlay-policy .policy-page { position: relative; }
/* ═══════════ 第7轮:用户测试 agent 反馈 ═══════════ */
/* P1-1 隐私页标题被悬浮返回键遮挡:正文上留返回键位 */
#p-policy .policy-body { padding-top: 52px; }
/* P1-2 姿态提示气泡压住底部引导文字:改到步骤文案下方、取景框上方 */
.cam-hint { bottom: auto; top: 26%; }
/* ═══════════ 第8轮:真机反馈 11 项 ═══════════ */
/* #1 城市下拉浮层层级高于肤质轮播圆点 */
.rs { z-index: 50; }
.rs-list { z-index: 50; }
/* #4 无进展提示再上移(取景/拍摄区下方,引导 chips 上方) */
.cam-nudge { bottom: 17.5%; } /* 引导 chips(bottom 22.4%) 与快门之间 */
/* #6 报告页头部:按 Figma 08 还原为内联白底顶栏(见报告页主区块),不再悬浮 */
/* ═══════════ 第9轮:真机反馈 ═══════════ */
/* #1 城市下拉浮层再提级,并加实体阴影边界(部分端 stacking 表现不同) */
.rs, .rs-list { z-index: 999; }
/* #3 姿态提示浮层改设计语言:无气泡底,纯白文字+投影,置于引导 chips 上方 */
.cam-hint {
top: auto; bottom: 26%;
background: none; padding: 0; border-radius: 0;
font-size: 15px; font-weight: 500; text-shadow: 0 1px 8px rgba(0,0,0,.65);
}
.cam-hint.ok { background: none; color: #34C759; }
/* #9 档案页顶栏移除,返回键悬浮(白圆带影,压暗背景上可读) */
.profile-float-back {
position: fixed; top: 12px; left: 12px; z-index: 21;
background: rgba(255,255,255,.92); border-color: transparent;
box-shadow: 0 1px 6px rgba(0,0,0,.18);
}
/* 下拉浮层与肤质圆点之间留明确间隙 */
.rs-list { max-height: 236px; }
+4 -1
View File
@@ -1,5 +1,8 @@
@echo off
rem 巨子测肤 H5 本机部署启动(提测用)。密钥由 server.js 自动加载。
set PORT=8088
cd /d %~dp0
set HOST=127.0.0.1
rem 无密钥时允许页面预览和 ?mock=1 演示,配置密钥后自动启用真实检测。
set LOCAL_PREVIEW=1
cd /d "%~dp0"
node server.js >> "%~dp0server.log" 2>&1
+113 -102
View File
@@ -12,6 +12,7 @@
<title>1'me 在线肌肤测试</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="preload" as="image" href="assets/img/bg-splash.jpg">
<link rel="stylesheet" href="vendor/mobile-select/mobile-select.css">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
@@ -32,49 +33,45 @@
<!-- ═══════════ 03 档案填写 ═══════════ -->
<section class="screen" id="s-profile">
<div class="profile-bg"></div>
<button class="nav-back profile-float-back" data-action="back-splash" aria-label="返回"><i class="chev left"></i></button>
<div class="profile-body">
<h1 class="page-title">您的肌肤状态</h1>
<!-- 三个选择控件同一套样式:点击后底部弹出滚轮(vendor/mobile-select) -->
<div class="form-row two">
<div class="field">
<label class="field-label">您的年龄</label>
<input id="f-age" class="field-input" type="text" inputmode="numeric" maxlength="2" placeholder="请输入">
<label class="field-label" for="pick-birth">您的出生年月</label>
<button type="button" class="pick" id="pick-birth" aria-haspopup="dialog"><span class="pick-value placeholder">请选择年月</span><i class="chev down"></i></button>
</div>
<div class="field">
<label class="field-label">您的性别</label>
<div class="field-select-wrap">
<select id="f-gender" class="field-input field-select">
<option value="" selected disabled>请选择</option>
<option>女</option><option>男</option>
</select>
<i class="chev down"></i>
</div>
<label class="field-label" for="pick-gender">您的性别</label>
<button type="button" class="pick" id="pick-gender" aria-haspopup="dialog"><span class="pick-value placeholder">请选择性别</span><i class="chev down"></i></button>
</div>
</div>
<p class="profile-age" id="profile-age" aria-live="polite">根据出生年月自动计算年龄</p>
<div class="field">
<label class="field-label">您的所在地区</label>
<div class="rs" id="region-rs">
<button type="button" class="rs-current" id="rs-current">
<span id="rs-value" class="rs-value rs-placeholder">请选择</span>
<i class="chev down"></i>
</button>
<ul class="rs-list" id="rs-list" hidden></ul>
</div>
<input type="hidden" id="f-region">
<label class="field-label" for="pick-region">您所在的地区</label>
<button type="button" class="pick" id="pick-region" aria-haspopup="dialog"><span class="pick-value placeholder">请选择省份和城市</span><i class="chev down"></i></button>
</div>
<input type="hidden" id="f-birth">
<input type="hidden" id="f-age">
<input type="hidden" id="f-gender">
<input type="hidden" id="f-region">
<div class="field">
<label class="field-label">您的日常肤质</label>
<div class="skin-card">
<div class="skin-axis">
<span class="axis-item"><i class="chip" style="background:#EAE6E1"></i>偏干</span>
<span class="axis-item"><i class="chip" style="background:#DBF6D6"></i>水油平衡</span>
<span class="axis-item"><i class="chip" style="background:#F5F078"></i>偏油</span>
<span class="axis-item"><i class="chip" style="background:#DBF6D6"></i>水油平衡</span>
</div>
<div class="skin-carousel" id="skin-carousel">
<div class="skin-track" id="skin-track"></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-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>
</div>
<div class="skin-dots" id="skin-dots"></div>
</div>
@@ -87,15 +84,18 @@
<!-- ═══════════ 05-07 拍照页 ═══════════ -->
<section class="screen" id="s-cam">
<div class="cam-bg" id="cam-bg"></div>
<button class="nav-back nav-dark cam-back-float" data-action="cam-back" aria-label="返回"><i class="chev left"></i></button>
<div class="cam-dots" id="cam-dots"><i></i><i></i><i></i></div>
<p class="cam-step" id="cam-step">1/3<br>请单人正脸出镜</p>
<p class="cam-step" id="cam-step">1/3<br>请正对屏幕</p>
<!-- 取景蒙版:SVG mask 镂空(框外 85% 黑,对齐 Figma 图层不透明度) + 轮廓线框;data-step 切换三种轮廓
轮廓由 Figma 拍照示意图(design/assets/images/*-拍照示意.png,alpha 镂空区)逐像素描摹生成,坐标为 Figma 375×812 整帧;viewBox 只取 y 86–812(帧顶 86px 是微信导航栏,不属于 H5 视口)
0=正脸(含耳部) 1=左脸出镜(用户头向自己右侧转,镜像预览中耳在画面左、鼻朝画面右) 2=右脸出镜(头向自己左侧转,耳在画面右、鼻朝画面左)
data-step 取 CAM_STEPS[].mask(拍摄顺序 正→右→左,对齐 Figma) -->
<svg class="cam-mask" id="cam-mask" data-step="0" viewBox="0 86 375 726" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<defs>
<linearGradient id="scan-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2EE6F0" stop-opacity="0"/>
<stop offset=".92" stop-color="#2EE6F0" stop-opacity=".32"/>
<stop offset="1" stop-color="#2EE6F0" stop-opacity=".95"/>
</linearGradient>
<mask id="cam-cut" maskUnits="userSpaceOnUse" x="-1000" y="-1000" width="2375" height="2812">
<rect x="-1000" y="-1000" width="2375" height="2812" fill="#fff"/>
<g fill="#000">
@@ -106,6 +106,10 @@
</mask>
</defs>
<rect class="cam-dim-rect" x="-1000" y="-1000" width="2375" height="2812" mask="url(#cam-cut)"/>
<!-- 动态捕捉扫描带:clipPath 由 app.js 复制当前三种轮廓生成(#cam-clip),只在轮廓内可见 -->
<g class="cam-scan" clip-path="url(#cam-clip)">
<rect class="cam-scan-band" x="40" y="150" width="295" height="84" fill="url(#scan-grad)"/>
</g>
<g class="cam-line">
<path data-shape="0" d="M54.1 388.5L56.1 387.1L58.2 386.0L60.3 383.5L62.4 382.2L64.5 381.4L66.5 370.5L68.6 357.6L70.7 343.0L72.8 327.1L74.9 310.3L76.9 302.9L79.0 297.1L81.1 292.3L83.2 288.0L85.3 284.2L87.3 280.8L89.4 277.8L91.5 275.0L93.6 272.3L95.7 269.9L97.7 267.6L99.8 265.5L101.9 263.5L104.0 261.6L106.1 259.9L108.1 258.3L110.2 256.8L112.3 255.3L114.4 254.0L116.5 252.7L118.5 251.4L120.6 250.2L122.7 249.1L124.8 248.1L126.8 247.1L128.9 246.2L131.0 245.3L133.1 244.4L135.2 243.7L137.2 242.9L139.3 242.2L141.4 241.6L143.5 241.0L145.6 240.4L147.6 239.9L149.7 239.4L151.8 238.9L153.9 238.5L156.0 238.1L158.0 237.7L160.1 237.4L162.2 237.0L164.3 236.7L166.4 236.4L168.4 236.1L170.5 235.9L172.6 235.8L174.7 235.7L176.8 235.5L178.8 235.4L180.9 235.4L183.0 235.3L185.1 235.3L187.2 235.3L189.2 235.3L191.3 235.3L193.4 235.3L195.5 235.3L197.6 235.3L199.6 235.4L201.7 235.5L203.8 235.7L205.9 235.9L207.9 236.1L210.0 236.4L212.1 236.7L214.2 237.0L216.3 237.4L218.3 237.7L220.4 238.1L222.5 238.5L224.6 238.9L226.7 239.4L228.7 239.9L230.8 240.6L232.9 241.2L235.0 241.9L237.1 242.6L239.1 243.3L241.2 244.0L243.3 244.8L245.4 245.6L247.5 246.5L249.5 247.5L251.6 248.4L253.7 249.5L255.8 250.7L257.9 251.9L259.9 253.2L262.0 254.6L264.1 256.0L266.2 257.4L268.3 259.1L270.3 260.7L272.4 262.5L274.5 264.5L276.6 266.5L278.7 268.7L280.7 271.0L282.8 273.6L284.9 276.4L287.0 279.3L289.0 282.6L291.1 286.2L293.2 290.2L295.3 294.7L297.4 300.1L299.4 306.6L301.5 315.7L303.6 332.1L305.7 347.2L307.8 360.9L309.8 373.0L311.9 381.7L314.0 382.7L316.1 384.5L318.2 387.3L320.2 388.6L321.3 390.2L321.3 408.3L320.2 412.8L318.2 417.1L316.1 425.1L314.0 432.9L311.9 439.5L309.8 445.1L307.8 449.8L305.7 453.7L303.6 456.8L301.5 459.2L299.4 461.1L297.4 462.5L295.3 464.7L293.2 467.6L291.1 471.4L289.0 476.0L287.0 481.4L284.9 486.5L282.8 491.4L280.7 496.0L278.7 500.5L276.6 504.7L274.5 508.7L272.4 512.6L270.3 516.2L268.3 519.7L266.2 523.0L264.1 526.2L262.0 529.3L259.9 532.3L257.9 535.1L255.8 537.9L253.7 540.5L251.6 543.0L249.5 545.3L247.5 547.6L245.4 549.7L243.3 551.8L241.2 553.8L239.1 555.8L237.1 557.6L235.0 559.3L232.9 560.9L230.8 562.4L228.7 563.8L226.7 565.2L224.6 566.5L222.5 567.6L220.4 568.7L218.3 569.8L216.3 570.7L214.2 571.7L212.1 572.6L210.0 573.4L207.9 574.1L205.9 574.7L203.8 575.2L201.7 575.7L199.6 576.2L197.6 576.6L195.5 576.9L193.4 577.1L191.3 577.2L189.2 577.3L187.2 577.4L185.1 577.3L183.0 577.2L180.9 577.0L178.8 576.7L176.8 576.3L174.7 576.0L172.6 575.6L170.5 575.1L168.4 574.6L166.4 573.9L164.3 573.2L162.2 572.3L160.1 571.4L158.0 570.5L156.0 569.4L153.9 568.3L151.8 567.2L149.7 566.0L147.6 564.7L145.6 563.3L143.5 561.8L141.4 560.2L139.3 558.6L137.2 556.9L135.2 555.0L133.1 553.1L131.0 551.1L128.9 549.0L126.8 546.8L124.8 544.4L122.7 542.0L120.6 539.4L118.5 536.8L116.5 534.1L114.4 531.2L112.3 528.2L110.2 525.0L108.1 521.7L106.1 518.2L104.0 514.6L101.9 510.9L99.8 507.0L97.7 502.8L95.7 498.5L93.6 493.9L91.5 489.1L89.4 484.2L87.3 479.0L85.3 473.9L83.2 469.7L81.1 466.3L79.0 463.8L76.9 461.9L74.9 460.3L72.8 458.2L70.7 455.5L68.6 452.1L66.5 447.9L64.5 442.9L62.4 436.8L60.3 429.5L58.2 421.2L56.1 417.0L54.1 412.3Z"/>
<path data-shape="1" d="M55.8 396.6L57.9 382.4L60.0 370.8L62.0 353.4L64.1 336.1L66.2 318.3L68.3 309.9L70.4 303.9L72.4 298.9L74.5 294.5L76.6 290.5L78.7 286.9L80.8 283.6L82.8 280.5L84.9 277.6L87.0 274.9L89.1 272.4L91.2 270.1L93.2 267.9L95.3 265.8L97.4 263.8L99.5 262.0L101.5 260.2L103.6 258.5L105.7 256.9L107.8 255.4L109.9 254.0L111.9 252.6L114.0 251.3L116.1 250.0L118.2 248.8L120.3 247.6L122.3 246.6L124.4 245.5L126.5 244.6L128.6 243.7L130.7 242.8L132.7 242.0L134.8 241.3L136.9 240.5L139.0 239.8L141.1 239.2L143.1 238.6L145.2 238.0L147.3 237.5L149.4 237.1L151.5 236.7L153.5 236.3L155.6 236.0L157.7 235.7L159.8 235.3L161.9 235.0L163.9 234.8L166.0 234.5L168.1 234.4L170.2 234.3L172.3 234.2L174.3 234.1L176.4 234.1L178.5 234.0L180.6 233.9L182.6 234.0L184.7 234.1L186.8 234.1L188.9 234.3L191.0 234.4L193.0 234.5L195.1 234.7L197.2 234.9L199.3 235.1L201.4 235.4L203.4 235.6L205.5 235.9L207.6 236.2L209.7 236.6L211.8 236.9L213.8 237.3L215.9 237.8L218.0 238.2L220.1 238.6L222.2 239.1L224.2 239.7L226.3 240.2L228.4 240.8L230.5 241.4L232.6 242.0L234.6 242.6L236.7 243.3L238.8 244.1L240.9 244.8L243.0 245.7L245.0 246.6L247.1 247.4L249.2 248.3L251.3 249.3L253.4 250.2L255.4 251.2L257.5 252.3L259.6 253.4L261.7 254.6L263.7 255.8L265.8 257.1L267.9 258.5L270.0 259.8L272.1 261.3L274.1 262.7L276.2 264.3L278.3 265.9L280.4 267.6L282.5 269.4L284.5 271.4L286.6 273.4L288.7 275.5L290.8 277.7L292.9 279.9L294.9 282.4L297.0 284.9L299.1 287.6L301.2 290.5L303.3 293.7L305.3 297.2L307.4 301.1L309.5 305.6L311.6 311.0L313.7 318.2L315.7 341.8L317.8 349.8L318.9 361.1L318.9 451.5L317.8 456.2L315.7 460.4L313.7 468.7L311.6 476.2L309.5 482.5L307.4 488.1L305.3 493.2L303.3 498.0L301.2 502.5L299.1 506.8L297.0 511.0L294.9 515.0L292.9 518.9L290.8 522.7L288.7 526.5L286.6 530.1L284.5 533.7L282.5 537.2L280.4 540.8L278.3 544.1L276.2 547.5L274.1 550.6L272.1 553.7L270.0 556.5L267.9 559.1L265.8 561.6L263.7 563.8L261.7 565.8L259.6 567.6L257.5 569.2L255.4 570.7L253.4 572.0L251.3 573.2L249.2 574.2L247.1 575.0L245.0 575.8L243.0 576.4L240.9 576.9L238.8 577.2L236.7 577.5L234.6 577.7L232.6 577.7L230.5 577.7L228.4 577.7L226.3 577.5L224.2 577.3L222.2 577.0L220.1 576.6L218.0 576.2L215.9 575.7L213.8 575.2L211.8 574.6L209.7 573.9L207.6 573.2L205.5 572.5L203.4 571.8L201.4 571.0L199.3 570.2L197.2 569.3L195.1 568.3L193.0 567.4L191.0 566.3L188.9 565.3L186.8 564.3L184.7 563.2L182.6 562.2L180.6 561.1L178.5 560.0L176.4 558.8L174.3 557.6L172.3 556.4L170.2 555.1L168.1 553.8L166.0 552.5L163.9 551.1L161.9 549.7L159.8 548.4L157.7 547.0L155.6 545.5L153.5 544.1L151.5 542.6L149.4 541.0L147.3 539.4L145.2 537.9L143.1 536.2L141.1 534.5L139.0 532.8L136.9 531.1L134.8 529.3L132.7 527.5L130.7 525.6L128.6 523.7L126.5 521.7L124.4 519.7L122.3 517.5L120.3 515.3L118.2 512.8L116.1 510.1L114.0 507.2L111.9 503.9L109.9 500.2L107.8 496.1L105.7 491.6L103.6 486.6L101.5 481.1L99.5 476.2L97.4 472.2L95.3 469.2L93.2 467.4L91.2 466.6L89.1 466.1L87.0 465.4L84.9 464.5L82.8 463.4L80.8 462.1L78.7 460.5L76.6 458.5L74.5 456.2L72.4 453.6L70.4 450.6L68.3 447.3L66.2 443.6L64.1 439.4L62.0 434.5L60.0 428.3L57.9 425.3L55.8 422.0Z"/>
@@ -113,9 +117,12 @@
</g>
</svg>
<p class="cam-status" id="cam-status">检测引擎加载中…</p>
<p class="cam-hint" id="cam-hint"></p>
<div class="cam-count" id="cam-count"></div>
<p class="cam-nudge" id="cam-nudge">识别不到?可点下方快门手动拍摄</p>
<p class="cam-hint" id="cam-hint" role="status" aria-live="polite"></p>
<div class="cam-progress" aria-label="拍摄进度">
<span data-capture-step="0">正脸</span><span data-capture-step="1">右脸</span><span data-capture-step="2">左脸</span>
</div>
<button type="button" class="cam-retry" data-action="retry-camera" hidden>重新开启自动识别</button>
<p class="cam-nudge" id="cam-nudge">识别不到?请正对镜头,并到光线充足处</p>
<div class="cam-guide" id="cam-guide"></div>
<video id="cam-video" autoplay playsinline muted></video>
<input type="file" id="cam-file" accept="image/*" capture="user" hidden>
@@ -124,6 +131,7 @@
<button class="cam-side" data-action="album" aria-label="相册">
<img src="assets/svg/图标-相册.svg" alt="相册">
</button>
<!-- 实时检测时自动抓拍、不显示快门;仅系统相机降级/识别不可用/mock 调试时出现 -->
<button class="cam-shutter" data-action="shutter" aria-label="拍照">
<img src="assets/svg/拍照-快门.svg" alt="快门">
</button>
@@ -137,90 +145,82 @@
<!-- ═══════════ 08 报告页 ═══════════ -->
<section class="screen" id="s-report">
<div class="report-body">
<!-- 顶栏(Figma 08:白底横带,左返回圆钮 + 居中标题) -->
<header class="report-nav">
<button class="nav-back" data-action="back-profile" aria-label="返回"><i class="chev left"></i></button>
<span class="report-nav-title">1'me在线肌肤测试</span>
</header>
<h1 class="report-title">您的肌肤状态表现</h1>
<!-- 总分:Figma 为两张独立白卡(综合评分 / 肌肤年龄) -->
<div class="stats-row">
<div class="card card-stat">
<span class="stat-label">综合评分</span>
<span class="stat-value" id="stat-score"><b>--</b><i>分</i></span>
<!-- 总分卡:左环形综合评分 / 右肌肤年龄 + 与实际年龄对比 -->
<div class="card card-summary">
<div class="sum-score">
<svg class="ring" viewBox="0 0 120 120" aria-hidden="true">
<circle class="ring-bg" cx="60" cy="60" r="52"/>
<circle class="ring-fg" id="ring-fg" cx="60" cy="60" r="52"/>
</svg>
<div class="ring-center">
<span class="stat-value" id="stat-score"><b>--</b><i>分</i></span>
<span class="stat-label">综合评分</span>
</div>
</div>
<div class="card card-stat">
<div class="sum-age">
<span class="stat-label">肌肤年龄</span>
<span class="stat-value" id="stat-age"><b>--</b><i>岁</i></span>
<span class="stat-sub" id="stat-age-note"></span>
<span class="stat-value stat-age" id="stat-age"><b>--</b><i>岁</i></span>
<span class="age-pill" id="stat-age-note" hidden></span>
</div>
</div>
<div class="card card-dims">
<div class="dims-row">
<div class="dim"><span class="dim-name">紧塑力</span><span class="dim-score" id="dim-firm">-.-</span></div>
<div class="dim"><span class="dim-name">活力感</span><span class="dim-score" id="dim-vita">-.-</span></div>
<div class="dim"><span class="dim-name">稳定力</span><span class="dim-score" id="dim-stab">-.-</span></div>
<div class="dim" data-dim="firm"><span class="dim-name">紧塑力</span><span class="dim-score" id="dim-firm"><b>-</b>.-</span><i class="dim-bar"><i></i></i><em class="dim-flag">优先关注</em></div>
<div class="dim" data-dim="vita"><span class="dim-name">活力感</span><span class="dim-score" id="dim-vita"><b>-</b>.-</span><i class="dim-bar"><i></i></i><em class="dim-flag">优先关注</em></div>
<div class="dim" data-dim="stab"><span class="dim-name">稳定力</span><span class="dim-score" id="dim-stab"><b>-</b>.-</span><i class="dim-bar"><i></i></i><em class="dim-flag">优先关注</em></div>
</div>
<p class="dims-note" id="dims-note">近期优先关注:<br>---</p>
<div class="dims-note" id="dims-note"><p class="dims-note-l1">近期优先关注:<b>---</b></p></div>
</div>
<!-- 三个维度各一张卡:卡内子 Tab 切换所辖两个分类;检测图点击放大 -->
<h2 class="sec-title">紧塑力</h2>
<div class="card card-detail">
<div class="detail-tabs" id="detail-tabs">
<button class="dtab active" data-tab="0"><span class="dtab-tx">紧塑力</span></button>
<button class="dtab" data-tab="1"><span class="dtab-tx">活力感</span></button>
<button class="dtab" data-tab="2"><span class="dtab-tx">稳定力</span></button>
<div class="detail-tabs">
<button class="dtab active" data-tab="0">轮廓</button>
<button class="dtab" data-tab="1">皱纹</button>
</div>
<div class="detail-panels">
<div class="dpanel active" data-panel="0">
<div class="ditem">
<h3 class="ditem-title">轮廓</h3>
<img id="ditem-img1" src="assets/img/det-1.jpg" loading="lazy" class="ditem-img" alt="轮廓检测图">
<div class="ditem-text">
<p class="ditem-para" id="ditem-para1">检测完成后显示分析结果。</p>
</div>
</div>
<div class="ditem">
<h3 class="ditem-title">皱纹</h3>
<img id="ditem-img2" src="assets/img/det-2.jpg" loading="lazy" class="ditem-img" alt="皱纹检测图">
<div class="ditem-text">
<p class="ditem-para" id="ditem-para2">检测完成后显示分析结果。</p>
</div>
</div>
</div>
<div class="dpanel" data-panel="1">
<div class="ditem">
<h3 class="ditem-title">光滑</h3>
<img id="viz-smooth" loading="lazy" class="ditem-img" alt="光滑检测标记图" hidden>
<div class="ditem-text">
<p class="ditem-para" id="tx-smooth">检测完成后显示分析结果。</p>
</div>
</div>
<div class="ditem">
<h3 class="ditem-title">肤色水润</h3>
<img id="viz-tone" loading="lazy" class="ditem-img" alt="肤色色沉图" hidden>
<div class="ditem-text">
<p class="ditem-para" id="tx-tone">检测完成后显示分析结果。</p>
</div>
</div>
</div>
<div class="dpanel" data-panel="2">
<div class="ditem">
<h3 class="ditem-title">敏感</h3>
<img id="viz-sens" loading="lazy" class="ditem-img" alt="敏感红区图" hidden>
<div class="ditem-text">
<p class="ditem-para" id="tx-sens">检测完成后显示分析结果。</p>
</div>
</div>
<div class="ditem">
<h3 class="ditem-title">痘痘</h3>
<img id="viz-acne" loading="lazy" class="ditem-img" alt="痘痘检测标记图" hidden>
<div class="ditem-text">
<p class="ditem-para" id="tx-acne">检测完成后显示分析结果。</p>
</div>
</div>
</div>
<div class="dpanel active" data-panel="0">
<img id="ditem-img1" src="assets/img/det-1.jpg" loading="lazy" class="ditem-img" alt="轮廓检测图">
<p class="ditem-para" id="ditem-para1">检测完成后显示分析结果。</p>
</div>
<div class="dpanel" data-panel="1">
<img id="ditem-img2" src="assets/img/det-2.jpg" loading="lazy" class="ditem-img" alt="皱纹检测图">
<p class="ditem-para" id="ditem-para2">检测完成后显示分析结果。</p>
</div>
</div>
<h2 class="sec-title">活力感</h2>
<div class="card card-detail">
<div class="detail-tabs">
<button class="dtab active" data-tab="0">光滑</button>
<button class="dtab" data-tab="1">肤色水润</button>
</div>
<div class="dpanel active" data-panel="0">
<img id="viz-smooth" loading="lazy" class="ditem-img" alt="光滑检测标记图" hidden>
<p class="ditem-para" id="tx-smooth">检测完成后显示分析结果。</p>
</div>
<div class="dpanel" data-panel="1">
<img id="viz-tone" loading="lazy" class="ditem-img" alt="肤色色沉图" hidden>
<p class="ditem-para" id="tx-tone">检测完成后显示分析结果。</p>
</div>
</div>
<h2 class="sec-title">稳定力</h2>
<div class="card card-detail">
<div class="detail-tabs">
<button class="dtab active" data-tab="0">敏感</button>
<button class="dtab" data-tab="1">痘痘</button>
</div>
<div class="dpanel active" data-panel="0">
<img id="viz-sens" loading="lazy" class="ditem-img" alt="敏感红区图" hidden>
<p class="ditem-para" id="tx-sens">检测完成后显示分析结果。</p>
</div>
<div class="dpanel" data-panel="1">
<img id="viz-acne" loading="lazy" class="ditem-img" alt="痘痘检测标记图" hidden>
<p class="ditem-para" id="tx-acne">检测完成后显示分析结果。</p>
</div>
</div>
@@ -248,7 +248,7 @@
<div class="prod-card">
<div class="prod-info">
<img class="prod-badge" src="assets/svg/prod-badge.svg" alt="CORE Ⅱ">
<h3 class="prod-name">以我自在有势挺括双仓精华液</h3>
<h3 class="prod-name">以我自在有势挺括<br>双仓精华液</h3>
<p class="prod-sub">参透皮下根源 28天校准皮肤状态</p>
</div>
<div class="prod-scroll" id="prod-scroll">
@@ -278,16 +278,25 @@
</div>
<!-- ═══════════ 弹窗:拍照引导 ═══════════ -->
<div class="overlay" id="p-photoguide">
<!-- 背景只保留启动页底图 + Slogan(不叠档案表单),对齐设计稿 04 -->
<div class="overlay overlay-guide" id="p-photoguide">
<div class="guide-bg"><p class="splash-slogan">Life in Full<br>为更好的生命表现</p></div>
<div class="popup">
<button class="popup-close" data-action="close-photoguide" aria-label="关闭">
<img src="assets/svg/图标-关闭.svg" alt="关闭">
</button>
<h2 class="popup-title popup-title-center">请拍摄正面及<br>左右侧面照片</h2>
<h2 class="popup-title popup-title-center">请拍摄或上传<br>正面及左右侧面照片</h2>
<button class="btn btn-primary popup-btn" data-action="start-camera">开始</button>
</div>
</div>
<!-- ═══════════ 弹层:报告检测图放大(点击任意处返回) ═══════════ -->
<div class="overlay overlay-zoom" id="p-zoom" role="dialog" aria-modal="true" aria-label="检测图片预览">
<button type="button" class="zoom-close" aria-label="关闭图片预览">×</button>
<img id="zoom-img" alt="检测图放大">
<p class="zoom-tip">点击图片或空白处返回报告</p>
</div>
<!-- ═══════════ 弹层:隐私政策与服务协议(全文) ═══════════ -->
<div class="overlay overlay-policy" id="p-policy">
<div class="policy-page">
@@ -449,6 +458,8 @@
<script src="vendor/faceapi/face-api.js" defer></script>
<script src="js/head-pose.js" defer></script>
<script src="js/skin-score.js" defer></script>
<script src="vendor/mobile-select/mobile-select.iife.min.js" defer></script>
<script src="js/region-data.js" defer></script>
<script>
// 仅 ?mock=1 调试:同步注入报告页可视化 fixture(生产模式不发起该请求)
if (/[?&]mock=1/.test(location.search)) document.write('<script src="assets/mock/report-fixture.js"><\/script>');
+501
View File
@@ -0,0 +1,501 @@
/* ═══════════════════════════════════════════════════════════
巨子测肤 · 后台查看页脚本(纯原生 ES5,零依赖)
页签:记录列表 / 失败与异常 / 统计概览;行点击开详情抽屉。
═══════════════════════════════════════════════════════════ */
'use strict';
/* ─── 工具 ─── */
function $(sel, root) { return (root || document).querySelector(sel); }
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
function esc(s) { // 拼 HTML 前转义(接口文案/失败信息不可信)
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function fmtTime(iso) {
if (!iso) return '-';
return iso.replace('T', ' ').slice(0, 19);
}
function fmtMs(ms) { return ms == null ? '-' : ms + 'ms'; }
function fmtScore(v) { return v == null ? '-' : v; }
var LV_CLASS = { '预警': 'lv-bad', '改善': 'lv-warn', '良好': 'lv-good' };
var DIM_NAMES = { firm: '紧塑力', vita: '活力感', stab: '稳定力' };
var CAT_ORDER = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne'];
var ST_CLASS = { '成功': 'st-ok', '失败': 'st-fail', '检测中': 'st-ing', '待检测': 'st-wait' };
function toast(msg) {
var t = $('#toast');
t.textContent = msg;
t.classList.remove('hidden');
clearTimeout(toast._timer);
toast._timer = setTimeout(function () { t.classList.add('hidden'); }, 2600);
}
function api(url, opts) {
return fetch(url, opts || {}).then(function (res) {
return res.json().then(function (data) {
if (!res.ok || data.ok === false) throw new Error(data.message || ('HTTP ' + res.status));
return data;
});
});
}
/* ─── 页签切换 ─── */
var TABS = { records: 1, fails: 1, stats: 1 };
function switchTab(name) {
var tabs = document.querySelectorAll('.tab');
for (var i = 0; i < tabs.length; i++) tabs[i].classList.toggle('on', tabs[i].dataset.tab === name);
var panels = document.querySelectorAll('.panel');
for (var j = 0; j < panels.length; j++) panels[j].classList.toggle('on', panels[j].id === 'tab-' + name);
if (name === 'records') loadRecords();
else if (name === 'fails') loadFails();
else if (name === 'stats') loadStats();
}
/* ─── 档位标签 ─── */
function lvHtml(level) {
if (!level) return '<span class="sub">-</span>';
return '<span class="lv ' + (LV_CLASS[level] || '') + '">' + esc(level) + '</span>';
}
function dimsHtml(dims) {
if (!dims) return '<span class="sub">-</span>';
var h = '';
for (var k in DIM_NAMES) {
var d = dims[k];
h += '<span class="lv ' + (d && LV_CLASS[d.level] || '') + '" title="' + DIM_NAMES[k] + '">' +
DIM_NAMES[k].slice(0, 1) + (d ? esc(d.level) : '-') + '</span>';
}
return h;
}
function profileHtml(it) {
var parts = [];
parts.push(it.real_age != null ? it.real_age + '岁' : '-');
parts.push(it.gender || '-');
parts.push(it.region || '-');
parts.push(it.daily_skin_type || '-');
return esc(parts.join(' / '));
}
/* ═══════════ 记录列表 ═══════════ */
var pageState = { page: 1, page_size: 20, total: 0 };
function collectFilters() {
var p = [];
function add(k, v) { if (v) p.push(k + '=' + encodeURIComponent(v)); }
add('from', $('#f-from').value);
add('to', $('#f-to').value);
add('detect_status', $('#f-status').value);
add('fail_code', $('#f-failcode').value);
add('age_min', $('#f-agemin').value);
add('age_max', $('#f-agemax').value);
add('group', $('#f-group').value);
add('dim', $('#f-dim').value);
add('dim_level', $('#f-dimlevel').value);
add('q', $('#f-q').value.trim());
p.push('page=' + pageState.page);
p.push('page_size=' + pageState.page_size);
return p.join('&');
}
function loadRecords() {
api('/api/admin/records?' + collectFilters()).then(function (data) {
pageState.total = data.total;
var tbody = $('#tbl-records tbody');
tbody.innerHTML = '';
if (!data.items.length) {
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;color:#8a94a6;padding:32px">暂无记录</td></tr>';
}
for (var i = 0; i < data.items.length; i++) {
(function (it) {
var tr = el('tr');
var photos = it.photos || {};
var thumb = photos.front
? '<img class="thumb" loading="lazy" decoding="async" src="' + photos.front + '" alt="正脸">'
: '<span class="sub">无</span>';
tr.innerHTML =
'<td>' + thumb + '</td>' +
'<td class="mono">' + esc(it.record_id) + (it.retry_of ? '<div class="sub">重试自 ' + esc(it.retry_of) + '</div>' : '') + '</td>' +
'<td>' + profileHtml(it) + '</td>' +
'<td>' + esc(it.shoot_status) + '</td>' +
'<td>' + esc(it.upload_status) + '</td>' +
'<td><span class="st ' + (ST_CLASS[it.detect_status] || '') + '">' + esc(it.detect_status) + '</span>' +
(it.fail_code ? '<div class="sub">' + esc(it.fail_code) + '</div>' : '') + '</td>' +
'<td><b>' + fmtScore(it.total_score) + '</b></td>' +
'<td>' + dimsHtml(it.dims) + '</td>' +
'<td>' + esc(it.essence || '-') + '</td>' +
'<td class="mono">' + fmtMs(it.cost_ms) + '</td>' +
'<td class="mono">' + fmtTime(it.created_at) + '</td>';
tr.addEventListener('click', function (e) {
if (e.target && e.target.className === 'thumb') { showLightbox(e.target.src); return; }
openDetail(it.record_id);
});
tbody.appendChild(tr);
})(data.items[i]);
}
var pages = Math.max(1, Math.ceil(data.total / data.page_size));
$('#page-info').textContent = '第 ' + data.page + ' / ' + pages + ' 页 · 共 ' + data.total + ' 条';
$('#btn-prev').disabled = data.page <= 1;
$('#btn-next').disabled = data.page >= pages;
}).catch(function (e) { toast('加载失败:' + e.message); });
}
/* ═══════════ 失败与异常 ═══════════ */
function loadFails() {
api('/api/admin/stats').then(function (st) {
var box = $('#fail-dist');
box.innerHTML = '';
if (!st.fail_dist.length) box.appendChild(el('div', 'pie-empty', '暂无失败记录'));
for (var i = 0; i < st.fail_dist.length; i++) {
(function (f) {
var chip = el('div', 'fail-chip');
chip.innerHTML = '<span class="code">' + esc(f.code) + '</span>' +
'<span class="pct">' + f.count + ' 次 · 占比 ' + f.pct + '%</span>' +
'<span class="bar"><i style="width:' + Math.min(100, f.pct) + '%"></i></span>';
box.appendChild(chip);
})(st.fail_dist[i]);
}
}).catch(function (e) { toast('加载失败:' + e.message); });
api('/api/admin/records?detect_status=失败&page_size=100').then(function (data) {
var tbody = $('#tbl-fails tbody');
tbody.innerHTML = '';
if (!data.items.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#8a94a6;padding:32px">暂无失败记录</td></tr>';
}
for (var i = 0; i < data.items.length; i++) {
(function (it) {
var tr = el('tr');
tr.innerHTML =
'<td class="mono">' + esc(it.record_id) + '</td>' +
'<td>' + esc(it.fail_stage || '-') + '</td>' +
'<td><span class="st st-fail">' + esc(it.fail_code || '-') + '</span></td>' +
'<td style="white-space:normal;max-width:340px">' + esc(it.fail_msg || '-') + '</td>' +
'<td>' + (it.real_age != null ? it.real_age + '岁' : '-') + '</td>' +
'<td>' + esc(it.shoot_status) + '</td>' +
'<td class="mono">' + fmtTime(it.created_at) + '</td>' +
'<td><button type="button" class="btn small primary">重试</button></td>';
tr.addEventListener('click', function (e) {
if (e.target && e.target.tagName === 'BUTTON') { doRetry(it.record_id); return; }
openDetail(it.record_id);
});
tbody.appendChild(tr);
})(data.items[i]);
}
}).catch(function (e) { toast('加载失败:' + e.message); });
}
function doRetry(id) {
if (!confirm('将使用原照片重新检测并生成新记录,确认重试 ' + id + ' ?')) return;
toast('重试中,请稍候(最长约 1 分钟)…');
api('/api/admin/records/' + encodeURIComponent(id) + '/retry', { method: 'POST' }).then(function (data) {
toast('重试完成,新记录 ' + data.record.record_id + '(' + data.record.detect_status + ')');
var active = $('.panel.on').id;
if (active === 'tab-records') loadRecords();
else if (active === 'tab-fails') loadFails();
}).catch(function (e) { toast('重试失败:' + e.message); });
}
/* ═══════════ 统计概览 ═══════════ */
var PIE_COLORS = ['#2563eb', '#2fa84f', '#f0a020', '#8a5cf6', '#d93026'];
function loadStats() {
api('/api/admin/stats').then(function (st) {
var cards = [
['今日检测', st.today_total],
['累计检测', st.total],
['成功率', st.success_rate + '%'],
['平均耗时', st.avg_cost_ms + 'ms']
];
var box = $('#stat-cards');
box.innerHTML = '';
for (var i = 0; i < cards.length; i++) {
var c = el('div', 'card');
c.innerHTML = '<div class="lab">' + cards[i][0] + '</div><div class="num">' + cards[i][1] + '</div>';
box.appendChild(c);
}
/* 三维度档位分布:每组三根横条 */
var dd = $('#dim-dist');
dd.innerHTML = '';
for (var d in DIM_NAMES) {
var dist = st.dim_dist[d] || {};
var max = Math.max(1, dist['预警'] || 0, dist['改善'] || 0, dist['良好'] || 0);
var row = el('div', 'dim-row');
var bars = '';
var levels = [['预警', 'b-bad'], ['改善', 'b-warn'], ['良好', 'b-good']];
for (var l = 0; l < levels.length; l++) {
var cnt = dist[levels[l][0]] || 0;
bars += '<div style="display:flex;align-items:center;gap:8px;margin:3px 0">' +
'<span style="width:34px;color:#5a6578;font-size:12px">' + levels[l][0] + '</span>' +
'<span style="flex:1;height:14px;background:#f0f2f5;border-radius:3px;overflow:hidden">' +
'<i style="display:block;height:100%;width:' + (cnt * 100 / max) + '%" class="' + levels[l][1] + '"></i></span>' +
'<span style="width:24px;text-align:right;font-size:12px;color:#4a5568">' + cnt + '</span></div>';
}
row.innerHTML = '<div class="dim-name">' + DIM_NAMES[d] + '</div>' + bars;
dd.appendChild(row);
}
/* 产品组别分布:纯 CSS conic-gradient 饼图 */
var gd = $('#group-dist');
gd.innerHTML = '';
var keys = Object.keys(st.group_dist);
if (!keys.length) {
gd.appendChild(el('div', 'pie-empty', '暂无成功记录'));
} else {
var total = 0, k;
for (k in st.group_dist) total += st.group_dist[k];
var acc = 0, segs = [];
for (var g = 0; g < keys.length; g++) {
var from = acc * 100 / total;
acc += st.group_dist[keys[g]];
var to = acc * 100 / total;
segs.push(PIE_COLORS[g % PIE_COLORS.length] + ' ' + from + '% ' + to + '%');
}
var legend = '';
for (var g2 = 0; g2 < keys.length; g2++) {
legend += '<div><i style="background:' + PIE_COLORS[g2 % PIE_COLORS.length] + '"></i>' +
esc(keys[g2]) + ':' + st.group_dist[keys[g2]] + ' 次(' + Math.round(st.group_dist[keys[g2]] * 100 / total) + '%)</div>';
}
gd.innerHTML = '<div class="pie" style="background:conic-gradient(' + segs.join(',') + ')"></div>' +
'<div class="pie-legend">' + legend + '</div>';
}
/* 近 14 天每日检测量(蓝=总量,绿=成功数) */
var dc = $('#daily-chart');
dc.innerHTML = '';
var daily = st.daily || [];
var dmax = 1;
for (var n = 0; n < daily.length; n++) dmax = Math.max(dmax, daily[n].total);
for (var m = 0; m < daily.length; m++) {
(function (day) {
var col = el('div', 'daily-col');
var h = Math.max(2, day.total * 130 / dmax);
var okH = day.total ? Math.max(1, day.success * 130 / dmax) : 0;
col.innerHTML = '<span class="daily-val">' + day.total + '</span>' +
'<div class="daily-bar" style="height:' + h + 'px"><div class="daily-bar ok" style="width:70%;left:15%;height:' + okH + 'px"></div></div>' +
'<span class="daily-date">' + day.date.slice(5) + '</span>';
col.title = day.date + ':检测 ' + day.total + ' 次,成功 ' + day.success + ' 次';
dc.appendChild(col);
})(daily[m]);
}
}).catch(function (e) { toast('加载失败:' + e.message); });
}
/* ═══════════ 记录详情(抽屉) ═══════════ */
var currentDetailId = null;
function openDetail(id) {
currentDetailId = id;
$('#d-title').textContent = '记录详情 ' + id;
$('#btn-retry').disabled = true;
$('#d-body').innerHTML = '<div class="sub">加载中…</div>';
$('#drawer').classList.remove('hidden');
$('#drawer-mask').classList.remove('hidden');
api('/api/admin/records/' + encodeURIComponent(id)).then(renderDetail)
.catch(function (e) { $('#d-body').innerHTML = '<div class="sub">加载失败:' + esc(e.message) + '</div>'; });
}
function closeDetail() {
$('#drawer').classList.add('hidden');
$('#drawer-mask').classList.add('hidden');
currentDetailId = null;
}
function dSec(title, innerHtml) {
return '<div class="d-sec"><h3>' + title + '</h3>' + innerHtml + '</div>';
}
function renderDetail(data) {
var r = data.record;
$('#btn-retry').disabled = !(data.photos && data.photos.front);
var h = '';
/* 三张照片 */
var photosHtml = '<div class="d-photos">';
var whichNames = { front: '正脸', left: '左脸', right: '右脸' };
var anyPhoto = false;
for (var w in whichNames) {
var u = data.photos[w];
if (!u) continue;
anyPhoto = true;
photosHtml += '<div class="d-photo"><img loading="lazy" decoding="async" src="' + u + '" alt="' + whichNames[w] + '" data-zoom="' + u + '">' +
'<a href="' + u + '" download="' + r.record_id + '-' + w + '.jpg">下载' + whichNames[w] + '</a></div>';
}
photosHtml += anyPhoto ? '' : '<span class="sub">无照片</span>';
photosHtml += '</div>';
h += dSec('检测照片', photosHtml);
/* 状态行 */
h += dSec('状态', '<div class="d-kv">' +
'<span>检测状态 <b>' + esc(r.detect_status) + '</b></span>' +
'<span>拍摄状态 <b>' + esc(r.shoot_status) + '</b></span>' +
'<span>上传状态 <b>' + esc(r.upload_status) + '</b></span>' +
'<span>创建时间 <b>' + fmtTime(r.created_at) + '</b></span>' +
'<span>更新时间 <b>' + fmtTime(r.updated_at) + '</b></span>' +
'<span>耗时 <b>' + fmtMs(r.cost_ms) + '</b></span>' +
(r.retry_of ? '<span>重试来源 <b class="mono">' + esc(r.retry_of) + '</b></span>' : '') +
'<span>被重试次数 <b>' + (r.retry_count || 0) + '</b></span>' +
'</div>');
/* 档案 */
h += dSec('档案信息', '<div class="d-kv">' +
'<span>年龄 <b>' + (r.real_age != null ? r.real_age + '岁' : '-') + '</b></span>' +
'<span>性别 <b>' + esc(r.gender || '-') + '</b></span>' +
'<span>地区 <b>' + esc(r.region || '-') + '</b></span>' +
'<span>日常肤质 <b>' + esc(r.daily_skin_type || '-') + '</b></span>' +
'<span>产品组别 <b>' + esc(r.group || '-') + '</b></span>' +
'</div>');
/* 失败:错误 + 版本链 */
if (r.detect_status === '失败') {
h += dSec('失败信息', '<div class="d-copy"><b>' + esc(r.fail_code || '-') + '</b>(环节:' + esc(r.fail_stage || '-') + ')<br>' + esc(r.fail_msg || '-') + '</div>');
}
/* 版本链(重试关系) */
var vHtml = '';
if (data.versions && data.versions.parent) {
vHtml += '<div>上一版本(本次为重试):<a data-id="' + esc(data.versions.parent.record_id) + '">' +
esc(data.versions.parent.record_id) + '(' + esc(data.versions.parent.detect_status) + ')</a></div>';
}
if (data.versions && data.versions.children && data.versions.children.length) {
vHtml += '<div>重试产生的记录:';
for (var c = 0; c < data.versions.children.length; c++) {
vHtml += '<a data-id="' + esc(data.versions.children[c].record_id) + '">' +
esc(data.versions.children[c].record_id) + '(' + esc(data.versions.children[c].detect_status) + ')</a>';
}
vHtml += '</div>';
}
if (vHtml) h += dSec('版本链', '<div class="d-versions">' + vHtml + '</div>');
/* 成功:评分 + 文案 */
if (r.detect_status === '成功') {
h += dSec('综合', '<div class="d-kv">' +
'<span>总分 <b>' + fmtScore(r.total_score) + '</b></span>' +
'<span>肌肤年龄(接口原始) <b>' + fmtScore(r.skin_age) + '岁</b></span>' +
'<span>六分类均分S <b>' + fmtScore(r.avg_s) + '</b></span>' +
'<span>左下颌线角 <b>' + (r.jawline_angle_l != null ? r.jawline_angle_l + '°' : '-') + '</b></span>' +
'<span>右下颌线角 <b>' + (r.jawline_angle_r != null ? r.jawline_angle_r + '°' : '-') + '</b></span>' +
'</div>');
/* 6 分类表格 */
var catRows = '';
for (var i = 0; i < CAT_ORDER.length; i++) {
var ck = CAT_ORDER[i], cat = r.cats && r.cats[ck];
var catName = (cat && cat.name) || ({ contour: '轮廓', wrinkles: '皱纹', smooth: '光滑', tone: '肤色水润', sens: '敏感', acne: '痘痘' })[ck];
catRows += '<tr><td>' + esc(catName) + '</td><td>' + fmtScore(cat && cat.P) + '</td><td>' + fmtScore(cat && cat.S) + '</td><td>' + lvHtml(cat && cat.level) + '</td></tr>';
}
h += dSec('六分类评分', '<table class="d-table"><thead><tr><th>分类</th><th>P</th><th>S</th><th>档位</th></tr></thead><tbody>' + catRows + '</tbody></table>');
/* 3 维度表格 */
var dimRows = '';
for (var dk in DIM_NAMES) {
var dim = r.dims && r.dims[dk];
dimRows += '<tr><td>' + DIM_NAMES[dk] + '</td><td>' + fmtScore(dim && dim.P) + '</td><td>' + fmtScore(dim && dim.S) + '</td><td>' + lvHtml(dim && dim.level) + '</td></tr>';
}
h += dSec('三维度评分', '<table class="d-table"><thead><tr><th>维度</th><th>P</th><th>S</th><th>档位</th></tr></thead><tbody>' + dimRows + '</tbody></table>');
/* 文案全文 */
var copyHtml = '';
if (r.copy_6) {
for (var c2 = 0; c2 < CAT_ORDER.length; c2++) {
var txt = r.copy_6[CAT_ORDER[c2]];
if (txt) copyHtml += '<div class="d-copy"><b>' + esc((r.cats[CAT_ORDER[c2]] || {}).name || '') + ':</b>' + esc(txt) + '</div>';
}
}
if (r.copy_summary) copyHtml += '<div class="d-copy"><b>综合文案:</b>' + esc(r.copy_summary) + '</div>';
if (r.lifestyle) copyHtml += '<div class="d-copy"><b>生活方式:</b>' + esc(r.lifestyle) + '</div>';
if (copyHtml) h += dSec('生成文案', copyHtml);
if (r.careAdvice && r.careAdvice.length) {
var advice = '';
for (var a = 0; a < r.careAdvice.length; a++) {
advice += '<div class="d-copy"><b>[' + esc(r.careAdvice[a].tag) + ']</b>' + esc(r.careAdvice[a].text) + '</div>';
}
h += dSec('护理建议', advice);
}
h += dSec('推荐精华', '<div class="d-copy"><b>' + esc((r.essence && r.essence.name) || '-') + '</b><br>' +
esc((r.essence && r.essence.text) || '') + '</div>' +
'<div class="d-kv"><span>触发判据 <b>' + esc(r.trigger || '-') + '</b></span></div>');
if (r.notes && r.notes.length) {
var notes = '<ul class="d-notes">';
for (var n2 = 0; n2 < r.notes.length; n2++) notes += '<li>' + esc(r.notes[n2]) + '</li>';
notes += '</ul>';
h += dSec('计算备注', notes);
}
}
/* 接口原始 JSON:details 折叠,pre 展示(face_maps 已在服务端裁剪,完整版走下载链接) */
if (data.raw_json) {
var rawDl = data.raw_file
? ' <a href="/api/admin/records/' + encodeURIComponent(r.record_id) + '/raw" download>下载完整原始 JSON</a>'
: '';
h += dSec('接口原始 JSON', '<details><summary>展开/收起(' + (data.raw_file || '') + ')' + rawDl + '</summary><pre>' +
esc(JSON.stringify(data.raw_json, null, 1)) + '</pre></details>');
} else {
h += dSec('接口原始 JSON', '<div class="sub">无(失败记录或未落盘)</div>');
}
var body = $('#d-body');
body.innerHTML = h;
/* 图片点击看大图 */
var imgs = body.querySelectorAll('img[data-zoom]');
for (var z = 0; z < imgs.length; z++) {
imgs[z].addEventListener('click', function () { showLightbox(this.getAttribute('data-zoom')); });
}
/* 版本链跳转 */
var links = body.querySelectorAll('.d-versions a');
for (var v = 0; v < links.length; v++) {
links[v].addEventListener('click', function () { openDetail(this.getAttribute('data-id')); });
}
}
/* ═══════════ 图片查看 ═══════════ */
function showLightbox(src) {
$('#lightbox-img').src = src;
$('#lightbox').classList.remove('hidden');
}
/* ═══════════ 事件绑定与初始化 ═══════════ */
function bind() {
var tabs = document.querySelectorAll('.tab');
for (var i = 0; i < tabs.length; i++) {
tabs[i].addEventListener('click', function () { switchTab(this.dataset.tab); });
}
$('#btn-search').addEventListener('click', function () { pageState.page = 1; loadRecords(); });
$('#btn-reset').addEventListener('click', function () {
var ids = ['f-from', 'f-to', 'f-status', 'f-failcode', 'f-agemin', 'f-agemax', 'f-group', 'f-dim', 'f-dimlevel', 'f-q'];
for (var i2 = 0; i2 < ids.length; i2++) $('#' + ids[i2]).value = '';
pageState.page = 1;
loadRecords();
});
$('#btn-prev').addEventListener('click', function () { if (pageState.page > 1) { pageState.page--; loadRecords(); } });
$('#btn-next').addEventListener('click', function () { pageState.page++; loadRecords(); });
$('#btn-close').addEventListener('click', closeDetail);
$('#drawer-mask').addEventListener('click', closeDetail);
$('#btn-retry').addEventListener('click', function () { if (currentDetailId) doRetry(currentDetailId); });
$('#lightbox').addEventListener('click', function () { this.classList.add('hidden'); });
$('#btn-export').addEventListener('click', function () {
var p = [];
if ($('#exp-from').value) p.push('from=' + $('#exp-from').value);
if ($('#exp-to').value) p.push('to=' + $('#exp-to').value);
var url = '/api/admin/export' + (p.length ? '?' + p.join('&') : '');
var a = el('a');
a.href = url;
a.download = '';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeDetail(); $('#lightbox').classList.add('hidden'); }
});
}
bind();
loadRecords();
+394 -230
View File
@@ -14,9 +14,9 @@
toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, Math.max(1800, String(msg).length * 130));
}
/* ─── 屏幕管理 ─── */
/* ─── 屏幕管理:页面内不放返回按钮,前进时 pushState,微信/浏览器返回键经 popstate 回到上一屏 ─── */
var currentScreen = 's-splash';
function showScreen(id) {
function showScreen(id, nav) { // nav: 'push' 新增历史 | 'replace' 替换当前历史 | 缺省不动历史
document.querySelectorAll('.screen').forEach(function (s) {
s.classList.toggle('active', s.id === id);
});
@@ -24,16 +24,28 @@
sc.scrollTop = 0;
currentScreen = id;
if (id === 's-report') resetReport(); // 每次进入报告页都还原初始状态
if (nav === 'push') history.pushState({ screen: id }, '');
else if (nav === 'replace') history.replaceState({ screen: id }, '');
}
// 代码里已切好屏、只需把历史栈退一格(如拍照失败回档案页):紧随的 popstate 不再切屏
var popSilentUntil = 0;
function popHistorySilently() { popSilentUntil = Date.now() + 1000; history.back(); }
window.addEventListener('popstate', function (e) {
if (Date.now() < popSilentUntil) { popSilentUntil = 0; return; }
var to = (e.state && e.state.screen) || 's-splash';
['p-privacy', 'p-photoguide', 'p-policy', 'p-zoom', 'p-history'].forEach(closeOverlay);
if (currentScreen === 's-cam') leaveCamera();
if (to === 's-cam') { to = 's-profile'; history.replaceState({ screen: to }, ''); } // 「前进」回拍照页不自动开相机
if (to !== currentScreen) showScreen(to);
});
/* ─── 报告页状态重置(Tab 回到「紧塑力」、产品横滑归零) ─── */
/* ─── 报告页状态重置(各维度卡回到第一个子 Tab、产品横滑归零) ─── */
function setDetailTab(card, idx) {
card.querySelectorAll('.dtab').forEach(function (t) { t.classList.toggle('active', t.dataset.tab === idx); });
card.querySelectorAll('.dpanel').forEach(function (p) { p.classList.toggle('active', p.dataset.panel === idx); });
}
function resetReport() {
document.querySelectorAll('.dtab').forEach(function (t, k) {
t.classList.toggle('active', k === 0);
});
document.querySelectorAll('.dpanel').forEach(function (p, k) {
p.classList.toggle('active', k === 0);
});
document.querySelectorAll('.card-detail').forEach(function (c) { setDetailTab(c, '0'); });
var ps = $('#prod-scroll');
if (ps) ps.scrollLeft = 0;
}
@@ -43,33 +55,42 @@
function closeOverlay(id) { $('#' + id).classList.remove('show'); }
// 点遮罩空白处关闭(与各自关闭按钮同行为:隐私→回启动页、拍照引导→留在档案页)
// e.target === overlay 确保只响应遮罩本身,不误伤弹窗卡片内交互
// 只响应遮罩本身(拍照引导另含底图层),不误伤弹窗卡片内交互
function bindBackdropClose(id) {
var ov = $('#' + id);
ov.addEventListener('click', function (e) {
if (e.target === ov) closeOverlay(id);
if (e.target === ov || e.target.closest('.guide-bg')) closeOverlay(id);
});
}
bindBackdropClose('p-privacy');
bindBackdropClose('p-photoguide');
bindBackdropClose('p-policy');
// ESC 关闭(仅这三层;分析中过渡不响应;协议层最顶,优先关)
// ESC 关闭(分析中过渡不响应;放大图/协议层在最上,优先关)
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape' && e.key !== 'Esc') return;
if ($('#p-policy').classList.contains('show')) closeOverlay('p-policy');
else if ($('#p-privacy').classList.contains('show')) closeOverlay('p-privacy');
else if ($('#p-photoguide').classList.contains('show')) closeOverlay('p-photoguide');
var order = ['p-zoom', 'p-policy', 'p-privacy', 'p-photoguide'];
for (var i = 0; i < order.length; i++) {
if ($('#' + order[i]).classList.contains('show')) {
if (order[i] === 'p-zoom') closeZoom(); else closeOverlay(order[i]);
return;
}
}
});
// 分析中弹层点遮罩取消:作废未决分析回调(mock 路径同样经 seq 丢弃)、关弹层、停相机、回档案页
$('#p-analyzing').addEventListener('click', function (e) {
if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片
// 退出拍照流程:作废未决快门/分析回调、掐断播报、释放摄像头
function leaveCamera() {
camSeq++;
voice.stop(true);
closeOverlay('p-analyzing');
stopCamera();
shutterLock = false; // 流程终止解锁
shutterLock = false;
}
// 分析中弹层点遮罩取消:回档案页(mock 路径同样经 seq 丢弃)
$('#p-analyzing').addEventListener('click', function (e) {
if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片
leaveCamera();
showScreen('s-profile');
popHistorySilently();
});
/* ─── 肤质轮播 ─── */
@@ -103,7 +124,13 @@
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;
}
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 };
@@ -144,32 +171,61 @@
if (drag.suppress) { e.preventDefault(); e.stopPropagation(); drag.suppress = false; }
}, true);
/* ─── 地区选项(自定义下拉:原生 select 各端样式不一,改自绘弹层) ─── */
var REGIONS = ['北京', '上海', '广州', '深圳', '杭州', '成都', '重庆', '武汉', '西安', '南京', '天津', '苏州', '长沙', '郑州', '青岛', '其他'];
var regionSel = $('#f-region'); // hidden input,存选中值
var rsBox = $('#region-rs'), rsList = $('#rs-list'), rsValue = $('#rs-value');
rsList.innerHTML = REGIONS.map(function (r) { return '<li data-v="' + r + '">' + r + '</li>'; }).join('');
function rsClose() { rsList.hidden = true; rsBox.classList.remove('open'); }
$('#rs-current').addEventListener('click', function (e) {
e.stopPropagation();
var willOpen = rsList.hidden;
rsList.hidden = !willOpen;
rsBox.classList.toggle('open', willOpen);
/* ─── 档案选择控件:出生年月 / 性别 / 省份城市,统一用 mobile-select 底部滚轮(选中值写入同名 hidden input) ─── */
var NOW_Y = new Date().getFullYear();
var YEARS = [];
for (var yy = NOW_Y - 100; yy <= NOW_Y - 1; yy++) {
var months = [];
for (var mm = 1; mm <= 12; mm++) {
var selectableAge = ageFromBirth(yy, mm);
if (selectableAge >= 1 && selectableAge <= 99) months.push({ id: mm, value: mm + '月' });
}
if (months.length) YEARS.push({ id: yy, value: yy + '年', childs: months });
}
var regionSel = $('#f-region');
function setPick(sel, text) { var el = $(sel + ' .pick-value'); el.textContent = text; el.classList.remove('placeholder'); }
function ageFromBirth(y, m) { var now = new Date(); return now.getFullYear() - y - (now.getMonth() + 1 < m ? 1 : 0); }
function refreshAge() {
var birth = $('#f-birth').value.split('-');
if (birth.length !== 2) return;
var age = ageFromBirth(+birth[0], +birth[1]);
$('#f-age').value = age;
$('#profile-age').textContent = '年龄:' + age + '岁(按出生月份计算)';
}
function makePicker(trigger, opts) {
if (typeof MobileSelect === 'undefined') return null;
return new MobileSelect(Object.assign({
trigger: trigger, triggerDisplayValue: false,
ensureBtnText: '确定', cancelBtnText: '取消'
}, opts));
}
makePicker('#pick-birth', {
title: '出生年月',
wheels: [{ data: YEARS }],
position: [YEARS.map(function (y) { return y.id; }).indexOf(NOW_Y - 30), 0],
onChange: function (val) {
var y = +val[0].id, m = +val[1].id;
$('#f-birth').value = y + '-' + (m < 10 ? '0' : '') + m;
refreshAge();
setPick('#pick-birth', y + '年' + m + '月');
}
});
rsList.addEventListener('click', function (e) {
var li = e.target.closest('li[data-v]');
if (!li) return;
regionSel.value = li.dataset.v;
rsValue.textContent = li.dataset.v;
rsValue.classList.remove('rs-placeholder');
rsClose();
makePicker('#pick-gender', {
title: '性别',
wheels: [{ data: ['女', '男'] }],
onChange: function (val) { $('#f-gender').value = val[0]; setPick('#pick-gender', val[0]); }
});
document.addEventListener('click', rsClose); // 点空白收起
// 年龄只收数字:inputmode=numeric 在部分安卓输入法仍可切到全键盘
$('#f-age').addEventListener('input', function () {
var v = this.value.replace(/\D/g, '').slice(0, 2);
if (v !== this.value) this.value = v;
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 }; }) };
}) }],
onChange: function (val) {
var prov = val[0] && val[0].value, city = val[1] && val[1].value;
var text = !city || city === prov ? prov : prov + ' ' + city; // 直辖市/港澳台只显示一级
regionSel.value = text;
setPick('#pick-region', text);
}
});
/* ─── 实时相机(getUserMedia)+ 系统相机降级 ─── */
@@ -181,9 +237,6 @@
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换)
var camMirrored = true; // 预览镜像:前置 true(照镜子习惯,Figma 侧脸轮廓即按镜像画面绘制),后置 false
var sideYawFlip = false; // 侧脸步语义 yaw 反向标志:个别机型前置帧本身已镜像时由自校准翻转
var mirrorCalibrated = false; // 侧脸自校准:本轮是否已翻转过一次(防振荡,一次校准管整个会话)
var oppFrames = 0; // 侧脸步语义 yaw 连续反向帧计数
// 预览是否镜像(仅预览;抓帧恒为原始视角)。后置不镜像时侧脸轮廓需水平翻转,才与画面中的脸朝向一致
function effMirror() { return camMirrored; }
function applyMirror() {
@@ -215,6 +268,7 @@
}
function startCamera() {
if (camStream) { stopCamera(); }
camFallback = false;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; }
camSeqCam++;
var token = camSeqCam;
@@ -249,18 +303,26 @@
}
function stopCamera() {
camSeqCam++; // 使进行中的 getUserMedia 回调失效
stopDetect(); // 检测循环/倒计时/无进展提示一并收尾
stopDetect(); // 检测循环/无进展提示一并收尾
$('#cam-status').classList.remove('show');
if (camStream) { camStream.getTracks().forEach(function (t) { t.stop(); }); camStream = null; }
camVideo.srcObject = null;
camLive = false;
$('#s-cam').classList.remove('cam-live');
$('#cam-mask').classList.remove('scanning');
$('#cam-fill').classList.remove('on');
if (capturing) shutterLock = false;
capturing = false;
okSince = 0;
}
function enableFallback() {
camFallback = true;
$('#cam-status').classList.remove('show'); // 与 stopCamera 对齐:去掉「检测引擎加载中」胶囊
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框
toast('已切换为系统相机拍摄');
resetDetectState();
setHint('请允许相机权限后重试', false, true);
toast('相机暂不可用,请重试或按顺序选择照片');
// 系统相机拍回的照片也要校验(有脸、角度对),模型照常预加载
if (!IS_MOCK && typeof faceapi !== 'undefined') loadModels().catch(function () {});
}
// 从视频帧截取照片(完整帧、摄像头原始视角,压到最长边 1280 的 JPEG)
// 抓完整帧而非取景框可见区:新 UI 轮廓外全黑,用户只关注轮廓内人脸;按可见区裁切在侧脸/偏站位时会切掉人脸(Face++ INVALID_IMAGE_FACE)
@@ -276,23 +338,66 @@
ctx.drawImage(camVideo, 0, 0, vw, vh, 0, 0, cw, ch);
return canvas.toDataURL('image/jpeg', 0.88);
}
// 文件 → 统一压成最长边 1280 的 JPEG → 推进流程(降级系统相机与相册选图共用)
// 文件 → 统一压成最长边 1280 的 JPEG → 校验合格才推进流程(降级系统相机与相册选图共用)
function readPhotoFile(f) {
if (!f) return;
var step = camStep, seq = camSeq;
if (currentScreen !== 's-cam' || capturing) return;
shutterLock = true;
okSince = 0;
var active = function () { return seq === camSeq && step === camStep && currentScreen === 's-cam'; };
function fail(message) { if (!active()) return; shutterLock = false; hideCamStatus(); toast(message); }
var reader = new FileReader();
reader.onerror = function () { fail('照片读取失败,请重新选择'); };
reader.onload = function () {
if (!active()) return;
var img = new Image();
img.onload = function () {
if (!active()) return;
var scale = Math.min(1, 1280 / Math.max(img.width, img.height));
var c = document.createElement('canvas');
c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale);
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
onPhotoCaptured(c.toDataURL('image/jpeg', 0.88));
var url = c.toDataURL('image/jpeg', 0.88);
shutterLock = true; // 校验期间不接受新的拍摄/选图
showCamStatus('照片检查中…');
checkStill(url, CAM_STEPS[step].target).then(function (err) {
if (!active()) return;
hideCamStatus();
shutterLock = false;
if (err) { toast(err); voice.play(VOICE.fail, { cut: true }); return; } // 不合格:停在本步重拍
onPhotoCaptured(url);
});
};
img.onerror = function () { fail('照片读取失败,请重新选择'); };
img.src = reader.result;
};
reader.readAsDataURL(f);
}
// 相册也须等模型就绪后校验单人、正确侧脸和光线,失败停留在当前步骤。
function checkStill(dataUrl, target) {
if (IS_MOCK) return Promise.resolve('');
if (typeof faceapi === 'undefined') return Promise.resolve('识别服务未加载,请刷新后重试');
return loadModels().then(function () { return new Promise(function (resolve) {
var img = new Image();
img.onload = function () {
faceapi
.detectAllFaces(img, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
.withFaceLandmarks()
.then(function (faces) {
if (!faces.length) { resolve('照片中未识别到人脸,请重新选择'); return; }
if (faces.length !== 1) { resolve('请使用只有一人的照片'); return; }
var d = faces[0], pose = HeadPose.check(d.landmarks.positions, target, false);
if (!pose.ok) { resolve('请按顺序选择' + (target === 'front' ? '正脸' : target === 'right' ? '右侧脸' : '左侧脸') + '照片,' + pose.hint); return; }
resolve(lightingHint(faceLuma(d.detection.box, img)));
}, function () { resolve('照片检查失败,请重新选择'); });
};
img.onerror = function () { resolve('照片读取失败,请重新选择'); };
img.src = dataUrl;
}); }).catch(function () { return '识别服务加载失败,请重试'; });
}
function showCamStatus(text) { var s = $('#cam-status'); s.textContent = text; s.classList.add('show'); }
function hideCamStatus() { $('#cam-status').classList.remove('show'); }
// 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程
camFile.addEventListener('change', function () {
shutterLock = false; // 选完(含空选择)即解锁;推进窗口由 onPhotoCaptured 重新上锁
@@ -429,22 +534,24 @@
})();
document.addEventListener('click', voice.unlock); // 任意点击即完成解锁
/* ─── 姿态检测(face-api + HeadPose,引导式自动三连拍) ─── */
/* ─── 姿态检测(face-api + HeadPose):动态捕捉——距离/居中/角度/光线全部合格并稳住片刻即自动抓拍,不倒计时、无手动快门 ─── */
var MODEL_URL = 'vendor/faceapi/models';
var DETECT_INTERVAL = 150; // 检测帧间隔(ms)
var DETECT_INPUT_SIZE = 416; // tinyFaceDetector 输入尺寸:320 下正脸置信度仅 0.67 且框偏松,416 置信度 ~0.99 框更准(CPU 端略慢,移动端 GPU 无感)
var DETECT_SCORE = 0.3; // 置信度阈值:0.4 在噪声帧易漏检,0.3 配合距离/角度闸门足够抗误检
var MISS_FRAMES_MAX = 3; // 连续漏检 ≥3 帧(~450ms)才报「未检测到人脸」,容忍单帧抖动
var OK_HOLD_MS = 600; // 姿态 ok 需连续维持时长,达标才进倒计时
var COUNTDOWN_S = 3; // 倒计时秒数
var NO_PROGRESS_MS = 12000; // 单步超过该时长未触发倒计时→浮出手动提示
var FACE_NEAR = 0.26, FACE_FAR = 0.58; // 人脸框高/视频高:<26% 太远,>58% 太近(以取景框≈42% 屏高、416 输入实测框 0.40–0.44 标定)
var OK_HOLD_MS = 450; // 条件连续合格时长:滤掉一闪而过的合格帧(防运动模糊),达标立即抓拍
var NO_PROGRESS_MS = 12000; // 单步超过该时长仍未抓拍→浮出调整提示
var PREVIEW_SCALE = 0.85; // 与 CSS #cam-video 的 scale 一致:预览缩小 15%
// 以屏幕显示的人脸大小判断距离,预览缩小 15% 后允许用户更靠近镜头。
var FACE_NEAR = 0.26, FACE_FAR = 0.58; // 按缩放后的屏幕占比判断,不受横竖视频分辨率影响
var CENTER_TOL = 0.18; // 人脸中心偏离取景中线超过屏宽 18% → 提示移入取景框
var LUMA_DARK = 65, LUMA_BRIGHT = 235; // 脸部平均亮度(0–255):过暗/过曝提示调整光线
var modelsPromise = null, modelsOk = false, detectDisabled = false; // detectDisabled:face-api 缺失→全程手动;模型加载失败仅当次禁用,下次进拍照页重试
var detRunning = false, detTimer = null; // 检测循环
var detEpoch = 0; // 启停/切换镜头时作废上一轮未完成的异步检测
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
var counting = false, cdTimer = null, cdN = 0; // 倒计时
var cdDeferCancel = null; // 倒计时等播报收尾的取消句柄(afterCurrent)
var nudgeTimer = null; // 12s 无进展提示
var capturing = false; // 白屏补光抓帧中(暂停姿态判定)
var lastHint = ''; // 提示文案去重
@@ -461,6 +568,15 @@
return modelsPromise;
}
// 扫描带裁切:复制镂空轮廓到 <clipPath id="cam-clip">,随 data-step 的 CSS 显隐一起切换
(function buildScanClip() {
var NS = 'http://www.w3.org/2000/svg';
var clip = document.createElementNS(NS, 'clipPath');
clip.id = 'cam-clip';
document.querySelectorAll('#cam-cut [data-shape]').forEach(function (p) { clip.appendChild(p.cloneNode(false)); });
$('#cam-mask defs').appendChild(clip);
})();
function setMask(cls) { // '' 静态白框 | 'no-face' 呼吸动画 | 'ok' 品牌绿定格
var m = $('#cam-mask');
m.classList.toggle('no-face', cls === 'no-face');
@@ -479,146 +595,148 @@
}
function hideNudge() { $('#cam-nudge').classList.remove('show'); }
// 每步进入/回退时的检测状态机重置(蒙版/提示/倒计时/无进展计时全部归位)
// 只有 mock 调试显示快门,正式页面在异常时提供重试入口。
function isManualMode() { return IS_MOCK; } // 手动快门只用于 mock 调试,正式流程始终自动捕捉
function syncCamMode() {
$('#s-cam').classList.toggle('cam-manual', isManualMode());
$('#cam-mask').classList.toggle('scanning', camLive && !isManualMode() && modelsOk && !detectDisabled);
$('[data-action="retry-camera"]').hidden = IS_MOCK || (!camFallback && !detectDisabled);
}
// 每步进入/回退时的检测状态机重置(蒙版/提示/无进展计时全部归位)
function resetDetectState() {
okSince = 0;
missFrames = 0;
oppFrames = 0;
lastHint = '';
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
setHint('');
setMask(camLive && !IS_MOCK && !detectDisabled && !camFallback ? 'no-face' : '');
setMask(camLive && !isManualMode() ? 'no-face' : '');
syncCamMode();
armNudge();
}
// 单步 12s 未触发倒计时→底部浮出轻提示(不阻塞流程);进入下一步/倒计时/抓帧时重置
// 单步 12s 仍未抓拍→底部浮出调整提示(不阻塞流程);进入下一步/抓帧时重置
function armNudge() {
clearTimeout(nudgeTimer);
hideNudge();
if (IS_MOCK || detectDisabled || camFallback) return; // 手动模式无需提示
if (isManualMode() || camFallback || detectDisabled) return;
if (currentScreen !== 's-cam') return;
nudgeTimer = setTimeout(function () {
if (currentScreen === 's-cam' && !counting && !shutterLock) $('#cam-nudge').classList.add('show');
if (currentScreen === 's-cam' && !shutterLock) $('#cam-nudge').classList.add('show');
}, NO_PROGRESS_MS);
}
function startCountdown() {
counting = true; // 立即置位:等待播报收尾期间姿态被破坏,走 cancelCountdown 统一取消
clearTimeout(nudgeTimer);
hideNudge();
setMask('ok'); // 轮廓保持绿色
setHint('保持不动', true, /* silent= */true); // 倒计时前不再抢播「保持不动」(会被 321 立刻切断,听着像吞字)
// 「保持不动」等仍在播放的提示音自然播完再进倒计时,画面数字与「三、二、一」同步起播
cdDeferCancel = voice.afterCurrent(beginCountdown);
}
function beginCountdown() {
cdDeferCancel = null;
if (!counting) return; // 等待期间已被取消(姿态破坏/退出拍照页)
cdN = COUNTDOWN_S;
var c = $('#cam-count');
c.textContent = cdN;
c.classList.add('show');
voice.play(VOICE.countdown); // 「三、二、一」与数字同步起播
cdTimer = setTimeout(cdTick, 1000);
}
function cdTick() {
cdN--;
if (cdN <= 0) {
counting = false;
$('#cam-count').classList.remove('show');
doCapture(); // 倒计时结束:白屏补光抓帧(与手动快门同路径)
return;
}
$('#cam-count').textContent = cdN;
cdTimer = setTimeout(cdTick, 1000);
}
function cancelCountdown() {
counting = false;
if (cdDeferCancel) { cdDeferCancel(); cdDeferCancel = null; } // 取消尚未起播的倒计时接续
clearTimeout(cdTimer);
$('#cam-count').classList.remove('show');
}
function maybeStartDetect() {
if (detRunning || IS_MOCK || detectDisabled || !modelsOk || !camLive || currentScreen !== 's-cam') return;
detRunning = true;
detTick();
syncCamMode();
detTick(++detEpoch);
}
function stopDetect() {
detRunning = false;
detEpoch++;
okSince = 0;
clearTimeout(detTimer);
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
}
// 递归 setTimeout 而非 setInterval:检测是异步的,避免帧与帧堆叠
function detTick() {
if (!detRunning) return;
function detTick(epoch) {
if (!detRunning || epoch !== detEpoch) return;
detTimer = setTimeout(function () {
if (!detRunning) return;
detectFrame().then(detTick, detTick);
if (!detRunning || epoch !== detEpoch) return;
detectFrame(epoch).then(function () { detTick(epoch); }, function () { detTick(epoch); });
}, DETECT_INTERVAL);
}
function detectFrame() {
function detectFrame(epoch) {
if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态
var step = camStep, seq = camSeq; // 快照:检测是异步的,帧落地时步骤/流程可能已变
return faceapi
.detectSingleFace(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
.detectAllFaces(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
.withFaceLandmarks()
.then(function (d) {
if (step !== camStep || seq !== camSeq) return; // 跨步骤陈帧/流程已重置:丢弃
if (detRunning && !shutterLock && !capturing) onDetect(d);
.then(function (faces) {
if (step !== camStep || seq !== camSeq || epoch !== detEpoch) return;
if (detRunning && !shutterLock && !capturing) onDetect(faces);
})
.catch(function () { /* 单帧异常忽略,下一帧继续 */ });
.catch(function () { if (epoch === detEpoch) okSince = 0; });
}
// 人脸框中心相对取景中线的水平偏移(占屏宽比例);按 object-fit:cover + 预览缩放换算到屏幕,镜像只影响符号
function faceOffsetX(box) {
var vw = camVideo.videoWidth, vh = camVideo.videoHeight, W = camVideo.clientWidth, H = camVideo.clientHeight;
if (!vw || !vh || !W || !H) return 0;
var s = Math.max(W / vw, H / vh) * PREVIEW_SCALE;
return ((box.x + box.width / 2) - vw / 2) * s / W;
}
function displayedFaceRatio(box) {
var vw = camVideo.videoWidth, vh = camVideo.videoHeight, W = camVideo.clientWidth, H = camVideo.clientHeight;
if (!vw || !vh || !W || !H) return 0;
return box.height * Math.max(W / vw, H / vh) * PREVIEW_SCALE / H;
}
function faceOffsetY(box) {
var vw = camVideo.videoWidth, vh = camVideo.videoHeight, W = camVideo.clientWidth, H = camVideo.clientHeight;
if (!vw || !vh || !W || !H) return 1;
var s = Math.max(W / vw, H / vh) * PREVIEW_SCALE;
var maskScale = Math.min(W / 375, H / 726);
var targetY = (H - 726 * maskScale) / 2 + 320 * maskScale;
return (H / 2 + (box.y + box.height / 2 - vh / 2) * s - targetY) / H;
}
function lightingHint(luma) {
if (luma == null) return '画面读取失败,请重试';
if (luma < LUMA_DARK) return '请到光线充足处';
if (luma > LUMA_BRIGHT) return '光线过强,请避开强光';
return '';
}
// 脸部平均亮度:人脸框缩到 16×16 取均值
var lumaCanvas = null;
function faceLuma(box, source) {
try {
if (!lumaCanvas) { lumaCanvas = document.createElement('canvas'); lumaCanvas.width = lumaCanvas.height = 16; }
var c = lumaCanvas.getContext('2d', { willReadFrequently: true });
c.drawImage(source || camVideo, box.x, box.y, box.width, box.height, 0, 0, 16, 16);
var px = c.getImageData(0, 0, 16, 16).data, sum = 0;
for (var i = 0; i < px.length; i += 4) sum += 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
return sum / (px.length / 4);
} catch (e) { return null; }
}
var missFrames = 0; // 连续漏检帧计数:单帧抖动不报「未检测到人脸」
function onDetect(d) {
function onDetect(faces) {
if (faces.length > 1) {
okSince = 0; setMask(''); setHint('请保持单人出镜', false); return;
}
var d = faces[0];
var hint = '', ok = false;
missFrames = d ? 0 : missFrames + 1;
var hasFace = !!d, noFace = !hasFace && missFrames >= MISS_FRAMES_MAX;
if (noFace) {
hint = '未检测到人脸';
} else if (!hasFace) {
return; // 偶发漏检帧:保持上一条提示与状态,不扰动倒计时/ok 累积
okSince = 0; // 文案抗抖,但任何漏检都中断连续合格时长
return;
} else {
// 提示优先级:无人脸 > 距离 > 角度
var ratio = d.detection.box.height / (camVideo.videoHeight || 1);
// 提示优先级:无人脸 > 距离 > 居中 > 角度 > 光线
var box = d.detection.box;
var ratio = displayedFaceRatio(box);
if (ratio < FACE_NEAR) hint = '请靠近一点';
else if (ratio > FACE_FAR) hint = '请远一点';
else if (Math.abs(faceOffsetX(box)) > CENTER_TOL || Math.abs(faceOffsetY(box)) > 0.16) hint = '请将脸部移入取景框';
else {
// 距离不合格时角度不判 ok。特征点取自原始帧(预览的 CSS 镜像不影响),侧脸步语义方向按原始视角判定:
// 标准机型恒为 false,个别前置帧本身已镜像的机型由自校准翻转 sideYawFlip;正脸步只看 |yaw|,与符号无关
// 按摄像头原始帧判断真实左右;持续转错方向也不能自动交换左右照片。
var tgt = CAM_STEPS[camStep].target;
var pose = HeadPose.check(d.landmarks.positions, tgt, tgt === 'front' ? camMirrored : sideYawFlip);
// 侧脸自校准:个别机型前置帧本身已镜像(语义 yaw 与实际转向持续相反)时自救——
// 需连续 30 帧(~10s)强反向才翻转一次:阈值要高,否则用户转错方向 1~2s 就会被误判为镜像机,
// 反而把错误的侧脸当合格抓走(左右图归属错乱)。翻转只动判定方向,不动预览画面
if (tgt !== 'front' && pose.yaw != null && !mirrorCalibrated) {
var wantSign = tgt === 'right' ? 1 : -1;
if (pose.yaw * wantSign < -12) {
if (++oppFrames >= 30) {
sideYawFlip = !sideYawFlip;
mirrorCalibrated = true;
oppFrames = 0;
}
} else oppFrames = 0;
var pose = HeadPose.check(d.landmarks.positions, tgt, false);
if (!pose.ok) hint = pose.hint;
else {
hint = lightingHint(faceLuma(box));
ok = !hint;
}
hint = pose.hint;
ok = pose.ok;
}
window.__dbgRatio = ratio; // 自动化校准/测试用(检测框高/视频高)
}
if (counting && !ok) cancelCountdown(); // 倒计时中任一帧不 ok→立即取消,回到对应提示
setMask(counting || ok ? 'ok' : (noFace ? 'no-face' : ''));
setHint(counting ? '保持不动' : hint, counting || ok);
if (counting) return;
if (ok) {
if (!okSince) okSince = Date.now();
else if (Date.now() - okSince >= OK_HOLD_MS) startCountdown();
} else {
okSince = 0;
}
setMask(ok ? 'ok' : (noFace ? 'no-face' : ''));
// 合格时「保持不动」只显示不播报:随即抓拍,「拍摄完成」会截断它
setHint(ok ? '保持不动' : hint, ok, ok);
if (!ok) { okSince = 0; return; }
if (!okSince) okSince = Date.now();
else if (Date.now() - okSince >= OK_HOLD_MS) { okSince = 0; doCapture(); }
}
/* ─── 拍照流程(三步,顺序对齐 Figma:正脸 → 右脸出镜 → 左脸出镜) ─── */
@@ -626,9 +744,9 @@
// 左脸出镜=头向自己的右侧转(target 'left',产物作 left_image)。
// mask 为 index.html 取景轮廓编号(1=左脸出镜 耳在画面左,2=右脸出镜 耳在画面右,均按镜像预览绘制)
var CAM_STEPS = [
{ step: '1/3<br>请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front', mask: 0 },
{ step: '2/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right', mask: 2 },
{ step: '3/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left', mask: 1 }
{ step: '1/3<br>请正对屏幕', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front', mask: 0 },
{ step: '2/3<br>请右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right', mask: 2 },
{ step: '3/3<br>请左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left', mask: 1 }
];
function photosByView(ph) { // 拍摄顺序 → 送检/报告/历史统一口径 [正脸, 左脸, 右脸]
var at = {};
@@ -643,30 +761,29 @@
$('#cam-guide').innerHTML = cfg.guides
.map(function (g) { return '<span>' + g + '</span>'; })
.join('<i></i>');
// 步骤圆点:当前步高亮、完成步绿色打勾
var dots = $('#cam-dots').children;
for (var i = 0; i < dots.length; i++) {
dots[i].classList.toggle('done', i < camStep);
dots[i].classList.toggle('on', i === camStep);
}
$('#cam-mask').setAttribute('data-step', cfg.mask); // 蒙版按步骤切换轮廓
document.querySelectorAll('[data-capture-step]').forEach(function (el, i) {
el.classList.toggle('done', i < camStep);
el.classList.toggle('current', i === camStep);
if (i === camStep) el.setAttribute('aria-current', 'step'); else el.removeAttribute('aria-current');
});
applyMirror();
resetDetectState();
voice.play(VOICE.step[camStep]); // 步骤引导语音
}
var camSeq = 0; // 流程令牌:cam-back/开始拍照时递增,使未决的快门/分析回调失效
var camSeq = 0; // 流程令牌:退出拍照/开始拍照时递增,使未决的快门/分析回调失效
var shutterLock = false; // 快门防连点锁:流程进行中直接忽略
// 照片就绪后推进流程(实时抓帧与降级选图共用):存图→步骤点变绿→300ms 过渡→下一步
// 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步
function onPhotoCaptured(dataUrl) {
shutterLock = true; // 推进窗口上锁:降级模式 change 里已解锁,这里重新锁住直到下一步/送检
if (currentScreen !== 's-cam' || !dataUrl || photos.length !== camStep) return;
shutterLock = true; // 推进窗口上锁,直到下一步/送检
photos[camStep] = dataUrl;
$('#cam-dots').children[camStep].classList.add('done');
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废(自动拍时倒计时本就等播报收尾)
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废
var seq = camSeq;
setTimeout(function () {
if (seq !== camSeq) return; // 期间已 cam-back/重新开始:放弃本次推进
if (seq !== camSeq) return; // 期间已退出/重新开始:放弃本次推进
if (camStep < CAM_STEPS.length - 1) {
camStep++;
renderCamStep(); // 内含检测状态机重置
@@ -677,29 +794,26 @@
}, 300);
}
// 白屏补光抓拍:全屏纯白瞬间点亮→约300ms 后抓全分辨率帧→保持300ms 再淡出(总 600–900ms)
// 合格即抓取当前帧,随后闪白反馈;不在延时期间抓取已偏离角度的照片。
function doCapture() {
if (shutterLock) return;
if (!camLive) return;
var shot = captureFromVideo();
if (!shot) { okSince = 0; return; }
shutterLock = true;
capturing = true;
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
var seq = camSeq;
var seq = camSeq, camera = camSeqCam;
var fill = $('#cam-fill');
fill.classList.add('on'); // .on 无过渡,瞬间点亮
if (navigator.vibrate) navigator.vibrate(30);
setTimeout(function () {
var shot = captureFromVideo();
setTimeout(function () {
fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出
capturing = false;
if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出)
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即重启倒计时
onPhotoCaptured(shot);
}, 300);
}, 300);
if (seq !== camSeq || camera !== camSeqCam) return;
fill.classList.remove('on');
capturing = false;
onPhotoCaptured(shot);
}, 180);
}
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
@@ -708,8 +822,15 @@
photos = photosByView(photos);
openOverlay('p-analyzing');
voice.play(VOICE.analyzing); // 「正在生成您的肌肤报告」
// 档案与检测一起上报(后台记录用):年龄非法值服务端按 null 处理
var profile = {
age: +($('#f-age').value || 0) || null,
gender: $('#f-gender').value || '',
region: regionSel.value || '',
skinType: SKINS[skinIndex].name
};
var t0 = Date.now();
requestAnalyze(photos, seq, function (errMsg, payload) {
requestAnalyze(photos, seq, profile, function (errMsg, payload) {
if (seq !== camSeq) return; // 流程已被重置:丢弃
var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁
setTimeout(function () {
@@ -722,15 +843,10 @@
voice.play(VOICE.fail, { cut: true });
toast(errMsg);
showScreen('s-profile'); // 回档案页,允许重新提交再拍
popHistorySilently();
return;
}
var vm = null;
var profile = {
age: +($('#f-age').value || 0) || null,
gender: $('#f-gender').value || '',
region: regionSel.value || '',
skinType: SKINS[skinIndex].name
};
try {
vm = SkinScore.buildViewModel(payload.result, payload.side, {
realAge: profile.age,
@@ -740,21 +856,24 @@
if (!vm) {
toast('报告生成失败,请重新拍摄');
showScreen('s-profile');
popHistorySilently();
return;
}
renderReport(vm, photos[0], photos[1], payload); // img1=正脸实拍,payload 供检测可视化取 face_maps/marks
storeReport(payload, photos, profile); // 以检测为单位存三张照片+分析结果(本地历史)
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
showScreen('s-report');
showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机
}, wait);
});
}
function shutter() {
if (!IS_MOCK) return;
if (shutterLock) return;
if (camLive) {
if (!isManualMode()) return; // 实时检测模式只自动抓拍,不接受手动快门
if (!camVideo.videoWidth) { toast('相机尚未就绪,请稍候'); return; }
doCapture(); // 手动兜底:任何时刻可拍当前步,同样走白屏补光,不依赖检测
doCapture(); // 识别不可用/mock:手动拍当前步,同样走白屏补光
} else if (camFallback) {
shutterLock = true; // 前置上锁防连点:change/cancel 回调里解锁
camFile.click(); // 降级:调起系统相机,回调里推进
@@ -766,7 +885,7 @@
/* ─── 分析请求(mock=1 直返内置样本;有 MOCK_FIXTURE 时用报告页可视化 fixture,便于离线/自动化验证) ─── */
var ANALYZE_TIMEOUT_MS = 45000; // 检测服务超时阈值:超时按「开小差」处理回档案页
function requestAnalyze(ph, seq, done) {
function requestAnalyze(ph, seq, profile, done) {
if (IS_MOCK && typeof SkinScore !== 'undefined') {
setTimeout(function () {
if (seq !== camSeq) return; // mock 同样尊重取消/重置
@@ -786,7 +905,15 @@
fetch('/api/skin-analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ front_image: ph[0], left_image: ph[1], right_image: ph[2] }),
body: JSON.stringify({
front_image: ph[0], left_image: ph[1], right_image: ph[2],
profile: { // 档案随检测上报,后台记录用(服务端缺省 null)
real_age: profile && profile.age,
gender: profile && profile.gender,
region: profile && profile.region,
daily_skin_type: profile && profile.skinType
}
}),
signal: ctrl ? ctrl.signal : undefined // 老内核无 AbortController:仅靠令牌丢弃迟到响应
}).then(function (res) { return res.json(); })
.then(function (data) {
@@ -819,11 +946,16 @@
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function setDim(scoreSel, dim, isWorst) {
var el = $(scoreSel);
// 设计稿:最低分标红,其余深色;无档位徽章
el.textContent = dim.S == null ? '-.-' : dim.S.toFixed(1);
el.classList.toggle('dim-bad', !!isWorst && dim.S != null);
// 维度卡:整数位大字 + 小数位小字;进度条按 S/10;最高分浅绿、最低分橙色并标红挂「优先关注」
function setDim(key, dim, rank) {
var box = document.querySelector('.dim[data-dim="' + key + '"]');
var el = $('#dim-' + key), has = dim.S != null;
var parts = has ? dim.S.toFixed(1).split('.') : ['-', '-'];
el.innerHTML = '<b>' + parts[0] + '</b>.' + parts[1];
el.classList.toggle('dim-bad', has && rank === 'worst');
box.classList.toggle('worst', has && rank === 'worst');
box.classList.toggle('best', has && rank === 'best');
box.querySelector('.dim-bar i').style.width = has ? Math.max(0, Math.min(100, dim.S * 10)) + '%' : '0';
}
function setCat(key, cat) {
@@ -1012,26 +1144,30 @@
for (i = 0; i < 3; i++) if (typeof ds[i] === 'number') { sum += ds[i]; n++; }
if (n) total = Math.round(sum / n * 10);
}
// 设计稿:大数字 30px/400 + 单位 15px/500(分/岁为小字后缀)
// 环形综合评分:满分 100,环长 2πr(r=52)≈326.73
$('#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>';
$('#stat-age-note').textContent = ''; // 肌龄小字按产品要求不展示(钳制留痕 vm.ageClampNote 仍随数据返回)
// 肌龄对比胶囊(新版设计「比实际年龄年轻2岁」);缺实际年龄或肌龄时不展示
var pill = $('#stat-age-note');
var diff = vm.skinAgeDisplay != null && vm.realAge ? vm.skinAgeDisplay - vm.realAge : null;
pill.hidden = diff == null;
pill.textContent = diff == null ? '' : diff < 0 ? '比实际年龄年轻' + (-diff) + '岁' : diff > 0 ? '比实际年龄大' + diff + '岁' : '与实际年龄相当';
// 三维度:S 值(1 位小数),最低分标红(设计稿无档位徽章)
// 三维度:按 P 排名,最低分标红 + 优先关注,最高分浅绿进度条
var dimPs = { firm: vm.dims.firm.P, vita: vm.dims.vita.P, stab: vm.dims.stab.P };
var worstDimKey = Object.keys(dimPs).reduce(function (a, b) {
if (dimPs[a] == null) return b;
if (dimPs[b] == null) return a;
return dimPs[b] < dimPs[a] ? b : a;
var ranked = Object.keys(dimPs).filter(function (k) { return dimPs[k] != null; })
.sort(function (a, b) { return dimPs[a] - dimPs[b]; });
var worstDimKey = ranked[0], bestDimKey = ranked.length > 1 ? ranked[ranked.length - 1] : null;
['firm', 'vita', 'stab'].forEach(function (k) {
setDim(k, vm.dims[k], k === worstDimKey ? 'worst' : k === bestDimKey ? 'best' : '');
});
setDim('#dim-firm', vm.dims.firm, worstDimKey === 'firm');
setDim('#dim-vita', vm.dims.vita, worstDimKey === 'vita');
setDim('#dim-stab', vm.dims.stab, worstDimKey === 'stab');
// 关注行(设计稿两行:「近期优先关注:」灰 + 维度名深色,下行为深色口号)
// 关注行(设计稿两行:「近期优先关注:」灰 + 维度名深色下划线,下行为灰色口号)
var DIM_SLOGAN = {
'紧塑力': '紧塑提拉力,轮廓上扬不松垮',
'活力感': '活力透亮感,水润澎弹有光泽',
'稳定力': '强韧稳态力,锁水保湿抵御刺激' // 设计稿原文
'紧塑力': '紧塑提拉力,轮廓上扬不松垮。',
'活力感': '活力透亮感,水润澎弹有光泽。',
'稳定力': '强韧稳态力,锁水保湿抵御刺激。' // 设计稿原文
};
$('#dims-note').innerHTML = vm.focus.name
? '<span class="dims-note-l1">近期优先关注:<b>' + esc(vm.focus.name) + '</b></span><br><span class="dims-note-l2">' + esc(DIM_SLOGAN[vm.focus.name] || '') + '</span>'
@@ -1080,26 +1216,29 @@
: '';
}
/* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */
/* ─── 相机可用后加载检测模型,完成前等待,失败可重试 ─── */
function beginDetect() {
var camera = camSeqCam;
if (IS_MOCK || camFallback) { resetDetectState(); return; }
if (typeof faceapi === 'undefined') {
detectDisabled = true; // face-api 本身缺失:全程手动,不再重试
detectDisabled = true;
resetDetectState();
toast('自动识别不可用,已切换手动拍摄');
toast('自动识别暂不可用,请重试');
return;
}
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
resetDetectState();
if (!modelsOk) $('#cam-status').classList.add('show'); // “检测引擎加载中…”
if (!modelsOk) showCamStatus('检测引擎加载中…');
loadModels().then(function () {
if (camera !== camSeqCam || currentScreen !== 's-cam') return;
$('#cam-status').classList.remove('show');
maybeStartDetect();
}).catch(function () {
if (camera !== camSeqCam || currentScreen !== 's-cam') return;
$('#cam-status').classList.remove('show');
detectDisabled = true; // 仅禁用当次;下次进拍照页 beginDetect 复位后重试
resetDetectState();
if (currentScreen === 's-cam') toast('自动识别不可用,已切换手动拍摄');
if (currentScreen === 's-cam') toast('自动识别暂不可用,请重试');
});
}
@@ -1119,16 +1258,41 @@
openOverlay('p-policy');
});
/* ─── 报告页 Tab ─── */
$('#detail-tabs').addEventListener('click', function (e) {
var btn = e.target.closest('.dtab');
if (!btn) return;
var idx = btn.dataset.tab;
document.querySelectorAll('.dtab').forEach(function (t) { t.classList.toggle('active', t === btn); });
document.querySelectorAll('.dpanel').forEach(function (p) {
p.classList.toggle('active', p.dataset.panel === idx);
/* ─── 报告页:每张维度卡各自的子 Tab;点检测图放大,再点返回 ─── */
var zoomSource = null;
function openZoom(img) {
if (!img || !img.src || img.hidden) return;
zoomSource = img;
$('#zoom-img').src = img.src;
$('#zoom-img').alt = img.alt;
openOverlay('p-zoom');
history.pushState({ screen: currentScreen, overlay: 'p-zoom' }, '');
$('.zoom-close').focus();
}
function closeZoom() {
closeOverlay('p-zoom');
if (zoomSource) zoomSource.focus({ preventScroll: true });
if (history.state && history.state.overlay === 'p-zoom') popHistorySilently();
}
document.querySelectorAll('.ditem-img').forEach(function (img) {
img.tabIndex = 0;
img.setAttribute('role', 'button');
img.setAttribute('aria-label', img.alt + ',点击放大');
img.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openZoom(img); }
});
});
document.querySelectorAll('.card-detail').forEach(function (card) {
card.addEventListener('click', function (e) {
var btn = e.target.closest('.dtab');
if (btn) { setDetailTab(card, btn.dataset.tab); return; }
var img = e.target.closest('.ditem-img');
if (img) openZoom(img);
});
});
$('#p-zoom').addEventListener('click', function () {
closeZoom();
});
/* ─── 动作分发 ─── */
var actions = {
@@ -1138,12 +1302,12 @@
'agree-continue': function () {
if (!agreed) { toast('请先阅读并同意隐私协议'); return; }
closeOverlay('p-privacy');
showScreen('s-profile');
showScreen('s-profile', 'push');
},
'back-splash': function () { showScreen('s-splash'); },
'submit-profile': function () {
var age = $('#f-age').value.trim();
if (!age || !/^\d{1,2}$/.test(age) || +age < 1 || +age > 99) { toast('请输入有效年龄'); return; }
refreshAge();
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; }
openOverlay('p-photoguide');
@@ -1155,30 +1319,29 @@
camStep = 0;
photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍)
shutterLock = false; // 重新进入流程,保险解锁
renderCamStep();
showScreen('s-cam');
showScreen('s-cam', 'push');
startCamera(); // beginDetect 在其成功回调里:确认相机可用才加载模型
},
'cam-back': function () {
camSeq++; // 使未决的快门/分析回调失效,避免回退被旧定时器顶回
voice.stop(true); // 回退/退出:上一步的播报不再接着念
shutterLock = false; // 中途退出也解锁
if (camStep > 0) { camStep--; renderCamStep(); } // renderCamStep 内含检测状态机重置
else { stopCamera(); showScreen('s-profile'); }
renderCamStep(); // 在 startCamera 复位降级标记之后渲染,快门显隐按本次模式
},
'shutter': shutter,
'album': function () {
if (shutterLock || currentScreen !== 's-cam') return;
okSince = 0;
shutterLock = true; // 与降级快门同策略:前置上锁,change/cancel 解锁
albumFile.click();
},
'flip': function () { // 切换前后镜头
if (shutterLock || currentScreen !== 's-cam') return;
if (camFallback) { toast('系统相机模式下请在系统相机内切换'); return; }
if (!camStream) { toast('相机启动中,请稍候'); return; }
camFacing = camFacing === 'user' ? 'environment' : 'user';
camMirrored = camFacing === 'user';
startCamera(); // 复用启停与预热(camSeqCam 防竞态)
},
'back-profile': function () { showScreen('s-profile'); },
'retry-camera': function () {
if (shutterLock || currentScreen !== 's-cam') return;
startCamera();
},
'history': function () { openHistory(); },
'close-history': function () { closeOverlay('p-history'); },
'clear-history': function () {
@@ -1271,7 +1434,7 @@
if (!vm) { toast('该记录已损坏'); return; }
renderReport(vm, entry.photos && entry.photos[0], null, entry.payload);
closeOverlay('p-history');
showScreen('s-report');
showScreen('s-report', currentScreen === 's-report' ? undefined : 'push');
});
/* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 photos[] 与 camStep) ─── */
@@ -1300,6 +1463,7 @@
});
/* ─── 初始化 ─── */
history.replaceState({ screen: 's-splash' }, ''); // 刷新后从启动页开始,历史栈的屏标记与之对齐
renderCamStep();
// 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口)
// 有 MOCK_FIXTURE 时走 fixture(检测可视化同步出图),否则回退 SkinScore 内置样本
+23 -12
View File
@@ -73,8 +73,8 @@
var HINT_KEEP = '保持不动';
var HINT_TURN_LEFT = '请向左转头'; // 用户向自己的左侧转
var HINT_TURN_RIGHT = '请向右转头'; // 用户向自己的右侧转
var HINT_RAISE = '请抬头';
var HINT_LOWER = '请低头';
var HINT_RAISE = '请略微抬头';
var HINT_LOWER = '请略微低头';
var HINT_FRONT = '请正对屏幕';
var RAD2DEG = 180 / Math.PI;
@@ -157,8 +157,8 @@
/**
* check(points, target, mirrored) -> { ok, hint }
* target: 'front' | 'left' | 'right'
* 'front' : |yaw|<=15 且 |pitch|<=18;不 ok 时一律 hint='请正对屏幕'
* (产品要求 2026-09-28:正脸步不提示左右转头/抬头低头,只引导回正)
* 'front' : |yaw|<=15 且 |pitch|<=18;不 ok 时按超出更多的一项给方向提示
* (UI 反馈 2026-10:动态提示「请向左转头」「请略微抬头」等,取代此前只提示回正)
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-75°,-28°]),|pitch|<=20
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [28°,75°]),|pitch|<=20
* ok=true 时 hint='保持不动';输入非法(含 estimate 返回 null 的退化特征点)时 ok=false、hint='请正对屏幕'。
@@ -181,8 +181,12 @@
if (Math.abs(sYaw) <= FRONT_YAW_MAX && Math.abs(pitch) <= FRONT_PITCH_MAX) {
return { ok: true, hint: HINT_KEEP, yaw: sYaw };
}
// 正脸步只提示回正(产品要求:不区分左右转头/抬头低头,避免正脸阶段引导混乱)
return { ok: false, hint: HINT_FRONT, yaw: sYaw };
// 按超出阈值比例更大的一项引导回正:头偏向自己左侧(yaw>0)→请向右转头;抬头过多(pitch>0)→请略微低头
var yawOver = Math.abs(sYaw) / FRONT_YAW_MAX, pitchOver = Math.abs(pitch) / FRONT_PITCH_MAX;
if (yawOver >= pitchOver) {
return { ok: false, hint: sYaw > 0 ? HINT_TURN_RIGHT : HINT_TURN_LEFT, yaw: sYaw };
}
return { ok: false, hint: pitch > 0 ? HINT_LOWER : HINT_RAISE, yaw: sYaw };
}
if (target === 'left' || target === 'right') {
@@ -293,22 +297,29 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
ok(bc.ok === true, '反向侧脸 target=left ok');
ok(HeadPose.check(back, 'right', false).ok === false, '反向侧脸 target=right 拒绝');
// 7) 侧脸做 front check → 拒绝并只引导回正(产品要求:正脸步不再提示左右转头)
// 7) 侧脸做 front check → 拒绝并引导转回(被摄者左转 → 请向右转头;反向 → 请向左转头)
var sc = HeadPose.check(makeSide(), 'front', false);
ok(sc.ok === false && sc.hint === '请正对屏幕', '侧脸 front 拒绝,hint=请正对屏幕');
ok(sc.ok === false && sc.hint === '请向右转头', '左转侧脸 front 拒绝,hint=请向右转头');
var scb = makeSide();
scb[30] = { x: 182.3, y: 140 };
scb[33] = { x: 182.3, y: 161.5 };
ok(HeadPose.check(scb, 'front', false).hint === '请向左转头', '右转侧脸 front 拒绝,hint=请向左转头');
// 8) roll:右眼(45 点)下移 10px → roll≈5.7°
var tilted = makeFrontal();
tilted[45] = { x: 250, y: 110 };
ok(Math.abs(HeadPose.estimate(tilted, false).roll - 5.71) < 0.2, 'roll≈5.71°');
// 9) 低头:鼻底上移 → pitch<0,front 拒绝并只提示回正(正脸步不再提示抬头/低头)
// 9) 低头:鼻底上移 → pitch<0,front 拒绝并提示略微抬头
var down = makeFrontal();
down[33] = { x: 200, y: 100 };
var de = HeadPose.estimate(down, false);
var dc = HeadPose.check(down, 'front', false);
ok(de.pitch < -30, '低头 pitch<0 (got ' + de.pitch.toFixed(2) + ')');
ok(dc.ok === false && dc.hint === '请正对屏幕', '低头 front 拒绝,hint=请正对屏幕');
ok(dc.ok === false && dc.hint === '请略微抬头', '低头 front 拒绝,hint=请略微抬头');
var up = makeFrontal();
up[33] = { x: 200, y: 200 };
ok(HeadPose.check(up, 'front', false).hint === '请略微低头', '抬头过多 front 拒绝,hint=请略微低头');
// 10) 非法输入:check 不抛异常,返回兜底引导
var bad = HeadPose.check(null, 'front', false);
@@ -327,7 +338,7 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
ok(HeadPose.estimate(tiny, false) === null, 'faceHeight 过小 → estimate 返回 null');
ok(HeadPose.check(tiny, 'front', false).hint === '请正对屏幕', 'faceHeight 过小 check 兜底');
// 13) 正脸阈值放宽到 |yaw|≤15°(产品要求 2026-09-28):yaw≈14.9° 应通过,yaw≈16.2° 应拒绝且只提示回正
// 13) 正脸阈值放宽到 |yaw|≤15°(产品要求 2026-09-28):yaw≈14.9° 应通过,yaw≈16.2° 应拒绝并引导转回
ok(HeadPose.config.FRONT_YAW_MAX === 15, 'config.FRONT_YAW_MAX = 15');
var slight = makeFrontal();
slight[30] = { x: 205.6, y: 140 }; // 偏移 5.6 → yaw≈14.9°(YAW_COEFF=3.8 标定)
@@ -342,7 +353,7 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
var se2 = HeadPose.estimate(slight2, false);
ok(se2.yaw > 15 && se2.yaw < 17.5, '偏头 yaw∈(15,17.5) (got ' + se2.yaw.toFixed(2) + ')');
var sc3 = HeadPose.check(slight2, 'front', false);
ok(sc3.ok === false && sc3.hint === '请正对屏幕', 'yaw≈16.2° front 拒绝,hint=请正对屏幕');
ok(sc3.ok === false && sc3.hint === '请向右转头', 'yaw≈16.2° front 拒绝,hint=请向右转头');
if (failures > 0) {
console.error('\n' + failures + ' 项自测失败');
+39
View File
@@ -0,0 +1,39 @@
/* 省份 → 城市(地级)数据。来源:china-division@2.7.0 dist/pc.json(modood/Administrative-divisions-of-China,WTFPL);
直辖市第二列只保留本市,另补港澳台与海外。 */
window.REGION_DATA = [
["北京市", ["北京市"]],
["天津市", ["天津市"]],
["河北省", ["石家庄市","唐山市","秦皇岛市","邯郸市","邢台市","保定市","张家口市","承德市","沧州市","廊坊市","衡水市"]],
["山西省", ["太原市","大同市","阳泉市","长治市","晋城市","朔州市","晋中市","运城市","忻州市","临汾市","吕梁市"]],
["内蒙古自治区", ["呼和浩特市","包头市","乌海市","赤峰市","通辽市","鄂尔多斯市","呼伦贝尔市","巴彦淖尔市","乌兰察布市","兴安盟","锡林郭勒盟","阿拉善盟"]],
["辽宁省", ["沈阳市","大连市","鞍山市","抚顺市","本溪市","丹东市","锦州市","营口市","阜新市","辽阳市","盘锦市","铁岭市","朝阳市","葫芦岛市"]],
["吉林省", ["长春市","吉林市","四平市","辽源市","通化市","白山市","松原市","白城市","延边朝鲜族自治州"]],
["黑龙江省", ["哈尔滨市","齐齐哈尔市","鸡西市","鹤岗市","双鸭山市","大庆市","伊春市","佳木斯市","七台河市","牡丹江市","黑河市","绥化市","大兴安岭地区"]],
["上海市", ["上海市"]],
["江苏省", ["南京市","无锡市","徐州市","常州市","苏州市","南通市","连云港市","淮安市","盐城市","扬州市","镇江市","泰州市","宿迁市"]],
["浙江省", ["杭州市","宁波市","温州市","嘉兴市","湖州市","绍兴市","金华市","衢州市","舟山市","台州市","丽水市"]],
["安徽省", ["合肥市","芜湖市","蚌埠市","淮南市","马鞍山市","淮北市","铜陵市","安庆市","黄山市","滁州市","阜阳市","宿州市","六安市","亳州市","池州市","宣城市"]],
["福建省", ["福州市","厦门市","莆田市","三明市","泉州市","漳州市","南平市","龙岩市","宁德市"]],
["江西省", ["南昌市","景德镇市","萍乡市","九江市","新余市","鹰潭市","赣州市","吉安市","宜春市","抚州市","上饶市"]],
["山东省", ["济南市","青岛市","淄博市","枣庄市","东营市","烟台市","潍坊市","济宁市","泰安市","威海市","日照市","临沂市","德州市","聊城市","滨州市","菏泽市"]],
["河南省", ["郑州市","开封市","洛阳市","平顶山市","安阳市","鹤壁市","新乡市","焦作市","濮阳市","许昌市","漯河市","三门峡市","南阳市","商丘市","信阳市","周口市","驻马店市","济源市"]],
["湖北省", ["武汉市","黄石市","十堰市","宜昌市","襄阳市","鄂州市","荆门市","孝感市","荆州市","黄冈市","咸宁市","随州市","恩施土家族苗族自治州","仙桃市","潜江市","天门市","神农架林区"]],
["湖南省", ["长沙市","株洲市","湘潭市","衡阳市","邵阳市","岳阳市","常德市","张家界市","益阳市","郴州市","永州市","怀化市","娄底市","湘西土家族苗族自治州"]],
["广东省", ["广州市","韶关市","深圳市","珠海市","汕头市","佛山市","江门市","湛江市","茂名市","肇庆市","惠州市","梅州市","汕尾市","河源市","阳江市","清远市","东莞市","中山市","潮州市","揭阳市","云浮市"]],
["广西壮族自治区", ["南宁市","柳州市","桂林市","梧州市","北海市","防城港市","钦州市","贵港市","玉林市","百色市","贺州市","河池市","来宾市","崇左市"]],
["海南省", ["海口市","三亚市","三沙市","儋州市","五指山市","琼海市","文昌市","万宁市","东方市","定安县","屯昌县","澄迈县","临高县","白沙黎族自治县","昌江黎族自治县","乐东黎族自治县","陵水黎族自治县","保亭黎族苗族自治县","琼中黎族苗族自治县"]],
["重庆市", ["重庆市"]],
["四川省", ["成都市","自贡市","攀枝花市","泸州市","德阳市","绵阳市","广元市","遂宁市","内江市","乐山市","南充市","眉山市","宜宾市","广安市","达州市","雅安市","巴中市","资阳市","阿坝藏族羌族自治州","甘孜藏族自治州","凉山彝族自治州"]],
["贵州省", ["贵阳市","六盘水市","遵义市","安顺市","毕节市","铜仁市","黔西南布依族苗族自治州","黔东南苗族侗族自治州","黔南布依族苗族自治州"]],
["云南省", ["昆明市","曲靖市","玉溪市","保山市","昭通市","丽江市","普洱市","临沧市","楚雄彝族自治州","红河哈尼族彝族自治州","文山壮族苗族自治州","西双版纳傣族自治州","大理白族自治州","德宏傣族景颇族自治州","怒江傈僳族自治州","迪庆藏族自治州"]],
["西藏自治区", ["拉萨市","日喀则市","昌都市","林芝市","山南市","那曲市","阿里地区"]],
["陕西省", ["西安市","铜川市","宝鸡市","咸阳市","渭南市","延安市","汉中市","榆林市","安康市","商洛市"]],
["甘肃省", ["兰州市","嘉峪关市","金昌市","白银市","天水市","武威市","张掖市","平凉市","酒泉市","庆阳市","定西市","陇南市","临夏回族自治州","甘南藏族自治州"]],
["青海省", ["西宁市","海东市","海北藏族自治州","黄南藏族自治州","海南藏族自治州","果洛藏族自治州","玉树藏族自治州","海西蒙古族藏族自治州"]],
["宁夏回族自治区", ["银川市","石嘴山市","吴忠市","固原市","中卫市"]],
["新疆维吾尔自治区", ["乌鲁木齐市","克拉玛依市","吐鲁番市","哈密市","昌吉回族自治州","博尔塔拉蒙古自治州","巴音郭楞蒙古自治州","阿克苏地区","克孜勒苏柯尔克孜自治州","喀什地区","和田地区","伊犁哈萨克自治州","塔城地区","阿勒泰地区","石河子市","阿拉尔市","图木舒克市","五家渠市","北屯市","铁门关市","双河市","可克达拉市","昆玉市","胡杨河市","新星市","白杨市"]],
["香港特别行政区", ["香港特别行政区"]],
["澳门特别行政区", ["澳门特别行政区"]],
["台湾省", ["台湾省"]],
["海外", ["海外"]]
];
+26
View File
@@ -0,0 +1,26 @@
@echo off
rem 宿主机端口转发 + 防火墙放行(需「以管理员身份运行」)
rem 镜像网络模式下通常可直接访问 http://<本机局域网IP>:8088 ,本脚本作兜底。
net session >nul 2>&1
if errorlevel 1 (
echo 请右键本文件 - 以管理员身份运行
pause
exit /b 1
)
netsh interface portproxy delete v4tov4 listenport=8088 listenaddress=0.0.0.0 >nul 2>&1
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8088 connectaddress=127.0.0.1 connectport=8088
netsh advfirewall firewall delete rule name="Juzi H5 8088" >nul 2>&1
netsh advfirewall firewall add rule name="Juzi H5 8088" dir=in action=allow protocol=TCP localport=8088 profile=any
echo.
echo === portproxy ===
netsh interface portproxy show all
echo === firewall ===
netsh advfirewall firewall show rule name="Juzi H5 8088"
echo.
echo 手机访问: http://192.168.3.19:8088/
echo 后台: http://192.168.3.19:8088/admin.html
echo mock: http://192.168.3.19:8088/?mock=1
pause
+564 -27
View File
@@ -4,6 +4,8 @@
2) POST /api/skin-analyze:三视图(正/左/右)中转 Face++ 专业版测肤
密钥只存在服务端(SKIN_API_KEY / SKIN_API_SECRET),
前端永远接触不到 api_secret。
3) 检测记录落库:data/records.jsonl(一行一条 JSON)+ data/photos/<record_id>/
原始返回仍落盘 data/results/(复核用)。后台查看页 admin.html + /api/admin/*。
启动: node server.js (默认 8080,可用 PORT 环境变量改)
═══════════════════════════════════════════════════════════ */
'use strict';
@@ -13,11 +15,19 @@ const fs = require('fs');
const path = require('path');
const PORT = Number(process.env.PORT) || 8080;
// 只绑回环:公网入口是 nginx(1me.xajuzi.com/state/),8080 不再对公网裸奔;需局域网调试时 HOST=0.0.0.0
const HOST = process.env.HOST || '127.0.0.1';
const ROOT = __dirname;
const FACEPP_URL = 'https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_pro';
const DATA_DIR = path.join(ROOT, 'data', 'results'); // 每次检测的原始返回落盘(复核用,已在 .gitignore)
const DATA_ROOT = path.join(ROOT, 'data');
const DATA_DIR = path.join(DATA_ROOT, 'results'); // 每次检测的原始返回落盘(复核用,已在 .gitignore)
const PHOTOS_DIR = path.join(DATA_ROOT, 'photos'); // 检测照片:data/photos/<record_id>/{front,left,right}.jpg
const RECORDS_FILE = path.join(DATA_ROOT, 'records.jsonl'); // 检测记录(一行一条 JSON)
/* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env ─── */
/* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */
const SkinScore = require('./js/skin-score.js');
/* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env / key.env ─── */
function loadCredentials() {
if (process.env.SKIN_API_KEY && process.env.SKIN_API_SECRET) {
return { key: process.env.SKIN_API_KEY, secret: process.env.SKIN_API_SECRET };
@@ -28,15 +38,21 @@ function loadCredentials() {
const secret = (txt.match(/SKIN_API_SECRET\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
if (key && secret) return { key, secret };
} catch (e) { /* 文件不存在则继续下一级兜底 */ }
try {
const txt = fs.readFileSync(path.join(ROOT, '..', 'reference', 'facepp-skinanalyze', 'credentials.env'), 'utf8');
const key = (txt.match(/FACEPP_API_KEY\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
const secret = (txt.match(/FACEPP_API_SECRET\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
if (key && secret) return { key, secret };
} catch (e) { /* 文件不存在则走下面的报错 */ }
for (const credentialFile of ['credentials.env', 'key.env']) {
try {
const txt = fs.readFileSync(path.join(ROOT, '..', 'reference', 'facepp-skinanalyze', credentialFile), 'utf8');
const key = (txt.match(/FACEPP_API_KEY\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
const secret = (txt.match(/FACEPP_API_SECRET\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
if (key && secret) return { key, secret };
} catch (e) { /* 文件不存在则尝试下一个配置文件 */ }
}
if (process.env.LOCAL_PREVIEW === '1') {
console.warn('[本机预览] 未配置 Face++ 密钥,真实检测不可用;演示入口: /?mock=1');
return null;
}
console.error('[启动失败] 找不到密钥:请设置 SKIN_API_KEY / SKIN_API_SECRET 环境变量,');
console.error(' 或在项目根目录放置 apikey.bash(内容为 export SKIN_API_KEY=... 等),');
console.error(' 或在 reference/facepp-skinanalyze/credentials.env 放置 FACEPP_API_KEY=... 等');
console.error(' 或在 reference/facepp-skinanalyze/credentials.env 或 key.env 放置 FACEPP_API_KEY=... 等');
process.exit(1);
}
const CRED = loadCredentials();
@@ -46,7 +62,7 @@ const MIME = {
'.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml',
'.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2'
'.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2', '.csv': 'text/csv; charset=utf-8'
};
function serveStatic(req, res, pathname) {
let p;
@@ -57,6 +73,9 @@ function serveStatic(req, res, pathname) {
return res.end('Bad Request');
}
if (p === '/') p = '/index.html';
// /data/ 下是检测照片/原始 JSON/记录库(敏感个人信息),禁止静态直出:
// 照片只能走 /api/admin/photos(由 nginx Basic Auth 保护),此处一律 403
if (p === '/data' || p.startsWith('/data/')) { res.writeHead(403); return res.end('Forbidden'); }
const file = path.normalize(path.join(ROOT, p));
if (file !== ROOT && !file.startsWith(ROOT + path.sep)) { res.writeHead(403); return res.end('Forbidden'); }
fs.readFile(file, (err, data) => {
@@ -82,6 +101,7 @@ const ERR_MSG = {
'AUTHORIZATION_ERROR': '检测服务额度或权限异常,请联系管理员',
'CONCURRENCY_LIMIT_EXCEEDED': '当前检测人数较多,请稍候重试',
'INTERNAL_ERROR': '检测服务开小差了,请稍候重试',
'TIMEOUT': '检测服务响应超时,请稍候重试',
'NETWORK_ERROR': '网络异常,请检查网络后重试'
};
@@ -125,9 +145,174 @@ function isUsableB64(b64) {
return Buffer.from(b64, 'base64').length > 500;
}
/* ═══════════════════════════════════════════════════════════
检测记录存储(records.jsonl,一行一条 JSON,零依赖嵌入式存储)
字段对照后台产品文档第五章 detect_record。
═══════════════════════════════════════════════════════════ */
function nowIso() { return new Date().toISOString(); }
function newRecordId() {
const d = new Date();
const p = n => String(n).padStart(2, '0');
const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
return `R${stamp}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
}
function readAllRecords() {
try {
return fs.readFileSync(RECORDS_FILE, 'utf8').split('\n')
.map(line => { try { return JSON.parse(line); } catch (e) { return null; } })
.filter(r => r && r.record_id);
} catch (e) { return []; } // 文件不存在视为空库
}
function appendRecord(rec) {
try {
fs.mkdirSync(DATA_ROOT, { recursive: true });
fs.appendFileSync(RECORDS_FILE, JSON.stringify(rec) + '\n');
} catch (e) { console.log(`[record] 记录追加失败(忽略): ${e.message}`); }
}
function updateRecord(rec) {
try {
const all = readAllRecords();
const i = all.findIndex(r => r.record_id === rec.record_id);
if (i >= 0) all[i] = rec;
fs.writeFileSync(RECORDS_FILE, all.map(r => JSON.stringify(r)).join('\n') + '\n');
} catch (e) { console.log(`[record] 记录更新失败(忽略): ${e.message}`); }
}
function findRecord(id) {
const safe = String(id || '').replace(/[^\w-]/g, '');
return readAllRecords().find(r => r.record_id === safe) || null;
}
/* 请求体里的档案信息归一化:年龄非法(非 1–120 整数)按 null,其余空串按 null */
function normalizeProfile(raw) {
const p = (raw && typeof raw === 'object') ? raw : {};
const age = Number(p.real_age);
const str = v => (typeof v === 'string' && v.trim()) ? v.trim() : null;
return {
real_age: Number.isInteger(age) && age >= 1 && age <= 120 ? age : null,
gender: str(p.gender),
region: str(p.region),
daily_skin_type: str(p.daily_skin_type)
};
}
/* 照片写盘:data/photos/<record_id>/{front,left,right}.jpg(本地磁盘代替对象存储,库只存相对地址)。
写盘失败不影响检测流程,只打日志。返回 {front|left|right: 相对路径} */
function writePhotos(recordId, parts) {
const out = {};
try {
const dir = path.join(PHOTOS_DIR, recordId);
fs.mkdirSync(dir, { recursive: true });
for (const which of ['front', 'left', 'right']) {
const b64 = parts[which];
if (!b64) continue;
fs.writeFileSync(path.join(dir, which + '.jpg'), Buffer.from(b64, 'base64'));
out[which] = `data/photos/${recordId}/${which}.jpg`;
}
} catch (e) { console.log(`[record] 照片写盘失败(忽略): ${e.message}`); }
return out;
}
/* 从侧脸结果取下颌线角度:quality≠1 时角度无效,记 null */
function jawlineAngle(side, which) {
if (!side || typeof side !== 'object') return null;
const info = side[which + '_jawline_info'];
if (!info || info[which + '_jawline_quality'] !== 1) return null;
const a = info[which + '_jawline_angle'];
return typeof a === 'number' ? a : null;
}
/* 原始返回完整落盘(含 face_maps base64),供复核/真值比对;写盘失败不影响检测。
返回落盘文件名(相对 data/results/),失败返回 null */
function dumpRaw(fpJson) {
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
const reqId = String(fpJson.request_id || 'noreq').replace(/[^\w,-]/g, '').slice(0, 40);
const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
const fname = `${stamp}-${reqId}.json`;
fs.writeFile(path.join(DATA_DIR, fname), JSON.stringify(fpJson), () => {});
console.log(`[analyze] 原始返回已落盘: data/results/${fname}`);
return fname;
} catch (e) { console.log(`[analyze] 落盘失败(忽略): ${e.message}`); return null; }
}
/* 评分引擎入参档案:SkinScore 用 realAge/skinType 键 */
function scoreProfile(rec) {
return { realAge: rec.real_age, skinType: rec.daily_skin_type };
}
/* 检测成功:用 SkinScore.buildViewModel 计算并补全记录字段(复用同一份公式) */
function fillSuccess(rec, fpJson) {
const vm = SkinScore.buildViewModel(fpJson.result, {
left: fpJson.left_side_result || (fpJson.side_result && fpJson.side_result.left_side_result) || null,
right: fpJson.right_side_result || (fpJson.side_result && fpJson.side_result.right_side_result) || null
}, scoreProfile(rec));
rec.detect_status = '成功';
rec.fail_stage = null; rec.fail_code = null; rec.fail_msg = null;
rec.skin_age = vm.skinAgeRaw;
rec.total_score = vm.total;
rec.jawline_angle_l = jawlineAngle(fpJson.left_side_result, 'left');
rec.jawline_angle_r = jawlineAngle(fpJson.right_side_result, 'right');
rec.cats = {}; rec.copy_6 = {};
for (const k of Object.keys(vm.cats)) {
rec.cats[k] = { P: vm.cats[k].P, S: vm.cats[k].S, level: vm.cats[k].level };
rec.copy_6[k] = vm.cats[k].text;
}
rec.dims = {};
for (const k of Object.keys(vm.dims)) rec.dims[k] = { P: vm.dims[k].P, S: vm.dims[k].S, level: vm.dims[k].level };
rec.avg_s = vm.avgS;
rec.group = vm.group;
rec.copy_summary = vm.summary;
rec.lifestyle = vm.lifestyle;
rec.careAdvice = vm.careAdvice;
rec.essence = vm.essence ? { name: vm.essence.name, text: vm.essence.text } : null;
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);
}
/* 检测失败:补失败字段 */
function fillFailure(rec, code) {
rec.detect_status = '失败';
rec.fail_stage = 'detect';
rec.fail_code = code;
rec.fail_msg = ERR_MSG[code] || '检测失败,请重试';
}
/* 执行一次检测:计时调用 Face++,按结果补全记录(成功/失败都落库)。返回 Face++ 原始返回 */
async function runDetect(rec, front, left, right) {
rec.detect_status = '检测中';
rec.updated_at = nowIso();
const t0 = Date.now();
const fpJson = await callFacepp(front, left, right);
rec.cost_ms = Date.now() - t0;
if (fpJson.error_message) {
const code = fpJson.error_message.split(':')[0];
console.log(`[analyze] Face++ 错误: ${code}`);
fillFailure(rec, code);
} else if (!fpJson.result) {
fillFailure(rec, 'INTERNAL_ERROR');
} else {
try {
fillSuccess(rec, fpJson);
} catch (e) { // 评分异常按检测失败落库,但不改写 C 端成功响应(原始结果仍透传)
console.log(`[analyze] 评分异常: ${e.message}`);
fillFailure(rec, 'INTERNAL_ERROR');
}
}
rec.updated_at = nowIso();
return fpJson;
}
/* 调用 Face++ 专业版三视图测肤;
* 接口超时/网络错误(fetch 抛错)、HTTP 5xx、CONCURRENCY_LIMIT_EXCEEDED → 等 4s 重试 1 次(V2.0 第七章) */
async function callFacepp(front, left, right) {
if (!CRED) return { error_message: 'NO_API_CREDENTIALS' };
const form = new FormData();
form.append('api_key', CRED.key);
form.append('api_secret', CRED.secret);
@@ -162,6 +347,7 @@ async function callFacepp(front, left, right) {
}
async function handleAnalyze(req, res) {
let rec = null; // 记录:正脸照片校验通过后创建(此前失败无从落库)
try {
const body = JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8'));
// 三个 image 字段先做类型检查:非字符串视为缺失(左/右图缺失时仅发正脸,接口自动降级为单视图)
@@ -177,28 +363,49 @@ async function handleAnalyze(req, res) {
return res.end(JSON.stringify({ ok: false, code: 'INVALID_IMAGE_SIZE', message: '照片数据异常,请重试' }));
}
const fpJson = await callFacepp(front, left, right);
/* ── 记录落库:生成 record_id → 写照片 → 落一条 detect_status=检测中 ── */
const profile = normalizeProfile(body.profile);
const recordId = newRecordId();
const photos = writePhotos(recordId, { front, left, right });
rec = {
record_id: recordId,
created_at: nowIso(),
updated_at: nowIso(),
real_age: profile.real_age,
gender: profile.gender,
region: profile.region,
daily_skin_type: profile.daily_skin_type,
img_front: photos.front || null,
img_left: photos.left || null,
img_right: photos.right || null,
shoot_status: (photos.left && photos.right) ? '已完成' : '降级',
upload_status: photos.front ? '成功' : '失败',
detect_status: '检测中',
fail_stage: null, fail_code: null, fail_msg: null,
skin_age: null, total_score: null,
jawline_angle_l: null, jawline_angle_r: null,
cats: null, dims: null, avg_s: null, group: null,
copy_6: null, copy_summary: null, lifestyle: null, careAdvice: null,
essence: null, essence_line: null, trigger: null, notes: null,
raw_file: null, cost_ms: null,
retry_of: null, retry_count: 0
};
appendRecord(rec);
console.log(`[record] 新检测记录 ${recordId}(${rec.shoot_status},照片${Object.keys(photos).length}/3)`);
const fpJson = await runDetect(rec, front, left, right);
updateRecord(rec);
if (fpJson.error_message) {
const code = fpJson.error_message.split(':')[0];
console.log(`[analyze] Face++ 错误: ${code}`);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, code, message: ERR_MSG[code] || '检测失败,请重试' }));
}
if (!fpJson.result) {
if (!fpJson.result) { // 与改造前一致:仅 result 缺失才走 INTERNAL_ERROR(评分异常不改写 C 端成功响应)
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, code: 'INTERNAL_ERROR', message: ERR_MSG.INTERNAL_ERROR }));
}
const result = fpJson.result;
// 原始返回完整落盘(含 face_maps base64),供复核/真值比对;写盘失败不影响检测响应
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
const reqId = String(fpJson.request_id || 'noreq').replace(/[^\w,-]/g, '').slice(0, 40);
const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
const fname = `${stamp}-${reqId}.json`;
fs.writeFile(path.join(DATA_DIR, fname), JSON.stringify(fpJson), () => {});
console.log(`[analyze] 原始返回已落盘: data/results/${fname}`);
} catch (e) { console.log(`[analyze] 落盘失败(忽略): ${e.message}`); }
// face_maps 是整幅 base64 叠加图,体积很大:只透传报告页所需图层(REPORT_MAPS),
// 值补 data: URL 前缀供前端 <img> 直接用;未选中的图层直接删除键;
// marks 坐标字段(acne/brown_spot/mole/closed_comedones/wrinkle_mark 等)本就在 result 里,原样透传
@@ -223,6 +430,11 @@ async function handleAnalyze(req, res) {
}
}));
} catch (e) {
if (rec) { // 记录已创建但流程异常:按检测失败补全,便于后台看到这条调用
fillFailure(rec, e.message === 'TOO_LARGE' ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR');
rec.updated_at = nowIso();
updateRecord(rec);
}
const tooLarge = e.message === 'TOO_LARGE';
const code = tooLarge ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR';
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -231,13 +443,338 @@ async function handleAnalyze(req, res) {
}
}
/* ═══════════════════════════════════════════════════════════
后台查看 API(本地查看版,无登录鉴权 —— 上线前需加网关/鉴权)
═══════════════════════════════════════════════════════════ */
function sendJson(res, status, obj) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(obj));
}
/* 记录里照片的在线 URL(有存才给) */
function photoUrls(rec) {
const urls = {};
if (rec.img_front) urls.front = `/api/admin/photos/${rec.record_id}/front`;
if (rec.img_left) urls.left = `/api/admin/photos/${rec.record_id}/left`;
if (rec.img_right) urls.right = `/api/admin/photos/${rec.record_id}/right`;
return urls;
}
/* 列表用的字段子集(不含 raw_json 与文案全文) */
function listItem(rec) {
return {
record_id: rec.record_id,
created_at: rec.created_at,
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
shoot_status: rec.shoot_status, upload_status: rec.upload_status, detect_status: rec.detect_status,
fail_code: rec.fail_code, fail_msg: rec.fail_msg,
total_score: rec.total_score, skin_age: rec.skin_age,
dims: rec.dims, group: rec.group,
essence: rec.essence ? rec.essence.name : null,
cost_ms: rec.cost_ms,
retry_of: rec.retry_of, retry_count: rec.retry_count || 0,
photos: photoUrls(rec)
};
}
/* 查询参数过滤。from/to 支持 YYYY-MM-DD 或完整 ISO;纯日期 to 含当天全天 */
function matchQuery(rec, q) {
if (q.from && rec.created_at < q.from) return false;
if (q.to) {
const to = q.to.length === 10 ? q.to + 'T23:59:59.999Z' : q.to;
if (rec.created_at > to) return false;
}
if (q.detect_status && rec.detect_status !== q.detect_status) return false;
if (q.fail_code && rec.fail_code !== q.fail_code) return false;
if (q.group && rec.group !== q.group) return false;
if (q.q) {
const needle = String(q.q).trim();
if (needle && !rec.record_id.startsWith(needle)) return false;
}
if (q.age_min != null) { if (rec.real_age == null || rec.real_age < q.age_min) return false; }
if (q.age_max != null) { if (rec.real_age == null || rec.real_age > q.age_max) return false; }
if (q.dim && q.dim_level) {
const d = rec.dims && rec.dims[q.dim];
if (!d || d.level !== q.dim_level) return false;
}
if (q.only_fail === '1' && rec.detect_status !== '失败') return false;
return true;
}
function handleRecordsQuery(req, res, url) {
const sp = url.searchParams;
const q = {
from: sp.get('from') || null,
to: sp.get('to') || null,
detect_status: sp.get('detect_status') || null,
fail_code: sp.get('fail_code') || null,
group: sp.get('group') || null,
dim: sp.get('dim') || null,
dim_level: sp.get('dim_level') || null,
q: sp.get('q') || null,
only_fail: sp.get('only_fail') || null,
age_min: sp.has('age_min') && sp.get('age_min') !== '' ? Number(sp.get('age_min')) : null,
age_max: sp.has('age_max') && sp.get('age_max') !== '' ? Number(sp.get('age_max')) : null
};
if ((q.age_min != null && !Number.isFinite(q.age_min)) || (q.age_max != null && !Number.isFinite(q.age_max))) {
return sendJson(res, 400, { ok: false, message: 'age_min/age_max 必须是数字' });
}
const page = Math.max(1, parseInt(sp.get('page') || '1', 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(sp.get('page_size') || '20', 10) || 20));
const filtered = readAllRecords().filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); // created_at 倒序
const total = filtered.length;
const items = filtered.slice((page - 1) * pageSize, page * pageSize).map(listItem);
sendJson(res, 200, { ok: true, total, page, page_size: pageSize, items });
}
function handleRecordDetail(req, res, id) {
const rec = findRecord(id);
if (!rec) return sendJson(res, 404, { ok: false, message: '记录不存在' });
// 原始 JSON 全文:读文件失败给 null。
// face_maps 是整幅 base64 叠加图(单文件可达数 MB),详情页直接渲染会卡死浏览器:
// 展示层把各图层值替换为占位说明,完整原文件走 /api/admin/records/:id/raw 下载
let rawJson = null;
if (rec.raw_file) {
const fname = String(rec.raw_file).replace(/[^\w.,-]/g, '');
try { rawJson = JSON.parse(fs.readFileSync(path.join(DATA_DIR, fname), 'utf8')); }
catch (e) { rawJson = null; }
}
if (rawJson && rawJson.result && rawJson.result.face_maps) {
for (const k of Object.keys(rawJson.result.face_maps)) {
const v = rawJson.result.face_maps[k];
if (typeof v === 'string' && v.length) {
rawJson.result.face_maps[k] = `<base64 图层已省略,共 ${v.length} 字符,点上方链接下载完整原文件>`;
}
}
}
// 版本链:它 retry_of 指向的(父)+ retry_of 指向它的(子)
const all = readAllRecords();
const parent = rec.retry_of ? (all.find(r => r.record_id === rec.retry_of) || null) : null;
const children = all.filter(r => r.retry_of === rec.record_id)
.map(r => ({ record_id: r.record_id, detect_status: r.detect_status, created_at: r.created_at }));
sendJson(res, 200, {
ok: true,
record: rec,
raw_json: rawJson,
photos: photoUrls(rec),
versions: {
parent: parent ? { record_id: parent.record_id, detect_status: parent.detect_status, created_at: parent.created_at } : null,
children
}
});
}
/* 完整原始 JSON 下载(face_maps base64 未裁剪,动辄数 MB,仅供复核下载,不做页面内展示) */
function handleRawDownload(req, res, id) {
const rec = findRecord(id);
if (!rec || !rec.raw_file) return sendJson(res, 404, { ok: false, message: '记录或原始 JSON 不存在' });
const fname = String(rec.raw_file).replace(/[^\w.,-]/g, '');
const file = path.join(DATA_DIR, fname);
fs.readFile(file, (err, data) => {
if (err) return sendJson(res, 404, { ok: false, message: '原始 JSON 文件不存在' });
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="${rec.record_id}-raw.json"`
});
res.end(data);
});
}
function handlePhoto(req, res, id, which) {
const safe = String(id || '').replace(/[^\w-]/g, '');
const file = path.join(PHOTOS_DIR, safe, which + '.jpg');
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); }
res.writeHead(200, {
'Content-Type': 'image/jpeg',
'Content-Disposition': `inline; filename="${safe}-${which}.jpg"`
});
res.end(data);
});
}
function handleStats(req, res) {
const all = readAllRecords();
const today = nowIso().slice(0, 10);
const finished = all.filter(r => r.detect_status === '成功' || r.detect_status === '失败');
const succeeded = finished.filter(r => r.detect_status === '成功');
const failed = finished.filter(r => r.detect_status === '失败');
const avgCost = finished.length
? Math.round(finished.reduce((s, r) => s + (r.cost_ms || 0), 0) / finished.length)
: 0;
// 失败原因分布
const failMap = {};
for (const r of failed) failMap[r.fail_code || 'UNKNOWN'] = (failMap[r.fail_code || 'UNKNOWN'] || 0) + 1;
const fail_dist = Object.keys(failMap).map(code => ({
code, count: failMap[code],
pct: failed.length ? Math.round(failMap[code] * 1000 / failed.length) / 10 : 0
})).sort((a, b) => b.count - a.count);
// 三维度档位分布(成功记录)
const dim_dist = { firm: { '预警': 0, '改善': 0, '良好': 0 }, vita: { '预警': 0, '改善': 0, '良好': 0 }, stab: { '预警': 0, '改善': 0, '良好': 0 } };
const group_dist = {};
for (const r of succeeded) {
for (const d of ['firm', 'vita', 'stab']) {
const lv = r.dims && r.dims[d] && r.dims[d].level;
if (lv && dim_dist[d][lv] != null) dim_dist[d][lv] += 1;
}
const g = r.group || '未分组';
group_dist[g] = (group_dist[g] || 0) + 1;
}
// 近 14 天每日检测量/成功数(含今天,今天在后)
const daily = [];
for (let i = 13; i >= 0; i--) {
const d = new Date(Date.now() - i * 86400000);
const day = d.toISOString().slice(0, 10);
const recs = all.filter(r => (r.created_at || '').slice(0, 10) === day);
daily.push({
date: day,
total: recs.length,
success: recs.filter(r => r.detect_status === '成功').length
});
}
sendJson(res, 200, {
ok: true,
today_total: all.filter(r => (r.created_at || '').slice(0, 10) === today).length,
total: all.length,
finished_total: finished.length,
success_total: succeeded.length,
fail_total: failed.length,
success_rate: finished.length ? Math.round(succeeded.length * 1000 / finished.length) / 10 : 0,
avg_cost_ms: avgCost,
fail_dist,
dim_dist,
group_dist,
daily
});
}
/* 手动重试(文档 4.4):读原记录照片重新检测,生成新记录,原记录 retry_count+1 */
async function handleRetry(req, res, id) {
const rec = findRecord(id);
if (!rec) return sendJson(res, 404, { ok: false, message: '记录不存在' });
const readB64 = which => {
const file = path.join(PHOTOS_DIR, rec.record_id, which + '.jpg');
try { return fs.readFileSync(file).toString('base64'); } catch (e) { return null; }
};
const front = readB64('front');
if (!front) return sendJson(res, 400, { ok: false, message: '该记录没有可用的正脸照片,无法重试' });
const nid = newRecordId();
const photos = writePhotos(nid, { front, left: readB64('left'), right: readB64('right') });
const nrec = {
record_id: nid,
created_at: nowIso(),
updated_at: nowIso(),
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
img_front: photos.front || null,
img_left: photos.left || null,
img_right: photos.right || null,
shoot_status: (photos.left && photos.right) ? '已完成' : '降级',
upload_status: photos.front ? '成功' : '失败',
detect_status: '检测中',
fail_stage: null, fail_code: null, fail_msg: null,
skin_age: null, total_score: null,
jawline_angle_l: null, jawline_angle_r: null,
cats: null, dims: null, avg_s: null, group: null,
copy_6: null, copy_summary: null, lifestyle: null, careAdvice: null,
essence: null, essence_line: null, trigger: null, notes: null,
raw_file: null, cost_ms: null,
retry_of: rec.record_id, retry_count: 0
};
appendRecord(nrec);
console.log(`[record] 手动重试 ${rec.record_id} → 新记录 ${nid}`);
await runDetect(nrec, front, nrec.img_left ? readB64('left') : null, nrec.img_right ? readB64('right') : null);
updateRecord(nrec);
rec.retry_count = (rec.retry_count || 0) + 1;
rec.updated_at = nowIso();
updateRecord(rec);
sendJson(res, 200, { ok: true, record: listItem(nrec) });
}
/* CSV 单元格转义;raw_json 等大字段严禁进单元格(Excel 单格 32767 字符截断) */
function csvCell(v) {
if (v == null) return '';
const s = String(v);
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
function handleExport(req, res, url) {
const q = {
from: url.searchParams.get('from') || null,
to: url.searchParams.get('to') || null
};
const filtered = readAllRecords().filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
const dimP = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].P : null;
const dimS = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].S : null;
const dimLv = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].level : null;
const header = ['记录ID', '创建时间', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
'失败环节', '失败码', '失败信息', '总分', '肌肤年龄',
'紧塑力P', '紧塑力S', '紧塑力档', '活力感P', '活力感S', '活力感档', '稳定力P', '稳定力S', '稳定力档',
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms'];
const lines = [header.join(',')];
for (const r of filtered) {
lines.push([
r.record_id, r.created_at, r.real_age, r.gender, r.region, r.daily_skin_type,
r.shoot_status, r.upload_status, r.detect_status,
r.fail_stage, r.fail_code, r.fail_msg, r.total_score, r.skin_age,
dimP(r, 'firm'), dimS(r, 'firm'), dimLv(r, 'firm'),
dimP(r, 'vita'), dimS(r, 'vita'), dimLv(r, 'vita'),
dimP(r, 'stab'), dimS(r, 'stab'), dimLv(r, 'stab'),
r.avg_s, r.group, r.essence ? r.essence.name : null, r.trigger, r.cost_ms
].map(csvCell).join(','));
}
const fromTag = (q.from || 'all').replace(/[^\d-]/g, '');
const toTag = (q.to || 'all').replace(/[^\d-]/g, '');
const fname = `skin-records-${fromTag}_${toTag}.csv`;
res.writeHead(200, {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${fname}"`
});
res.end('' + lines.join('\r\n')); // UTF-8 BOM:Excel 直接打开不乱码
}
async function handleAdmin(req, res, pathname, url) {
try {
const retryM = pathname.match(/^\/api\/admin\/records\/([\w-]+)\/retry$/);
if (req.method === 'POST' && retryM) return await handleRetry(req, res, retryM[1]);
const rawM = pathname.match(/^\/api\/admin\/records\/([\w-]+)\/raw$/);
if (req.method === 'GET' && rawM) return handleRawDownload(req, res, rawM[1]);
const detailM = pathname.match(/^\/api\/admin\/records\/([\w-]+)$/);
if (req.method === 'GET' && detailM) return handleRecordDetail(req, res, detailM[1]);
const photoM = pathname.match(/^\/api\/admin\/photos\/([\w-]+)\/(front|left|right)$/);
if (req.method === 'GET' && photoM) return handlePhoto(req, res, photoM[1], photoM[2]);
if (req.method === 'GET' && pathname === '/api/admin/records') return handleRecordsQuery(req, res, url);
if (req.method === 'GET' && pathname === '/api/admin/stats') return handleStats(req, res);
if (req.method === 'GET' && pathname === '/api/admin/export') return handleExport(req, res, url);
return sendJson(res, 404, { ok: false, message: '接口不存在' });
} catch (e) {
console.log(`[admin] ${e.message}`);
return sendJson(res, 500, { ok: false, message: '服务内部错误' });
}
}
http.createServer((req, res) => {
if (req.method === 'POST' && req.url.split('?')[0] === '/api/skin-analyze') return handleAnalyze(req, res);
if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') {
const pathname = req.url.split('?')[0];
if (!CRED && req.method === 'POST' && (pathname === '/api/skin-analyze' || /^\/api\/admin\/records\/[\w-]+\/retry$/.test(pathname))) {
req.resume();
return sendJson(res, 503, { ok: false, code: 'NO_API_CREDENTIALS', message: '本机尚未配置 Face++ 密钥,请先配置密钥或使用演示模式(?mock=1)' });
}
if (req.method === 'POST' && pathname === '/api/skin-analyze') return handleAnalyze(req, res);
if (req.method === 'GET' && pathname === '/api/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: true }));
}
return serveStatic(req, res, req.url.split('?')[0]);
}).listen(PORT, '0.0.0.0', () => {
console.log(`[server] 巨子测肤 H5 已启动: http://127.0.0.1:${PORT} (静态 + /api/skin-analyze)`);
if (pathname.startsWith('/api/admin/')) {
return handleAdmin(req, res, pathname, new URL(req.url, 'http://localhost'));
}
return serveStatic(req, res, pathname);
}).listen(PORT, HOST, () => {
console.log(`[server] 巨子测肤 H5 已启动: http://${HOST}:${PORT} (静态 + /api/skin-analyze + /api/admin/*)`);
console.log(`[server] 后台查看页: http://${HOST}:${PORT}/admin.html`);
});
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
export HOST="${HOST:-0.0.0.0}"
export PORT="${PORT:-8088}"
if pgrep -f '[n]ode server.js' >/dev/null; then
echo "already running: $(pgrep -af '[n]ode server.js')"
exit 0
fi
nohup node server.js >> server.log 2>&1 &
echo "started pid=$! http://${HOST}:${PORT}"
+25 -13
View File
@@ -2,15 +2,20 @@
* 无头 Chromium 冒烟:mock 三连拍走到报告页,并核对语音清单是否真的被拉取、解码、起播。
*
* 依赖:全局 playwright + `npx playwright install chromium`
* 服务:node h5/server.js(默认 http://127.0.0.1:8080)
* 服务:node h5/server.js(默认 http://127.0.0.1:8080,可用 BASE_URL 覆盖)
*
* node h5/test/e2e-browser.mjs
*/
import { execSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
const pwRoot = execSync('npm root -g', { encoding: 'utf8' }).trim();
const { chromium } = await import(pathToFileURL(pwRoot + '/playwright/index.mjs').href);
const pwModule = process.env.PLAYWRIGHT_MODULE || path.join(execSync('npm root -g', { encoding: 'utf8' }).trim(), 'playwright/index.mjs');
const { chromium } = await import(pathToFileURL(pwModule).href);
const outputDir = process.env.E2E_OUTPUT_DIR || os.tmpdir();
fs.mkdirSync(outputDir, { recursive: true });
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080';
const EXPECTED = [
@@ -119,10 +124,17 @@ try {
await page.getByRole('button', { name: '开始检测' }).click();
await page.locator('#privacy-check').click();
await page.getByRole('button', { name: '继续' }).click();
await page.locator('#f-age').fill('28');
await page.locator('#f-gender').selectOption('女');
await page.locator('#rs-current').click();
await page.locator('#rs-list li[data-v="北京"]').click();
// 三个滚轮选择器:打开后直接点「确定」取默认项(出生年月默认 30 年前 1 月、女、北京市)
for (const sel of ['#pick-birth', '#pick-gender', '#pick-region']) {
await page.locator(sel).click();
await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
await page.waitForTimeout(400);
}
const prof = await 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,
}));
check('档案滚轮选择', /^\d{4}-\d{2}$/.test(prof.birth) && +prof.age > 0 && prof.gender && prof.region, JSON.stringify(prof));
await page.getByRole('button', { name: '提交' }).click();
await page.getByRole('button', { name: '开始' }).click();
@@ -131,11 +143,11 @@ try {
return v && v.videoWidth > 0 && document.querySelector('#s-cam').classList.contains('cam-live');
}, null, { timeout: 12000 });
const step1 = (await page.locator('#cam-step').innerText()).replace(/\s+/g, '');
check('进入拍照 1/3', step1.includes('1/3') && step1.includes('正脸'), step1);
check('进入拍照 1/3', step1.includes('1/3') && step1.includes('正对屏幕'), step1);
// 听完每步引导再按快门(最长 step2 4.3s):引导、「拍摄完成」、「分析中」都应完整播出
// mock 无人脸检测,快门作为调试入口保留;听完每步引导再按(最长 step2 4.3s):引导、「拍摄完成」、「分析中」都应完整播出
// 步骤顺序与轮廓对齐 Figma:正脸 → 右脸(耳在画面右,mask 2)→ 左脸(耳在画面左,mask 1)
const FIGMA_STEPS = [['正脸', '0'], ['右脸', '2'], ['左脸', '1']];
const FIGMA_STEPS = [['正对屏幕', '0'], ['右脸', '2'], ['左脸', '1']];
for (let i = 0; i < 3; i++) {
const n = i + 1;
await page.waitForFunction((num) => {
@@ -169,12 +181,12 @@ try {
check('流程语音无截断', cut.length === 0, cut.map((v) => `${v.id}@${v.cutAt}/${v.dur}`).join(','));
check('页面无脚本异常', pageErrors.length === 0, pageErrors.join(' | '));
await page.screenshot({ path: '/tmp/juzi-e2e-report.png', fullPage: true });
console.log('截图 /tmp/juzi-e2e-report.png');
await page.screenshot({ path: path.join(outputDir, 'juzi-e2e-report.png'), fullPage: true });
console.log('截图 ' + path.join(outputDir, 'juzi-e2e-report.png'));
} catch (err) {
console.error('RUN ERROR', err);
fails.push(String(err && err.message || err));
try { await page.screenshot({ path: '/tmp/juzi-e2e-fail.png', fullPage: true }); } catch (e) {}
try { await page.screenshot({ path: path.join(outputDir, 'juzi-e2e-fail.png'), fullPage: true }); } catch (e) {}
} finally {
await browser.close();
}
+255
View File
@@ -0,0 +1,255 @@
/** UI 反馈验收:真实交互 + 可控检测输入;拦截分析请求,不调用 Face++。 */
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const modulePath = process.env.PLAYWRIGHT_MODULE || path.join(execSync('npm root -g', { encoding: 'utf8' }).trim(), 'playwright/index.mjs');
const { chromium } = await import(pathToFileURL(modulePath).href);
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080';
const OUT = process.env.E2E_OUTPUT_DIR || path.join(os.tmpdir(), 'juzi-feedback');
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({ headless: true, args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
const context = await browser.newContext({ viewport: { width: 375, height: 812 }, permissions: ['camera'], reducedMotion: 'reduce' });
let page;
const errors = [], requests = [];
const pass = name => console.log('PASS ' + name);
// 只替换检测输入和亮度采样;实际 HeadPose、质量判断、相机抓帧、步骤锁与送检仍运行产品代码。
const detector = `
window.scene = { pose: 'front', luma: 130, count: 1, ratio: .45, offX: 0, offY: 0, modelFail: false, pattern: false, calls: 0 };
var origPixels = CanvasRenderingContext2D.prototype.getImageData;
CanvasRenderingContext2D.prototype.getImageData = function(...args) {
if (this.canvas.width !== 16 || this.canvas.height !== 16) return origPixels.apply(this,args);
var a = new Uint8ClampedArray(16*16*4); a.fill(window.scene.luma); return { data: a };
};
window.faceapi = {
nets: { tinyFaceDetector: { loadFromUri: () => scene.modelFail ? Promise.reject(new Error('offline')) : Promise.resolve() }, faceLandmark68Net: { loadFromUri: () => Promise.resolve() } },
TinyFaceDetectorOptions: function() {},
detectAllFaces: source => ({ withFaceLandmarks: () => {
var s = {...scene}; scene.calls++;
var w = source.videoWidth || source.naturalWidth, h = source.videoHeight || source.naturalHeight;
var pts = Array.from({length:68},()=>({x:200,y:150}));
pts[36]={x:150,y:100}; pts[45]={x:250,y:100}; pts[19]={x:170,y:70}; pts[24]={x:230,y:70}; pts[8]={x:200,y:240};
pts[30]={x: s.pose==='right'?217.7:s.pose==='left'?182.3:200,y:140};
pts[33]={x:200,y: s.pose==='down'?100:s.pose==='up'?200:161.5};
var box={x:w*(.5+s.offX)-h*s.ratio/2,y:h*(.5+s.offY-s.ratio/2),width:h*s.ratio,height:h*s.ratio};
var count=s.pattern && scene.calls%3===0 ? 0 : s.count;
var result=Array.from({length:count},()=>({detection:{box},landmarks:{positions:pts}}));
return new Promise(resolve=>setTimeout(()=>resolve(result),s.delay||0));
} })
};`;
await context.route('**/vendor/faceapi/face-api.js', route => route.fulfill({ contentType: 'application/javascript', body: detector }));
const fixture = JSON.parse(fs.readFileSync('h5/test/fixtures/case1.json', 'utf8'));
await context.route('**/api/skin-analyze', async route => {
requests.push(route.request().postDataJSON());
await route.fulfill({ json: { ok: true, result: fixture.result || fixture, side: {} } });
});
async function chooseDefaults() {
for (const id of ['birth', 'gender', 'region']) {
await page.locator('#pick-' + id).click();
await page.waitForTimeout(450);
await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
await page.waitForTimeout(450); // 插件内部关闭动画锁
}
}
async function profile() {
await page.goto(BASE + '/');
await page.getByRole('button', { name: '开始检测', exact: true }).click();
await page.locator('#privacy-check').click();
await page.getByRole('button', { name: '继续', exact: true }).click();
}
async function start() {
await chooseDefaults();
await page.getByRole('button', { name: '提交', exact: true }).click();
await page.getByRole('button', { name: '开始', exact: true }).click();
await page.waitForFunction(() => document.querySelector('#cam-video').videoWidth > 0 && document.querySelector('#s-cam').classList.contains('cam-live'));
}
async function scene(values) { await page.evaluate(v => Object.assign(window.scene, v), values); }
async function hint(text) { await page.waitForFunction(t => document.querySelector('#cam-hint').textContent === t, text); }
async function step(n) { await page.waitForFunction(n => document.querySelector('#cam-step').textContent.startsWith(n + '/3'), n); }
async function shot(name) { await page.screenshot({ path: path.join(OUT, name + '.png') }); }
try {
page = await context.newPage();
page.on('pageerror', e => errors.push(e.message));
await page.goto(BASE + '/');
await shot('01-splash');
await profile();
assert.equal(await page.locator('.pick-value.placeholder').count(), 3);
await chooseDefaults();
const data = await page.evaluate(() => ({ birth: document.querySelector('#f-birth').value, age: +document.querySelector('#f-age').value, region: document.querySelector('#f-region').value }));
assert.equal(data.age, 30);
assert.equal(data.region, '北京市');
assert.match(await page.locator('#profile-age').innerText(), /30岁/);
assert.equal(await page.evaluate(() => document.body.getBoundingClientRect().top), 0);
assert.ok(await page.locator('.page-title').evaluate(el => el.getBoundingClientRect().top >= 0));
await shot('02-profile');
pass('出生年月自动计算年龄、控件占位和省市默认值');
await page.locator('#pick-birth').click();
await page.waitForTimeout(450);
for (let i = 0; i < 3; i++) {
const wheel = await page.locator('.ms-mobile-select.ms-show .ms-wheel').nth(1).boundingBox();
await page.mouse.move(wheel.x + wheel.width / 2, wheel.y + wheel.height - 22);
await page.mouse.down();
await page.mouse.move(wheel.x + wheel.width / 2, wheel.y + 22, { steps: 12 });
await page.mouse.up();
await page.waitForTimeout(200);
}
await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
await page.waitForTimeout(450);
const december = await page.evaluate(() => ({ birth: document.querySelector('#f-birth').value, age: +document.querySelector('#f-age').value, month: new Date().getMonth() + 1 }));
assert.ok(december.birth.endsWith('-12'));
assert.equal(december.age, december.month < 12 ? 29 : 30);
assert.ok(await page.locator('#pick-birth .pick-value').evaluate(el => el.scrollWidth <= el.clientWidth));
await shot('02-profile');
pass('滚动选择12月、未过生日月份减一岁,完整年月不被截断');
// 省份滚轮点击相邻可见项,市级选项必须跟随更新。
await page.locator('#pick-region').click();
await page.waitForTimeout(450);
await page.locator('.ms-mobile-select.ms-show .ms-select-container').first().getByText('河北省', { exact: true }).click();
await page.waitForTimeout(350);
assert.ok((await page.locator('.ms-mobile-select.ms-show .ms-select-container').nth(1).innerText()).includes('石家庄市'));
await shot('03-region');
await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
await page.waitForTimeout(450);
assert.match(await page.locator('#pick-region').innerText(), /河北省.*石家庄市/);
pass('省市联动滚轮实际选择');
await page.getByRole('button', { name: '提交', exact: true }).click();
await page.locator('#p-photoguide .popup').waitFor({ state: 'visible' });
await page.waitForTimeout(150);
await shot('04-guide');
assert.equal(await page.locator('#p-photoguide .profile-body').count(), 0);
await scene({ luma: 20 });
await page.getByRole('button', { name: '开始', exact: true }).click();
await hint('请到光线充足处');
assert.equal(await page.locator('.cam-shutter').isVisible(), false);
await scene({ luma: 250 }); await hint('光线过强,请避开强光');
await scene({ luma: 130, ratio: .2 }); await hint('请靠近一点');
await scene({ ratio: .8 }); await hint('请远一点');
await scene({ ratio: .45, offY: .45 }); await hint('请将脸部移入取景框');
await scene({ offY: 0, count: 2 }); await hint('请保持单人出镜');
await scene({ count: 1, pose: 'down' }); await hint('请略微抬头');
await scene({ pose: 'up' }); await hint('请略微低头');
await scene({ pose: 'front', pattern: true });
await page.waitForTimeout(2200);
assert.match(await page.locator('#cam-step').innerText(), /^1\/3/);
assert.equal(requests.length, 0);
pass('光线、距离、居中、多人、抬低头与丢帧均阻止误拍;正式页面无手动快门');
await scene({ pattern: false });
await step(2);
assert.equal(await page.locator('.cam-progress .done').count(), 1);
await scene({ pose: 'left' }); await hint('请向左转头');
// 持续错误侧脸超过旧版 30 帧自校准阈值,仍不允许交换左右。
const calls = await page.evaluate(() => scene.calls);
await page.waitForFunction(n => scene.calls > n + 32, calls);
assert.match(await page.locator('#cam-step').innerText(), /^2\/3/);
await shot('05-camera');
await scene({ pose: 'right' }); await step(3);
assert.equal(await page.locator('.cam-progress .done').count(), 2);
await scene({ pose: 'left' });
await page.locator('#s-report.active').waitFor();
assert.equal(requests.length, 1);
assert.ok(requests[0].front_image && requests[0].left_image && requests[0].right_image);
pass('条件合格自动抓拍,正→右→左严格顺序,三张齐备后只送检一次');
await page.goto(BASE + '/?mock=1&screen=report');
await page.waitForFunction(() => document.querySelector('#ditem-img1').naturalWidth > 0);
await shot('06-report');
await page.locator('.card-detail').first().scrollIntoViewIfNeeded();
await shot('07-report-detail');
await page.locator('#ditem-img1').click();
await page.locator('#p-zoom.show').waitFor();
await shot('08-zoom');
await page.locator('#zoom-img').click();
await page.waitForFunction(() => !history.state?.overlay);
assert.equal(await page.locator('#p-zoom').isVisible(), false);
await page.locator('#ditem-img1').press('Enter');
await page.keyboard.press('Escape');
await page.waitForFunction(() => !history.state?.overlay);
assert.equal(await page.locator('#p-zoom').isVisible(), false);
pass('图片点击/键盘放大,再点或 Escape 返回,历史栈正确');
for (const width of [320, 375, 430]) {
await page.setViewportSize({ width, height: 812 });
assert.ok(await page.evaluate(() => document.querySelector('#s-report').scrollWidth <= document.body.clientWidth));
assert.ok(await page.locator('.ditem-para').first().evaluate(el => parseFloat(getComputedStyle(el).fontSize) >= 14));
}
pass('320/375/430px 报告无横向溢出,正文不小于14px');
await page.setViewportSize({ width: 375, height: 812 });
await profile();
await scene({ modelFail: true });
await start();
await page.locator('.cam-retry:visible').waitFor();
assert.equal(await page.locator('.cam-shutter').isVisible(), false);
await shot('09-retry');
await scene({ modelFail: false, luma: 20 });
await page.locator('.cam-retry').click();
await hint('请到光线充足处');
assert.equal(await page.locator('.cam-retry').isVisible(), false);
pass('模型失败可重试,恢复后继续当前步且不会降级手动快门');
// 相册错误侧脸/光线不合格必须留在同一步;失败后仍可以再次选择。
await scene({ pose: 'left', luma: 130 });
const photo = path.resolve('reference/facepp-skinanalyze/examples/images/C3_180.jpg');
await page.locator('#album-file').setInputFiles(photo);
await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('请按顺序选择正脸'));
assert.match(await page.locator('#cam-step').innerText(), /^1\/3/);
await scene({ pose: 'front', luma: 20 });
await page.locator('#album-file').setInputFiles(photo);
await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('光线充足'));
assert.match(await page.locator('#cam-step').innerText(), /^1\/3/);
pass('相册照片同样校验视角和光线,不合格不推进');
await page.close();
page = await context.newPage();
page.on('pageerror', e => errors.push(e.message));
await page.addInitScript(() => {
const original = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
let first = true;
navigator.mediaDevices.getUserMedia = options => {
if (first) { first = false; return Promise.reject(new DOMException('Denied', 'NotAllowedError')); }
return original(options);
};
});
await profile();
await chooseDefaults();
await scene({ luma: 20 });
await page.getByRole('button', { name: '提交', exact: true }).click();
await page.getByRole('button', { name: '开始', exact: true }).click();
await page.locator('.cam-retry:visible').waitFor();
assert.equal(await page.locator('.cam-shutter').isVisible(), false);
await page.locator('.cam-retry').click();
await hint('请到光线充足处');
pass('相机拒绝授权后重试恢复,仍停留正脸步骤');
const native = await browser.newPage(); // 新 context 使用仓库实际 face-api 和模型
await native.goto(BASE + '/?mock=1&screen=report');
await native.locator('#ditem-img1').scrollIntoViewIfNeeded(); // lazy 图片须进入视口才会加载
const faceCount = await native.evaluate(async () => {
await Promise.all([faceapi.nets.tinyFaceDetector.loadFromUri('vendor/faceapi/models'), faceapi.nets.faceLandmark68Net.loadFromUri('vendor/faceapi/models')]);
const img = document.querySelector('#ditem-img1');
await img.decode();
const result = await faceapi.detectAllFaces(img, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .3 })).withFaceLandmarks();
return result.filter(d => d.landmarks.positions.length === 68).length;
});
assert.ok(faceCount > 0);
await native.close();
pass('实际本地模型可加载并返回人脸与68个特征点');
assert.deepEqual(errors, []);
pass('页面无脚本异常');
} catch (e) {
console.error(e);
if (page) await shot('FAIL');
process.exitCode = 1;
} finally {
await browser.close();
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017-present, LIHONG OU(onlyhom)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+6
View File
@@ -0,0 +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`。
档案页的出生年月 / 性别 / 地区滚轮由它渲染,样式覆盖写在 `css/style.css` 的 mobile-select 段。
本地补丁:点击滚轮行时,使用当前滚轮底部位置与实际 `optionHeight` 计算选中项,替代原版的 `window.innerHeight` 和固定 `40px`;保证 44px 行高、底部安全区和页面滚动时点击项与显示项一致。拖动与联动逻辑保持原版。
+164
View File
@@ -0,0 +1,164 @@
/*
* mobile-select v1.4.0
* Homepage: https://github.com/onlyhom/mobile-select
* Released under the MIT License.
* (c) 2017-present
*/
.ms-mobile-select {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
opacity: 0;
visibility: hidden;
-webkit-transition: opacity 0.4s, z-index 0.4s;
transition: opacity 0.4s, z-index 0.4s;
transform: translate(0, 0);
}
.ms-mobile-select * {
margin: 0;
padding: 0;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.ms-mobile-select .ms-gray-layer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 888;
display: block;
}
.ms-mobile-select .ms-content {
width: 100%;
display: block;
position: fixed;
z-index: 889;
color: black;
-webkit-transition: all 0.4s;
transition: all 0.4s;
bottom: -350px;
left: 0;
background: white;
}
.ms-mobile-select .ms-fix-width {
width: 90%;
margin: 0 auto;
position: relative;
}
.ms-mobile-select .ms-fix-width:after {
content: ".";
display: block;
height: 0;
clear: both;
visibility: hidden;
}
.ms-mobile-select .ms-btn-bar {
border-bottom: 1px solid #dcdcdc;
font-size: 15px;
height: 45px;
position: relative;
text-align: center;
line-height: 45px;
}
.ms-mobile-select .ms-cancel,
.ms-mobile-select .ms-ensure {
height: 45px;
width: 55px;
cursor: pointer;
position: absolute;
top: 0;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
.ms-mobile-select .ms-cancel {
left: 0;
color: #666;
}
.ms-mobile-select .ms-ensure {
right: 0;
color: #1e83d3;
}
.ms-mobile-select .ms-title {
font-size: 15px;
padding: 0 15%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ms-mobile-select .ms-panel:after {
content: ".";
display: block;
height: 0;
clear: both;
visibility: hidden;
}
.ms-mobile-select .ms-wheels {
width: 100%;
height: 200px;
overflow: hidden;
}
.ms-mobile-select .ms-wheel {
position: relative;
z-index: 0;
float: left;
width: 50%;
height: 200px;
overflow: hidden;
-webkit-transition: width 0.3s ease;
transition: width 0.3s ease;
}
.ms-mobile-select .ms-select-container {
display: block;
text-align: center;
}
.ms-mobile-select .ms-select-container li {
font-size: 15px;
display: block;
height: 40px;
line-height: 40px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
.ms-mobile-select .ms-select-line {
height: 40px;
width: 100%;
position: absolute;
top: 80px;
pointer-events: none;
-webkit-box-sizing: border-box;
box-sizing: border-box;
border-top: 1px solid #dcdcdc;
border-bottom: 1px solid #dcdcdc;
}
.ms-mobile-select .ms-shadow-mask {
position: absolute;
top: 0;
width: 100%;
height: 200px;
background: linear-gradient(to bottom, #fffffff5, #ffffffcc 25%, #ffffff00 49%, #ffffff00 51%, #ffffffcc 75%, #fffffff5);
opacity: 0.9;
pointer-events: none;
}
.ms-mobile-select.ms-show {
opacity: 1;
z-index: 10000;
visibility: visible;
}
.ms-mobile-select.ms-show .ms-content {
bottom: 0;
}
.ms-default-trigger {
min-height: 1em;
color: #888;
}
/** Prevent Page Scrolling When a Modal is Open */
body.ms-show {
overflow-y: hidden;
}
File diff suppressed because one or more lines are too long
@@ -1,16 +0,0 @@
# ~/.openclaw/credentials.env — 统一凭据存放(勿提交 git、勿发到聊天)
# 用法: set -a; source ~/.openclaw/credentials.env; set +a
# ---- 旷视 Face++(巨子测肤 skinanalyze_pro 测试接口)----
# 试用key(cAa5...)已弃用: 旷视规则试用key不能调专业版。当前为正式key(EcZ...)
FACEPP_API_KEY=EcZiqYhA8eQSiRgXVem8mijgzPfSC2qp
FACEPP_API_SECRET=p62QRWv9tQitZ1tvXmEBV8AgmaS00lO8
# ---- FaceStyle 专业版调用参数 ----
FACEPP_SKINANALYZE_URL=https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_pro
# ---- 飞书 / OCR(合同盖章核验项目)----
# 实际值在 ~/Desktop/合同盖章核验项目/service/.env(未入库)
# ---- 智谱 / 火山引擎 ----
# 已存于 ~/.zshrc 及 openclaw.json providers 配置