Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a7593b736 | ||
|
|
658b232a39 | ||
|
|
f7902b86c8 | ||
|
|
55f26d91af | ||
|
|
499be22771 | ||
|
|
c06d15a2d4 | ||
|
|
3de730a127 | ||
|
|
3d32434d6f | ||
|
|
67b6d86fce | ||
|
|
ecb4bb2515 | ||
|
|
b6ba6b65c5 |
@@ -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.*
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
kpx234XimQGtFPo6
|
||||
@@ -0,0 +1,58 @@
|
||||
# 1meAI测肤旷视UI问题反馈
|
||||
|
||||
## UI交互界面调整
|
||||
|
||||
##### 交互
|
||||
|
||||
1. 年龄调整为滚轮,消费者选择出生年份月份,系统自动计算年龄(下拉设计)。
|
||||
|
||||

|
||||
|
||||
1. 地区做成滚动形式,省份城市形式(下拉设计)。 找现存的插件,提供选择省份和城市。
|
||||
|
||||

|
||||
|
||||
1. 拍摄方式修改为**动态捕捉方式**,再加入 远一点,近一点 请向左转头,请略微抬头等的动态提示。同时,如果消费者的角度光线等必要条件合适,立刻拍摄,不再倒计时拍摄, 不要手动拍摄的按钮。
|
||||
|
||||

|
||||
|
||||
1. 拍摄的时候画面缩小 15%, 使得摄像头离面部近一点也能正常拍摄。 (当前拍摄,需要手机离面部很远才符合要求)
|
||||
2. 正脸,两个侧脸的拍摄,必须安装顺序完成,拍摄完成后才能拍摄下一张。
|
||||
|
||||

|
||||
|
||||
##### **设计**
|
||||
|
||||
具体设计问题:
|
||||
|
||||
1 主页面 参考下面图片, 左边的是当前的状态,右边的是要达到效果的标准,
|
||||
|
||||
- Life in Full 为更好的生命表现 字号,位置,粗细 要和图片一致。
|
||||
- 开始检测的按钮不要圆角,要长方形带一点倒角。
|
||||
|
||||

|
||||
|
||||
2 设置的页面
|
||||
|
||||
- 不再要返回的按钮
|
||||
- “您的年龄“, “您的性别”, “您所在的地区”,需要居中,不是左对齐
|
||||
- 选择年龄,性别,和地区的选择控件风格要一致
|
||||
- 提交按钮 同样 要长方形带一点倒角
|
||||
|
||||

|
||||
|
||||
拍照页面,参考图片右边的标准
|
||||
|
||||
- 弹出“请拍摄正面及左右侧面照片” 这个对话框的时候, 对话框大小需要和右边的标准一致,背景要把选择年龄,性别,地区等UI元素去掉。 “开始” 按钮要长方形带一点倒角
|
||||
|
||||

|
||||
|
||||
报告页面
|
||||
|
||||
- 去掉返回按钮和 "1'me 在线肌肤测试" 的文字
|
||||
- “您的肌肤状态表现”,这个部分字体有点小,需要整体优化一下
|
||||
- 再往下的图片和说明部分,也是字体有点小,同样需要整体优化一下
|
||||
- 点击图片需要放大,再次点击返回
|
||||
|
||||

|
||||
|
||||
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 527 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 622 KiB |
|
After Width: | Height: | Size: 723 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -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"
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
.
|
||||
@@ -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._
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
@@ -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 路径时使用全局安装。
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>');
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
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();
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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 内置样本
|
||||
|
||||
@@ -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 + ' 项自测失败');
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/* 省份 → 城市(地级)数据。来源:china-division@2.7.0 dist/pc.json(modood/Administrative-divisions-of-China,WTFPL);
|
||||
直辖市第二列只保留本市,另补港澳台与海外。 */
|
||||
window.REGION_DATA = [
|
||||
["北京市", ["北京市"]],
|
||||
["天津市", ["天津市"]],
|
||||
["河北省", ["石家庄市","唐山市","秦皇岛市","邯郸市","邢台市","保定市","张家口市","承德市","沧州市","廊坊市","衡水市"]],
|
||||
["山西省", ["太原市","大同市","阳泉市","长治市","晋城市","朔州市","晋中市","运城市","忻州市","临汾市","吕梁市"]],
|
||||
["内蒙古自治区", ["呼和浩特市","包头市","乌海市","赤峰市","通辽市","鄂尔多斯市","呼伦贝尔市","巴彦淖尔市","乌兰察布市","兴安盟","锡林郭勒盟","阿拉善盟"]],
|
||||
["辽宁省", ["沈阳市","大连市","鞍山市","抚顺市","本溪市","丹东市","锦州市","营口市","阜新市","辽阳市","盘锦市","铁岭市","朝阳市","葫芦岛市"]],
|
||||
["吉林省", ["长春市","吉林市","四平市","辽源市","通化市","白山市","松原市","白城市","延边朝鲜族自治州"]],
|
||||
["黑龙江省", ["哈尔滨市","齐齐哈尔市","鸡西市","鹤岗市","双鸭山市","大庆市","伊春市","佳木斯市","七台河市","牡丹江市","黑河市","绥化市","大兴安岭地区"]],
|
||||
["上海市", ["上海市"]],
|
||||
["江苏省", ["南京市","无锡市","徐州市","常州市","苏州市","南通市","连云港市","淮安市","盐城市","扬州市","镇江市","泰州市","宿迁市"]],
|
||||
["浙江省", ["杭州市","宁波市","温州市","嘉兴市","湖州市","绍兴市","金华市","衢州市","舟山市","台州市","丽水市"]],
|
||||
["安徽省", ["合肥市","芜湖市","蚌埠市","淮南市","马鞍山市","淮北市","铜陵市","安庆市","黄山市","滁州市","阜阳市","宿州市","六安市","亳州市","池州市","宣城市"]],
|
||||
["福建省", ["福州市","厦门市","莆田市","三明市","泉州市","漳州市","南平市","龙岩市","宁德市"]],
|
||||
["江西省", ["南昌市","景德镇市","萍乡市","九江市","新余市","鹰潭市","赣州市","吉安市","宜春市","抚州市","上饶市"]],
|
||||
["山东省", ["济南市","青岛市","淄博市","枣庄市","东营市","烟台市","潍坊市","济宁市","泰安市","威海市","日照市","临沂市","德州市","聊城市","滨州市","菏泽市"]],
|
||||
["河南省", ["郑州市","开封市","洛阳市","平顶山市","安阳市","鹤壁市","新乡市","焦作市","濮阳市","许昌市","漯河市","三门峡市","南阳市","商丘市","信阳市","周口市","驻马店市","济源市"]],
|
||||
["湖北省", ["武汉市","黄石市","十堰市","宜昌市","襄阳市","鄂州市","荆门市","孝感市","荆州市","黄冈市","咸宁市","随州市","恩施土家族苗族自治州","仙桃市","潜江市","天门市","神农架林区"]],
|
||||
["湖南省", ["长沙市","株洲市","湘潭市","衡阳市","邵阳市","岳阳市","常德市","张家界市","益阳市","郴州市","永州市","怀化市","娄底市","湘西土家族苗族自治州"]],
|
||||
["广东省", ["广州市","韶关市","深圳市","珠海市","汕头市","佛山市","江门市","湛江市","茂名市","肇庆市","惠州市","梅州市","汕尾市","河源市","阳江市","清远市","东莞市","中山市","潮州市","揭阳市","云浮市"]],
|
||||
["广西壮族自治区", ["南宁市","柳州市","桂林市","梧州市","北海市","防城港市","钦州市","贵港市","玉林市","百色市","贺州市","河池市","来宾市","崇左市"]],
|
||||
["海南省", ["海口市","三亚市","三沙市","儋州市","五指山市","琼海市","文昌市","万宁市","东方市","定安县","屯昌县","澄迈县","临高县","白沙黎族自治县","昌江黎族自治县","乐东黎族自治县","陵水黎族自治县","保亭黎族苗族自治县","琼中黎族苗族自治县"]],
|
||||
["重庆市", ["重庆市"]],
|
||||
["四川省", ["成都市","自贡市","攀枝花市","泸州市","德阳市","绵阳市","广元市","遂宁市","内江市","乐山市","南充市","眉山市","宜宾市","广安市","达州市","雅安市","巴中市","资阳市","阿坝藏族羌族自治州","甘孜藏族自治州","凉山彝族自治州"]],
|
||||
["贵州省", ["贵阳市","六盘水市","遵义市","安顺市","毕节市","铜仁市","黔西南布依族苗族自治州","黔东南苗族侗族自治州","黔南布依族苗族自治州"]],
|
||||
["云南省", ["昆明市","曲靖市","玉溪市","保山市","昭通市","丽江市","普洱市","临沧市","楚雄彝族自治州","红河哈尼族彝族自治州","文山壮族苗族自治州","西双版纳傣族自治州","大理白族自治州","德宏傣族景颇族自治州","怒江傈僳族自治州","迪庆藏族自治州"]],
|
||||
["西藏自治区", ["拉萨市","日喀则市","昌都市","林芝市","山南市","那曲市","阿里地区"]],
|
||||
["陕西省", ["西安市","铜川市","宝鸡市","咸阳市","渭南市","延安市","汉中市","榆林市","安康市","商洛市"]],
|
||||
["甘肃省", ["兰州市","嘉峪关市","金昌市","白银市","天水市","武威市","张掖市","平凉市","酒泉市","庆阳市","定西市","陇南市","临夏回族自治州","甘南藏族自治州"]],
|
||||
["青海省", ["西宁市","海东市","海北藏族自治州","黄南藏族自治州","海南藏族自治州","果洛藏族自治州","玉树藏族自治州","海西蒙古族藏族自治州"]],
|
||||
["宁夏回族自治区", ["银川市","石嘴山市","吴忠市","固原市","中卫市"]],
|
||||
["新疆维吾尔自治区", ["乌鲁木齐市","克拉玛依市","吐鲁番市","哈密市","昌吉回族自治州","博尔塔拉蒙古自治州","巴音郭楞蒙古自治州","阿克苏地区","克孜勒苏柯尔克孜自治州","喀什地区","和田地区","伊犁哈萨克自治州","塔城地区","阿勒泰地区","石河子市","阿拉尔市","图木舒克市","五家渠市","北屯市","铁门关市","双河市","可克达拉市","昆玉市","胡杨河市","新星市","白杨市"]],
|
||||
["香港特别行政区", ["香港特别行政区"]],
|
||||
["澳门特别行政区", ["澳门特别行政区"]],
|
||||
["台湾省", ["台湾省"]],
|
||||
["海外", ["海外"]]
|
||||
];
|
||||
@@ -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
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
@@ -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}"
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 行高、底部安全区和页面滚动时点击项与显示项一致。拖动与联动逻辑保持原版。
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 配置
|
||||