feat: 完善淘宝小程序拍照、报告与登录流程

接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
This commit is contained in:
xsl
2026-11-04 15:07:51 +08:00
parent 381da60ae4
commit 774376fa16
147 changed files with 17420 additions and 597 deletions
@@ -0,0 +1 @@
https://1me.xajuzi.com/media/videos/261026-shuangcang5-mobile-v2.html
@@ -0,0 +1 @@
https://1me.xajuzi.com/media/videos/261026-shuangcang5-46ae3089.html
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
实际淘宝 IDE 模拟器运行截图(非真机截图)。login-centered.png 从完整 IDE 窗口截图中裁取模拟器屏幕,未修改页面内容。
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

@@ -0,0 +1,11 @@
# 报告侧脸大图对照
- mini-contour.png:当前小程序轮廓大图的浏览器投影。
- mini-wrinkles.png:当前小程序皱纹大图的浏览器投影。
- h5-contour.png:同一项目示例照片经 H5 reportZoomSource 实际函数生成的大图。
这些是使用项目示例照片及实际页面代码生成的浏览器预览,并非淘宝真机截图,也不能作为提审运行截图。原生图片组件最终显示效果仍需手机验证。
当前实现将 H5 裁切规则通过报告专用 canvas 生成独立位图,放大层只对位图 aspectFit,不再使用原生图片组件模拟裁切。预览脚本执行实际 report-side-zoom 模块的绘制命令。导出失败时等比显示完整原照片。
最新调整:按用户要求,mini-contour.png 和 mini-wrinkles.png 图片填充宽高均缩小至前一版的 80%,居中后向下移动缩放后图片高度的 5%;h5-contour.png 保留 H5 原规则作为历史对照,因此不再代表两者应当完全相同。
@@ -0,0 +1,5 @@
同一张项目示例照片的浏览器对照预览,不是淘宝真机运行截图,不用于冒充提审截图。
mini-contour.png:真实小程序报告放大图 AXML/ACSS 与控制器数据的浏览器投影。
mini-wrinkles.png:皱纹视图同一放大规则的浏览器投影(示例数据)。
h5-contour.png:使用线上 H5 reportZoomSource 原始函数及本地对应 CSS 生成的对照。
修复:不再在尺寸获取失败时按 3:4 拉伸;复用已读取的自然尺寸,未知尺寸使用 aspectFit,图片 onLoad 后补齐真实几何。
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

@@ -0,0 +1,4 @@
2 张淘宝 IDE 模拟器实际运行截图,未使用 H5 投影、Mock 报告或重绘界面。仅裁去 IDE 外框,未修改界面内容。
01:登录页。02:未勾选协议点击游客体验后,真实显示“请先阅读并同意用户协议和隐私政策”。
格式 PNG,单张 239×423,均小于 4MB,满足用户提供的最大尺寸 2160×3840。
后续主页与档案运行截图需要先手动完成模拟器中的淘宝登录验证。
Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+21
View File
@@ -3,8 +3,29 @@
这份目录存放**不在 git 里自动生效**的服务器侧配置:systemd unit、nginx 片段、安装脚本。
换机器/换人接手时,按本文重建即可。
## 淘宝拍照语音静态资源
`taobao-voice-media.py` 用于修复 `/state/assets/voice/` 的媒体响应。当前已部署:清单内 14 个公开 MP3 和 manifest.json 从 `/home/ubuntu/juzi/h5/assets/voice/` 复制到 `/var/www/juzi-voice-20261102/`,Nginx 在原 HTTPS server 增加该路径的 alias,返回 `audio/mpeg` 并支持 Range/206。此步骤只 reload Nginx,不重启三个业务应用;原 `/state/` 页面、后台认证、数据目录禁用及 `/tb/` 白名单保留。
在服务器使用 `sudo python3 /tmp/juzi-voice-media-20261102.py` 执行上传后的脚本;脚本验证清单和路径、拒绝源/目标符号链接、备份配置并执行 `nginx -t`,验证或 reload 失败会回滚。原站点配置备份为 `/etc/nginx/sites-available/juzi.voice-20261102.bak`。若日后更新语音文件,重新执行脚本刷新公开副本。14 条线上音频完整内容已与本地文件逐一比对一致。
客户端声音状态见 `[voice] VOICE-20261102-02` 真机 Console 日志;事件包括 loading/canplay/play_requested/playing/error。日志不包含原生错误对象、账号、照片或完整 URL。
## 拓扑
### 公开视频(2026-11-02)
用户指定的 `261026改双仓5.mp4` 已上传,源文件 SHA256 为 `46ae3089e4da884f03313b71809814813b88f1abc92d374dc8210f01db10b026`。视频为 2160×3840 H.264/AAC,约 32 秒;仅用 FFmpeg `-c copy -movflags +faststart` 调整封装,不重编码。
- 服务器文件:`/var/www/juzi-media/videos/261026-shuangcang5-46ae3089.mp4`,SHA256 `64790107cce2f78e68668474fb06d2e11acf3d76d9e05b7ccec4f588e89eb02e`。
- 播放页:`https://1me.xajuzi.com/media/videos/261026-shuangcang5-46ae3089.html`。
- 二维码:项目内 `deliverables/261026-shuangcang5/video-qr.png`,已解码确认指向上述播放页。
- 发布脚本:`deploy/publish-video.py`;Nginx 为 `/media/videos/` 单独 alias,禁止目录索引及写入方法,支持 MP4 MIME、Range/206。
- 本次配置备份:`/etc/nginx/sites-available/juzi.public-video-20261102T113656914226Z.bak`。仅 reload Nginx,三个业务服务未重启。
- 验证:上传文件哈希一致、公网 HTML 200、MP4 206/32 字节范围请求正常;移动视口 Chromium 已实际开始播放,视频无解码错误。手机淘宝/微信内的具体播放体验仍以扫码为准。
微信反馈(能开页、播放卡住)后,另发布 `261026-shuangcang5-mobile-v2.mp4/.html`:720×1280、H.264 Main Level 3.1、AAC、faststart,5,329,575 字节,SHA256 `9f34e5a568230e87fb206f101467db9eb22d31c5951cedb26d02eacad709e33c`。保留原 4K 文件;旧 HTML 也改为播放兼容版,备份在 `/tmp/juzi-public-video-20261102-46ae3089/legacy-player-before-v2.html`。新二维码 `deliverables/261026-shuangcang5/video-qr-mobile-v2.png` 指向新的 HTML 路径以避开旧页面缓存。新增用户点击播放按钮及内联属性、等待/失败提示和直接视频链接;移动视口浏览器通过真实按钮点击已开始播放 720×1280 视频,微信真机仍待用户扫码验证。
```
淘宝小程序(taobao/,AppKey 35486228)
→ 淘宝云网关(注入 app_key / open_id / sign,单请求 10s / 128KB 上限)
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Publish an explicitly selected public video and player; reload nginx only."""
import argparse
import hashlib
from pathlib import Path
import re
import shutil
import subprocess
from datetime import datetime, timezone
PUBLIC = Path('/var/www/juzi-media/videos')
CONFIG = Path('/etc/nginx/sites-available/juzi')
MARKER = '# BEGIN juzi-public-videos'
BLOCK = ''' # BEGIN juzi-public-videos
location ^~ /media/videos/ {
alias /var/www/juzi-media/videos/;
autoindex off;
types { video/mp4 mp4; text/html html; }
default_type application/octet-stream;
charset utf-8;
add_header X-Content-Type-Options nosniff;
add_header Cache-Control "public, max-age=3600";
limit_except GET HEAD { deny all; }
}
# END juzi-public-videos
'''
def checked(args):
subprocess.run(args, check=True, capture_output=True, text=True)
def digest(path):
result = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
result.update(chunk)
return result.hexdigest()
def main():
parser = argparse.ArgumentParser()
parser.add_argument('staging')
parser.add_argument('stem')
parser.add_argument('sha256')
args = parser.parse_args()
assert re.fullmatch(r'[a-z0-9-]{1,80}', args.stem)
assert re.fullmatch(r'[a-f0-9]{64}', args.sha256)
staging = Path(args.staging)
assert staging.resolve() == staging and staging.parent == Path('/tmp')
assert staging.name.startswith('juzi-public-video-') and not staging.is_symlink()
sources = [staging / (args.stem + suffix) for suffix in ('.mp4', '.html')]
assert all(p.is_file() and not p.is_symlink() for p in sources)
assert digest(sources[0]) == args.sha256, 'uploaded video checksum mismatch'
assert not CONFIG.is_symlink()
original = CONFIG.read_text()
if MARKER not in original:
assert 'location ^~ /media/videos/' not in original
assert original.count(' location /state/ {') == 1
for directory in [PUBLIC.parent, PUBLIC]:
assert not directory.is_symlink()
directory.mkdir(mode=0o755, exist_ok=True)
assert directory.resolve() == directory
for source in sources:
target = PUBLIC / source.name
assert not target.is_symlink()
if target.exists():
assert digest(target) == digest(source), 'refusing to overwrite a different published file'
else:
shutil.copyfile(source, target)
target.chmod(0o644)
if MARKER not in original:
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ')
backup = CONFIG.with_name('juzi.public-video-' + stamp + '.bak')
assert not backup.exists()
shutil.copy2(CONFIG, backup)
CONFIG.write_text(original.replace(' location /state/ {', BLOCK + ' location /state/ {', 1))
try:
checked(['nginx', '-t'])
checked(['systemctl', 'reload', 'nginx'])
except Exception:
shutil.copy2(backup, CONFIG)
checked(['nginx', '-t'])
checked(['systemctl', 'reload', 'nginx'])
raise
print('nginx backup: ' + str(backup))
else:
assert BLOCK in original, 'existing video location differs; inspect before changing'
checked(['nginx', '-t'])
print('video sha256: ' + digest(PUBLIC / sources[0].name))
print('player: https://1me.xajuzi.com/media/videos/' + args.stem + '.html')
if __name__ == '__main__':
main()
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=1me taobao mini program backend (Node, /tb/ debug instance)
Description=1'me taobao mini program backend (Node, /tb/ debug instance)
After=network.target
[Service]
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Serve public voice assets with MP3 MIME/range support; keep app services running.
Run on the existing Ubuntu host using sudo. Only manifest-listed public files
are copied. Nginx config is backed up and rolled back if validation/reload fails.
"""
import json
from pathlib import Path
import re
import shutil
import subprocess
SOURCE = Path('/home/ubuntu/juzi/h5/assets/voice')
PUBLIC = Path('/var/www/juzi-voice-20261102')
CONFIG = Path('/etc/nginx/sites-available/juzi')
BACKUP = CONFIG.with_name('juzi.voice-20261102.bak')
MARKER = '# BEGIN juzi-voice-media 20261102'
BLOCK = ''' # BEGIN juzi-voice-media 20261102
location ^~ /state/assets/voice/ {
alias /var/www/juzi-voice-20261102/;
types { audio/mpeg mp3; application/json json; }
default_type application/octet-stream;
add_header Cache-Control "public, max-age=3600";
limit_except GET HEAD { deny all; }
}
# END juzi-voice-media 20261102
'''
def checked(command):
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode:
raise RuntimeError('command failed: ' + ' '.join(command) + '\n' + result.stderr)
def main():
manifest = json.loads((SOURCE / 'manifest.json').read_text())
names = manifest['files']
assert names and all(isinstance(name, str) and re.fullmatch(r'[a-z0-9-]+', name) for name in names)
files = [SOURCE / (name + '.mp3') for name in names] + [SOURCE / 'manifest.json']
assert all(file.is_file() and not file.is_symlink() and file.resolve().parent == SOURCE.resolve() for file in files)
original = CONFIG.read_text()
if MARKER not in original:
assert not BACKUP.exists(), 'existing backup must be preserved'
assert original.count(' location /state/ {') == 1
PUBLIC.mkdir(mode=0o755, parents=True, exist_ok=True)
assert PUBLIC.resolve() == Path('/var/www/juzi-voice-20261102')
for file in files:
target = PUBLIC / file.name
assert not target.is_symlink()
shutil.copyfile(file, target)
target.chmod(0o644)
if MARKER not in original:
shutil.copy2(CONFIG, BACKUP)
CONFIG.write_text(original.replace(' location /state/ {', BLOCK + ' location /state/ {', 1))
try:
checked(['nginx', '-t'])
checked(['systemctl', 'reload', 'nginx'])
except Exception:
shutil.copy2(BACKUP, CONFIG)
checked(['nginx', '-t'])
checked(['systemctl', 'reload', 'nginx'])
raise
else:
checked(['nginx', '-t'])
print(json.dumps({'publicFiles': len(files), 'nginxConfigValid': True,
'backup': str(BACKUP), 'businessServicesRestarted': False}))
if __name__ == '__main__':
main()
+3 -3
View File
@@ -50,8 +50,8 @@ SAE `nnn` 上跑的是 `0.0.2` JAR,SAE 环境变量已清空,JAR 内置 `ver
| 资源 | 值 |
|---|---|
| 开放平台应用(当前正确) | `1me 肌肤状态检测`,AppKey **35486228** |
| 旧应用(不再使用) | `1me肌肤检测`,AppKey 35479221;旧 Secret 不要再用 |
| 开放平台应用(当前正确) | `1'me 肌肤状态检测`,AppKey **35486228** |
| 旧应用(不再使用) | `1'me肌肤检测`,AppKey 35479221;旧 Secret 不要再用 |
| 聚石塔云应用 | `1me-net`,ID **62829**,默认应用;生产/测试 SLB 都是 `192.168.65.185:80` |
| 聚石塔云应用(未用) | `app-new`,ID 62861;没接 SAE/CLB,别用 |
| SAE 应用 | `nnn`,ID `7c9f2d2a-95d0-4879-afad-323d30f85d30` |
@@ -148,7 +148,7 @@ Set-ExecutionPolicy -Scope Process Bypass
### 5.1 IDE 真机预览(第一优先级)
1. 确认 IDE 当前小程序是 **35486228**(`1me 肌肤状态检测`),不是 35479221。
1. 确认 IDE 当前小程序是 **35486228**(`1'me 肌肤状态检测`),不是 35479221。
2. 重新编译。
3. 点顶部「预览」,手机淘宝扫码(**不要用模拟器**;`cloud.application.httpRequest` 模拟器不支持)。
4. 启动页右上角点「SAE MVP 连通性」。
+3 -3
View File
@@ -188,7 +188,7 @@ curl.exe -X POST -H "Content-Type: application/json" -d "{}" http://127.0.0.1:80
### 4.3 小程序
1. 淘宝 IDE 导入仓库里的 `taobao/`(`mini.project.json` 的 `appid` 留空,以 IDE 账号为准)
2. **确认 IDE 当前小程序是 35486228(`1me 肌肤状态检测`)**
2. **确认 IDE 当前小程序是 35486228(`1'me 肌肤状态检测`)**
3. `config.js` 保持 `transport: 'cloud'` / `cloudType: 'tower'` / `cloudEnv: 'test'`
4. 顶部「预览」→ **手机淘宝扫码**(模拟器不支持 `cloud.application.httpRequest`)
5. 启动页右上角有三个**开发版入口**(`environment !== 'production'` 时才显示):
@@ -205,8 +205,8 @@ curl.exe -X POST -H "Content-Type: application/json" -d "{}" http://127.0.0.1:80
| 资源 | 值 |
|---|---|
| 开放平台应用 | `1me 肌肤状态检测`,AppKey **35486228** |
| 旧应用(废弃) | `1me肌肤检测` 35479221 —— **不要再用它的 Secret** |
| 开放平台应用 | `1'me 肌肤状态检测`,AppKey **35486228** |
| 旧应用(废弃) | `1'me肌肤检测` 35479221 —— **不要再用它的 Secret** |
| 聚石塔云应用 | `1me-net`,ID **62829**,默认应用 |
| 私网 CLB | `lb-k2j324dwux1svaf2akwqx` / `192.168.65.185:80` |
| SAE 应用 | `nnn`,`cn-zhangjiakou:my-namespace` |
+447
View File
@@ -0,0 +1,447 @@
## 2026-11-04 未登录首页重复标题的动态清理
用户反馈未登录登录页出现两个标题。此前仅 JSON defaultTitle=零宽空格,尚未在生命周期调用 setNavigationBar,不能证明首页平台标题已在真机隐藏。本轮在公共页眉包装器的 onLoad/onShow/onReady 调用 my.setNavigationBar({title:零宽空格}),不修改导航背景及返回/胶囊;保留原页面生命周期、Promise 返回值与业务登录流程。回调用独立序号及可见性隔离,离页及旧回调不能更改现页标题状态。
登录页额外启用 nativeTitleFallback:若平台明确拒绝清理(fail、抛错或 Promise reject),隐藏自定义标题文字,保留平台原生标题,避免叠加;该降级情况下不能承诺原生标题居中。非登录页沿用现有居中标题。保留背景品牌图片和登录按钮。新增页面进入/返回时的原生清理、旧回调隔离、拒绝/异常不阻断业务测试,相关回归通过。真机是否实际接受标题清理仍待新预览验证,未将模拟器结果等同真机。
## 2026-11-04 全部业务页白色页眉标题水平居中
用户要求所有页面顶部标题左右居中。官方 app.json window 与 setNavigationBar 文档未提供标题对齐参数,未使用猜测的 titleAlign 等字段。改为保留原生透明导航及其返回/分享/关闭控件,将原生 defaultTitle 设置为零宽空格,使用公共 components/brand-header.axml + app.acss 显示 1'me肌肤状态检测;标题容器左右对称预留 76px,按全屏中心对齐,白底黑字。
utils/page-header.js 包装业务页注册,保留并调用原生命周期,onLoad/onResize 根据 statusBarHeight(或 safeArea.top)及 titleBarHeight 计算页眉安全区。六页正文/取景根节点避让页眉;档案表单高度收缩使用扣除页眉后的高度,相机初始蒙版尺寸同样扣除页眉,最终仍由已挂载 .cam 真实尺寸校正。分享文字和页眉标题共用同一常量,避免原生空标题影响分享。
完整回归及共享 H5 校验通过,新增实际模板/样式的 320/375/414 三屏宽、20/59px 状态栏检查,覆盖全屏中心、安全区及生命周期保留。淘宝 IDE 编译完成,模拟器实际登录页确认白色页眉标题居中、系统时间和原生右侧胶囊保留且不重叠。截图 deliverables/centered-header-20261104/login-centered.png 为真实 IDE 模拟器运行截图,明确非真机;手机刘海/动态岛安全区效果仍需实测。
官方参考:https://developer.alibaba.com/docs/doc.htm?articleId=117457&docType=1&treeId=785
## 2026-11-04 分享卡片大标题显式统一
用户确认不带撇号出现在分享卡片大标题。原 app.js 全局 onShareAppMessage 已返回 1'me肌肤状态检测,不能将问题简单归因为源码漏字或断言全局钩子不受支持。官方文档 articleId=118909 明确支持 Page 分享及 App 全局回退。为明确各页输出,六个业务页面均新增页面级 onShareAppMessage,共用 utils/share.js,title 和 desc 均为 1'me肌肤状态检测,path 固定 pages/login/login,不携带报告/照片/档案/会话信息;App 全局钩子也共用此函数。
138 项前端、会话商品、页面交互、拍照测试通过,包括逐页检查分享配置与登录页标题一致、分享对象无私人参数及每次返回独立对象。此次修改不涉及开放平台登记的小程序名称。真机分享卡片最终标题仍需从新预览版本重新分享验证,不能根据代码测试直接声称客户端已修好。
## 2026-11-04 倒计时锁定,不再因识别变化取消
用户反馈稍动仍取消倒计时,要求移除这项判断。此前 countdownReady 仍检查单脸、源帧边界、分数与新鲜度,本轮移除倒计时阶段的全部姿态/位置/清晰度/人脸数复查。正式 native-face 启动倒计时前保持原检测和稳定门槛;开始后暂停新推理,在途结果不能改变倒计时,到零补光结束直接 takePhoto,不再进入 nativeCaptureWait。离页、切换镜头、身份状态变化、相机停止/故障仍通过会话与序号取消拍摄。移除已不用的 countdownReady 元数据。
专项回归覆盖无新识别结果仍拍摄、角度/位置/置信度/人脸消失不打断倒计时、在途结果不取消,以及离页/切换/故障取消。完整测试及共享 H5 校验通过。H5/备用 AR 判定未改动;手机效果仍需扫码实测。
## 2026-11-04 推荐商品切换直播间链接
用户提供新链接 https://e.tb.cn/h.jhEroZP98ha4Res,短链页面中的目标 item.taobao.com/item.htm 明确返回 id=1090510453077。更新 config.productItemId 及 productItemIds 三档统一目标,商品为用户提供的“【李佳琦直播间】1'me以我自在有势挺括双仓精华抗皱”。保留推荐评分、文案与 tb.openDetail 交互。106 项会话/商品/拍照适配测试通过,覆盖三档相同商品、重复点击、返回报告及游客直接进入商品。实际手机商品详情打开效果尚未实测。
## 2026-11-03 两处侧脸缩略图填充缩小 10%
用户要求轮廓/皱纹下面的两张小图脸部缩小。sideThumbnailStyle 的图片缩放由 cover*2.8 调整为 cover*2.52(原宽高的 90%),未读取尺寸时的 .side-img 兜底 transform 同步为 2.52;画框尺寸、纵向锚点及左右顺序不变。两处共用同一份 sides 数据及样式,原照片与皱纹标注同步缩放。大图仍为 cover*1.12、居中下移图片高度 5%,不受小图调整影响。64 项相关测试通过,缩略图覆盖横/竖/方照片与三个屏幕宽度,并验证相对原填充为 90%。
## 2026-11-03 侧脸大图缩小 20%,下移调整为 5%
用户要求减小主体:轮廓/皱纹侧脸大图原填充尺寸宽高均乘 0.8(原 cover*1.4 改为 cover*1.12),保持居中,再下移缩放后图片高度的 5%。大图窗口大小不变,原照片和标注同步使用该矩形;缩略图及拍照流程不变。64 项相关测试通过,包含尺寸为原 80%、5% 下移、跨图片比例及屏幕宽度检查。
## 2026-11-03 两处侧脸大图统一居中后下移 10%
用户要求皱纹页卡也采用居中裁切,并统一将大图填充区域向下移动图片高度的 10%。轮廓/皱纹侧脸大图共用 rectangle:left=(画框宽-缩放图片宽)/2,top=(画框高-缩放图片高)/2+缩放图片高*0.1。10% 取图片显示高度,不取画框高度;原照片和对应标注共用位置。保留倍率、缩略图和拍照逻辑。64 项相关测试通过,覆盖三种图片比例及三个屏幕宽度、实际 canvas 位移、标注对齐与回调隔离。
## 2026-11-03 轮廓侧脸放大改为图片居中
用户最新明确要求轮廓小图放大直接截图片中间,替代此前 H5 纵向偏移规则。轮廓大图使用 centerCrop,横纵位置均为(画框尺寸-缩放后图片尺寸)/2,取消向上部取样的偏移;保留原缩放倍率和等比展示。仅轮廓侧脸大图生效,缩略图、皱纹大图及拍照流程不变。新增横/竖/方图居中断言与实际轮廓 canvas 绘制中心验证,64 项报告相关回归通过。手机效果仍待重新编译后确认。
## 2026-11-03 侧脸放大改用独立位图
用户真机仍反馈轮廓侧脸大图只剩脑门和鼻子;此前浏览器几何一致不能证明淘宝原生组件显示一致,不能据此声称已修复真机问题。本轮按 H5 reportZoomSource 的绘制路径生成独立 JPEG:新增 utils/report-side-zoom.js 和报告专用离屏 canvas(与拍照 canvas 隔离),源照片/该侧标注使用相同矩形绘制;放大层只对生成图片 aspectFit,不再执行原生 overflow 图片裁切。读取尺寸优先 getImageInfo,回退原照片已知尺寸,导出图片的 onLoad 尺寸不能覆盖原图缓存。
生成期间或 API 不支持/导出失败,显示完整原照片 aspectFit,不再回退旧的激进原生裁切。导出有互斥、generation/账号/可见性守卫;旧结果不能重新打开大图。63 项相关测试通过,包括 H5 九种尺寸矩形对照、实际绘制命令与照片/标注对齐、关闭后的回调隔离。截图脚本改为执行实际 bake 模块绘制命令,输出仍明确为浏览器预览,不是真机截图。相机启动和识别本轮未修改;原生显示仍需用户重新编译后实测。
## 2026-11-03 倒计时后允许正常移动
用户要求取消倒计时后轻微移动导致无法拍摄的规则。正式 native-face 流程开始倒计时前仍按原角度/居中/距离与稳定帧判定;倒计时、补光、快门等待期间改用 countdownReady,只检查清晰的单人面部及其源画面边界,不再因角度、居中或距离偏离原阈值重新计时。新鲜帧 1.5 秒、性能故障停止、离页/切换镜头取消、账号隔离及原生拍照互斥均保留。
新增回归:启动后转头仍完成一次原生拍照、人脸消失取消;保留旧帧与在途识别等待测试。相机/质量/前端相关 97 项测试通过,共享 H5 文件校验通过。修改仅限小程序原生识别链路,未改共享 H5 判定或备用 AR 流程;手机实际效果仍待预览确认。
## 2026-11-03 扫描品牌黄与侧脸大图原生布局
用户确认相机目前恢复;本轮未修改识别、启动、角度或抓拍逻辑。实时扫描带 RGB 改为品牌黄 245/240/120,动画时序不变。
用户继续反馈侧脸大图裁切靠上。轮廓/皱纹大图改为最终屏幕尺寸的裁切窗口,图片和标注使用相同百分比定位,不再依赖小尺寸 overflow 容器整体 transform 放大。保留 H5 reportZoomSource 的 .5 fillScale 与 .25 contentDown 规则。新增浏览器几何对照覆盖 320/375/414 三宽及竖/横/方形照片九种组合,与 H5 drawImage 矩形误差小于 .15 CSS px;60 项相关测试及共享校验通过。浏览器截图已更新,淘宝真机裁切效果仍需核验,尚不能断言原生 transform 是唯一根因。
## 2026-11-03 侧脸小图点开后大图比例修正
用户反馈轮廓/皱纹侧脸大图扁平。检查发现原大图在 getImageInfo 不可用或失败时默认 3:4,而 image 使用 scaleToFill;当实际照片比例不同,会产生拉伸。实际手机是否命中此失败分支尚未采到日志,不据此断言全部真机根因。
现小图缓存自然宽高,放大优先复用;尺寸未知时显示 aspectFit,不再猜测 3:4,真实图片 onLoad 后补齐 H5 1.4 倍居中裁切及 .25 高度下移几何。大图基础照片始终等比;原照片与其标注层、左右顺序保持关联。尺寸回调带源图片及 zoom generation/账号校验,关闭或离页后不能重开。59 项相关测试及 H5 共享校验通过。
输出 deliverables/report-side-zoom-20261103/,以项目 contour-demo/left.jpg 和真实 AXML/ACSS/控制器数据生成浏览器投影,并用线上 reportZoomSource 函数生成 H5 对照;明确标记非淘宝真机截图。相机用户补充为恢复按钮常驻且点击也无法拍摄,需继续依据其错误提示定位,不能只归因于测试入口。
## 轮廓/皱纹侧脸小图裁切还原
已重新读取线上 `/state/css/ui-20261012.css` 和 `/state/js/app.js`。H5 两张侧脸缩略图使用居中 cover、translateY(-25%) scale(2.8),图框 104.35u×92.25u。此前小程序虽使用相同变换字符串,仍依赖原生 image 的 aspectFill/transform 执行效果,用户反馈实际不一致;尚不能确定具体原生差异机制。
现按照片自然尺寸显式计算 cover 后 2.8 倍的宽高,left=(208.7-imageWidth)/2,top=184.5/4-imageHeight/2(rpx),原生 image 使用 scaleToFill 和 transform:none。照片与侧脸皱纹图层共享同一 photoStyle;图片加载和 getImageInfo 均能更新自然尺寸,异步皱纹刷新保留已知几何,离页/旧照片/账号切换的回调不能写入。左右反向顺序、主图、放大图和拍照逻辑保持当前实现。
57 项相关测试通过,包含以真实 H5 CSS 在 Chromium 中计算 cover/transform 作为对照,320/375/414px 屏宽与竖图/横图/方图共 9 组显示几何误差均小于 0.15 CSS px(布局取整差异)。共享 H5 校验通过;淘宝真机实际效果仍待新版普通预览确认。
## 平台首屏性能检测(任务 20406447)
用户提供版本 0.0.1、2026-11-02 19:53:33 的平台报告:华为 P30/Android,得分 85,明确不通过项为首屏渲染 4565ms(标准 <2000ms);包体约 1099KiB,RSS 67MB、vmSize 242MB、FPS/CPU 指标均在报告阈值内。仅凭总耗时无法确定唯一瓶颈,不能将相机识别或语音确定为根因。
本轮只优化首屏:登录背景、品牌和游客图标三张 H5 原图以原始字节复制到 taobao/assets/startup(总 131632 字节),登录改用本地相对路径;App.onLaunch 移除无首屏用途的语音播放器创建,voice.play 的现有按需创建逻辑保留。没有修改相机代码、页面样式、登录校验或云环境。公网图片本机抽测约 0.29–0.37 秒,不代表平台 P30 网络性能。
新增回归验证首屏素材与 H5 字节一致、启动不加载语音模块/创建播放器;完整 node test/*.test.cjs 及共享 H5 校验通过。需要重新上传新的开发版本并重跑平台性能检测,尚无优化后真实首屏成绩,不能声称已达到 2000ms。
## 语音真机定位续查(16:46)
用户再次确认正式拍照仍无语音,拍照正常。IDE Console 仅存 VOICE-20261102-02 的 UNAVAILABLE/category=domain;不能据此断言域名白名单拒绝。VOICE-20261102-04 增加固定 operation 和受限 errorType,将 create、listen_ended/error/play/canplay、set_source、play 分开定位,不记录原始错误或 URL。补充 SDK appxDomain TypeError 回归测试;语音与拍照适配共 78 项测试通过。未修改相机、UI、交互;未声称声音已恢复。
IDE 已生成真机调试码版本 **0.1.2611021646.10**,待手机重新扫码进入正式拍照页,读取新版本错误日志后继续修复。服务器 MP3 MIME/Range 先前已修复,不再次修改云端设置。
# H5 → 淘宝小程序适配实施记录(2026-11-02)
## 报告返回手势、顶部安全区及列表核对
按用户要求,报告详情页配置 `gestureBack:"NO"`,保留原生左上角返回;同时开启报告/列表二级页原生导航栏 `navigationBarForceEnable:true`、`transparentTitle:"none"`。报告内容顶部原 56rpx 增至 72rpx,并以系统安全区为下限;历史列表保留 H5 的 56rpx 顶距并增加安全区下限。页卡横向手势及页面纵向滚动保持,不用全页 touchmove 拦截来禁用返回。需真机确认宿主执行手势设置及标题位置。
列表页对照 H5 的 history-content、account-status-history、history-count、identity-note、history-item、history-detail、history-retry 源码,主要布局和尺寸一致;淘宝身份和游客仅本次查看提示为既定渠道差异。当前不以“听到咔嚓”认定引导语音通过。用户另反馈调试版相机停在“开启实时相机”,已询问点击后的结果,尚不能判为相机功能回归。
真机 Console 已出现 VOICE-20261102-02 的 error/category=domain。此分类来自筛选后的错误文字,不能单独断言平台域名白名单问题;版本 VOICE-20261102-03 进一步区分 APPX_DOMAIN_CONTEXT、DOMAIN_REJECTED、JS_CONTEXT_ERROR 等安全错误指纹,需后续日志确定。播放器发声仍待验证。
## 真机仍无声:音频原生就绪事件与资源服务修正
用户已确认正式流程仍完全无声,手机媒体音量和静音模式已排除;此前的代码单测不能代替真机发声确认。核对安装的淘宝 SDK,音频上下文具有 `onCanPlay/onCanplay` 与 `onPlay` 事件。按[淘宝购物小程序音频规范](https://developer.alibaba.com/docs/doc.htm?articleId=121184&docType=1&treeId=776),移除淘宝不支持的 `obeyMuteSwitch` 属性设置;监听可播放事件后再调用 play,两个大小写版本均兼容,重复/迟到的就绪事件不能重播旧音频。继续使用每条新实例和单通道队列。
新增 `VOICE-20261102-02` 开发日志,区分 loading、canplay、play_requested、playing、error,记录公开语音 ID 和经筛选的错误代码/类别;不打印错误原对象、URL、照片或账号信息。不增加业务 UI 控件。播放器诊断可通过 `voice.diagnostics()` 获取安全快照。
已确认原 HTTPS MP3 返回 `application/octet-stream`,Range 请求也返回整文件 200。部署 `deploy/taobao-voice-media.py` 修正仅 `/state/assets/voice/` 静态路径,公开目录 `/var/www/juzi-voice-20261102/` 仅包含清单内 14 个 MP3 和 manifest.json,Nginx 负责 audio/mpeg、206 与 Range。配置备份 `/etc/nginx/sites-available/juzi.voice-20261102.bak`;已通过 nginx -t 并 reload,H5/小红书/淘宝业务服务进程均未重启。14 条音频分段请求均返回 206 和正确类型,原主页 200、后台 401、数据路径 404,保护规则不变。源 server.js 同步补充 MP3/AAC MIME,未部署整份 H5 业务代码。
完整 244 项回归通过,共享规则校验通过;同时补齐了 WebGL 离线测试包中新增亮度取样模块的显式允许列表。IDE 正在生成本次代码的真机调试码,待手机扫码后核对原生播放事件与实际声音,不把这些改动表述成真机已修复。
## 主流程人脸亮度检测
检查发现 H5 的光线规则已复用,但此前普通相机识别结果不含亮度,正式调用也未传入 luma,因此只有固定“光线充足”引导,实时明暗提示未生效。现复用本地人脸识别已复制、转换好的小尺寸 RGB 帧,在首个人脸框内以 16×16 网格取样,使用 H5 的 .299R + .587G + .114B 均值;不新增 Canvas、Tensor、整帧复制或网络上传。
识别结果携带仅一个聚合 luma 值,质量适配器接入 H5 既有规则,侧脸放宽判定也保留此值:低于 110 提示“建议到光线明亮处拍摄”,高于 220 提示“光线过强,建议避开强光”,成功姿态时按 H5 附加“保持不动即可拍摄”。明暗均为建议,不作为拍摄否决条件;单人、完整取景、角度和新鲜度限制不变。缺失/无脸/无效输入保持未知,不伪装为亮度 0。只评估人脸画面明暗,不代表物理照度、逆光或光照均匀度检测。
96 项相关回归通过,覆盖不同背景、人脸 ROI、RGB 权重、通道格式归一、真实模型亮度输出、前/侧脸提示,以及过暗/过亮时仍可自动取图;需重编译预览后确认手机实际提示。此次不调整界面布局或已有语音文件。
## 正式拍照语音排查与恢复
用户反馈拍照没有语音。线上 step1-front、step2-rightface、countdown-321 三个 MP3 均可通过 HTTPS 读取,返回 200;手机实际无声原因仍需新版真机确认。代码检查发现普通相机及 AR 姿态结果未接入 `VOICE.hint`,现按 H5 映射补齐正对、左右转头、距离及保持不动提示;同提示不逐帧重复播放,换步/倒计时清理过期提示。正式三步引导、拍摄完成、分析与失败语音保留。倒计时先等步骤引导收尾,最长等待 3 秒后继续,保留三秒视觉计时与取图逻辑;等待期间继续识别真实帧,姿态失效取消等待,旧回调不重启拍摄,语音不可用时不阻断流程。
播放器不再每次先 stop 同一个实例再复用。改为单通道、每条播放结束或打断后释放实例,下一条重新创建;旧实例迟到的 ended/error 不可清除新语音队列,同步回调错误不再导致空对象异常。可写属性设置播放音量,支持时关闭遵循静音开关,平台不支持的属性失败不会阻断拍照。测试页继续遵守此前的静音、无上传规则,不把专项测试页当作正式语音验收。
新增播放器和主流程语音回归,验证无 stop-before-first-play、停止后不复用、迟到回调隔离、排队/去重、异常资源释放及正式转头提示触发;未调整 UI 或语音文件内容。
本轮完整 236 项回归及共享 H5 校验通过,IDE 自动编译完成;手机实际听音仍待重新编译预览后的正式流程确认。
## H5 排版与交互还原
已通过只读 HTTPS 获取线上 `/state/` 的 index.html、style.css、ui-20261012.css、swipe-track.js;统一 CRLF/LF 后与本地内容一致。以这些源码为基准修正此前遗漏,不改变评分、报告文案、照片处理或推荐规则。
- 报告三组详情卡原先仅渲染当前面板、只能点击标签。改为全部面板挂载的横向轨道,检测图区域支持跟手拖动、短滑回弹、快划切换、边界回弹及标签联动,竖向手势仍用于阅读页面;文字区不绑定横滑。滑动中保持卡片高度,结束后按当前面板实测高度调整;侧脸标注异步完成后重新测量,避免裁切文案。
- 检测图和侧脸图新增与 H5 一样的点按放大/再次点击关闭。放大保留当前裁切、标记和图层;滑动释放不误触放大。离页、切换账号或缩放尺寸时取消未完成的手势/图片回调,避免私有图片重新弹出。
- 档案肤质轮播从“松手才跳页”补为 H5 跟手轨道,箭头、圆点同步,取消/纵向/多指手势不误选。
- 正常主页、登录和拍照页不再展示开发工具入口;需要专项验收时,开发环境显式启用 `cameraDebug:true` 可显示入口。主页游客状态恢复“游客模式”,去掉额外的游客操作按钮,保留 H5 的开始检测/我的报告;游客可从历史页进入淘宝登录,关闭小程序后不保存本次会话。报告页去掉 H5 没有的附加历史/结束体验按钮,历史入口统一从主页进入。
完整 227 项测试通过,新增 8 项针对手势、页卡高度、图层状态、放大与身份切换的行为回归。只读浏览器投影预览覆盖 320/375/414px 的主页、档案、报告布局,未见页面横向溢出,页卡宽高与当前面板吻合;预览脚本与截图保存在 `temp/check-h5-ui-layout.cjs`、`temp/h5-ui-restoration-20261102/`,使用项目测试样本,不能替代淘宝真机渲染/手势验收。共享 H5 校验通过。需重编译预览后核对真机横滑及放大体验。
## 正式主流程接入与回归
主流程沿用登录 → 启动页授权 → 档案 → 普通前置相机自动三连拍 → 压缩/上传 → 报告,入口为不带 debug 参数的 `/pages/camera/camera`。原页面与操作保持不变。首次进入、相机授权状态尚未确定时,自动衔接平台相机授权再开始取景;已明确拒绝时不反复请求,仍使用原权限提示与相册入口。
补充游客和淘宝账号两条多页面集成回归:运行实际登录、档案、拍照、上传客户端及报告页面代码,仅相机帧/JPEG、传输服务响应与平台 API 使用固定测试替身。验证游客不请求账号授权、不持久化档案/会话,正式账号保存自己的档案,普通业务三连拍完成后按 front/left/right 上传,只提交一个 request_id,渲染三组七项报告并打开 `1088839442533`;退出游客清除本次报告。完整 219 项回归通过,共享 H5 与本地模型产物校验通过;没有发起真实付费检测。云网关到真实服务和手机报告结果仍待验收。
## 当前状态:用户确认自动三连拍通过
用户已反馈“自动三连拍已经好了”,将最近交付的前置相机三步自动抓拍标记为当前手机真机通过;不推广为所有机型、后置或 AR 路线已通过。正式业务配置从临时上传优先恢复为 `photoInputMode:'camera'`,沿用同一普通相机识别与拍照实现;相册入口保留,页面布局和交互不变。正式业务离线回归使用实际配置验证三张照片只触发一次报告请求。
本轮再次确认远端 `juzi-taobao` 为 active,18081 `/api/health` 返回正常;这不代表小程序云网关已通。下一项仍是游客云服务访问、三图上传和手机真实报告,尚未完成端到端验收。
## 前置相机第二、第三张方向校准(LOCAL-FACE-20261102-09)
用户真机确认第二、第三张的角度方向相反:提示向左转头时,实际接受的是向右转头。普通前置相机的本地姿态判定现传入 `mirrored:true`,翻转 yaw 符号,等价于对调两个侧脸步骤接受的原始角度方向。步骤仍是正脸/右脸/左脸,原 UI、转头提示、遮罩、照片标签及提交顺序不变;后置相机仍用原映射,AR 对照及共享 H5 文件不变。保留 `08` 的放宽阈值,不以取消方向检查来放行。
相关 71 项测试通过,覆盖前置左右方向互换、错误方向提示、后置方向保留、小角度自动三连拍及照片输出顺序。随后用户已确认自动三连拍可用。
## 性别选择控件对齐 H5
档案页原先使用系统 `picker mode=selector`,现改为页面内底部弹层的单列 `picker-view`,复用已有出生年月/地区弹层容器。对照 `h5/js/app.js` 的 `genderPicker` 与 `h5/css/style.css` 的 mobile-select 样式:标题“性别”,选项“女/男”,左右“取消/确定”,50px 标题栏、220px 滚轮、44px 淡黄色选中框、90% 内容宽度及安全区留白。触发框沿用原样,滚动仅更新草稿,确定后更新性别,取消不更改字段;再次打开定位已保存选项。出生年月与地区的原实现保持不变。需重新编译预览后确认手机实际观感。
## 最新真机反馈与角度调整(LOCAL-FACE-20261102-08)
用户提供 `07` 真机诊断:WebGL 自测及预热通过,使用 `quad_strip`,本次识别约 70ms,已自动拍下一张;第二步仍为 `quality.reason=angle`。因此初始化及性能已在该设备本次测试通过,左右三连拍尚未完成。
按用户要求先放宽转头条件:仅在淘宝普通相机本地识别中,将侧脸 yaw 范围由 24–84° 放宽到 12–100°,侧脸 pitch 上限 29° → 40°、roll 上限 22° → 30°。只接受原单人、清晰度、完整取景和居中检查都通过后的侧脸角度失败;方向、正脸条件、450ms 稳定保持、倒计时及 1.5 秒新鲜度限制保留。共享 H5 文件、正式上传优先配置、页面布局与按钮交互不变。`08` 仍需真机确认能否完成三连拍。
## 最新约束:保持原有 UI 和交互,优先完成上传与报告
用户要求 UI 和交互与原来保持一致。本次撤回此前新增的上传面板、登录协议样式、动态按钮文案、入口版本标签和额外提示弹窗;恢复原相册/快门/切换镜头控制条、取景引导和步骤提示。保留接口修复及同步异常后解除按钮锁定的逻辑,不因排障新增业务页面或改变页面布局。
游客入口真机错误已确认为 `FAIL BIZ TARGET SERVICE_ERROR:.访问目标服务异常`。淘宝专用 Node 健康,最近日志未见对应新请求,仍需核对云应用、私网 CLB 和 SAE Java 转发服务;尚未确认云端根因,也未完成手机端报告验收。用户可通过此前提供的阿里云 SAE 控制台链接登录;代开浏览器的系统命令被自动权限检查拒绝,未绕过。
用户已调整优先级:实时抓拍继续保留,先验收三图上传闭环。`photoInputMode:'upload'` 在正式业务页跳过尚未通过真机验收的 GPU 初始化,沿用原相册图标选图、快门调用系统相机和三步引导,不显示独立上传面板。复用压缩、分片上传、同一 request_id 重试及报告跳转;游客身份与本次会话规则保持不变。开发版实时识别入口独立保留。
本轮联合测试 209 项通过;后续文案微调的相机回归 60 项通过,共享 H5 规则校验通过。已将匹配的 server.js、lib/*.js、js/*.js 更新到淘宝专用 `/home/ubuntu/juzi-tb/h5`,服务 `juzi-taobao`、端口 18081。备份为 `/home/ubuntu/juzi-tb-backup-upload-20261102.tar.gz`,数据目录 `/home/ubuntu/juzi-tb-data` 保留。发布检查曾因幂等标记查错文件触发自动回滚,修正后重新部署成功。健康检查、游客路由的同意门槛及 challenge 已在服务器通过;未调用真实付费检测,手机三图生成报告仍待验收。未部署 H5/小红书服务或统一后台。
手机验收:重新编译并预览 → 扫新码 → 正常游客体验或淘宝登录 → 开始检测并填写档案 → 依次选择三张照片 → 查看报告和商品跳转。此轮无需进入“相机测试”。以下相机版本记录为此前专项历史。
## 目标与基准
- 以 `https://1me.xajuzi.com/state/` 的页面、评分、七项报告、三视角拍摄和等待动画为基准。
- 源码起点 `381da60`,工作分支 `codex/taobao-h5-adaptation`。已重新 fetch;远端当前与此起点相同。
- 线上 HTML、评分及报告呈现文件已核对。本地 `app.js` 多了小红书渠道兼容,拍摄规则来自同一份 H5 源码。
- 淘宝账号替代微信;保留游客体验,游客报告仅本次可见。三档推荐文案保留,全部跳商品 `1088839442533`。
- 游客入口不需要淘宝账号登录/账号授权,不读取账号资料;仅在同意隐私告知后建立临时检测会话,相机权限在实际拍摄时申请。账号恢复不能阻塞游客按钮,迟到的旧账号结果不能覆盖游客流程。
- 最新用户选择:颜色校验仅保留为可选开发工具,不作为拍照/导出/报告门槛。缺少格式且严格匹配四通道的帧使用应用默认 RGBA;平台明确格式优先。
- **实时识别抓拍是首版验收要求。当前主线为普通前置相机;手机已识别人脸且姿态合格,CPU 优化版单帧从约 10.7 秒降至约 4.25 秒,仍未满足实时要求,首版尚未验收。正在验证原生 WebGL 加速候选。**
## 平台测肤接入:新增前置核对(2026-11-02)
已核对淘宝公开的[《肌肤测试》接入文档](https://developer.alibaba.com/docs/doc.htm?treeId=635&articleId=118965&docType=1)。页面更新于 2023-01-29,因此具体可用能力、申请入口和运营联系人仍需以目标应用后台及当前运营确认为准。
文档分别列出平台算法与自带算法两条路线。项目实际使用 Face++ 测肤服务及自有评分、报告逻辑,按“自带算法”路线准备;该路线仍有商家资质、ISV 调用资质、方案确认和测肤白名单流程。用户已反馈“尚未申请/目前不清楚”,当前按测肤白名单未申请、ISV 资质待确认处理;不能以已有 AppKey、可预览或普通相机可调用代替开通确认。
### 修正后的推进顺序
1. 核实天猫品牌/美容护肤主营类目、对应应用及 ISV 资质,确认自带算法测肤白名单进度。申请材料说明现有算法供应商、三视角采集、用途、数据流和游客仅本次查看规则;提交申请、联系运营由商家确认后执行。
2. 获得该应用实际开放的拍照组件及接入契约,确认能否实时取景、获得人脸点位或识别结果、自动触发抓拍、连续采集三视角并返回可送检照片。**首版 H5 式实时识别抓拍目标不变**;若组件限制影响目标,先确认平台支持的实现方式,再确定正式相机方案。
3. 增加拍摄前的品牌方人脸采集告知和明确授权,覆盖正式账号、游客及相机测试入口。当前登录页展示的是 Face++ 政策,不等于品牌方采脸弹窗。需要补齐品牌主体名称、完整面部图像用途、14 周岁承诺、肖像使用范围、实际第三方处理情况、品牌隐私链接及退出/删除联系方式;拒绝后不启动采集。官方模板的营销共享条款需按实际业务与当前审核口径确认,不能直接扩张实际共享用途。
4. 在平台要求的测肤结果边界适配一级 `skinType`:0 干性、1 中性、2 混合偏干、3 混合偏油、4 油性。先确认回传接口和现有算法结果的语义,再加适配;不直接改 H5 历史结构,也不用问卷答案冒充算法检测结果。品牌七项报告继续沿用。
5. 平台接入、采集授权与相机能力一起验收,再进行真实三连拍、报告、商品回跳及部署验收。
文档没有写明“所有普通相机调用一律禁止”,也没有证明原生 `ar-camera` 就是白名单开放的测肤拍照组件。目前的 AR 实现和测试页保留为技术验证;即使真机有帧、能导出 JPEG,也不能据此认定测肤业务已获平台准入。正式拍摄组件方案仍待上述信息确认。
### 对接说明草稿(尚未提交)
业务为 1'me 以我品牌的淘宝小程序肌肤检测及产品推荐,拟采用自带算法接入:第三方 Face++ 提供检测能力,品牌系统负责评分、七项报告及推荐,算法服务不是品牌完全自研。参考 H5 为 `https://1me.xajuzi.com/state/`,首版要求页内实时识别、稳定后自动抓拍正脸/右脸/左脸三张图,随后生成报告并跳转指定天猫商品。
拟使用平台对该应用开放的测肤拍照组件,保留 H5 的引导与报告规则。图像经品牌后端送算法服务;淘宝与 H5 的身份、报告存储隔离,管理后台可只读汇总。游客按当前实现仅在本次会话处理,品牌后端不将其照片和报告写入持久目录;这不代表已确认第三方算法服务的保留规则。正式账号的照片/报告保留及删除规则仍需确定,再同步到隐私告知。
商家需补充:应用名称及 AppID、品牌运营主体、店铺主体及主营类目、开发主体/ISV 信息、品牌隐私政策链接与用户联系渠道。申请时不附密钥或用户照片。
需向当前平台运营确认的问题:
- Face++ 加品牌自有评分的接入分类、所需资质及当前申请流程是什么?
- 开通后提供哪个插件/组件、版本和技术文档?是否支持三视角实时识别抓拍、自定义引导、合适的图像导出和自带算法送检?若由组件内置识别抓拍,如何取得结果和处理取消/失败?
- 现有原生 `ar-camera` 能否用于该业务,还是必须使用另行开放的测肤组件?不能把两者视为同一权限。
- 采脸授权的现行文案、品牌及第三方披露要求是什么?游客是否有额外接入规则?
- `skinType` 通过哪个接口/事件返回,是否要求同时回传其他结果字段?
### 白名单申请的执行路径
优先由店铺负责人联系已有的天猫美容护肤行业运营,说明要申请“自带算法测肤拍照组件白名单”,请其确认当前测肤产品运营及接入要求。没有对接人时,可登录[淘宝开放平台支持中心](https://open.taobao.com/supportCenter)创建接入咨询工单,请求转接对应业务负责人;[官方支持中心说明](https://developer.alibaba.com/docs/doc.htm?articleId=849&docType=1&treeId=456)确认开发者可在线提交问题/需求。工单用于咨询和转接,正式白名单仍按业务运营给出的流程申请。
建议材料包括店铺链接与类目、品牌和开发主体、ISV 资质状态、目标应用名称及 AppID/AppKey、Face++ 与品牌评分的数据流、三视角实时抓拍流程、采集授权和隐私规则。先取得方案确认,再向当前运营指定的渠道提交白名单申请;通过后核对获批应用、组件/接口范围及技术文档。
公开测肤文档中的运营花名来自 2023 年,未公布邮箱,不推测联系地址。文档“申请插件”链接指向[能力中心](https://open.taobao.com/ability),本次未能验证登录后的申请按钮及能力 ID 63 的开放状态。也可从[文档关联提问入口](https://developer.alibaba.com/support/create_or_edit_problem.htm?docId=118965&docType=1)登录后咨询。
可发给运营/工单的说明:
> 我们计划为 1'me 以我品牌接入淘宝小程序肌肤检测,使用第三方 Face++ 检测及品牌自有评分、报告,申请自带算法路线的测肤拍照组件白名单。首版需要页内实时识别、稳定后自动抓拍正脸/右脸/左脸三张图,并支持默认前置和前后置切换。请协助确认商家及 ISV 资质要求、当前申请入口/材料和业务对接人,并提供对应组件文档。应用名称/AppID/AppKey、品牌主体及开发主体待补充。
## 已实施
1. 独立登录首屏、淘宝/游客两入口、协议、失败重试、正式账号签名恢复和宿主换号隔离。
2. 游客临时 Bearer 仅内存保存;账号库、照片、原始结果、报告和侧脸缓存不落盘。商品页往返保留本次报告,主动结束或冷启动清理。
3. 通过网关完整签名与单用 challenge 建立游客,不要求 open_id,不接受客户端自报身份。正式 `/me` 对照当次签名身份与旧 Bearer,避免宿主换号后继续显示旧报告。
4. 上传和临时任务按 Bearer 会话隔离;稳定 request_id/photo_refs 复用原任务。队列满不消费照片,提交响应丢失重试不会重新收费;过期任务返回明确错误。
5. 拍照蒙版从 H5 原始 SVG 轮廓生成本地 PNG,包含三方向定位线;图标、完成卡、扫描等待画面与 H5 对齐。`scripts/build-camera-assets.cjs` 可重建。
6. 补齐原来缺失的 `compress-canvas`;补压缩超时、迟到回调隔离、上传/恢复阶段提示及错误重试。切后台后继续查询原任务。
7. 推荐跳转统一固定商品 ID,防重复点击,提供缺 API/失败反馈;新报告、游客报告、历史报告的内存状态保持。
8. 后台可按显式配置只读汇总 H5 与淘宝的数据源,支持渠道筛选、分页、统计、详情、照片、原始 JSON 和 CSV。外源重试禁止写入。详见 [后台部署说明](TAOBAO-ADMIN-CHANNELS.md)。
游客入口补充修复:登录页恢复旧账号期间允许直接选择游客;选择后立即失效旧账号恢复、清除旧 Bearer 和本地账号缓存。游客 challenge/guest-session 不携带旧账号 Bearer、不调用 `scope.userInfo` 授权;旧 `/me` 成功或 401、以及其迟到页面回调均不能重定向/覆盖游客流程,游客请求失败也不会自动恢复旧账号。用户未同意告知时仍保留原会话并提示同意,不提前切换身份。相关 API 时序与页面回归共 32 项通过。服务器游客接口仍需部署配套版本后才能手机验收。
## AR 实时相机
普通 `camera` 的实时帧监听在旧官方文档中标为暂不支持。本轮改用淘宝原生 `ar-camera`,模式 `faceDetection2D`,事件 `onARFrame` 返回人脸框及 106 点坐标。
### 默认前置与镜头切换(初始排查,最新映射见后续更新)
用户反馈页内实时相机实际打开后置。原先的 `devicePosition: 'front'` 只绑定普通 `camera`,没有传给正在使用的 `ar-camera`;AR 分支的切换按钮也只弹出提示。现已向 AR 组件显式绑定驼峰属性 `devicePosition`,默认请求前置;正式拍照页右下角的翻转图标增加“切换镜头”文字,测试页也提供同名按钮。
点击切换会卸载旧组件,再按新方向挂载;取消旧倒计时、清除旧帧,并按相机会话标记丢弃旧组件的帧/停止/错误事件。已拍好的照片和当前拍摄步骤保留。人脸原始坐标仍沿用现有约定,不仅凭镜头方向推断坐标是否镜像。
本次镜头改动的相机、测试页及前端相关回归共 **28 项通过**,覆盖切换取消倒计时、旧组件事件隔离、已拍照片保留,以及测试页切镜头/重开时共享画布导出串行。这些是逻辑验证,不代表真实镜头已切换。
证据边界:淘宝缓存文档只描述按 `mode` 自动选择相机,没有列出方向参数。`devicePosition` 的明确字段及默认后置来自 [Taro 官方 Alipay 适配源码](https://github.com/NervJS/taro/blob/main/packages/taro-platform-alipay/src/components.ts#L155-L164),因此本轮是基于共享组件线索的兼容修正,**尚未证实淘宝宿主接受该参数**。没有调用未证实的 AR 切镜头方法,也没有直接用另一套 `<ar>` 替换识别与图像导出链。必须在手机确认实际镜头改变,不能以页面状态变化或本地测试通过代替验收。
用户随后重新编译预览反馈“默认仍是后置”,所以当前前置请求补丁未达到目标,镜头控制问题仍未解决;不把请求方向状态当作实际镜头状态,也不认定未开白名单就是该故障的原因。下一步需要目标应用实际可用组件的方向控制契约及真机能力确认。
### 继续排查:已复现并修复的本地问题
1. 正式拍摄页原先在切后台时解除拍摄忙状态,但旧原生画布导出还可能继续。已用持久 Promise 队列串行化 AR 导出和普通三图压缩;队列跨后台/恢复保留,旧照片不能恢复到新会话。测试实际延迟 `canvasPutImageData` 回调,验证不会并发写同一画布。
2. 已复现补光后的 300ms 内相机报错/停止,定时器仍能采用旧帧进入下一步。现在会使未完成抓拍的序号失效、清除倒计时/补光/旧帧;已提交分析任务继续运行。
3. 三张照片拍完后,相机原先仍在等待动画背后运行。现在进入报告处理时卸载实时组件并清空帧引用;分析失败时保持关闭,主动“重新拍摄”才恢复。
4. 云 SDK 的 `init` 或请求 Promise 若不结束,原有轮询截止时间无法中断当前等待。传输层已新增独立超时:初始化 15 秒,每次云/本机请求 12 秒;迟到结果被忽略,初始化失败可重新建立。重试仍沿用原任务/request_id,不在超时处理里自动发起新检测。
上述是已复现逻辑缺陷的本地修复,不等于用户手机上的报告卡住根因全部确认,也未解决默认后置问题。
### 新的两组真机诊断
- 原 AR 测试页版本为 `AR-DIAG-20261102-01`,展示可读取的设备/淘宝/SDK 版本、实际 context 方法名、原生事件类型和白名单元信息。收到不符合标准结构的回调也会计数,避免将“有回调但解析失败”误认为没有帧。帧计数保存在 JS 中,页面与元信息扫描最多每 400ms 更新一次;点击“复制诊断”才写剪贴板,文字不含图像、landmarks 或身份。登录首屏入口已从左上角小字改为醒目的黄色“相机测试(开发版)”按钮,仍仅在开发环境显示。
- 从该页新增“普通相机对照测试”,对应 `pages/camnative/camnative`。默认请求普通 `camera` 的前置,支持前后置重挂,按本机淘宝 SDK 实现调用 `onCameraFrame(callback)` 返回的 listener 的 `start/stop`。区分“方法存在”“start 成功”和“实际收到尺寸、字节非空的原始帧”;3/10 秒无帧给出明确状态。手动拍照仅作本页临时预览,无账号/上传/业务存储依赖。
- 普通相机对照页只验证取景、原始帧与照片能力,**不作为实时人脸识别三连拍的替代方案**。有原始帧也不代表已有可用人脸关键点或已取得测肤业务准入。
### 淘宝专用后端只读核对
本次通过现有 SSH 授权只读连接 `ubuntu@1me.xajuzi.com`,确认 `juzi-taobao` 为 active,回环 `18081/api/health` 的 `ok=true`。读取 `/home/ubuntu/juzi-tb/h5` 四个业务文件的哈希/功能标记后,确认与本地版本不同:已部署路由没有 `guest-session`,会话模块没有本轮 `request_id` 幂等实现。没有读取凭据、用户照片或报告,也没有重启/部署/修改远端。
因此新版游客和完整幂等链路仍需匹配的 Node 版本才能进行手机验收。相机诊断页无需后端,可先独立验证。
本次代码完成后运行 **87 项自动测试全部通过**,共享 H5 规则校验通过;淘宝 IDE 再次本地编译完成,界面显示 0 错误、0 警告。没有调用真实 Face++ 检测。默认前置、真实切换和完整三连拍仍等待用户手机的两组诊断结果。
### 用户真机反馈与颜色校验(后续更新)
用户已确认测试页为 AR 相机真机验收,计数持续增加,默认后置但按钮可以切换。其提供的旧测试页文字显示:720×1280、一张人脸、几何姿态判断通过,`capturedImage` 为 3,686,400 字节的 ArrayBuffer,未带像素格式;JPEG 导出报“相机原始图像格式尚未确认”。这个长度等于宽×高×4,可确认四通道长度匹配,但不能据此决定 R/B 或 alpha 的位置。默认方向和导出问题仍单独处理,不直接反转枚举值。
已修正两个有本机淘宝 SDK 源码依据的画布兼容问题:JPEG 结果兼容直接字符串及 `dataURL`/`dataUrl`/`base64` 外壳,验证 JPEG MIME、标准 Base64 与 JPEG 标记;像素写入优先使用淘宝 `ctx.putImageData`,保留探测到的 `my.canvasPutImageData` 兼容路径。四通道原始像素恰巧以 JPEG/PNG 标记字节开头时,不能误按压缩图片解码。
AR 测试页现为 **AR-DIAG-20261102-04**:顶部固定“复制诊断信息”和“导出照片”,复制失败仍展示可长按选择的脱敏文字。按用户后续要求,默认直接导出,不再要求做颜色校验。`config.arPixelFormat='RGBA'` 只补缺少格式且严格匹配四通道的图像;平台明确格式、开发页显式预览格式及当前运行内可选确认的格式优先,显式未知格式和不匹配的字节长度仍报错。应用默认值不是已确认的平台通道规范,诊断分别记录 default 与 confirmed。
颜色校验移至正文高级可选区域:明确点击才冻结一次原图,不复制 anchors,并串行生成最长边 480 的 RGBA/BGRA/ARGB/ABGR 预览。可选确认仅留在本次运行、仅用于同一图像字段路径,不写 Storage、不上传;不做校验或冷启动后也可使用应用默认值。正式导出保留最长边 1280,跨机型颜色与方向仍需后续验收。
用户进一步确认,切到实际自拍画面时页面显示“请求后置镜头”。据此将当前 AR 的 `config.arFrontPosition` 设为 `back`,测试页默认请求该值;正式页分开逻辑镜头 `devicePosition` 与 AR 请求 `arDevicePosition`,默认逻辑前置对应此请求值,切换后使用另一请求值。轮廓和扫描线的镜像继续按逻辑镜头计算;普通 `camera` 仍按其标准 front/back 属性控制。该配置来自当前真机反馈,新版本首次启动是否直接自拍及其他机型映射仍需验证,不能视为淘宝所有设备统一反向。
普通相机对照页同步补充固定复制入口和可选择文字后备,版本为 **CAM-NATIVE-20261102-02**。本次相关修改后 **126 项自动测试全部通过**,共享规则校验通过。仍需真机验证候选颜色、方向和 JPEG;未部署 Node 或调用真实 Face++。
默认导出与 AR 请求映射修改后,再次运行 **131 项自动测试全部通过**,共享 H5 规则校验通过。新增覆盖未校验直接导出、默认值不覆盖平台格式或可选确认、正式三连拍采用默认格式,以及反向 AR 请求不改变逻辑蒙版/普通相机。此次仍未部署 Node 或进行真实检测。
手机更新预览时,需在 IDE“预览”弹窗生成新的预览二维码,再扫码核对上述版本号;只重新编译本地模拟器不能当作手机已更新。用户此前提供的页面尚没有新版本号/复制按钮,不以该旧页面验收新入口。
点位转换使用官方图示的语义对应:轮廓取 0、2…32,眉鼻眼取 33…63,嘴取 84…103。H5 的头部姿态、距离、居中及稳定性判断原样同步到 `vendor/`,由哈希校验,未将未注明单位的原生 Euler 值当作角度。
`useCapturedImage` 只在官方文档中承诺返回图像 ArrayBuffer,未完整注明字段和像素格式。实现读取实际帧结构,支持可识别 JPEG/PNG 和明确标注的 RGBA/BGRA/ARGB/ABGR 四通道图像;按用户最新选择,缺少格式且严格匹配四通道的图像可使用应用默认值直接导出。显式未知格式或尺寸/字节不匹配仍明确报错。没有调用未记载的 AR `takePhoto` 方法。
### 真机验收步骤
后续手机反馈已确认:AR 反向请求映射仍未实现默认自拍;普通 `camera` 独立诊断则默认前置、可切换、可拍照出图,并且原始帧计数持续增加。因此继续验证“普通前置相机 + H5 本地人脸模型 + 原生 JPEG 自动三连拍”路线,不能把原 AR 请求映射标为修复成功。
用户反馈复制按钮与系统时间重叠后,两个诊断页的固定工具栏均新增顶部安全间距:读取可用状态栏/安全区/标题栏高度,最少留出 112px,正文同步下移。普通相机版号更新为 **CAM-NATIVE-20261102-03**,AR 页为 **AR-DIAG-20261102-05**。同时修正普通诊断页版号在 `Page()` 后才赋值导致初始页面可能不显示版号的问题。两页相关 39 项本地回归通过;真实手机点击位置仍待新预览验证。用户已要求跳过颜色校验,不再把此操作作为本轮测试步骤。
### 普通相机本地识别接线(最新实现)
`config.captureMode` 已切为 `native-face` 开发候选:普通 `camera` 默认请求 front,单次实时帧推理后复用 H5 的姿态、居中、距离和稳定判断,再倒计时并用同一相机 `takePhoto` 获取 JPEG。正/右/左三张接入既有顺序压缩及单次任务提交;手机已提供普通帧宽高/字节数,实际本地识别、方向和耗时仍须手机验证。显式未知格式、额外行填充及旋转标记会报错,不静默改成手动拍照。
`utils/native-face.js` 使用从 H5 原有 face-api 和量化权重构建的本地 CPU 副本:无需 DOM、网络、Node 模块或 Face++。无 DOM 环境下以仓库本地参考图真实跑过检测及 68 点,专项 6 项通过;模型占 68 个 tensor、2,200,644 字节,最后一个引擎释放后回到 0。CPU 耗时受环境影响,本机直接 Node 连续参考帧约 369–552ms,隔离 VM 约 1s;均不代表手机表现。构建脚本去掉了库内会被 IDE 静态解析的 Node-only require 分支,保存原版权声明、许可与来源 SHA。
帧缓冲在返回宿主前同步缩采样为独立 RGB,避免原生复用缓冲改变待处理图像;并发推理前先跳过忙帧。倒计时末若检测未完成,最多等 1.5 秒复核新结果,仍从原始帧采集时刻判断新鲜度;连续 3 次识别超过 1.5 秒会明确停止。切后台、换镜头、相机异常及报告处理清理监听和倒计时,旧推理/旧快门回调不能推进新会话。
本轮最终 **163 项自动测试全部通过**,H5 共享规则逐字节校验及本地模型构建校验通过。没有调用真实检测或部署服务。手机仍需验收帧格式、速度、方向、自动三连拍及报告闭环;当前服务器缺少新版游客接口的部署事实不变。
后续用户提供 `AR-DIAG-20261102-05` 的 iPhone15,2/iOS 27.0.1/淘宝 10.68.10/SDK 2.9.4 诊断,`frameEvents=0`、context 未观测。该记录属于 AR 页,并非普通相机元信息;无帧不能推断能力不支持。为减少误入,登录与首页开发测试入口现直接进入普通相机,AR 页普通相机按钮移至固定工具栏。两测试模式用 `redirectTo` 切换,避免反复堆叠页面;原生拍照或 AR 导出进行中不允许切换。用户当前手机版本仍可使用正文旧“普通相机对照测试”入口,无需为取得元信息反复生成二维码。
### 普通相机真机帧已取得
用户随后复制 `CAM-NATIVE-20261102-03`:同一设备上 `requestedPosition=front`,`takePhoto/onCameraFrame/listener.start/stop` 均存在,监听启动成功,145 个回调均有有效原始字节。图像为 **540×960、2,073,600 字节**,等于宽×高×4,未报告 pixelFormat/format。这证明有紧密四通道长度的连续图像,不能仅凭长度认定通道排列;按用户要求沿用应用 RGBA 默认值,不要求颜色校验。`nativeReady=false` 只表示该事件未观测,正式流程不依赖此标记;当前这份诊断未发起手动拍照,照片能力仍来自此前用户真机反馈。
真实模型新增同尺寸竖屏回归:参考图等比居中放入 540×960,缺格式采用默认值,并在 detect 调用后立即复用清空原生缓冲。实际检测出 1 脸和 68 点,输出坐标合法,tensor 无增长;模型专项现 **7 项通过**。本机隔离环境约 937ms,仍不代表手机性能。
普通诊断页更新为 **CAM-NATIVE-20261102-04**,顶部增加“开始自动三连拍测试”。该入口使用独立 `debug=local-face` 开发模式复用正式相机的识别、稳定判定、倒计时、正/右/左自动抓拍和 JPEG 压缩,完成后仅本页临时预览,不要求账号/档案、不上传照片、不创建检测任务、不播放远程语音。已有手动 `debug=camera` 保持原来的本机直连开关。测试入口不等于正式报告验证;已有账号的应用全局前台恢复仍可能独立进行 `/me` 校验,因此不宣称整个应用全程零网络。
自动验收版本为 **LOCAL-FACE-20261102-01**,复制入口避开状态栏,包含安全的 stage/failureStage、模型耗时、人脸计数、拍照计数及独立 canIUse 信号。正式相机属性与已验证普通诊断页对齐为 `mode=normal`、`frame-size=medium`。新增隔离回归验证不调用账号 API/getApp/discardAnalysis,不改变其他正在进行的检测;旧模式开关和生产环境登录门槛不放宽。最终 **169 项自动测试全部通过**,共享 H5 规则和模型构建校验通过;没有真实检测或部署操作。
### 手机性能阻塞与后续优化
用户在 `LOCAL-FACE-20261102-01` 实测:`canIUse.camera/createCameraContext=yes`,6 次处理后在正脸阶段因 performance 停止;最后一帧 **10,734ms、1 脸、quality=ok**,源图仍为 540×960/2,073,600 字节。连续 3 个结果超过 1.5 秒后正确停止,照片计数 0。问题是当前识别链路耗时,不能以姿态合格认定抓拍可用,也不能把结果完成时间改作源帧时间或直接放宽门限。
新版诊断 `LOCAL-FACE-20261102-02` 分开记录原始回调、忙时跳过、实际检测次数及 snapshot/model/tensor/detector/landmark/total 耗时,区分配置请求值和引擎实际值,补运行时版本。正常推理期间的额外 100 个回调只跳过、不复制图像、不刷新 UI;桥接的大帧交付和 GC 仍需实测排查。
连续预览改用官方 [camera 的 small 帧尺寸](https://developer.alibaba.com/docs/doc.htm?articleId=120279&docType=1&treeId=776),降低原生到 JS 的图像传输量,原生正式拍照仍保留 high JPEG。轻量 68 点模型来自 [face-api 官方权重](https://github.com/justadudewhohacks/face-api.js/tree/master/weights),单独存于 `reference/native-face-models` 并记录来源/SHA,H5 原始模型不改。定位输入为 160、缩采样最长边保留 320;本地等比双脸图在 128 输入只检出 1 脸,故未用该更低尺寸作为默认值。
CPU 优化仅将旧 TFJS padding 的逐标量坐标换算改为连续 typed-array 块复制,数值运算顺序不变;1×1 卷积实验未取得稳定收益,未纳入发布候选。原 540×960 参考竖屏的同图隔离基准约 full256 934ms → tiny160+pad-copy 302ms(约 3.1 倍),模型张量内存约 2.20MB → 1.08MB。更低 maxSide 192 仅省约 24ms,未采用。上述是本地基准,手机的输入帧大小、计算时间及自动抓拍仍待 `LOCAL-FACE-20261102-02` 验收。
上一轮还完成 `temp/native-face-webgl-prototype.cjs` 离线原型:显式 WebGL1 注入可在屏蔽 DOM 的模块中通过矩阵乘法、卷积、读回及真实 68 点模型,数值自测确实执行 shader;没有网络请求。但这不证明淘宝手机原生 GL 已兼容。原型阶段尚未启用 enableSkia 或 GPU;在本轮 CPU 真机复验后,已继续实现下述 WebGL 候选。MNN 插件未接入。
本轮最终 **174 项自动测试全部通过**,共享 H5 规则与模型生成校验通过。独立复核额外对照 250 组 padding,覆盖 rank 1–5、空维、TypedArray 偏移和特殊常量;多模型失败恢复与最终释放均通过。源帧 1.5 秒新鲜度、连续 3 次慢结果停止、多人拒绝和调试页无上传规则保持不变。
### 原生 WebGL 候选(后续开发)
用户在 `LOCAL-FACE-20261102-02` 复验:small 帧已实际降为 **360×640/921,600 字节**,tiny/160/pad-copy 均生效;处理 3 帧后仍停在 performance,最后一帧总耗时 **4,253ms**,其中 snapshot 8、model 29、tensor 0、detector 2,373、landmark 1,843ms。人脸及姿态合格,照片计数仍为 0。这证明减少传输量已生效,而本轮主要瓶颈在模型 CPU 计算,不能继续用同一 CPU 方案宣称实时通过。
下一候选 `LOCAL-FACE-20261102-03` 使用官方 [同层/离屏 Canvas](https://developer.alibaba.com/docs/doc.htm?articleId=118999&docType=1&treeId=776) 的原生 WebGL1 入口。按文档启用 `app.json.window.enableSkia:"true"`,配置明确请求 WebGL;不能仅以 API 存在或后端名称验收。初始化先做真实矩阵、卷积和 typed-array 读回数值自测,然后预热模型;数值错误、缺能力或上下文丢失时明确停止,不自动回到已证实过慢的 CPU。手机兼容性及速度仍待真机验证。
本机 SDK 的 legacy `createCanvasContext` 有原生 Canvas 兼容路由,保留现有 JPEG 压缩方式,不引入未证实的 2D 导出接口;文件读取结果补 JPEG 标记和标准 Base64 验证,防止把 PNG/坏数据标成 JPEG。真实手机仍需验证开启 Skia 后三张 JPEG 压缩、方向与覆盖层。
图形引擎的离线真实 WebGL 回归已验证:数值自测确实执行 shader 并读回;tiny/full 可共享同一后端;GPU 存活时拒绝切换 CPU;真实参考图输出 1 脸和 68 点;初始化缺浮点能力、读回错误及上下文失效均明确失败。挂起真实 Tensor 读回期间销毁 owner 不会提前删除资源,操作结束后旧结果被拒绝,模型及 Buffer/Texture/Framebuffer/Shader/Program/Renderbuffer 均释放;新会话可以重建。以上使用隔离 Chromium 软件 GL,网络请求为 0,不能当作淘宝手机支持或速度的证据。
相机在初始化时显示“正在校验图形加速并预热模型…”,模型就绪后才监听真实帧,初始化 30 秒未结束会停止并保留可复制诊断。GPU 错误会使原引擎退役,待其初始化/推理结束后才能重建,避免复用失效的缓存或在旧回调仍运行时删除资源。JPEG 的回调与 Promise 双通道都受同一完成标记保护,原生 binding 的 Promise 拒绝会被处理,不引发未处理拒绝。
本轮联合回归 **190 项全部通过**,其中 4 项使用真实隔离 WebGL,覆盖数值自测、模型、失败恢复、共享后端、挂起销毁、GPU 资源释放及重建。H5 共享规则与模型生成校验通过。没有上传手机照片、进行真实 Face++ 调用或部署后端;手机 GPU 自测、实时速度和三张 JPEG 闭环尚待版本 `03` 验收。
### WebGL 真机数值兼容(后续开发)
用户在 `LOCAL-FACE-20261102-03` 返回:原生 Canvas/WebGL 上下文创建及后端注册已走通,矩阵自测执行了 4 次绘制,但严格数值校验失败,`MATMUL_FAILED`,没有开启真实帧监听或拍照。旧诊断把错误长度、非有限值和误差统一归此码;`pixelReads=0` 当时只在卷积成功后更新,**不能解读为完全没有像素读回**。当前不能认定手机支持可用 GPU 推理,也不能绕过自测继续抓拍。
离线兼容实验故意破坏 `readPixels(FLOAT)` 返回后,默认路径确实矩阵失败;关闭 `WEBGL_DOWNLOAD_FLOAT_ENABLED` 时,TFJS 1.7 的真实备用路径将浮点结果编码为 RGBA 字节并用 `UNSIGNED_BYTE` 读回,矩阵/卷积及完整 68 点模型可通过。同样可关闭 `WEBGL_PACK` 使用非打包 shader。依据为 [TFJS 1.7 官方后端实现](https://github.com/tensorflow/tfjs/blob/tfjs-core-v1.7.0/tfjs-core/src/backends/webgl/backend_webgl.ts) 与 [开关定义](https://github.com/tensorflow/tfjs/blob/tfjs-core-v1.7.0/tfjs-core/src/backends/webgl/flags_webgl.ts),未强造 HAS_WEBGL、WebGL 版本或浮点能力。
版本 `LOCAL-FACE-20261102-04` 按 `default` → `packed_byte`(仅禁用浮点下载)→ `unpacked_byte`(再禁用打包 shader)逐档执行原有严格合成矩阵/卷积/读回自测,每次失败先释放后端和图形对象,通过才加载模型及预热;三种均不通过时明确停止,不回退 CPU,不放宽 1.5 秒源帧新鲜度。新增诊断包括 profile、attemptCount、合成输入的长度、非有限值数、误差及校验和,以及 floatReads/byteReads。合成指标和兼容配置仅在初始化阶段缓存,后续模型检测返回不能覆盖;不复制照片或人脸坐标。
版本 `04` 的联合回归 **194 项全部通过**,含 6 项隔离真实 WebGL 测试:浮点读回损坏后进入字节读回、前两档失败后进入非打包字节读回、三档失败停止、两条兼容路径与 CPU 参考图的人脸框/置信度/68 点几何对照,以及每次换档前图形对象释放。共享 H5 规则、模型生成及差异格式校验通过,淘宝 IDE 本地重新编译完成(DevServer: Done)。这些本地结果不代表手机 GPU、速度或自动三连拍已通过。
### 原生 WebGL 读回检查(版本 05)
用户在 `LOCAL-FACE-20261102-04` 真机复验:三档均 `MATMUL_FAILED`,最后 `unpacked_byte` 记录 5 次绘制、1 次 `UNSIGNED_BYTE` 读回,输出长度 256、非有限值 0、244 项不匹配、最大绝对误差 22、预期校验和 2786/实际 0。帧监听和照片计数均为 0。日志呈全零特征;聚合数据不能单独证明每个元素都为零,更不能据此确定是绘制、上传还是读回出错。此前代码在数值不匹配后即抛错,尚未检查该次 GL 错误,不能将“未报告 GL 错误”解读为“没有 GL 错误”。
版本 **LOCAL-FACE-20261102-05** 根据[淘宝官方 Canvas 注意事项](https://developer.alibaba.com/docs/doc.htm?articleId=121761&docType=1&treeId=829)使用 `getContext('webgl',{enable_flip_y_after_read_pixels:false})`,关闭宿主默认附加的 flipY。隔离实验中,按较大 canvas 高度转换较小 framebuffer 的读取坐标可以产生全零且 `getError=0`;这说明该路径值得验证,**尚未证实手机内部存在这种坐标转换**。
TF 初始化前增加 2×2 RGBA8 清屏/读回、非对称 shader、字节纹理及浮点纹理上传采样检查,使用固定非零合成输入和预填目标哨兵值,区分未写回 TypedArray、错误像素、shader 编译/链接、纹理上传及 framebuffer 问题。基础检查失败即停止并释放资源;通过后仍需通过原有严格矩阵/卷积数值自测、兼容档选择及模型预热。没有降低数值阈值、放宽 1.5 秒新鲜度或自动回到 CPU。
新增诊断保留 `nativeProbe` 阶段与规范化错误、各基础检查结果、哨兵改写数、不匹配数、预期/实际校验和、读回返回值类型枚举、GL 错误和 framebuffer 状态。TF 自测在数值判定前采集 GL 状态;这些固定合成数据只在初始化时缓存,实际人脸检测不能覆盖,不复制照片、像素或人脸坐标。手机兼容性、根因、速度及自动三连拍仍待版本 `05` 验收。
本轮 `05` 联合回归 **199 项全部通过**,含 8 项隔离真实 WebGL 测试。新增测试验证默认坐标转换模拟的全零现象、关闭翻转后的非对称小 FBO 及完整人脸模型,并覆盖未修改读回出参、仅返回 ArrayBuffer/TypedArray/Promise、编译/链接返回 0、顶点上传、字节/浮点纹理故障共 9 类原生预检失败。失败时未注册 TF 后端、未加载模型,各类 GPU 对象释放。共享 H5 规则及模型构建校验通过,淘宝 IDE 本地重新编译完成(DevServer: Done)。实际手机结果仍待确认。
### 原生绘制与兼容选路(版本 06)
用户在 `LOCAL-FACE-20261102-05` 返回:原生清屏读回通过,非对称 shader 检查失败,`changedBytes=16`、3 项不匹配、预期校验和 2244/实际 2040,`getError=0`、FBO 状态 36053(完整)。实际校验和与原清屏颜色的 2040 相同。离线真实 GL 中,停止索引绘制、剔除所有三角形或把顶点上传清零均可复现这一组指标,因此它不能唯一确定绘制未覆盖、坐标、缓冲区上传或 GL 状态中的哪一项是手机根因。帧数及自动照片仍为 0。
版本 **LOCAL-FACE-20261102-06** 显式规范化 GL 绘制状态及颜色写入,在每次绘制前清黑以区分旧背景和实际绘制。先以不依赖 `gl_FragCoord` 的恒色 shader 分别验证 `drawArrays` 与索引绘制,再执行原非对称 shader、字节/浮点纹理检查。新增 `constantArrays`/`constantIndexed` 结果、可选顶点/索引缓冲区字节数及固定合成的四个 RGBA uint32 像素值;缓冲区元数据查询不额外作为硬门槛。
原生兼容策略按 `standard` → `buffer_copy`(将上传视图的字节紧密拷为标准 `bufferData` ArrayBuffer)→ `framebuffer_y`(仅为已绑定 FBO 补偿 viewport/scissor Y)→ `combined` 自测选择。仅原生 `VALUE_MISMATCH` 允许尝试下一路,其余错误明确停止;不能按手机型号认定某路可用。四路在同一个上下文上进行,每次检查结束清理本路图形对象;后续 TF 的 `default`/`packed_byte`/`unpacked_byte` 仍最多创建三个上下文。任何被选中的原生策略都须通过全部原生检查及后续原有严格矩阵/卷积数值校验和真实模型预热,保留 1.5 秒源帧新鲜度,不回退 CPU。
诊断同时保留 `nativeInitialStage`/`nativeInitialCode` 与最终 `nativeStrategy`,避免兼容通过后丢失最初失败信息。四像素、缓冲区及分层结果均来自固定合成输入,只在初始化时读取缓存,实际人脸检测不能覆盖。实际手机的选路、兼容性、速度及完整自动三连拍仍待版本 `06` 验收。
本轮 `06` 联合回归 **204 项全部通过**,其中 9 项为隔离真实 WebGL 测试。四种原生策略分别在第 1/2/3/4 次选中;模拟继承绘制状态、typed view 上传失效、viewport/scissor 坐标转换及组合情况后,实际模型和 CPU 人脸框/置信度/68 点几何对照通过。紧密 ArrayBuffer 拷贝保留子视图偏移和长度;索引绘制不执行时明确停在 `constant_indexed`,不会因 `drawArrays` 通过而准入。10 类原生故障保持注册后端/加载模型前停止,所有张量和跟踪 GPU 对象最终为 0。共享 H5 规则、模型生成及差异格式校验通过;淘宝 IDE 本地重新编译完成(DevServer: Done)。上述不能替代版本 `06` 的手机验收。
### 固定四边形的索引兼容(当前候选 07)
用户在 `06` 返回 `nativeInitialStage=constant_indexed`、`VALUE_MISMATCH`,由执行顺序可知标准路径清屏和普通恒色绘制已通过。索引缓冲为 12 字节,但长度不能证明其内容和原生索引执行正确。最后 `buffer_copy_framebuffer_y` 的 `constant_arrays` 黑色失败属于后续候选,不能覆盖初始故障。真实帧数、照片仍为 0。
`LOCAL-FACE-20261102-07` 在每次原生索引绘制前显式重新绑定索引缓冲。若标准普通绘制通过、索引数值失败,则在相同 native 策略下验证四顶点 `TRIANGLE_STRIP`,保持原非对称像素、字节/浮点纹理和矩阵/卷积严格自测及真实模型预热。依据固定四点与 `[0,1,2,2,1,3]` 的[上游 TFJS 数据](https://raw.githubusercontent.com/tensorflow/tfjs/tfjs-core-v1.7.0/tfjs-core/src/backends/webgl/gpgpu_util.ts),第二个 strip 三角形与原第二三角形同为 `(2,1,3)`;这只适用于自有固定 quad。
实际转换同时要求精确的 `TRIANGLES/6/UNSIGNED_SHORT/offset0` 参数,以及已追踪上传的 12 字节固定索引内容。视图只读取其字节范围;未知缓冲、不同索引、数字分配、部分更新或删除后不再准入,错误为 `DRAW_UNSUPPORTED`。诊断明确 `drawStrategy=indexed/quad_strip` 和实际 strip 绘制计数;替代路径的 `constantIndexed` 保持 `not_run`,另以 `constantQuad` 表示替代输出,不宣称原生索引接口通过。原生自测最多 8 次且共用一个上下文,逐路释放检查对象;1500ms 新鲜度和不回退 CPU 的规则不变。手机是否能完成初始化、速度及三连拍仍待 `07` 验收。
本轮 `07` 联合回归 **207 项全部通过**,其中 11 项为隔离真实 WebGL 测试。模拟原生索引绘制完全无效后,标准原生策略在第 2 次原生检查选中 `quad_strip`,原矩阵/卷积、模型预热、实际人脸及 68 点与 CPU 几何对照通过。另验证 10 类错误调用/索引/缓冲状态均不会发出替代绘制,正确的 typed-view 字节边界仍能识别;资源和张量最终为 0。共享 H5 规则、模型生成及差异格式校验通过,淘宝 IDE 本地重新编译完成(DevServer: Done)。手机版本 `07` 尚未验收。
1. 在电脑淘宝 IDE 顶部点“预览”,等待预览弹窗打开。
2. 在弹窗内点橙色“重新编译并预览”,等待生成新二维码;仅本地重新编译不会更新旧的手机预览。
3. 手机打开淘宝,使用“扫一扫”扫描这个新二维码,进入本次小程序预览。
4. 在手机登录首屏点黄色“相机测试(开发版)”;已进入首页时在开发工具区点同名入口,进入普通相机诊断。
5. 在“普通 camera · 独立诊断”确认版号 `CAM-NATIVE-20261102-04`,点顶部“开始自动三连拍测试”。
6. 进入后确认版号为 `LOCAL-FACE-20261102-07`。若出现“开启实时相机”,点一次,并由用户处理系统相机权限。先等待基础图形检查、数值校验和模型预热,约 30 秒内无需重复点击。
7. 将正脸居中并保持不动,等待自动倒计时及第一张拍摄;随后按页面提示分别露出右脸、左脸,完成三张自动拍摄和压缩。
8. 卡住、报错或完成后,点页面上方的“复制识别诊断”,回到聊天粘贴文字;无需发送照片或校验颜色。后续再验证镜头切换、方向、切后台恢复及拒绝授权。
9. 相机路线通过后,在配套服务版本下验收稳定识别 → 3 秒倒计时 → 三步完成 → 报告,分别测试游客和淘宝账号。再次核对左右脸语义与图层坐标。
## 已运行的验证
- 淘宝前后端、游客无落盘、幂等、账号隔离、AR 几何、相机生命周期及后台汇总共 **61 项本地自动测试全部通过**;共享 H5 评分/报告/拍摄规则哈希一致。
- H5 四组浏览器回归:游客不留存、HTTPS 微信登录/退出/跨设备、报告/后台/CSV、异步侧脸图层。
- Java Maven `clean package`,11 项测试通过;转发层原样支持新增路径,无需新增 Java 业务接口。
- IDE 曾发生桌面进程崩溃;已发现 Crashpad 转储,JavaScript 日志未记录对应原因。重新打开后再次编译成功(0 错误、0 警告),未新增转储;尚未确定首次闪退根因,不直接归为 AR/GPU。
## 部署顺序与边界
淘宝专用 Node 已按上方“最新目标”记录备份并更新游客、签名恢复和幂等代码;H5 及小红书服务尚未更新。本轮未迁移或删除业务数据,手机完整报告及真实网关仍待验收。
1. 先完成平台测肤资质/白名单及开放拍照组件核对,补齐采集授权;在平台支持的组件方案上通过实时识别、图像导出和三视角抓拍验收。现有 AR 验证只是其中一项技术验证。
2. 部署淘宝专用 Node,保留独立数据目录;验证新登录/游客/三图报告,不向 H5 生产数据目录写入。
3. 发布含本轮后台改动的 H5 管理服务,并明确配置只读 `JUZI_ADMIN_TAOBAO_DATA_ROOT`,保留原 nginx 管理员认证及 `/tb/` 网关入口限制。
4. 运行大报告/历史详情的真实网关与低内存手机测试。前台最近 5 份只是索引规则,后台照片和快照尚无自动清理;保留周期、备份、删除另行确定,不能直接合并两个文件目录。
5. 验收正式/测试环境隔离、资源域名、商品详情能力、新老报告及游客回跳;关闭开发入口。按现有交接要求处理测试凭据后再正式发布。
### 明确未完成
- 平台自带算法路线的商家/ISV 资质和白名单状态确认、正式拍照组件契约、品牌采脸授权及平台结果字段适配。
- 普通相机在手机上的本地模型耗时、转头方向、覆盖层和完整自动三连拍。AR 保留为可选对照,其默认自拍和图像导出仍未验收。
- 手机上的新游客接口和原系统相机报告故障最终复验(本地回归已覆盖,线上尚未更新)。
- 固定商品真机跳转、真实网关大响应及后台跨源上线配置。
- 照片保留/清理周期、数据库/对象存储迁移;本轮没有自动删除旧数据。
+40
View File
@@ -0,0 +1,40 @@
# H5 / 淘宝统一后台:数据源与部署
统一后台增加“全部渠道 / H5 / 淘宝小程序”筛选,覆盖记录、统计、失败记录及 CSV 导出。用户账号、照片、报告仍由各自业务服务保存,不做账号合并,也不改变 H5 用户历史。
## 默认行为
未配置额外数据源时,后台继续读取当前服务的 `JUZI_DATA_ROOT`,现有 CSV 列顺序和末尾“渠道”保持不变。新接口返回数据来源标识,不公开服务器目录。
## 接入淘宝只读数据源
在承载管理后台的 **H5 服务**环境中配置绝对路径,例如:
```ini
JUZI_ADMIN_TAOBAO_DATA_ROOT=/home/ubuntu/juzi-tb-data
```
需要部署本次修改的 `h5/server.js`、`h5/lib/admin-sources.js`、`h5/admin.html` 和 `h5/js/admin.js`,然后按原部署流程重启该 H5 服务。淘宝业务服务继续使用原数据目录。管理后台进程需要拥有该目录的只读权限;两个服务不得共用同一可写 JSON 目录。
该配置只增加管理读取,不修改淘宝账号库或检测文件,不迁移、不删除历史数据。配置的额外目录必须存在,与当前数据目录独立,并位于 H5 静态应用目录之外;配置错误时服务启动会明确失败。
后台仍经原有 nginx 管理员认证入口访问,保持业务服务监听回环地址;不要为汇总功能放开 `/tb/` 网关白名单或取消后台认证。小红书隔离实例不接入此额外数据源。
## 路由与数据口径
- 每条汇总记录包含 `source`、`admin_key` 和只读状态。两个数据源即使有相同记录 ID,也能分别打开正确详情、照片和原始 JSON。
- 详情和媒体请求通过后台已有路径的 `source=local` 或 `source=taobao` 选取已配置的数据源;HTTP 请求不能指定任意文件目录。
- 全局列表先合并再筛选、稳定排序和分页。CSV 沿用所有记录筛选条件,导出日期可另选;接入额外数据源后,仅在原有列末尾追加“数据来源、记录唯一键”。
- 按渠道统计检测记录及拍照流程;游客不留存的检测不会伪装成已保存报告。拍照漏斗是匿名客户端事件,不能视为人数或计费次数。淘宝尚未上报漏斗事件时显示零事件,不据此推断淘宝无检测。
- 外部数据源为只读:详情和失败页关闭重试按钮,服务端也拒绝外源重试。重试必须在原服务后台进行,避免写入错误渠道。
- “用户最近 5 份报告”是用户历史展示范围,不是后台磁盘保留期限。本次不会删除旧照片、原始结果、报告快照或检测记录;清理与备份周期需另行约定。
## 验证
```powershell
node --test taobao/test/admin-channels.test.cjs
$env:JUZI_ADMIN_BROWSER_QA = '1'
node --test taobao/test/admin-channels.test.cjs
```
测试使用独立临时数据目录和伪造图片,不请求 Face++,也不访问或修改线上数据。浏览器验证截图位于 `temp/taobao-adaptation/admin-channels.png`。
@@ -1,4 +1,4 @@
# 1meAI测肤旷视UI问题反馈
# 1'meAI测肤旷视UI问题反馈
## UI交互界面调整
+10 -3
View File
@@ -17,6 +17,12 @@
</header>
<main>
<div class="filters channel-filter">
<label>渠道
<select id="f-channel"><option value="">全部渠道</option><option value="h5">H5</option><option value="taobao">淘宝小程序</option></select>
</label>
<span class="hint" id="channel-summary">正在读取渠道数据…</span>
</div>
<!-- ─── 记录列表(文档 4.1/4.2) ─── -->
<section id="tab-records" class="panel on">
<div class="filters">
@@ -71,7 +77,7 @@
<table id="tbl-records">
<thead>
<tr>
<th>照片</th><th>记录ID</th><th>年龄/性别/地区/肤质</th><th>拍摄状态</th><th>上传状态</th>
<th>照片</th><th>记录ID / 来源</th><th>年龄/性别/地区/肤质</th><th>拍摄状态</th><th>上传状态</th>
<th>检测状态</th><th title="紧塑力×4+透亮感×3+稳韧力×3,四舍五入取整数">综合得分</th><th>肌龄(原始 / 展示)</th><th>三维度评分 / 档位</th><th>推荐精华</th><th>耗时</th><th>创建时间</th>
</tr>
</thead>
@@ -92,7 +98,7 @@
<table id="tbl-fails">
<thead>
<tr>
<th>记录ID</th><th>失败环节</th><th>失败码</th><th>失败信息</th><th>年龄</th><th>拍摄状态</th><th>创建时间</th><th>操作</th>
<th>记录ID / 来源</th><th>失败环节</th><th>失败码</th><th>失败信息</th><th>年龄</th><th>拍摄状态</th><th>创建时间</th><th>操作</th>
</tr>
</thead>
<tbody></tbody>
@@ -103,6 +109,7 @@
<!-- ─── 统计概览(文档 4.5) ─── -->
<section id="tab-stats" class="panel">
<div class="cards" id="stat-cards"></div>
<div class="block"><h3>全部渠道检测概览</h3><div id="channel-stats"></div><p class="hint">此表展示全部渠道对照,上方指标按当前渠道筛选。按检测记录统计,不是跨渠道去重人数;游客临时报告不进入已保存检测记录统计。</p></div>
<div class="block">
<h3>拍照 → 报告页转化(上线后新流程)</h3>
<div id="capture-funnel"></div>
@@ -128,7 +135,7 @@
<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>
<span class="hint">沿用当前渠道及记录筛选条件;导出日期可另选。北京时间,UTF-8 BOM,不含接口原始 JSON。</span>
</div>
</div>
</section>
+64 -22
View File
@@ -49,6 +49,27 @@ function api(url, opts) {
});
});
}
function channelName(channel) { return ({ h5: 'H5', taobao: '淘宝小程序', rednote: '小红书' })[channel] || channel || 'H5'; }
function selectedChannelQuery(url) {
var channel = $('#f-channel').value;
return channel ? url + (url.indexOf('?') < 0 ? '?' : '&') + 'channel=' + encodeURIComponent(channel) : url;
}
function sourceQuery(source) { return '?source=' + encodeURIComponent(source || 'local'); }
function renderSources(data) {
var sources = data.sources || [];
$('#channel-summary').textContent = sources.length > 1
? '已汇总 H5 与淘宝数据;外部渠道记录只读,重试请进入原服务后台。'
: '当前读取本服务的数据;尚未接入其他服务的数据源。';
}
function renderChannelStats(data) {
renderSources(data);
var rows = data.by_channel || {}, html = '<table><thead><tr><th>渠道</th><th>累计检测</th><th>今日检测</th><th>成功</th><th>失败</th></tr></thead><tbody>';
Object.keys(rows).forEach(function (key) {
var row = rows[key];
html += '<tr><td>' + esc(channelName(key)) + '</td><td>' + row.total + '</td><td>' + row.today_total + '</td><td>' + row.success_total + '</td><td>' + row.fail_total + '</td></tr>';
});
$('#channel-stats').innerHTML = html + '</tbody></table>';
}
/* ─── 页签切换 ─── */
var TABS = { records: 1, fails: 1, stats: 1 };
@@ -116,6 +137,7 @@ function collectFilters() {
add('from', $('#f-from').value);
add('to', $('#f-to').value);
add('detect_status', $('#f-status').value);
add('channel', $('#f-channel').value);
add('fail_code', $('#f-failcode').value);
add('age_min', $('#f-agemin').value);
add('age_max', $('#f-agemax').value);
@@ -130,6 +152,7 @@ function collectFilters() {
function loadRecords() {
api('/api/admin/records?' + collectFilters()).then(function (data) {
renderSources(data);
pageState.total = data.total;
var tbody = $('#tbl-records tbody');
tbody.innerHTML = '';
@@ -145,7 +168,7 @@ function loadRecords() {
: '<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 class="mono">' + esc(it.record_id) + '<div class="sub">' + esc(channelName(it.channel)) + (it.readonly ? ' · 只读' : '') + '</div>' + (it.retry_of ? '<div class="sub">重试自 ' + esc(it.retry_of) + '</div>' : '') + '</td>' +
'<td>' + profileHtml(it) + '</td>' +
'<td>' + esc(it.shoot_status) + captureQualityHtml(it) + '</td>' +
'<td>' + esc(it.upload_status) + '</td>' +
@@ -159,7 +182,7 @@ function loadRecords() {
'<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);
openDetail(it.record_id, it.source);
});
tbody.appendChild(tr);
})(data.items[i]);
@@ -175,6 +198,7 @@ function loadRecords() {
function renderCaptureFunnel(st) {
var funnel = st.capture_funnel;
var panel = $('#capture-funnel');
if (panel) panel.parentNode.hidden = !funnel;
if (funnel && panel) {
var labels = [['started','开始拍照'],['front','正面拍完'],['right','右脸拍完'],['left','三张拍完'],['submitted','提交检测'],['response','收到成功结果'],['report','进入报告页']];
var html = '<table><thead><tr><th>阶段</th><th>累计流程</th><th>今日流程</th></tr></thead><tbody>';
@@ -190,7 +214,8 @@ function renderCaptureFunnel(st) {
}
}
function loadFails() {
api('/api/admin/stats').then(function (st) {
api(selectedChannelQuery('/api/admin/stats')).then(function (st) {
renderSources(st);
var box = $('#fail-dist');
box.innerHTML = '';
if (!st.fail_dist.length) box.appendChild(el('div', 'pie-empty', '暂无失败记录'));
@@ -205,7 +230,7 @@ function loadFails() {
}
}).catch(function (e) { toast('加载失败:' + e.message); });
api('/api/admin/records?detect_status=失败&page_size=100').then(function (data) {
api(selectedChannelQuery('/api/admin/records?detect_status=失败&page_size=100')).then(function (data) {
var tbody = $('#tbl-fails tbody');
tbody.innerHTML = '';
if (!data.items.length) {
@@ -215,17 +240,17 @@ function loadFails() {
(function (it) {
var tr = el('tr');
tr.innerHTML =
'<td class="mono">' + esc(it.record_id) + '</td>' +
'<td class="mono">' + esc(it.record_id) + '<div class="sub">' + esc(channelName(it.channel)) + '</div></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>';
'<td>' + (it.readonly ? '<span class="sub">只读,请到原服务后台重试</span>' : '<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);
if (e.target && e.target.tagName === 'BUTTON') { doRetry(it.record_id, it.source); return; }
openDetail(it.record_id, it.source);
});
tbody.appendChild(tr);
})(data.items[i]);
@@ -233,10 +258,10 @@ function loadFails() {
}).catch(function (e) { toast('加载失败:' + e.message); });
}
function doRetry(id) {
function doRetry(id, source) {
if (!confirm('将使用原照片重新检测并生成新记录,确认重试 ' + id + ' ?')) return;
toast('重试中,请稍候(最长约 1 分钟)…');
api('/api/admin/records/' + encodeURIComponent(id) + '/retry', { method: 'POST' }).then(function (data) {
api('/api/admin/records/' + encodeURIComponent(id) + '/retry' + sourceQuery(source), { method: 'POST' }).then(function (data) {
toast('重试完成,新记录 ' + data.record.record_id + '(' + data.record.detect_status + ')');
var active = $('.panel.on').id;
if (active === 'tab-records') loadRecords();
@@ -247,7 +272,8 @@ function doRetry(id) {
/* ═══════════ 统计概览 ═══════════ */
var PIE_COLORS = ['#2563eb', '#2fa84f', '#f0a020', '#8a5cf6', '#d93026'];
function loadStats() {
api('/api/admin/stats').then(function (st) {
api(selectedChannelQuery('/api/admin/stats')).then(function (st) {
renderChannelStats(st);
renderCaptureFunnel(st);
var cards = [
['今日检测', st.today_total],
@@ -332,18 +358,23 @@ function loadStats() {
/* ═══════════ 记录详情(抽屉) ═══════════ */
var currentDetailId = null;
var currentDetailSource = 'local';
var detailSeq = 0;
function openDetail(id) {
function openDetail(id, source) {
var seq = ++detailSeq;
currentDetailId = id;
currentDetailSource = source || 'local';
$('#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>'; });
api('/api/admin/records/' + encodeURIComponent(id) + sourceQuery(currentDetailSource)).then(function (data) { if (seq === detailSeq) renderDetail(data); })
.catch(function (e) { if (seq === detailSeq) $('#d-body').innerHTML = '<div class="sub">加载失败:' + esc(e.message) + '</div>'; });
}
function closeDetail() {
detailSeq++;
$('#drawer').classList.add('hidden');
$('#drawer-mask').classList.add('hidden');
currentDetailId = null;
@@ -355,7 +386,8 @@ function dSec(title, innerHtml) {
function renderDetail(data) {
var r = data.record;
$('#btn-retry').disabled = !(data.photos && data.photos.front);
$('#btn-retry').disabled = data.readonly || !(data.photos && data.photos.front);
$('#btn-retry').title = data.readonly ? '只读汇总记录,请在原服务后台重试' : '';
var h = '';
/* 三张照片 */
@@ -375,6 +407,7 @@ function renderDetail(data) {
/* 状态行 */
h += dSec('状态', '<div class="d-kv">' +
'<span>渠道 <b>' + esc(channelName(r.channel)) + (data.readonly ? '(只读汇总)' : '') + '</b></span>' +
'<span>检测状态 <b>' + esc(r.detect_status) + '</b></span>' +
'<span>拍摄状态 <b>' + esc(r.shoot_status) + '</b></span>' +
'<span>上传状态 <b>' + esc(r.upload_status) + '</b></span>' +
@@ -553,7 +586,7 @@ function renderDetail(data) {
if (data.raw_json) {
var rawFile = data.raw_file || (window.JuziEntry && window.JuziEntry.mode === 'rednote' ? r.raw_file : null);
var rawDl = rawFile
? ' <a href="' + adminURL('/api/admin/records/' + encodeURIComponent(r.record_id) + '/raw') + '" download>下载完整原始 JSON</a>'
? ' <a href="' + adminURL('/api/admin/records/' + encodeURIComponent(r.record_id) + '/raw' + sourceQuery(data.source)) + '" download>下载完整原始 JSON</a>'
: '';
h += dSec('接口原始 JSON', '<details><summary>展开/收起(' + (rawFile || '') + ')' + rawDl + '</summary><pre>' +
esc(JSON.stringify(data.raw_json, null, 1)) + '</pre></details>');
@@ -572,7 +605,7 @@ function renderDetail(data) {
/* 版本链跳转 */
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')); });
links[v].addEventListener('click', function () { openDetail(this.getAttribute('data-id'), data.source); });
}
}
@@ -584,6 +617,14 @@ function showLightbox(src) {
/* ═══════════ 事件绑定与初始化 ═══════════ */
function bind() {
if (window.JuziEntry && window.JuziEntry.mode === 'rednote') {
$('#f-channel').innerHTML = '<option value="">全部渠道</option><option value="rednote">小红书</option>';
}
$('#f-channel').addEventListener('change', function () {
pageState.page = 1;
var active = $('.panel.on').id.replace('tab-', '');
switchTab(active);
});
var tabs = document.querySelectorAll('.tab');
for (var i = 0; i < tabs.length; i++) {
tabs[i].addEventListener('click', function () { switchTab(this.dataset.tab); });
@@ -599,13 +640,14 @@ function bind() {
$('#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); });
$('#btn-retry').addEventListener('click', function () { if (currentDetailId) doRetry(currentDetailId, currentDetailSource); });
$('#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 = adminURL('/api/admin/export' + (p.length ? '?' + p.join('&') : ''));
var p = new URLSearchParams(collectFilters());
p.delete('page'); p.delete('page_size');
if ($('#exp-from').value) p.set('from', $('#exp-from').value);
if ($('#exp-to').value) p.set('to', $('#exp-to').value);
var url = adminURL('/api/admin/export?' + p.toString());
var a = el('a');
a.href = url;
a.download = '';
+16 -1
View File
@@ -33,6 +33,7 @@ module.exports = function createAccounts(root, env = process.env) {
if (base) { base.pathname = prefix; base.search = ''; base.hash = ''; }
const secure = !!(base && base.protocol === 'https:');
const guestSessions = new Map(); // No guest identities/sessions are written to disk.
const taobaoGuests = new Map(); // Separate, temporary Bearer sessions; never written to db.
const appId = env.WECHAT_APP_ID || '';
const secret = env.WECHAT_APP_SECRET || '';
const available = !rednote && !!(appId && secret && secure && !/^(localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(base.hostname));
@@ -226,13 +227,27 @@ module.exports = function createAccounts(root, env = process.env) {
save();
return { token, expires_in: 30 * 86400, user: publicUser(user) };
}
function loginTaobaoGuest() {
for (const [key, value] of taobaoGuests) if (value.expires <= Date.now()) taobaoGuests.delete(key);
if (taobaoGuests.size >= 1000) return null;
const token = random(), user = { id: crypto.randomUUID(), kind: 'taobao_guest' };
taobaoGuests.set(hash(token), { user, expires: Date.now() + 8 * 3600000 });
return { token, expires_in: 8 * 3600, user: publicUser(user) };
}
function taobaoSession(req) {
const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || '');
const key = match && hash(match[1]), guest = key && taobaoGuests.get(key);
if (guest) {
if (guest.expires > Date.now()) return guest.user;
taobaoGuests.delete(key);
return null;
}
const row = match && db.sessions[hash(match[1])];
return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === 'taobao') : null;
}
function taobaoLogout(req) {
const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || '');
if (match && taobaoGuests.delete(hash(match[1]))) return;
if (match && taobaoSession(req)) { delete db.sessions[hash(match[1])]; save(); }
}
function reports(owner) {
@@ -247,5 +262,5 @@ module.exports = function createAccounts(root, env = process.env) {
return snapshot;
}
return { route, session, storeReport, reportPresentation, reportSnapshot, updateWrinkleViews, sameOrigin, wechatAvailable: available,
loginTaobao, taobaoSession, taobaoLogout, reports, ownedReport };
loginTaobao, loginTaobaoGuest, taobaoSession, taobaoLogout, reports, ownedReport };
};
+72
View File
@@ -0,0 +1,72 @@
'use strict';
// Administrative reads only. Detection/authentication continue to write solely
// to the process's own DATA_ROOT. Source paths never come from an HTTP request.
const fs = require('node:fs');
const path = require('node:path');
module.exports = function ({ localRoot, localChannel = 'h5', taobaoRoot, publicRoot }) {
const sources = new Map([['local', { id: 'local', root: path.resolve(localRoot), channel: localChannel, readonly: false }]]);
const inside = (file, root) => file === root || file.startsWith(root + path.sep);
if (taobaoRoot) {
if (!path.isAbsolute(taobaoRoot)) throw Error('JUZI_ADMIN_TAOBAO_DATA_ROOT must be absolute');
const root = fs.realpathSync(taobaoRoot);
const primary = fs.realpathSync(localRoot);
if (inside(root, primary) || inside(primary, root)) throw Error('Administrative data sources must be separate directories');
if (publicRoot && inside(root, fs.realpathSync(publicRoot))) throw Error('Administrative external data must be outside the public application directory');
sources.set('taobao', { id: 'taobao', root, channel: 'taobao', readonly: true });
}
function source(id = 'local') { return sources.get(id) || null; }
function records(id) {
const selected = id ? [source(id)].filter(Boolean) : [...sources.values()];
return selected.flatMap(s => {
let text;
const target = file(s.id, 'records.jsonl');
if (!target) return [];
try { text = fs.readFileSync(target, 'utf8'); }
catch (e) { if (e.code === 'ENOENT') return []; throw e; }
return text.split('\n').flatMap(line => {
let rec; try { rec = JSON.parse(line); } catch (_) { return []; }
if (!rec || typeof rec.record_id !== 'string' || !/^[\w-]+$/.test(rec.record_id)) return [];
return [{ ...rec, channel: rec.channel || s.channel, admin_source: s.id,
admin_key: s.id + ':' + rec.record_id, admin_readonly: s.readonly }];
});
});
}
function find(id, sourceId = 'local') { return records(sourceId).find(r => r.record_id === id) || null; }
function file(sourceId, ...parts) {
const s = source(sourceId);
if (!s || parts.some(p => typeof p !== 'string' || !p || p !== path.basename(p) || p === '.' || p === '..')) return null;
const target = path.resolve(s.root, ...parts);
if (!inside(target, s.root)) return null;
try { return inside(fs.realpathSync(target), fs.realpathSync(s.root)) ? target : null; }
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
}
function presentation(rec) {
const target = file(rec.admin_source, 'accounts', 'reports', rec.owner_id, rec.record_id + '.json');
if (!target) return null;
try { return JSON.parse(fs.readFileSync(target, 'utf8')).presentation || null; }
catch (_) { return null; }
}
function funnel(now, channel, localFunnel) {
const stats = [...sources.values()].filter(s => !channel || s.channel === channel).map(s => {
if (s.id === 'local') return localFunnel.stats(now);
return require('./capture-funnel')(path.join(s.root, 'capture-funnel.jsonl')).stats(now);
});
function sum(scope) {
const rows = stats.map(st => st[scope]);
const counts = {};
for (const st of rows) for (const [stage, n] of Object.entries(st.counts)) counts[stage] = (counts[stage] || 0) + n;
const reports = counts.report || 0;
return { counts, rate: counts.started ? Math.round(reports / counts.started * 1000) / 10 : 0,
response_without_report: rows.reduce((n, r) => n + r.response_without_report, 0),
unfinished: rows.reduce((n, r) => n + r.unfinished, 0),
avg_ms: reports ? Math.round(rows.reduce((n, r) => n + (r.avg_ms || 0) * r.counts.report, 0) / reports) : null };
}
// Keep the event schema even when a selected channel has no telemetry yet.
if (!stats.length) return null;
const starts = stats.map(st => st.since).filter(Number.isFinite);
return { since: starts.length ? Math.min(...starts) : null, total: sum('total'), today: sum('today') };
}
return { source, records, find, file, presentation, funnel,
descriptors: () => [...sources.values()].map(({ id, channel, readonly }) => ({ id, channel, readonly })) };
};
+12 -5
View File
@@ -18,19 +18,26 @@ function signature(params, body, secret) {
}
return crypto.createHash('md5').update(secret + content + secret).digest('hex').toUpperCase();
}
function verify(url, body, env = process.env) {
// Guest requests still require the real signed app gateway, without userInfo
// authorization or an open_id. Body fields never establish an identity.
function verifyGateway(url, body, env = process.env) {
if (!env.TAOBAO_APP_KEY || !env.TAOBAO_APP_SECRET) return null;
const params = Object.create(null);
for (const [key, value] of new URL(url, 'http://localhost').searchParams) {
if (key in params) return null; // Never ambiguously verify duplicate parameters.
params[key] = value;
}
if (params.app_key !== env.TAOBAO_APP_KEY || !/^[A-Za-z0-9_-]{4,256}$/.test(params.open_id || '') ||
if (params.app_key !== env.TAOBAO_APP_KEY ||
!/^[A-Fa-f0-9]{32,64}$/.test(params.sign || '')) return null;
const expected = signature(params, body, env.TAOBAO_APP_SECRET);
if (!expected || expected.length !== params.sign.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(params.sign.toUpperCase()))) return null;
// Login challenge is single-use and signed inside the body, preventing replay.
return { providerKey: crypto.createHash('sha256').update(params.app_key + ':' + params.open_id).digest('hex') };
return { appKey: params.app_key, openId: params.open_id };
}
module.exports = { signature, verify };
function verify(url, body, env = process.env) {
const gateway = verifyGateway(url, body, env);
if (!gateway || !/^[A-Za-z0-9_-]{4,256}$/.test(gateway.openId || '')) return null;
// Login challenge is single-use and signed inside the body, preventing replay.
return { providerKey: crypto.createHash('sha256').update(gateway.appKey + ':' + gateway.openId).digest('hex') };
}
module.exports = { signature, verify, verifyGateway };
+26 -9
View File
@@ -11,6 +11,7 @@ module.exports = function ({ accounts, wrinkleJobs, readBody, sendJson, handleAn
try {
const raw = (await readBody(req, pathname === '/api/taobao/photo' ? 70 * 1024 : 4096)).toString('utf8');
const body = JSON.parse(raw);
if (!body || typeof body !== 'object' || Array.isArray(body)) return reply(400, { ok: false, message: '请求数据异常' });
if (pathname === '/api/taobao/challenge') {
const challenge = taobaoSessions.challenge(req.socket.remoteAddress);
return reply(challenge ? 200 : 429, challenge ? { ok: true, challenge } : { ok: false, message: '操作频繁,请稍后重试' });
@@ -21,29 +22,45 @@ module.exports = function ({ accounts, wrinkleJobs, readBody, sendJson, handleAn
if (!identity || !taobaoSessions.consume(body.challenge)) return reply(401, { ok: false, message: '淘宝身份校验失败,请重新授权登录' });
return reply(200, { ok: true, ...accounts.loginTaobao(identity.providerKey) });
}
if (pathname === '/api/taobao/guest-session') {
if (body.consent !== true) return reply(400, { ok: false, message: '请先阅读并同意隐私协议' });
if (!TaobaoAuth.verifyGateway(req.url, raw) || !taobaoSessions.consume(body.challenge)) {
return reply(401, { ok: false, message: '游客会话校验失败,请重新开始' });
}
const guest = accounts.loginTaobaoGuest();
return reply(guest ? 200 : 429, guest ? { ok: true, ...guest } : { ok: false, message: '操作频繁,请稍后再试' });
}
const owner = accounts.taobaoSession(req);
if (!owner) return reply(401, { ok: false, message: '检测会话已失效,请重新登录' });
if (pathname === '/api/taobao/me') return reply(200, { ok: true, user: { id: owner.id, kind: owner.kind } });
if (!owner) return reply(401, { ok: false, message: '检测会话已失效,请重新开始' });
if (pathname === '/api/taobao/me') {
// A cached Bearer must not restore the previous Taobao account after
// the host app switches accounts. Guests require a signed app gateway
// but intentionally have no dependency on userInfo/open_id authorization.
const identity = owner.kind === 'taobao_guest' ? null : TaobaoAuth.verify(req.url, raw);
const matches = owner.kind === 'taobao_guest' ? !!TaobaoAuth.verifyGateway(req.url, raw)
: !!identity && identity.providerKey === owner.provider_key;
return reply(matches ? 200 : 401, matches ? { ok: true, user: { id: owner.id, kind: owner.kind } }
: { ok: false, message: '淘宝账号状态已变化,请重新进入' });
}
if (pathname === '/api/taobao/photo') {
const { status, ...data } = taobaoSessions.upload(req, body);
return reply(status, { ok: status === 200, ...data });
}
if (pathname === '/api/taobao/skin-analyze') {
const photos = taobaoSessions.takePhotos(req, body.photo_refs);
if (!photos) return reply(409, { ok: false, message: '照片上传未完成或检测进行中,请重试' });
// Start once and poll: cloud gateway requests have a maximum 10s timeout.
const job = taobaoSessions.start(req, () => new Promise(resolve => {
const attempt = taobaoSessions.beginAnalysis(req, body, photos => new Promise(resolve => {
const response = { setHeader() {}, writeHead() {}, end(text, cb) { resolve(JSON.parse(text)); if (cb) cb(); } };
handleAnalyze(req, response, true, { ...body, ...photos }, owner).catch(() => resolve({ ok: false, message: '检测服务暂不可用,请重试' }));
}), true);
if (!job) { taobaoSessions.finish(req); return reply(429, { ok: false, message: '服务繁忙,请稍后再试' }); }
return reply(202, { ok: true, job });
}));
return reply(attempt.status, { ok: attempt.status === 202,
...(attempt.job ? { job: attempt.job } : { message: attempt.message }) });
}
if (pathname === '/api/taobao/analysis') {
const result = taobaoSessions.result(req, body.job);
return reply(result ? 200 : 404, result ? { ok: true, ...result } : { ok: false, message: '检测任务已失效,请查看历史报告或重新检测' });
return reply(result ? 200 : 404, result ? { ok: true, ...result } : { ok: false, message: owner.kind === 'taobao_guest' ? '本次检测任务已失效,请重新开始' : '检测任务已失效,请查看历史报告或重新检测' });
}
if (pathname === '/api/taobao/reports') {
if (owner.kind === 'taobao_guest') return reply(body.id ? 404 : 200, body.id ? { ok: false, message: '游客模式不保存历史报告' } : { ok: true, total: 0, items: [] });
if (!body.id) return reply(200, { ok: true, total: accounts.reports(owner.id).length, items: accounts.reports(owner.id) });
const report = accounts.ownedReport(owner.id, body.id);
return reply(report ? 200 : 404, report ? { ok: true, report } : { ok: false, message: '报告不存在' });
+73 -13
View File
@@ -3,8 +3,11 @@ const crypto = require('node:crypto');
// Short-lived transfers/jobs/challenges. Identity/history live in accounts.js.
module.exports = function ({ session, now = Date.now } = {}) {
const transfers = new Map(), challenges = new Map(), rates = new Map(), jobs = new Map();
const transfers = new Map(), challenges = new Map(), rates = new Map(), jobs = new Map(), submissions = new Map();
const lifetime = 5 * 60000, maxChunk = 64 * 1024, maxPhoto = 4 * 1024 * 1024;
// Keep only small idempotency tombstones after result expiry. Never silently
// charge again when a client retries an old request_id in the same session.
const submissionLifetime = 30 * 86400000;
let bytes = 0;
function clearPhotos(row) {
for (const photo of Object.values(row.photos)) bytes -= photo.bytes;
@@ -15,6 +18,12 @@ module.exports = function ({ session, now = Date.now } = {}) {
for (const [id, until] of challenges) if (until <= now()) challenges.delete(id);
for (const [id, row] of rates) if (row.until <= now()) rates.delete(id);
for (const [id, job] of jobs) if (job.expires <= now() && job.status !== 'running') jobs.delete(id);
for (const [id, value] of submissions) if (value.expires <= now()) submissions.delete(id);
}
function scope(req, owner) {
const authorization = req.headers && req.headers.authorization;
return typeof authorization === 'string' && authorization
? crypto.createHash('sha256').update(authorization).digest('hex') : owner.id;
}
function challenge(address) {
sweep();
@@ -35,8 +44,9 @@ module.exports = function ({ session, now = Date.now } = {}) {
sweep();
const owner = session(req);
if (!owner) return null;
if (!transfers.has(owner.id) && create && transfers.size < 1000) transfers.set(owner.id, { photos: {}, active: false, expires: now() + lifetime });
return transfers.get(owner.id) || null;
const key = scope(req, owner);
if (!transfers.has(key) && create && transfers.size < 1000) transfers.set(key, { photos: {}, active: false, expires: now() + lifetime });
return transfers.get(key) || null;
}
function upload(req, body) {
const current = row(req, true);
@@ -64,35 +74,85 @@ module.exports = function ({ session, now = Date.now } = {}) {
current.expires = now() + lifetime;
return { status: 200, complete: photo.chunks.length === total, photo_ref: id };
}
function takePhotos(req, refs) {
const current = row(req);
function availablePhotos(current, refs) {
if (!current || current.active) return null;
if (!refs || !['front', 'left', 'right'].every(view => {
const photo = current.photos[view];
return photo && photo.id === refs[view] && photo.chunks.length === photo.total;
})) return null;
const photos = Object.fromEntries(['front', 'left', 'right'].map(view => [view + '_image', current.photos[view].chunks.join('')]));
return Object.fromEntries(['front', 'left', 'right'].map(view => [view + '_image', current.photos[view].chunks.join('')]));
}
function takePhotos(req, refs) {
const current = row(req), photos = availablePhotos(current, refs);
if (!photos) return null;
clearPhotos(current); current.active = true;
return photos;
}
function finish(req, ownerId) { const current = ownerId ? transfers.get(ownerId) : row(req); if (current) { current.active = false; current.expires = now() + lifetime; } }
function revoke(req) { const current = row(req); if (current) { clearPhotos(current); current.expires = 0; sweep(); } }
function start(req, run, releasePhotos = false) {
function finish(req, key) { const current = key ? transfers.get(key) : row(req); if (current) { current.active = false; current.expires = now() + lifetime; } }
function revoke(req) {
const owner = session(req);
if (!owner) return;
const key = scope(req, owner), current = transfers.get(key);
if (current) { clearPhotos(current); transfers.delete(key); }
for (const [id, job] of jobs) if (job.scope === key) { job.result = null; jobs.delete(id); }
for (const [id, value] of submissions) if (value.scope === key) submissions.delete(id);
}
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
return value && typeof value === 'object'
? Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])) : value;
}
function submission(req, requestId, body) {
sweep();
const owner = session(req);
if (!owner) return { status: 401, message: '检测会话已失效,请重新开始' };
if (requestId === undefined) return { status: 200 }; // Older clients remain supported.
if (typeof requestId !== 'string' || !/^[A-Za-z0-9_-]{8,128}$/.test(requestId)) {
return { status: 400, message: '检测请求标识异常,请重新开始' };
}
const key = scope(req, owner) + ':' + requestId;
const fingerprint = crypto.createHash('sha256').update(JSON.stringify(stable({ photo_refs: body.photo_refs, profile: body.profile }))).digest('hex');
const previous = submissions.get(key);
if (previous) {
if (previous.fingerprint !== fingerprint) return { status: 409, message: '检测请求已用于其他照片或档案,请重新开始' };
if (!jobs.has(previous.job)) return { status: 410, message: '本次检测任务已失效,请重新开始新的检测' };
return { status: 202, job: previous.job };
}
if (submissions.size >= 10000) return { status: 429, message: '服务繁忙,请稍后再试' };
return { status: 200, key, fingerprint };
}
function start(req, run, releasePhotos = false, attempt) {
sweep();
const owner = session(req);
if (!owner || jobs.size >= 1000) return null;
const id = crypto.randomBytes(24).toString('hex');
const job = { owner: owner.id, status: 'running', expires: now() + lifetime, result: null };
const key = scope(req, owner);
const job = { owner: owner.id, scope: key, status: 'running', expires: now() + lifetime, result: null };
jobs.set(id, job);
if (attempt && attempt.key) submissions.set(attempt.key, { job: id, scope: key, fingerprint: attempt.fingerprint, expires: now() + submissionLifetime });
Promise.resolve().then(run).then(result => { job.result = result; }, () => { job.result = { ok: false, message: '检测服务暂不可用,请重试' }; })
.finally(() => { job.status = 'complete'; job.expires = now() + lifetime; if (releasePhotos) finish(req, owner.id); });
.finally(() => { job.status = 'complete'; job.expires = now() + lifetime; if (releasePhotos) finish(req, key); });
return id;
}
function beginAnalysis(req, body, run) {
const attempt = submission(req, body.request_id, body);
if (attempt.status !== 200) return attempt;
const current = row(req), photos = availablePhotos(current, body.photo_refs);
if (!photos) return { status: 409, message: '照片上传未完成或检测进行中,请重试' };
// Reserve the job and its idempotency key BEFORE consuming uploads. There
// are no awaits here; the paid callback is queued for the next microtask.
// A full job queue leaves the complete uploads available for a later retry.
const job = start(req, () => run(photos), true, attempt);
if (!job) return { status: 429, message: '服务繁忙,请稍后再试' };
clearPhotos(current); current.active = true;
return { status: 202, job };
}
function result(req, id) {
sweep();
const job = jobs.get(id), owner = session(req);
return job && owner && job.owner === owner.id ? { status: job.status, payload: job.result } : null;
return job && owner && job.owner === owner.id && job.scope === scope(req, owner)
? { status: job.status, payload: job.result } : null;
}
const timer = setInterval(sweep, 60000); timer.unref();
return { challenge, consume, upload, takePhotos, finish, revoke, start, result };
return { challenge, consume, upload, takePhotos, finish, revoke, start, beginAnalysis, submission, result };
};
+62 -38
View File
@@ -33,6 +33,8 @@ const CaptureDiagnostics = require('./lib/capture-diagnostics.js');
const WrinkleViews = require('./lib/wrinkle-views.js');
const captureFunnel = require('./lib/capture-funnel.js')(path.join(DATA_ROOT, 'capture-funnel.jsonl'));
const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts'));
const adminSources = require('./lib/admin-sources')({ localRoot: DATA_ROOT, localChannel: entry.channel,
taobaoRoot: entry.rednote ? null : process.env.JUZI_ADMIN_TAOBAO_DATA_ROOT, publicRoot: ROOT });
const wrinkleJobs = require('./lib/wrinkle-jobs.js')({
dir:path.join(DATA_ROOT,'wrinkle-jobs'),
collect:async context=>WrinkleViews.forReport(await WrinkleViews.collect(context.primary,context.photos,{
@@ -93,7 +95,8 @@ 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', '.csv': 'text/csv; charset=utf-8'
'.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2', '.csv': 'text/csv; charset=utf-8',
'.mp3': 'audio/mpeg', '.aac': 'audio/aac'
};
function serveStatic(req, res, pathname) {
let p;
@@ -578,9 +581,10 @@ function sendJson(res, status, obj) {
function photoUrls(rec) {
const urls = {};
const prefix = entry.rednote ? '/rednote' : '';
if (rec.img_front) urls.front = `${prefix}/api/admin/photos/${rec.record_id}/front`;
if (rec.img_left) urls.left = `${prefix}/api/admin/photos/${rec.record_id}/left`;
if (rec.img_right) urls.right = `${prefix}/api/admin/photos/${rec.record_id}/right`;
const source = rec.admin_source ? '?source=' + encodeURIComponent(rec.admin_source) : '';
if (rec.img_front) urls.front = `${prefix}/api/admin/photos/${rec.record_id}/front${source}`;
if (rec.img_left) urls.left = `${prefix}/api/admin/photos/${rec.record_id}/left${source}`;
if (rec.img_right) urls.right = `${prefix}/api/admin/photos/${rec.record_id}/right${source}`;
return urls;
}
@@ -603,6 +607,8 @@ function listItem(rec) {
created_at: rec.created_at,
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
channel: rec.channel || 'h5',
source: rec.admin_source || 'local', admin_key: rec.admin_key || 'local:' + rec.record_id,
readonly: !!rec.admin_readonly,
shoot_status: rec.shoot_status, upload_status: rec.upload_status, detect_status: rec.detect_status,
capture_quality: rec.capture_quality || null,
capture_policy_version: rec.capture_diagnostics ? rec.capture_diagnostics.version : null,
@@ -648,7 +654,7 @@ function matchQuery(rec, q) {
return true;
}
function handleRecordsQuery(req, res, url) {
function adminQuery(url) {
const sp = url.searchParams;
const q = {
from: sp.get('from') || null,
@@ -665,21 +671,32 @@ function handleRecordsQuery(req, res, url) {
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 必须是数字' });
throw Error('age_min/age_max 必须是数字');
}
try { q.timeRange = BeijingTime.range(q.from, q.to); }
if (q.channel && !['h5', 'taobao', 'rednote'].includes(q.channel)) throw Error('渠道参数无效');
q.timeRange = BeijingTime.range(q.from, q.to);
return q;
}
function adminRecordOrder(a, b) {
const delta = BeijingTime.milliseconds(b.created_at) - BeijingTime.milliseconds(a.created_at);
return (Number.isFinite(delta) ? delta : 0) || String(a.admin_key).localeCompare(String(b.admin_key));
}
function handleRecordsQuery(req, res, url) {
let q;
try { q = adminQuery(url); }
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
const sp = url.searchParams;
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().map(displayRecommendationRecord).filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); // created_at 倒序
const filtered = adminSources.records().map(displayRecommendationRecord).filter(r => matchQuery(r, q))
.sort(adminRecordOrder); // 跨数据源统一排序;同时间用来源+ID稳定分页
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 });
sendJson(res, 200, { ok: true, total, page, page_size: pageSize, items, sources: adminSources.descriptors() });
}
function handleRecordDetail(req, res, id) {
const rec = displayRecommendationRecord(findRecord(id));
function handleRecordDetail(req, res, id, source = 'local') {
const rec = displayRecommendationRecord(adminSources.find(id, source));
if (!rec) return sendJson(res, 404, { ok: false, message: '记录不存在' });
// 原始 JSON 全文:读文件失败给 null。
// face_maps 是整幅 base64 叠加图(单文件可达数 MB),详情页直接渲染会卡死浏览器:
@@ -687,7 +704,7 @@ function handleRecordDetail(req, res, id) {
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')); }
try { rawJson = JSON.parse(fs.readFileSync(adminSources.file(source, 'results', fname), 'utf8')); }
catch (e) { rawJson = null; }
}
if (rawJson && rawJson.result && rawJson.result.face_maps) {
@@ -699,11 +716,11 @@ function handleRecordDetail(req, res, id) {
}
}
// 版本链:它 retry_of 指向的(父)+ retry_of 指向它的(子)
const all = readAllRecords();
const all = adminSources.records(source);
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 }));
let presentation = rec.presentation || accounts.reportPresentation(rec.owner_id, rec.record_id);
.map(r => ({ record_id: r.record_id, source, detect_status: r.detect_status, created_at: r.created_at }));
let presentation = rec.presentation || (source === 'local' ? accounts.reportPresentation(rec.owner_id, rec.record_id) : adminSources.presentation(rec));
if (presentation && (presentation.gradeRule !== ReportUI.gradeRule || presentation.copyRule !== ReportUI.copyRule) && rawJson && rawJson.result) {
// Recover text variables from raw data, but retain the saved scores,
// recommendation and care order. No database/snapshot mutation.
@@ -719,23 +736,26 @@ function handleRecordDetail(req, res, id) {
if(presentation && rec.recommendation && rec.recommendation.ageBasis==='display')presentation=Object.assign({},presentation,{product:ReportUI.products.find(p=>p.tier===rec.recommendation.tier)||null});
sendJson(res, 200, {
ok: true,
source, readonly: !!rec.admin_readonly,
raw_file: rec.raw_file || null,
record: Object.assign({}, rec, { total_score: displayTotalScore(rec), total_score_rule: 'dimensions-433-v1', skin_age_display: displaySkinAge(rec),
dims: displayScoreLevels(rec.dims,true), sub_scores: displayScoreLevels(rec.sub_scores), grade_rule: ReportUI.gradeRule, presentation }),
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,
parent: parent ? { record_id: parent.record_id, source, 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);
function handleRawDownload(req, res, id, source = 'local') {
const rec = adminSources.find(id, source);
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);
const file = adminSources.file(source, 'results', fname);
if (!file) return sendJson(res, 404, { ok: false, message: '原始 JSON 文件不存在' });
fs.readFile(file, (err, data) => {
if (err) return sendJson(res, 404, { ok: false, message: '原始 JSON 文件不存在' });
res.writeHead(200, {
@@ -746,14 +766,15 @@ function handleRawDownload(req, res, id) {
});
}
function handlePhoto(req, res, id, which) {
const safe = String(id || '').replace(/[^\w-]/g, '');
const file = path.join(PHOTOS_DIR, safe, which + '.jpg');
function handlePhoto(req, res, id, which, source = 'local') {
const rec = adminSources.find(id, source);
const file = rec && adminSources.file(source, 'photos', id, which + '.jpg');
if (!file) { res.writeHead(404); return res.end('Not Found'); }
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"`
'Content-Disposition': `inline; filename="${id}-${which}.jpg"`
});
res.end(data);
});
@@ -761,8 +782,9 @@ function handlePhoto(req, res, id, which) {
function handleStats(req, res, url) {
const channel = url.searchParams.get('channel') || null; // 传 channel 时以下指标仅统计该渠道
if (channel && !['h5', 'taobao', 'rednote'].includes(channel)) return sendJson(res, 400, { ok: false, message: '渠道参数无效' });
const now = Date.now(), today = BeijingTime.day(now);
const allRaw = readAllRecords().map(displayRecommendationRecord);
const allRaw = adminSources.records().map(displayRecommendationRecord);
// 按渠道拆分概览(始终基于全量,历史无 channel 字段的记录按 'h5' 计)
const by_channel = {};
for (const r of allRaw) {
@@ -822,7 +844,8 @@ function handleStats(req, res, url) {
fail_total: failed.length,
success_rate: finished.length ? Math.round(succeeded.length * 1000 / finished.length) / 10 : 0,
avg_cost_ms: avgCost,
capture_funnel: captureFunnel.stats(now),
capture_funnel: adminSources.funnel(now, channel, captureFunnel),
sources: adminSources.descriptors(),
by_channel,
fail_dist,
dim_dist,
@@ -883,15 +906,11 @@ function csvCell(v) {
}
function handleExport(req, res, url) {
const q = {
from: url.searchParams.get('from') || null,
to: url.searchParams.get('to') || null,
channel: url.searchParams.get('channel') || null
};
try { q.timeRange = BeijingTime.range(q.from, q.to); }
let q;
try { q = adminQuery(url); }
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
const filtered = readAllRecords().map(displayRecommendationRecord).filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
const filtered = adminSources.records().map(displayRecommendationRecord).filter(r => matchQuery(r, q))
.sort(adminRecordOrder);
const dimP = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].P : null;
const dimS = (r, d) => (r.dims && r.dims[d]) ? SkinScore.capDimensionScore(r.dims[d].S) : null;
const dimLv = (r, d) => (r.dims && r.dims[d]) ? ReportUI.levelForScore(r.dims[d].S) : null;
@@ -899,6 +918,8 @@ function handleExport(req, res, url) {
'失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄', '前端展示肌龄(推荐依据)',
'紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档',
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本', '渠道'];
const includeSources = adminSources.descriptors().length > 1;
if (includeSources) header.push('数据来源', '记录唯一键');
const lines = [header.join(',')];
for (const r of filtered) {
lines.push([
@@ -915,7 +936,7 @@ function handleExport(req, res, url) {
r.capture_quality ? r.capture_quality.status : null,
r.capture_diagnostics ? r.capture_diagnostics.version : null,
r.channel || 'h5'
].map(csvCell).join(','));
].concat(includeSources ? [r.admin_source, r.admin_key] : []).map(csvCell).join(','));
}
const fromTag = (q.from || 'all').replace(/[^\d-]/g, '');
const toTag = (q.to || 'all').replace(/[^\d-]/g, '');
@@ -929,14 +950,17 @@ function handleExport(req, res, url) {
async function handleAdmin(req, res, pathname, url) {
try {
const source = url.searchParams.get('source') || 'local';
if (!adminSources.source(source)) return sendJson(res, 404, { ok: false, message: '数据来源不存在' });
const retryM = pathname.match(/^\/api\/admin\/records\/([\w-]+)\/retry$/);
if (req.method === 'POST' && retryM && source !== 'local') return sendJson(res, 409, { ok: false, message: '该渠道为只读汇总,请在原服务后台重试' });
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]);
if (req.method === 'GET' && rawM) return handleRawDownload(req, res, rawM[1], source);
const detailM = pathname.match(/^\/api\/admin\/records\/([\w-]+)$/);
if (req.method === 'GET' && detailM) return handleRecordDetail(req, res, detailM[1]);
if (req.method === 'GET' && detailM) return handleRecordDetail(req, res, detailM[1], source);
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' && photoM) return handlePhoto(req, res, photoM[1], photoM[2], source);
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, url);
if (req.method === 'GET' && pathname === '/api/admin/export') return handleExport(req, res, url);
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=1me rednote H5 - isolated browser identities
Description=1'me rednote H5 - isolated browser identities
After=network.target
[Service]
@@ -0,0 +1 @@
[{"weights":[{"name":"dense0/conv0/filters","shape":[3,3,3,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008194216092427571,"min":-0.9423348506291708}},{"name":"dense0/conv0/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006839508168837603,"min":-0.8412595047670252}},{"name":"dense0/conv1/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009194007106855804,"min":-1.2779669878529567}},{"name":"dense0/conv1/pointwise_filter","shape":[1,1,32,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0036026100317637128,"min":-0.3170296827952067}},{"name":"dense0/conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.000740380117706224,"min":-0.06367269012273527}},{"name":"dense0/conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":1,"min":0}},{"name":"dense0/conv2/pointwise_filter","shape":[1,1,32,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":1,"min":0}},{"name":"dense0/conv2/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0037702228508743585,"min":-0.6220867703942692}},{"name":"dense1/conv0/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0033707996209462483,"min":-0.421349952618281}},{"name":"dense1/conv0/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.014611541991140328,"min":-1.8556658328748217}},{"name":"dense1/conv0/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002832523046755323,"min":-0.30307996600281956}},{"name":"dense1/conv1/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006593170586754294,"min":-0.6329443763284123}},{"name":"dense1/conv1/pointwise_filter","shape":[1,1,64,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.012215249211180444,"min":-1.6001976466646382}},{"name":"dense1/conv1/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002384825547536214,"min":-0.3028728445370992}},{"name":"dense1/conv2/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005859645441466687,"min":-0.7617539073906693}},{"name":"dense1/conv2/pointwise_filter","shape":[1,1,64,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013121426806730382,"min":-1.7845140457153321}},{"name":"dense1/conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0032247188044529336,"min":-0.46435950784122243}},{"name":"dense2/conv0/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002659512618008782,"min":-0.32977956463308894}},{"name":"dense2/conv0/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015499923743453681,"min":-1.9839902391620712}},{"name":"dense2/conv0/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0032450980999890497,"min":-0.522460794098237}},{"name":"dense2/conv1/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005911862382701799,"min":-0.792189559282041}},{"name":"dense2/conv1/pointwise_filter","shape":[1,1,128,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021025861478319356,"min":-2.2077154552235325}},{"name":"dense2/conv1/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.00349616945958605,"min":-0.46149436866535865}},{"name":"dense2/conv2/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008104994250278847,"min":-1.013124281284856}},{"name":"dense2/conv2/pointwise_filter","shape":[1,1,128,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.029337059282789044,"min":-3.5791212325002633}},{"name":"dense2/conv2/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0038808938334969913,"min":-0.4230174278511721}},{"name":"fc/weights","shape":[128,136],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.014016061670639936,"min":-1.8921683255363912}},{"name":"fc/bias","shape":[136],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0029505149698724935,"min":0.088760145008564}}],"paths":["face_landmark_68_tiny_model-shard1"]}]
+14
View File
@@ -0,0 +1,14 @@
[
{
"file": "face_landmark_68_tiny_model-weights_manifest.json",
"source": "https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights/face_landmark_68_tiny_model-weights_manifest.json",
"bytes": 4441,
"sha256": "3c63b8984302c187b218d9ef5aa149ed8c2c7fa3fe54db078614692bc48d153c"
},
{
"file": "face_landmark_68_tiny_model-shard1",
"source": "https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights/face_landmark_68_tiny_model-shard1",
"bytes": 77224,
"sha256": "b98e9f2f7da76f8a6dda9741a36ed485b224b889d552de2b2c1bb16217f67bfc"
}
]
+36 -7
View File
@@ -1,6 +1,34 @@
## 2026-11-03 正式包开发入口清理
正式路由仅保留登录、主页、档案、拍照、报告和历史六页。`pages/mvp`、`pages/camtest`、`pages/camnative` 已从 app.json 移除,并同时加入编译和上传排除项;开发测试源码保留供离线回归,不随正式包注册或上传。登录和主页的开发入口、诊断弹层、对应处理方法与样式已清理。主拍照页 `?debug=local-face` 还必须显式启用 cameraDebug,当前配置为 false。
“重新开启相机”是实际失败后的恢复入口,不是测试按钮:只在真实错误/权限提示下显示,登录校验、初次启动、预览、拍摄完成过渡和报告处理中隐藏;点击重试时先清除旧错误,失败再恢复提示。没有增加自动重试,也没有改变相机方向、角度、识别后端、照片顺序、亮度门限或 1.5 秒新鲜度限制。
# 淘宝小程序开发说明
当前完成本地开发闭环:授权登录 → 基础档案 → 正/右/左三步手动拍摄或相册选图 → 服务端检测 → 七项报告 → 最近5份历史报告。真实淘宝授权、云网关、相机和图片组件仍需在关联 AppKey 的淘宝 IDE 与真机中验收。
用户已确认自动三连拍在当前手机通过,正式业务配置已恢复 `photoInputMode: 'camera'`,使用与测试页相同的识别和自动拍照逻辑;原相册入口保留。UI 和交互保持原样,不新增上传面板。若需独立排查选图上传,仍可临时设为 `'upload'` 跳过实时识别。淘宝专用 Node 已备份并更新新版游客及幂等接口,云服务访问异常及实际手机三图报告仍需验收。
正式入口:登录/游客 → 开始检测 → 原隐私弹窗 → 档案 → 拍照引导的“开始” → 自动三连拍 → 上传及报告。首次使用时衔接平台相机授权,无需先进入开发测试页。游客和正式账号的多页面集成回归均已覆盖,测试服务响应不代表真实云网关已通过。
页面按 H5 还原:报告检测图区域支持左右跟手滑动切换页卡,标签和高度同步;点检测图可放大,再点关闭;档案肤质卡支持跟手横滑、箭头和圆点联动。正常页面隐藏开发诊断入口,主页保留“开始检测/我的报告”,报告页不附加额外按钮。专项诊断时可在开发环境临时启用 `cameraDebug:true` 显示测试入口。
当前适配:独立登录首屏(淘宝账号/游客)→ 基础档案 → 正/右/左三步拍摄或相册选图 → 检测等待 → 七项报告 → 对应商品。正式淘宝账号保留最近 5 份历史;游客会话、档案和报告仅本次内存使用。商品 I/II/III 统一打开 `1090510453077`,推荐规则沿用 H5。
**首版要求实时识别抓拍。** 当前默认采用普通 `camera` 前置取景及连续帧,在本机运行人脸定位和轻量 68 点模型,复用 H5 姿态及稳定性判断,倒计时后用原生 `takePhoto` 获取 JPEG。用户已验证普通相机默认前置、帧数增加、可以拍照出图;手机已识别人脸并通过正脸姿态,但优化后的 CPU 单帧仍约 4.25 秒。当前候选改用原生 WebGL 加速,需通过数值自测、预热后再处理真实帧;手机兼容性、速度和自动三连拍仍待验收。AR 路线保留为显式开发对照,其默认自拍修正尚未通过真机验证。诊断页面保留在开发源码中,已移出正式路由及上传包。诊断不登录、不上传、不产生检测费用。
本轮实施状态、验收和部署顺序见 [适配交接](../docs/TAOBAO-ADAPTATION-2026-11-02.md)。
开发版“相机测试(开发版)”现直接进入 `CAM-NATIVE-20261102-04`,点顶部“开始自动三连拍测试”可进入 `LOCAL-FACE-20261102-09`。该页复用正式取景、实时识别、正/右/左自动拍摄及 JPEG 压缩,最后只展示本页临时照片,不要求账号或档案、不上传照片、不创建检测任务。卡住时点“复制识别诊断”,只复制模型阶段、耗时、帧尺寸、计数、安全错误代码及固定合成输入的图形自测指标,不复制图像或人脸坐标。测试页不播放语音;应用自身已有的静态资源加载及账号恢复不受这个页内模式控制。
手机在旧版 `LOCAL-FACE-20261102-01` 已识别 1 脸并通过姿态,CPU 优化版 `02` 从约 10.7 秒降至 4.25 秒,仍因结果过期停止,尚未完成自动抓拍。版本 `03` 明确请求原生 WebGL1,并按淘宝官方文档开启 `window.enableSkia:"true"`;手机矩阵自测执行 4 次绘制后报 `MATMUL_FAILED`。版本 `04` 依次验证 `default`、`packed_byte`(禁用浮点下载)和 `unpacked_byte`(再禁用打包 shader),真机三档均失败。最后一档记录 5 次绘制、1 次字节读回,矩阵校验和 0/预期 2786、244 项不匹配、最大误差 22,呈现全零特征;这些聚合指标尚不能单独证明全部元素为零或确定根因,仍未处理真实相机帧。
版本 `05` 按[淘宝官方说明](https://developer.alibaba.com/docs/doc.htm?articleId=121761&docType=1&treeId=829)关闭 `readPixels` 的宿主额外翻转,并增加 2×2 原生图形检查。手机清屏读回通过,非对称 shader 有 3 项不匹配,预期校验和 2244/实际 2040;实际值与此前清屏校验和相同。这与绘制未覆盖的情况一致,也可能来自坐标、缓冲区上传或绘制状态,尚不能唯一确定根因,仍未处理真实相机帧。
版本 `06` 显式规范化 GL 绘制状态,每次绘制前清黑,先分别验证恒色 `drawArrays`/索引绘制,再验证原非对称 shader 和字节/浮点纹理。原生兼容候选按 `standard` → `buffer_copy`(将视图字节紧密拷贝为标准 `bufferData` ArrayBuffer)→ `framebuffer_y`(仅补偿已绑定 FBO 的 viewport/scissor Y 坐标)→ `combined` 自测;仅 `VALUE_MISMATCH` 允许换路,其他错误停止。四路使用同一上下文,每路结束清理自己的检查对象;TF 三档选择最多创建 3 个上下文。每条可用路径都须通过全部原生检查及后续严格矩阵/卷积自测、真实模型预热,不能按机型硬开,也不放宽 1.5 秒源帧新鲜度或回退 CPU。诊断保留原始失败阶段/代码、最终 `nativeStrategy`、固定合成的四像素及缓冲区字节数,仅初始化时缓存;真实人脸结果不能覆盖。手机实际选路、识别速度和三连拍仍待验收。
当前候选 `07` 针对真机 `06` 的 `nativeInitialStage=constant_indexed`:标准路径普通恒色绘制已通过,索引绘制失败;最后的坐标兼容黑屏不代表原始根因。原生索引调用前先重新绑定索引缓冲。若仍然是普通绘制通过而索引数值失败,则验证等价的四顶点 `TRIANGLE_STRIP` 路径;通过非对称图案、纹理、原矩阵/卷积和模型预热后才使用。仅把自有固定索引 `[0,1,2,2,1,3]`、`TRIANGLES/6/UNSIGNED_SHORT/offset0` 转换,未知签名、缓冲未确认或部分修改时明确拒绝。诊断新增 `drawStrategy=quad_strip`、实际绘制计数和 `constantQuad`;此时 `constantIndexed=not_run`,不宣称原生索引已通过。原生候选最多 8 次、仍使用同一上下文,手机效果与速度待验收。
此入口仅在 `environment: 'development'`、`captureMode: 'native-face'`、`nativeCamera: true` 时开放,允许当前 cloud 配置;`debug=local-face` 参数不能在生产环境绕过登录。原来的 `debug=camera` 手动验收仍要求显式 `cameraDebug` 开关及本机直连,不变更其范围。
## 本地启动
@@ -20,7 +48,7 @@ npm run dev:backend
代理使用固定的本地测试身份并模拟淘宝网关签名;Face++ 返回仓库内的固定样本。模拟逻辑仅在开发启动脚本中,正式服务器不会接受客户端自报 `open_id`,也没有免验签的开发登录后门。本地 H5 的微信入口不启用;微信 `/state/` 授权由独立浏览器回归验证。
`config.js` 当前是本地配置:`transport: 'direct'`、端口18180。直连传输只允许 localhost/127.0.0.1;不要把它当成淘宝正式网络方案。IDE 中导入本目录并关联自己的 AppKey。桌面模拟器是否允许访问回环地址需以实际 IDE 为准;手机上的127.0.0.1指向手机自身。
`config.js` 默认是 `cloud`/`tower`/`test`,使用聚石塔云网关。仅做本机样本联调时手动切换到 `transport: 'direct'`、`baseUrl: 'http://127.0.0.1:18180'`;结束后恢复云配置。代理为正式和游客会话签名,不绕过后端校验。手机上的 127.0.0.1 指向手机自身。
## 文件职责
@@ -30,14 +58,15 @@ npm run dev:backend
依次检查:首次授权、拒绝后相册仍可使用、授权设置恢复、前后镜头、三张方向、切后台恢复、压缩最长边≤1280。真实系统权限和相机画面仍需淘宝IDE/手机验收,自动测试仅验证代码流程。
- `pages/splash`:协议同意、淘宝授权登录、退出和历史入口。
- `pages/login`:淘宝/游客登录、协议、登录恢复,以及仅开发版显示的 AR 测试入口。
- `pages/splash`:品牌首页、开始检测、历史、退出/结束游客体验。
- `pages/profile`:账号隔离的基础信息缓存、出生年月和省市滚轮、肤质选择。
- `pages/camera`:手动三步拍摄/预览确认;离页取消、重复点击保护;单画布顺序压缩成最长边1280的 JPEG。
- `pages/camera`:普通前置相机本地实时识别、正/右/左三步引导、H5 轮廓及定位线、自动倒计时和原生 JPEG 拍摄;保留 AR 开发对照、完成卡与扫描等待页。相册路径拍后直接推进;单画布顺序压缩最长边 1280 的 JPEG;暂停后复用原分析任务,不重新收费。
- `pages/report`:服务端返回的当前评分/话术、七项分析、护理选三、精华、真实图层和坐标标注;侧脸图层异步加载。
- `pages/history`:本人最近5份完整报告。
- `utils/transport.js`:本机代理或淘宝云 SDK 传输,保留反代子路径配置。
- `utils/api.js`:账号凭据、64KiB照片分片、检测任务提交与轮询、历史报告。
- `vendor/`:从 H5 同步的评分、报告呈现、文案及哈希清单,不手工修改。
- `vendor/`:从 H5 同步的评分、报告呈现、拍照规则及哈希清单;`native-face/` 为从 H5 原始模型生成的小程序 CPU 副本,含来源记录。分别使用共享同步脚本和 `node scripts/build-native-face.cjs` 重建,不手工改生成文件。
- `scripts/`、`test/`、`docs/`:开发与回归文件,IDE 配置排除上传。
H5 规则变更后运行 `npm run sync:shared`;`npm run check:shared` 会检查逐字节一致。在线报告优先使用服务端返回的 `vm/presentation`,原始检测分数和展示肌龄分别保存。
@@ -93,7 +122,7 @@ npm run test:h5
- 真实 AppKey/cloudAppId、签名参数和授权权限包联调。
- 真实网关传输报告大图时的返回体限制与内存占用;已减少小程序 `setData` 中重复照片和非当前Tab图层。
- Android/iOS相机权限、前置相片方向、cover-view覆盖、系统相机降级、后台恢复、原生canvas及大图读取。
- UI真机对齐。当前相机使用椭圆框和左右转头提示,尚未迁移H5精确取景轮廓与实时姿态自动拍摄。
- 商品跳转尚需真实商品ID与平台能力验收;当前报告展示推荐产品,没有伪造商品链接。
- UI真机对齐。H5 取景轮廓及实时姿态自动拍摄已接入本地代码,仍需在手机验证定位、左右转头提示、识别耗时和完整三连拍。
- 商品目标已确认:`1090510453077`(1'me以我自在有势挺括双仓精华液)。仍需验证平台能力、游客/新报告/历史报告跳转及返回状态。
以上是待真实平台验收的范围,不影响已通过的本地接口与H5回归结论。
+5
View File
@@ -10,6 +10,11 @@ page {
-webkit-font-smoothing: antialiased;
}
/* Center against the full screen, independently of native left/right buttons. */
.brand-header { position:fixed; left:0; top:0; width:100%; box-sizing:border-box; background:#FFFFFF; color:#282828; z-index:70; pointer-events:none; }
.brand-header-line { position:relative; width:100%; display:flex; align-items:center; justify-content:center; }
.brand-header-title { display:block; width:calc(100% - 152px); text-align:center; font-size:15px; font-weight:500; line-height:22px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* ─── 主按钮:长方形 + 小倒角(H5 设计稿 181×37,圆角 5) ─── */
.btn-primary {
display: flex;
+12 -2
View File
@@ -10,11 +10,21 @@ App({
payload: null
},
onShareAppMessage: function () {
return require('./utils/share').message();
},
onLaunch: function () {
// 预取语音清单(失败静默,不影响主流程)
require('./utils/voice.js').init();
require('./utils/api.js').resetForLaunch();
// The login screen needs no native audio player. voice.play() creates its
// context lazily when spoken guidance is actually needed.
},
onShow: function () {
var api = require('./utils/api.js');
if (api.hasSession() && !api.isGuest()) api.restore().catch(function () {});
},
onHide: function () {
require('./utils/api.js').suspendSession();
require('./utils/voice.js').stop(true);
},
// 真机上看不到 console:开发版里把未捕获异常弹出到屏幕上,方便截图定位。
+9 -5
View File
@@ -1,19 +1,23 @@
{
"pages": [
"pages/login/login",
"pages/splash/splash",
"pages/profile/profile",
"pages/camera/camera",
"pages/report/report",
"pages/history/history",
"pages/mvp/mvp",
"pages/camtest/camtest"
"pages/history/history"
],
"window": {
"defaultTitle": "1'me在线肌肤测试",
"defaultTitle": "​",
"enableSkia": "true",
"titleBarColor": "#FFFFFF",
"backgroundColor": "#101823",
"pullRefresh": false,
"allowsBounceVertical": "NO"
"allowsBounceVertical": "NO",
"navigationBarForceEnable": true,
"transparentTitle": "always",
"navigationBarTextStyle": "black",
"gestureBack": "NO"
},
"permission": {
"scope.camera": {
+1 -1
View File
@@ -1,5 +1,5 @@
# 小程序内置资源
这里只放确实需要随包内置的资源。当前UI图片、推荐产品图与语音均通过 `config.js` 的 `assetBase` 加载,复用共享后端的现有素材;没有修改H5资源。
这里只放确实需要随包内置的资源。`startup/` 内置登录首屏的三张 H5 原图(约 129 KiB),避免首屏等待公网图片;图片内容、尺寸与 H5 一致。拍照遮罩等资源也随包内置。其余 UI 图片、推荐产品图与语音通过 `config.js` 的 `assetBase` 加载,复用共享后端素材;没有修改 H5 资源。
正式淘宝端需要确认资源域名权限。开发与平台验收事项见 [../README.md](../README.md)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+8
View File
@@ -0,0 +1,8 @@
<template name="brand-header">
<!-- Native transparent navigation keeps its back/menu/close controls. -->
<view class="brand-header" style="height:{{brandHeader.height}}px;padding-top:{{brandHeader.top}}px;">
<view class="brand-header-line" style="height:{{brandHeader.bar}}px;">
<text class="brand-header-title" a:if="{{brandHeader.showTitle}}">{{brandHeader.title}}</text>
</view>
</view>
</template>
+26 -5
View File
@@ -17,12 +17,33 @@ module.exports = {
cloudAppId: '', // 聚石塔默认云应用可留空;多个云应用时填关联页的 cloudAppId
cloudEnv: 'test',
basePath: '',
// 淘宝 <camera> 组件在真机(SDK 2.9.4)上会整页崩溃,且 canIUse('camera') 是假阳性。
// 关掉后拍照页改为「系统相机 / 相册」两条 my.chooseImage 通道(权限 scope.camera 已授予)。
// 等确认组件可用后把这里改回 true 即可恢复页内取景。
nativeCamera: false,
// 当前手机已验证普通 camera 默认前置、连续帧和原生 JPEG;在本地跑
// H5 人脸模型并自动拍摄。当前用户手机已确认自动三连拍可完成。
captureMode: 'native-face',
// 正式入口使用已验证的自动三连拍;原相册入口保留。
// 如需单独排查选图上传,可临时设为 'upload' 跳过实时识别。
photoInputMode: 'camera',
// 连续识别帧使用平台支持的小尺寸,减少跨桥数据量;正式拍照仍为 high JPEG。
nativeFrameSize: 'small',
// 轻量 68 点模型;160 输入保留本地等比双脸用例的多人识别能力。
nativeFaceInputSize: 160,
nativeFaceMaxSide: 320,
nativeFaceLandmarkModel: 'tiny',
// CPU 路线真机仍约4.25s;使用经数值自测的原生WebGL,失败明确停止。
nativeFaceBackend: 'webgl',
// 仅作为没有格式标记的紧密四通道帧的默认值;显式未知格式仍报错。
nativePixelFormat: 'RGBA',
// AR 仅保留作开发对照。此值来自单次切换反馈,首次默认自拍未验收
// 通过;不把该请求值视为已确认的实际镜头方向。
arFrontPosition: 'back',
// 未标注格式且长度精确匹配四通道时使用的应用默认值;平台格式优先。
// 用户已选择直接采用默认照片颜色,开发页颜色校验不作为使用门槛。
arPixelFormat: 'RGBA',
nativeCamera: true,
mvpLocal: false,
mvpBaseUrl: 'http://127.0.0.1:8080',
mock: false,
productItemIds: {} // Optional real Taobao item IDs keyed by tier 1/2/3
// 当前三个推荐档位均进入用户确认的同一款天猫商品;评分及推荐文案仍按 H5。
productItemId: '1090510453077',
productItemIds: { 1: '1090510453077', 2: '1090510453077', 3: '1090510453077' }
};
+10 -2
View File
@@ -4,18 +4,26 @@
"miniprogramRoot": "./",
"appid": "",
"exclude": [
"graphify-out/**",
"scripts/**",
"test/**",
"docs/**",
"README.md",
"package-lock.json"
"package-lock.json",
"pages/mvp/**",
"pages/camtest/**",
"pages/camnative/**"
],
"uploadExclude": [
"graphify-out/**",
"scripts/**",
"test/**",
"docs/**",
"README.md",
"package-lock.json"
"package-lock.json",
"pages/mvp/**",
"pages/camtest/**",
"pages/camnative/**"
],
"enableAppxNg": true,
"component2": true,
+55 -22
View File
@@ -1,6 +1,6 @@
/* ═════ 05-07 拍照页 ═════
对齐 H5 h5/css/style.css 的 #s-cam 静态态:
.cam-step top 7.1%,21.5px/500,letter-spacing 1u,text-shadow
.cam-step top 7.1%,21.5px/700,letter-spacing 1u,text-shadow
.cam-guide bottom 22.4%,14.5px,分隔线 15u 高
.cam-controls bottom 5.2%,高 62u,内边距 56u,快门 62u
单位换算:calc(N * var(--u)) → 2N rpx */
@@ -14,7 +14,7 @@
.cam-bg {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
background: #262626;
background: #fff;
}
/* ─── 顶部步骤文案 ─── */
@@ -25,7 +25,7 @@
text-align: center;
color: #fff;
font-size: 21.5px;
font-weight: 500;
font-weight: 700;
line-height: 1.5;
letter-spacing: 2rpx;
text-shadow: 0 1px 10px rgba(0, 0, 0, .4);
@@ -43,26 +43,13 @@
z-index: 0;
}
/* ─── 取景蒙版:H5 用 SVG 镂空出人脸轮廓;这里用「椭圆开窗 + 巨大 box-shadow」实现同样的暗框 + 中间透亮 ─── */
/* H5 原始轮廓生成的透明 PNG;额外边缘遮罩保持不同比例视口的暗框。 */
.cam-mask {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
z-index: 1;
pointer-events: none;
}
.cam-hole {
position: absolute;
left: 50%;
top: 50%;
width: 440rpx;
height: 580rpx;
margin-left: -220rpx;
margin-top: -290rpx;
border-radius: 50%;
box-shadow: 0 0 0 3000rpx rgba(0, 0, 0, .82);
}
.mask-1 { transform: translateX(-26rpx) rotate(-4deg); }
.mask-2 { transform: translateX(26rpx) rotate(4deg); }
/* ─── 底部引导条 ─── */
.cam-guide {
@@ -101,7 +88,7 @@
z-index: 2;
}
.cam-side {
width: 120rpx;
width: 60rpx;
height: 124rpx;
display: flex;
align-items: center;
@@ -113,7 +100,7 @@
width: 124rpx;
height: 124rpx;
border-radius: 50%;
border: 4rpx solid #fff;
border: none;
display: flex;
align-items: center;
justify-content: center;
@@ -126,6 +113,36 @@
background: #fff;
}
.cam-side image { width:60rpx; height:60rpx; }
.cam-side-switch { position:relative; }
.cam-switch-label { position:absolute; top:calc(50% + 34rpx); left:50%; transform:translateX(-50%); white-space:nowrap; font-size:10px; line-height:1.3; }
.cam-verifying { position:absolute; top:0; right:0; bottom:0; left:0; z-index:100; display:flex; flex-direction:column; align-items:center; justify-content:center; background:#101823; color:#fff; font-size:14px; gap:32rpx; }
.cam-shutter-auto { visibility:hidden; }
.cam-live-hint { position:absolute; bottom:27%; left:24rpx; right:24rpx; text-align:center; color:#fff; font-size:17px; font-weight:500; z-index:3; }
.cam-live-hint.good { color:#34C759; }
.cam-shutter image { width:124rpx; height:124rpx; }
.cam-mask-image { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); }
.cam-mask-image.mirror { transform:translate(-50%,-50%) scaleX(-1); }
.cam-live-scan { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); overflow:hidden; -webkit-mask-size:100% 100%; mask-size:100% 100%; }
.cam-live-scan.mirror { transform:translate(-50%,-50%) scaleX(-1); }
.scan-front { -webkit-mask-image:url('../../assets/camera/hole-front.png'); mask-image:url('../../assets/camera/hole-front.png'); }
.scan-right { -webkit-mask-image:url('../../assets/camera/hole-right.png'); mask-image:url('../../assets/camera/hole-right.png'); }
.scan-left { -webkit-mask-image:url('../../assets/camera/hole-left.png'); mask-image:url('../../assets/camera/hole-left.png'); }
.cam-live-band { position:absolute; top:10%; left:0; right:0; height:12%; background:linear-gradient(to bottom,rgba(245,240,120,0),rgba(245,240,120,.32),rgba(245,240,120,.95)); animation:camera-scan 2.2s ease-in-out infinite; }
@keyframes camera-scan { from {transform:translateY(0);} to {transform:translateY(440%);} }
.cam-mask-edge { position:absolute; background:rgba(0,0,0,.85); }
.edge-top { top:0; left:0; right:0; } .edge-bottom { bottom:0; left:0; right:0; }
.edge-left { left:0; top:0; bottom:0; } .edge-right { right:0; top:0; bottom:0; }
.cam-next { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:460rpx; box-sizing:content-box; padding:44rpx 32rpx 40rpx; border-radius:24rpx; background:rgba(0,0,0,.72); color:#fff; text-align:center; z-index:5; }
.cam-next-ok { margin:0 auto 24rpx; border-radius:50%; width:80rpx; height:80rpx; line-height:80rpx; background:#34C759; font-size:26px; }
.cam-next-done { font-size:17px; font-weight:500; }
.cam-next-tip { margin-top:16rpx; white-space:pre-line; font-size:14px; line-height:1.5; }
.cam-countdown { position:absolute; top:45%; left:50%; transform:translate(-50%,-50%); color:#fff; font:600 88px/1 Arial,sans-serif; z-index:8; }
.cam-fill { position:absolute; top:0; left:0; right:0; bottom:0; background:#fff; z-index:9; }
.cam-enable { position:absolute; bottom:16%; left:50%; transform:translateX(-50%); padding:16rpx 32rpx; border:1px solid #fff; border-radius:8rpx; color:#fff; z-index:4; }
.cam-settings { position:absolute; top:24rpx; left:24rpx; color:#fff; font-size:12px; z-index:4; }
.compress-canvas { position:fixed; left:200vw; top:0; pointer-events:none; }
/* ─── 提示/错误 ─── */
.cam-hint {
position: absolute;
@@ -179,7 +196,7 @@
.cam-analyzing {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, .78);
background: #000;
display: flex;
flex-direction: column;
align-items: center;
@@ -188,15 +205,27 @@
}
.cam-analyzing-text {
color: #fff;
font-size: 15px;
font-size: 17px;
letter-spacing: 1rpx;
padding:0 40rpx;
text-align:center;
z-index:2;
}
.cam-cancel {
margin-top: 32rpx;
position:absolute; bottom:20rpx; left:0; right:0; text-align:center;
color: rgba(255, 255, 255, .7);
font-size: 13px;
text-decoration: underline;
}
.scan-stage { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); }
.scan-artwork { width:100%; height:100%; }
.scan-face { position:absolute; left:0; top:0; right:0; bottom:0; -webkit-mask-image:url('../../assets/camera/scan-face-mask.png'); mask-image:url('../../assets/camera/scan-face-mask.png'); -webkit-mask-size:100% 100%; mask-size:100% 100%; overflow:hidden; }
.scan-beam { position:absolute; left:0; right:0; top:20%; height:18%; background:linear-gradient(to bottom,#F5F078 0%,#F5F078 35%,rgba(245,240,120,0) 100%); animation:report-scan 1s ease-in-out infinite; }
@keyframes report-scan { 0%,100% {transform:translateY(0);} 50% {transform:translateY(166%);} }
.scan-flip-icon { position:absolute; left:77.314815%; top:87.900526%; width:8.888889%; height:4.591105%; background:#000; display:flex; align-items:center; justify-content:center; }
.scan-flip-icon image { width:83.333333%; height:83.333333%; }
.analysis-stage { position:absolute; bottom:100rpx; left:32rpx; right:32rpx; text-align:center; color:#bbb; font-size:12px; }
.analysis-retry,.analysis-retake { color:#fff; font-size:14px; padding:20rpx 36rpx; margin-top:24rpx; border:1px solid #777; border-radius:10rpx; z-index:2; }
/* ─── 权限诊断(开发版) ─── */
.cam-diag-entry {
@@ -239,6 +268,10 @@
padding: 14rpx 0;
font-size: 13px;
}
.cam-probe-toolbar { position:fixed; z-index:50; left:24rpx; right:24rpx; padding:14rpx 18rpx; border-radius:12rpx; background:rgba(12,39,56,.94); color:#fff; }
.cam-probe-version { margin-bottom:10rpx; font-size:22rpx; line-height:30rpx; }
.cam-probe-actions { display:flex; gap:14rpx; }
.cam-probe-actions button { flex:1; margin:0; padding:0 8rpx; font-size:25rpx; line-height:64rpx; background:#F5F078; color:#142632; }
/* ─── 调试模式结果 ─── */
.cam-debug {
+63 -24
View File
@@ -1,23 +1,41 @@
<!-- ═════ 05-07 拍照页 ═════
两种形态,由 config.nativeCamera 决定:
① cameraEnabled=true → 真机取景:<camera> 铺满 + 椭圆镂空蒙版 + 快门直接 takePhoto(对齐 H5 #s-cam)
② cameraEnabled=false → 系统相机:静态版面 + 快门调 my.chooseImage
关键:<camera> 不能嵌 <cover-view>(微信专有组件,淘宝上会导致整页崩溃),
所以相机是兄弟节点,覆盖层用普通 view + box-shadow 镂空。 -->
<view class="cam">
<import src="../../components/brand-header.axml" />
<template is="brand-header" data="{{brandHeader: brandHeader}}" />
<!-- H5 三步取景。普通前置帧在本地识别人脸并自动抓拍,AR 为显式可选分支。
所有覆盖层为兄弟节点。 -->
<view class="cam" style="top:{{brandHeader.height}}px;">
<view class="cam-verifying" a:if="{{identityPending}}">
<view>{{identityError || '正在确认登录状态…'}}</view>
<view a:if="{{identityError}}" onTap="onRetryIdentity">重试</view>
</view>
<!-- ①真机取景:相机铺满,不加任何子节点 -->
<camera a:if="{{cameraEnabled}}" id="skin-camera" class="cam-device"
device-position="{{devicePosition}}" flash="off"
<ar-camera a:if="{{cameraEnabled && useAR}}" id="skin-ar-camera-{{cameraSession}}" class="cam-device" data-camera-session="{{cameraSession}}"
devicePosition="{{arDevicePosition}}" mode="faceDetection2D" useCapturedImage="{{true}}"
onInit="onARInit" onARFrame="onARFrame" onError="onCameraError" onStop="onCameraStop" />
<camera a:if="{{cameraEnabled && !useAR}}" id="{{nativeCameraId}}" class="cam-device" data-camera-session="{{cameraSession}}"
mode="normal" device-position="{{devicePosition}}" frame-size="{{nativeFrameSize}}" flash="off"
onError="onCameraError" onStop="onCameraStop" />
<!-- ②系统相机兜底:H5 相机静态态(白底 + 85% 黑蒙版 ≈ #262626) -->
<view class="cam-bg" a:if="{{!cameraEnabled}}" />
<!-- 取景蒙版:H5 用 SVG 镂空;这里用带巨大 box-shadow 的椭圆开窗,视觉等效 -->
<!-- PNGs are rasterized from the exact H5 SVG paths, including feature lines. -->
<view class="cam-mask">
<view class="cam-hole mask-{{maskStep}}" />
<view class="cam-mask-edge edge-top" style="height:{{maskInsetY}}px" />
<view class="cam-mask-edge edge-bottom" style="height:{{maskInsetY}}px" />
<view class="cam-mask-edge edge-left" style="width:{{maskInsetX}}px" />
<view class="cam-mask-edge edge-right" style="width:{{maskInsetX}}px" />
<image class="cam-mask-image {{cameraEnabled && devicePosition === 'back' && maskStep ? 'mirror' : ''}}" style="{{maskStageStyle}}" src="../../assets/camera/mask-{{maskName}}{{cameraEnabled ? '-live' : ''}}.png" mode="scaleToFill" />
<view a:if="{{cameraEnabled && autoCapture && !doneTip && !analyzing}}" class="cam-live-scan scan-{{maskName}} {{devicePosition === 'back' && maskStep ? 'mirror' : ''}}" style="{{maskStageStyle}}"><view class="cam-live-band" /></view>
</view>
<view class="cam-next" a:if="{{doneTip && !analyzing}}">
<view class="cam-next-ok">✓</view>
<view class="cam-next-done">{{doneTip}}</view>
<view class="cam-next-tip">{{nextTip}}</view>
</view>
<view class="cam-countdown" a:if="{{countdown}}">{{countdown}}</view>
<view class="cam-fill" a:if="{{fillLight}}" />
<view class="cam-step">
<view class="cam-step-no">{{stepText}}</view>
<view class="cam-step-tip">{{stepTip}}</view>
@@ -40,36 +58,57 @@
</view>
<!-- 分析中 -->
<view class="cam-analyzing" a:if="{{analyzing}}">
<view class="cam-analyzing-text">{{debugMode ? '正在验证本地图片压缩…' : '正在生成您的肌肤报告…'}}</view>
<view class="cam-cancel" onTap="onCancel">{{debugMode ? '退出验收' : '返回,稍后查看历史报告'}}</view>
<view class="cam-analyzing" a:if="{{analyzing || analysisError}}">
<view class="scan-stage" style="{{scanStageStyle}}" a:if="{{!debugMode && !analysisError}}">
<image class="scan-artwork" src="../../assets/camera/scan-base.png" mode="scaleToFill" onError="onScanError" />
<view class="scan-face"><view class="scan-beam" /></view>
<view class="scan-flip-icon"><image src="../../assets/camera/icon-switch-camera.png" mode="aspectFit" /></view>
</view>
<view class="cam-analyzing-text" a:if="{{debugMode || analysisError || scanError}}">{{analysisError || (debugMode ? '正在验证本地图片压缩…' : '检测中…')}}</view>
<view class="analysis-stage" a:if="{{!analysisError}}">{{analysisStage}}</view>
<view class="analysis-retry" a:if="{{analysisError}}" onTap="onRetryAnalysis">重试查看本次检测</view>
<view class="analysis-retake" a:if="{{analysisError}}" onTap="onRestart">重新拍摄</view>
<view class="cam-cancel" onTap="onCancel">{{debugMode ? '退出验收' : '取消'}}</view>
</view>
<!-- 底部控制条:相册 / 快门 / 翻转 -->
<view class="cam-controls" a:if="{{!previewUrl && !analyzing}}">
<view class="cam-side" onTap="onAlbum">相册</view>
<view class="cam-shutter" onTap="onShutter">
<view class="cam-shutter-dot" />
<view class="cam-controls" a:if="{{!previewUrl && !analyzing && !analysisError && !doneTip}}">
<view class="cam-side" onTap="onAlbum"><image src="../../assets/camera/icon-album.png" mode="aspectFit" /></view>
<view class="cam-shutter {{autoCapture && cameraEnabled ? 'cam-shutter-auto' : ''}}" onTap="onShutter">
<image src="../../assets/camera/icon-shutter.png" mode="aspectFit" />
</view>
<view class="cam-side" onTap="{{cameraEnabled ? 'onFlip' : 'onOpenSettings'}}">{{cameraEnabled ? '翻转' : '授权设置'}}</view>
<view class="cam-side cam-side-switch" onTap="onFlip"><image src="../../assets/camera/icon-switch-camera.png" mode="aspectFit" /><text class="cam-switch-label">切换镜头</text></view>
</view>
<!-- 提示/错误文案 -->
<view class="cam-hint" a:if="{{permissionMessage && !previewUrl && !analyzing}}">{{permissionMessage}}</view>
<view class="cam-live-hint {{liveGood ? 'good' : ''}}" a:if="{{cameraEnabled && autoCapture && !doneTip && !analyzing}}">{{liveHint}}</view>
<view class="cam-enable" a:if="{{cameraSupported && !cameraEnabled && !identityPending && !previewUrl && !doneTip && !debugComplete && !analyzing && !analysisError && (camError || permissionMessage)}}" onTap="onEnableCamera">重新开启相机</view>
<view class="cam-settings" a:if="{{permissionMessage && !analyzing}}" onTap="onOpenSettings">授权设置</view>
<!-- Must remain mounted and laid out; display:none/hidden causes empty exports on phones. -->
<canvas id="compress-canvas" canvas-id="compress-canvas" width="{{compressWidth}}" height="{{compressHeight}}" class="compress-canvas" style="width:{{compressWidth}}px;height:{{compressHeight}}px" />
<!-- 开发版:权限诊断 -->
<view class="cam-diag-entry" a:if="{{showDiagnose}}" onTap="onDiagnose">权限诊断</view>
<view class="cam-diag" a:if="{{diagText}}" catchTap="noop">
<view class="cam-probe-toolbar" a:if="{{localFaceProbe}}" style="top:{{probeToolbarTop}}px;">
<view class="cam-probe-version">自动三连拍验收 · {{captureProbeVersion}}</view>
<view class="cam-probe-actions">
<button size="mini" onTap="onCopyCaptureDiagnostics">复制识别诊断</button>
<button size="mini" onTap="onCancel">退出验收</button>
</view>
</view>
<view class="cam-diag-entry" a:if="{{showDiagnose && !localFaceProbe}}" onTap="onDiagnose">权限诊断</view>
<view class="cam-diag" a:if="{{diagText}}" style="{{localFaceProbe ? 'top:' + (probeToolbarTop + 96) + 'px;' : ''}}" catchTap="noop">
<scroll-view class="cam-diag-scroll" scroll-y>
<text class="cam-diag-text">{{diagText}}</text>
<text class="cam-diag-text" selectable="{{true}}">{{diagText}}</text>
</scroll-view>
<view class="cam-diag-close" onTap="onCloseDiagnose">关闭</view>
</view>
<!-- 调试模式结果页 -->
<view class="cam-debug" a:if="{{debugComplete}}">
<view class="cam-debug" a:if="{{debugComplete}}" style="{{localFaceProbe ? 'padding-top:' + (probeToolbarTop + 96) + 'px;' : ''}}">
<view class="cam-debug-title">三步拍摄与 JPEG 压缩已完成</view>
<view class="cam-debug-note">请核对实际照片方向是否为正脸、右脸、左脸;本页不判断姿态是否合格。</view>
<view class="cam-debug-note">{{localFaceProbe ? '三张照片已通过实时识别自动拍摄;请核对正脸、右脸、左脸顺序和实际方向。照片只在本页临时预览,不上传、不生成报告。' : '请核对实际照片方向是否为正脸、右脸、左脸;本页不判断姿态是否合格。'}}</view>
<view class="cam-debug-item" a:for="{{debugPhotos}}" a:key="key">
<image class="cam-debug-img" src="{{item.photo}}" mode="aspectFit" />
<view class="cam-debug-meta">{{item.title}} · JPEG {{item.width}}×{{item.height}} · 约{{item.kilobytes}}KB</view>
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -1,6 +1,10 @@
{
"defaultTitle": "1'me在线肌肤测试",
"titleBarColor": "#101823",
"defaultTitle": "​",
"titleBarColor": "#FFFFFF",
"backgroundColor": "#101823",
"disableScroll": true
"disableScroll": true,
"navigationBarTextStyle": "black",
"navigationBarForceEnable": true,
"transparentTitle": "always",
"gestureBack": "NO"
}
+21
View File
@@ -0,0 +1,21 @@
page { background:#f1f4f7; color:#16212c; }
.native-toolbar { position:fixed; top:112px; left:0; right:0; z-index:1000; height:144rpx; padding:12rpx 24rpx; box-sizing:border-box; background:#fff; border-bottom:1px solid #ced9e2; }
.native-toolbar-auto { height:228rpx; }
.native-auto-entry { margin:0 0 12rpx; padding:0 12rpx; height:68rpx; line-height:64rpx; background:#126597; color:#fff; font-size:28rpx; font-weight:600; }
.native-toolbar-version { margin-bottom:10rpx; font-size:22rpx; line-height:30rpx; color:#466172; }
.native-toolbar-actions { display:flex; gap:14rpx; }
.native-toolbar-actions button { flex:1; margin:0; font-size:26rpx; line-height:68rpx; }
.native-toolbar-actions .native-copy { background:#126597; color:#fff; font-weight:600; }
.native-test { padding:172rpx 24rpx 64rpx; padding-top:calc(112px + 172rpx); }
.native-title { font-size:36rpx; font-weight:600; }
.native-note { margin:14rpx 0; font-size:24rpx; line-height:1.65; color:#596774; }
.native-preview { height:560rpx; margin:24rpx 0; background:#161d25; border-radius:12rpx; overflow:hidden; }
.native-camera { display:block; width:100%; height:100%; }
.native-empty { padding:120rpx 28rpx; color:#d6e0e8; text-align:center; }
.native-actions { display:flex; flex-wrap:wrap; gap:14rpx; }
.native-actions button { margin:0 0 12rpx; font-size:25rpx; }
.native-card { margin-top:20rpx; padding:22rpx; border-radius:12rpx; background:#fff; font-size:25rpx; line-height:1.75; word-break:break-all; }
.native-status { margin:12rpx 0; color:#165b8c; font-weight:600; }
.native-meta { margin-top:12rpx; font-family:monospace; font-size:23rpx; }
.native-diagnostic-text { display:block; white-space:pre-wrap; font-family:monospace; font-size:23rpx; line-height:1.65; user-select:text; -webkit-user-select:text; }
.native-photo { display:block; width:100%; height:540rpx; margin:18rpx 0; background:#e6eaee; }
+53
View File
@@ -0,0 +1,53 @@
<view class="native-toolbar {{showAutoTest ? 'native-toolbar-auto' : ''}}" style="top:{{toolbarTop}}px;">
<view class="native-toolbar-version">普通相机 · {{probeVersion}}</view>
<button a:if="{{showAutoTest}}" class="native-auto-entry" onTap="onOpenAutoCapture" disabled="{{photoBusy}}">开始自动三连拍测试</button>
<view class="native-toolbar-actions">
<button class="native-copy" size="mini" type="primary" onTap="onCopyDiagnostics">复制诊断信息</button>
<button size="mini" onTap="onGenerateDiagnostics">生成可选文字</button>
</view>
</view>
<view class="native-test" style="padding-top:calc({{toolbarTop}}px + {{showAutoTest ? 256 : 172}}rpx);">
<view class="native-title">普通 camera · 独立诊断</view>
<view class="native-note">仅验证前后镜头、真实帧和手动拍照;不是正式三连拍替代。照片仅本页临时预览,不登录、不上传、不做检测。</view>
<view a:if="{{showAutoTest}}" class="native-note">上方“开始自动三连拍测试”将进入正式取景界面的本地验收,验证识别、转头提示、自动拍摄和压缩,不上传照片、不创建检测任务。</view>
<view class="native-card native-diagnostic">
<view class="native-status">{{copyStatus || '点击顶部按钮生成当前脱敏诊断'}}</view>
<view class="native-note">生成或复制时会刷新实时计数。请求镜头不代表实际镜头;内容不含照片路径、像素、人脸坐标、账号或原始错误文字。</view>
<text a:if="{{diagnosticText}}" class="native-diagnostic-text" selectable="{{true}}">{{diagnosticText}}</text>
<view a:else class="native-note">当前尚未生成。即使没有剪贴板 API,也可点击“生成可选文字”后长按文字选择复制。</view>
</view>
<view class="native-preview">
<camera a:if="{{cameraEnabled}}" id="native-camera-{{cameraSession}}" data-camera-session="{{cameraSession}}" class="native-camera"
mode="normal" device-position="{{devicePosition}}" frame-size="medium" flash="off"
onReady="onNativeReady" onError="onNativeError" onStop="onNativeStop" />
<view a:else class="native-empty">相机已卸载,可点重新打开</view>
</view>
<view class="native-actions">
<button size="mini" onTap="onFlip" disabled="{{photoBusy}}">切换到{{devicePosition === 'front' ? '后置' : '前置'}}</button>
<button size="mini" onTap="onRetryCamera" disabled="{{photoBusy}}">重新打开并监听</button>
<button size="mini" onTap="onStopFrames">停止帧监听</button>
<button size="mini" onTap="onTakePhoto" disabled="{{photoBusy || !cameraEnabled}}">{{photoBusy ? '拍照处理中' : '手动拍一张'}}</button>
</view>
<view class="native-card">
<view>请求镜头:{{devicePosition}} · 会话 {{cameraSession}}</view>
<view>{{cameraStatus}}</view>
<view>onReady 事件:{{nativeReady ? '收到' : '未收到'}}</view>
<view>{{contextStatus}}</view>
<view class="native-status">{{frameStatus}}</view>
<view>start:{{startStatus}}</view><view>stop:{{stopStatus}}</view>
<view>原始帧 {{frameCount}} · 回调 {{frameCallbacks}} · 非标准回调 {{invalidFrameCount}}</view>
<view a:if="{{firstFrameMs}}">首帧等待:{{firstFrameMs}}</view>
<view a:if="{{frameInfo}}" class="native-meta">{{frameInfo}}</view>
<view class="native-note">帧计数只检查正数尺寸和非空字节,不假定 RGBA,也不代表已经得到人脸关键点。</view>
</view>
<view class="native-card">
<view>{{photoStatus}}</view>
<block a:if="{{photoPath}}"><view>拍照时请求镜头:{{photoPosition}}</view>
<image class="native-photo" src="{{photoPath}}" mode="aspectFit" onError="onPreviewError" />
<button size="mini" onTap="onClearPhoto">清除本页预览</button>
</block>
</view>
<view class="native-card">
<button size="mini" onTap="onOpenARProbe" disabled="{{photoBusy}}">AR 相机对照(可选)</button>
</view>
</view>
+269
View File
@@ -0,0 +1,269 @@
var Diagnostics = require('../../utils/ar-camera').diagnostics;
var development = require('../../utils/development');
var PROBE_VERSION = 'CAM-NATIVE-20261102-04';
var KNOWN_FORMATS = ['RGBA', 'RGBA8888', 'RGB', 'RGB888', 'RGBX', 'BGRA', 'BGRA8888', 'BGR', 'BGR888',
'ARGB', 'ARGB8888', 'ABGR', 'ABGR8888', 'NV21', 'NV12', 'YUV420', 'YUV420P', 'YUV420SP', 'I420', 'YV12', 'JPEG', 'JPG', 'PNG'];
function message(error, fallback) {
var value = error && (error.errorMessage || error.errMsg || error.message);
return typeof value === 'string' ? value.slice(0, 160) : fallback;
}
function positive(value) { return typeof value === 'number' && isFinite(value) && value > 0; }
function frameMeta(frame) {
frame = frame || {};
var data = frame.data, bytes = data && data.byteLength;
var meta = { width: positive(frame.width) ? frame.width : 0, height: positive(frame.height) ? frame.height : 0,
dataType: Object.prototype.toString.call(data), byteLength: positive(bytes) ? bytes : 0 };
if (typeof frame.timestamp === 'number' && isFinite(frame.timestamp)) meta.timestamp = frame.timestamp;
if (typeof frame.pixelFormat === 'string') meta.pixelFormat = frame.pixelFormat.slice(0, 40);
if (typeof frame.format === 'string') meta.format = frame.format.slice(0, 40);
return meta;
}
Page({
data: {
probeVersion: PROBE_VERSION, toolbarTop: 112, showAutoTest: false, diagnosticText: '', copyStatus: '',
cameraEnabled: false, cameraSession: 0, devicePosition: 'front', nativeReady: false,
cameraStatus: '正在打开普通 camera', contextStatus: '尚未创建上下文',
frameStatus: '尚未监听', startStatus: '尚未调用', stopStatus: '尚未调用',
frameCallbacks: 0, frameCount: 0, invalidFrameCount: 0, firstFrameMs: '', frameInfo: '',
photoBusy: false, photoStatus: '仅在点击拍照时生成临时照片', photoPath: '', photoPosition: ''
},
onLoad: function () {
this.alive = true; this.visible = false; this.pageReady = false;
this.generation = 0; this.photoOperation = null;
this.diagnosticSystem = Diagnostics.system(my); this.copySequence = 0;
this.setData({ toolbarTop: Diagnostics.toolbarTop(my), showAutoTest: development.nativeFaceProbeEnabled() });
this.contextCapabilities = { takePhoto: false, onCameraFrame: false, listenerStart: false, listenerStop: false };
this.listenerResults = { start: { state: 'not_called', code: 'unavailable' }, stop: { state: 'not_called', code: 'unavailable' } };
this.photoAttempted = false; this.photoResultSuccess = false; this.photoReturnedPath = false;
},
onReady: function () { this.pageReady = true; this.mountCamera(); },
onShow: function () {
this.visible = true;
if (this.photoOperation && this.photoOperation.timedOut) this.setData({ photoBusy: true, photoStatus: '拍照10秒未返回;等待原操作结束,或退出此诊断页后重试' });
this.mountCamera();
},
onHide: function () {
this.visible = false; this.disposeCamera();
this.setData({ cameraEnabled: false, photoBusy: !!this.photoOperation, cameraStatus: '页面已隐藏,相机已卸载' });
},
onUnload: function () {
this.visible = false; this.alive = false; this.disposeCamera();
if (this.photoOperation) clearTimeout(this.photoOperation.timer);
this.photoOperation = null; this.data.photoPath = '';
},
current: function (generation) { return this.alive && this.visible && this.data.cameraEnabled && this.generation === generation; },
diagnosticReport: function () {
var caps = this.contextCapabilities || {}, results = this.listenerResults || {}, meta = this.lastDiagnosticFrame;
var lines = [PROBE_VERSION].concat(this.diagnosticSystem || []);
lines.push('requestedPosition=' + (this.data.devicePosition === 'front' || this.data.devicePosition === 'back' ? this.data.devicePosition : 'unknown'));
lines.push('actualPosition=not_verified; requestedPosition does not identify the actual lens');
lines.push('context.takePhoto=' + !!caps.takePhoto + '; context.onCameraFrame=' + !!caps.onCameraFrame);
lines.push('listener.start=' + !!caps.listenerStart + '; listener.stop=' + !!caps.listenerStop);
lines.push('nativeReady=' + !!this.data.nativeReady + '; cameraMounted=' + !!this.data.cameraEnabled);
lines.push('frameCallbacks=' + (this.frameCallbacks || 0) + '; rawFrames=' + (this.frameCount || 0) + '; invalidCallbacks=' + (this.invalidFrameCount || 0));
if (meta) lines.push('frame.width=' + meta.width + '; frame.height=' + meta.height + '; frame.byteLength=' + meta.byteLength + '; frame.pixelFormat=' + meta.pixelFormat + '; frame.format=' + meta.format);
['start', 'stop'].forEach(function (key) {
var result = results[key] || { state: 'not_called', code: 'unavailable' };
lines.push('listener.' + key + '.state=' + result.state + '; listener.' + key + '.code=' + result.code);
});
lines.push('photo.attempted=' + !!this.photoAttempted + '; photo.pending=' + !!this.photoOperation + '; photo.success=' + !!this.photoResultSuccess + '; photo.returnedPath=' + !!this.photoReturnedPath + '; photo.previewPresent=' + !!this.data.photoPath);
return lines.join('\n');
},
onGenerateDiagnostics: function () {
if (!this.alive) return;
this.setData({ diagnosticText: this.diagnosticReport(), copyStatus: '脱敏诊断已生成,可长按下方文字选择复制。' });
},
onCopyDiagnostics: function () {
if (!this.alive) return;
var that = this, sequence = ++this.copySequence, report = this.diagnosticReport();
this.setData({ diagnosticText: report, copyStatus: '正在复制;也可长按下方脱敏文字选择复制。' });
function status(value) { if (that.alive && that.visible && sequence === that.copySequence) that.setData({ copyStatus: value }); }
try {
if (typeof my.setClipboard !== 'function') { status('当前宿主缺少剪贴板 API,请长按下方脱敏文字选择复制。'); return; }
my.setClipboard({ text: report, success: function () { status('普通相机诊断已复制,仅含版本和相机状态。'); },
fail: function () { status('剪贴板复制失败,请长按下方脱敏文字选择复制。'); } });
} catch (_) { status('剪贴板复制失败,请长按下方脱敏文字选择复制。'); }
},
currentEvent: function (event) {
if (!this.alive || !this.visible || !this.data.cameraEnabled) return false;
var target = event && event.currentTarget, session = target && target.dataset && target.dataset.cameraSession;
var match = target && typeof target.id === 'string' && /^native-camera-(\d+)$/.exec(target.id);
if (session === undefined && match) session = match[1];
return session === undefined || Number(session) === this.data.cameraSession;
},
mountCamera: function () {
if (!this.alive || !this.visible || !this.pageReady || this.data.cameraEnabled) return;
var that = this, generation = ++this.generation, session = this.data.cameraSession + 1;
this.frameCallbacks = 0; this.frameCount = 0; this.invalidFrameCount = 0; this.lastFramePaint = 0;
this.lastDiagnosticFrame = null;
this.contextCapabilities = { takePhoto: false, onCameraFrame: false, listenerStart: false, listenerStop: false };
this.listenerResults = { start: { state: 'not_called', code: 'unavailable' }, stop: { state: 'not_called', code: 'unavailable' } };
this.setData({ cameraEnabled: true, cameraSession: session, nativeReady: false, cameraStatus: '已请求' + (this.data.devicePosition === 'front' ? '前置' : '后置') + ',请观察真实取景',
contextStatus: '正在创建上下文', frameStatus: '尚未监听', startStatus: '尚未调用', stopStatus: '尚未调用',
frameCallbacks: 0, frameCount: 0, invalidFrameCount: 0, firstFrameMs: '', frameInfo: '', photoBusy: !!this.photoOperation }, function () {
if (!that.current(generation) || session !== that.data.cameraSession) return;
if (typeof my.createCameraContext !== 'function') { that.setData({ contextStatus: '缺少 my.createCameraContext', frameStatus: '无法监听' }); return; }
try {
that.cameraContext = my.createCameraContext('native-camera-' + session);
if (!that.cameraContext) throw new Error('未返回 CameraContext');
var methods = Diagnostics.methods(that.cameraContext);
that.contextCapabilities.takePhoto = methods.indexOf('takePhoto') >= 0;
that.contextCapabilities.onCameraFrame = methods.indexOf('onCameraFrame') >= 0;
that.setData({ contextStatus: 'takePhoto: ' + (typeof that.cameraContext.takePhoto === 'function') + ';onCameraFrame: ' + (typeof that.cameraContext.onCameraFrame === 'function') });
that.startFrames(generation);
} catch (error) { if (that.current(generation)) that.setData({ contextStatus: message(error, '创建相机上下文失败'), frameStatus: '无法监听' }); }
});
},
onNativeReady: function (event) { if (this.currentEvent(event)) this.setData({ nativeReady: true }); },
startFrames: function (generation) {
if (!this.current(generation)) return;
var that = this, context = this.cameraContext;
if (!context || typeof context.onCameraFrame !== 'function') { this.listenerResults.start = { state: 'missing_method', code: 'unavailable' }; this.setData({ frameStatus: '当前上下文缺少 onCameraFrame,不能取得实时帧' }); return; }
this.listenerResults.start = { state: 'pending', code: 'unavailable' };
this.framesActive = true; this.framesStartedAt = Date.now();
this.setData({ frameStatus: '等待真实帧;start 成功也不代表收到帧', startStatus: '正在调用' });
this.frameTimer3 = setTimeout(function () {
if (that.current(generation) && that.framesActive && !that.frameCount) that.setData({ frameStatus: '3秒未收到可用原始帧,继续等到10秒(回调 ' + that.frameCallbacks + ' 次)' });
}, 3000);
this.frameTimer10 = setTimeout(function () {
if (!that.current(generation) || !that.framesActive || that.frameCount) return;
that.setData({ frameStatus: '10秒仍未收到可用原始帧,本轮已停止;不能判定宿主支持', frameCallbacks: that.frameCallbacks, invalidFrameCount: that.invalidFrameCount });
that.stopFrames(false);
}, 10000);
try {
this.frameListener = context.onCameraFrame(function (frame) { that.receiveFrame(generation, frame); });
var methods = Diagnostics.methods(this.frameListener);
this.contextCapabilities.listenerStart = methods.indexOf('start') >= 0; this.contextCapabilities.listenerStop = methods.indexOf('stop') >= 0;
if (!this.frameListener || typeof this.frameListener.start !== 'function' || typeof this.frameListener.stop !== 'function') throw new Error('未返回具备 start/stop 的 CameraFrameListener');
this.frameListener.start({
success: function () { if (that.current(generation) && that.framesActive) { that.listenerResults.start = { state: 'success', code: 0 }; that.setData({ startStatus: '调用成功(需另看真实帧计数)' }); } },
fail: function (error) {
if (!that.current(generation) || !that.framesActive) return;
that.listenerResults.start = { state: 'failure', code: diagnosticCode(error) };
that.setData({ startStatus: message(error, '监听启动失败'), frameStatus: '监听启动失败' }); that.stopFrames(false);
}
});
} catch (error) {
if (!this.current(generation)) return;
this.listenerResults.start = { state: 'throw', code: diagnosticCode(error) };
this.setData({ startStatus: message(error, '监听接口调用失败'), frameStatus: '监听接口调用失败' }); this.stopFrames(false);
}
},
receiveFrame: function (generation, frame) {
if (!this.current(generation) || !this.framesActive) return;
this.frameCallbacks++;
var meta = frameMeta(frame), valid = meta.width > 0 && meta.height > 0 && meta.byteLength > 0;
this.lastDiagnosticFrame = { width: meta.width, height: meta.height, byteLength: meta.byteLength,
pixelFormat: diagnosticFormat(meta.pixelFormat), format: diagnosticFormat(meta.format) };
var firstValid = valid && !this.frameCount;
if (valid) {
this.frameCount++;
if (this.frameCount === 1) {
this.clearFrameTimers();
this.setData({ firstFrameMs: Math.max(0, Date.now() - this.framesStartedAt) + 'ms' });
}
} else this.invalidFrameCount++;
// Keep no frame/pixel buffer; publish scalar metadata at most ~3 times/s.
if (this.frameCallbacks === 1 || firstValid || Date.now() - this.lastFramePaint >= 350) {
this.lastFramePaint = Date.now();
this.setData({ frameCallbacks: this.frameCallbacks, frameCount: this.frameCount, invalidFrameCount: this.invalidFrameCount,
frameInfo: JSON.stringify(meta), frameStatus: this.frameCount ? '已收到原始帧;像素格式和人脸识别能力仍需另验' : '收到回调,但尺寸或原始字节缺失,未计为可用帧' });
}
},
clearFrameTimers: function () { clearTimeout(this.frameTimer3); clearTimeout(this.frameTimer10); this.frameTimer3 = null; this.frameTimer10 = null; },
stopFrames: function (showStatus) {
this.framesActive = false; this.clearFrameTimers();
var listener = this.frameListener; this.frameListener = null;
var that = this, generation = this.generation;
if (showStatus && this.current(generation)) this.setData({ frameStatus: '已主动停止监听', frameCallbacks: this.frameCallbacks, frameCount: this.frameCount, invalidFrameCount: this.invalidFrameCount });
if (!listener || typeof listener.stop !== 'function') return;
this.listenerResults.stop = { state: 'pending', code: 'unavailable' };
if (this.current(generation)) this.setData({ stopStatus: '正在调用' });
try {
listener.stop({ success: function () { if (that.current(generation)) { that.listenerResults.stop = { state: 'success', code: 0 }; that.setData({ stopStatus: '调用成功' }); } },
fail: function (error) { if (that.current(generation)) { that.listenerResults.stop = { state: 'failure', code: diagnosticCode(error) }; that.setData({ stopStatus: message(error, '停止监听失败') }); } } });
} catch (error) { if (this.current(generation)) { this.listenerResults.stop = { state: 'throw', code: diagnosticCode(error) }; this.setData({ stopStatus: message(error, '停止监听异常') }); } }
},
onStopFrames: function () { this.stopFrames(true); },
disposeCamera: function () {
this.generation++; this.stopFrames(false); this.cameraContext = null;
},
remount: function (position) {
if (!this.alive || !this.visible) return;
if (this.photoOperation) { this.setData({ photoStatus: '上次拍照尚未结束,请等待;若持续无响应,请退出此诊断页再进入' }); return; }
this.disposeCamera();
var that = this;
this.setData({ cameraEnabled: false, devicePosition: position }, function () { that.mountCamera(); });
},
onFlip: function () { this.remount(this.data.devicePosition === 'front' ? 'back' : 'front'); },
onRetryCamera: function () { this.remount(this.data.devicePosition); },
onNativeError: function (event) {
if (!this.currentEvent(event)) return;
this.disposeCamera();
this.setData({ cameraEnabled: false, cameraStatus: message(event && event.detail, '相机组件报错,请检查系统相机权限'), frameStatus: '相机异常,监听已停止' });
},
onNativeStop: function (event) {
if (!this.currentEvent(event)) return;
this.disposeCamera(); this.setData({ cameraEnabled: false, cameraStatus: '相机组件已异常停止,可点重新打开', frameStatus: '监听已停止' });
},
onTakePhoto: function () {
if (!this.alive || !this.visible || !this.data.cameraEnabled || this.photoOperation) return;
var context = this.cameraContext, that = this;
if (!context || typeof context.takePhoto !== 'function') { this.setData({ photoStatus: '当前上下文缺少 takePhoto' }); return; }
var operation = { generation: this.generation, position: this.data.devicePosition, done: false };
this.photoAttempted = true; this.photoResultSuccess = false; this.photoReturnedPath = false;
this.photoOperation = operation;
this.setData({ photoBusy: true, photoStatus: '正在拍照;仅在本页预览' });
operation.timer = setTimeout(function () {
// A timeout cannot cancel native takePhoto. Keep the lock until its callback
// or page destruction, so a late native export cannot overlap a retry.
if (operation.done || that.photoOperation !== operation) return;
operation.timedOut = true;
if (that.alive && that.visible) that.setData({ photoStatus: '拍照10秒未返回;等待原操作结束,或退出此诊断页后重试' });
}, 10000);
try {
context.takePhoto({ quality: 'high', success: function (result) { that.finishPhoto(operation, 'success', result); },
fail: function (error) { that.finishPhoto(operation, 'fail', error); },
complete: function () { that.finishPhoto(operation, 'complete'); } });
} catch (error) { this.finishPhoto(operation, 'fail', error); }
},
finishPhoto: function (operation, kind, result) {
if (operation.done || this.photoOperation !== operation) return;
operation.done = true; clearTimeout(operation.timer); this.photoOperation = null;
if (!this.alive || !this.visible) return;
this.setData({ photoBusy: false });
if (!this.current(operation.generation)) return;
var file = result && (result.tempImagePath || result.tempFilePath || result.apFilePath);
this.photoReturnedPath = typeof file === 'string' && !!file;
this.photoResultSuccess = kind === 'success' && this.photoReturnedPath;
if (kind === 'success' && typeof file === 'string' && file) this.setData({ photoPath: file, photoPosition: operation.position, photoStatus: '已取得临时照片,请观察照片是否来自所请求镜头' });
else this.setData({ photoStatus: message(result, kind === 'success' ? '拍照成功回调未返回可预览的临时路径' : '拍照未成功返回照片') });
},
onOpenAutoCapture: function () {
if (!development.nativeFaceProbeEnabled() || this.photoOperation || this.data.photoBusy) return;
my.redirectTo({ url: '/pages/camera/camera?debug=local-face' });
},
onOpenARProbe: function () {
if (this.photoOperation || this.data.photoBusy) return;
my.redirectTo({ url: '/pages/camtest/camtest' });
},
onClearPhoto: function () { this.setData({ photoPath: '', photoPosition: '', photoStatus: '本页照片预览已清除' }); },
onPreviewError: function () { if (this.alive && this.visible) this.setData({ photoStatus: '已收到拍照路径,但本页图片预览加载失败' }); }
});
function diagnosticFormat(value) {
return typeof value !== 'string' ? 'not_reported' : (KNOWN_FORMATS.indexOf(value.toUpperCase()) >= 0 ? value.toUpperCase() : 'unrecognized');
}
function diagnosticCode(error) {
if (!error || typeof error !== 'object') return 'unavailable';
var keys = ['error', 'errorCode', 'code'];
for (var index = 0; index < keys.length; index++) {
var descriptor;
try { descriptor = Object.getOwnPropertyDescriptor(error, keys[index]); } catch (_) { continue; }
if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) continue;
var value = descriptor.value;
if (typeof value === 'number' && isFinite(value) && Math.floor(value) === value && Math.abs(value) <= 999999999) return value;
if (typeof value === 'string' && /^-?\d{1,9}$/.test(value)) return Number(value);
}
return 'unavailable';
}
+5
View File
@@ -0,0 +1,5 @@
{
"defaultTitle": "普通相机诊断",
"pullRefresh": false,
"gestureBack": "NO"
}
+40 -20
View File
@@ -1,20 +1,40 @@
.wrap {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
background: #000;
}
.cam {
position: absolute;
left: 0; top: 0;
width: 100%;
height: 100%;
}
.tip {
position: absolute;
left: 0; right: 0;
bottom: 80rpx;
text-align: center;
color: #fff;
font-size: 13px;
z-index: 2;
}
.wrap { position: absolute; left: 0; top: 0; width:100vw; height:100vh; padding-top:calc(112px + 240rpx); box-sizing:border-box; background: #000; overflow-y: auto; overflow-x:hidden; }
.cam { position: fixed; left: 0; top: 0; width: 100%; height: 100%; }
.diag-toolbar { position: fixed; z-index: 20; left: 0; top: 112px; width: 100%; box-sizing: border-box; padding: 12rpx 24rpx; background: #ffe56c; color: #211800; box-shadow: 0 6rpx 18rpx rgba(0,0,0,.4); }
.toolbar-version { font-size: 11px; line-height: 1.5; margin-bottom: 8rpx; }
.native-probe-entry { margin:0 0 12rpx; padding:0 12rpx; height:72rpx; line-height:68rpx; background:#126597; color:#fff; font-size:14px; font-weight:700; }
.toolbar-actions { display:flex; }
.copy-diagnostics, .export-photo { flex:1; width:0; height:72rpx; line-height:68rpx; box-sizing:border-box; margin:0; padding:0 8rpx; background:#ffda00; color:#211800; border:2rpx solid #705500; font-size:14px; font-weight:700; }
.export-photo { margin-left:12rpx; background:#fff7d3; }
.export-photo[disabled] { background:#ece3ac; color:#817c61; border-color:#b6ac78; }
.copy-status { margin-top: 8rpx; font-size: 11px; line-height: 1.5; }
.copy-fallback { margin-top: 8rpx; padding: 12rpx; background: #fffbe9; border-radius: 8rpx; }
.copy-help { margin-bottom: 8rpx; font-size: 10px; line-height: 1.5; }
.copy-text-scroll { height: 240rpx; }
.copy-text { font-size: 11px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; user-select: text; }
.probe { position: relative; z-index: 2; width:702rpx; max-width:calc(100vw - 48rpx); box-sizing:border-box; margin: 24rpx; padding: 24rpx; border-radius: 12rpx; background: rgba(0, 0, 0, .76); color: #fff; }
.title { font-size: 18px; font-weight: 600; margin-bottom: 16rpx; }
.version { font-size: 12px; color: #f5f078; margin-top: 8rpx; }
.diagnostics { font-size: 10px; line-height: 1.6; margin-top: 12rpx; color: #c7e0f5; word-break: break-all; }
.diagnostics text { white-space: pre-wrap; }
.note { font-size: 11px; line-height: 1.6; color: #ddd; margin-top: 12rpx; word-break: break-all; }
.facing { margin-top: 16rpx; color: #f5f078; font-size: 13px; line-height: 1.6; }
.actions { display: flex; flex-wrap: wrap; margin: 20rpx 0; }
.actions button { font-size: 12px; margin: 0 12rpx 12rpx 0; padding: 0 16rpx; max-width:100%; }
.calibration { margin: 16rpx 0; padding: 12rpx; border: 1rpx solid #777; border-radius: 8rpx; }
.calibration-optional { margin-top: 12rpx; padding: 0 12rpx; font-size: 11px; }
.calibration-confirmed { margin-top: 12rpx; font-size: 12px; line-height: 1.6; color: #bceaa7; }
.calibration-grid { display: flex; flex-wrap: wrap; margin-top: 16rpx; }
.calibration-card { width: calc(50% - 12rpx); margin: 0 12rpx 16rpx 0; box-sizing: border-box; background: #161616; }
.calibration-format { padding: 8rpx; font-size: 12px; text-align: center; color: #f5f078; }
.calibration-image { width: 100%; height: 320rpx; background: #222; }
.calibration-card button { padding: 0 6rpx; margin: 8rpx 0 0; font-size: 11px; }
.targets { display: flex; flex-wrap: wrap; }
.target { margin: 0 12rpx 12rpx 0; padding: 12rpx; font-size: 11px; border: 1rpx solid #777; border-radius: 8rpx; }
.target.active { border-color: #f5f078; color: #f5f078; }
.status { font-size: 14px; line-height: 1.6; }
.error { margin-top: 16rpx; padding: 12rpx; border: 1rpx solid #e79384; color: #ffd1c7; font-size: 11px; line-height: 1.6; word-break: break-all; }
.fields { max-height: 240rpx; margin-top: 16rpx; }
.fields text { font-size: 10px; line-height: 1.6; white-space: pre-wrap; color: #c7e0f5; }
.snapshot { margin-top: 20rpx; width: 100%; height: 440rpx; background: #111; }
.compress-canvas { position: fixed; left: -10000px; top: -10000px; pointer-events: none; }
+63 -5
View File
@@ -1,6 +1,64 @@
<!-- 相机组件冒烟测试:页面里只有一个 <camera>,不放任何嵌套覆盖组件。
用来判断淘宝真机到底支不支持相机组件(之前整页崩,怀疑是嵌了微信专有的 <cover-view>)。 -->
<view class="wrap">
<camera id="test-camera" class="cam" device-position="front" flash="off" onError="onError" />
<view class="tip">如果能看到实时画面 → 相机组件可用</view>
<view class="wrap" style="padding-top:calc({{toolbarTop}}px + 240rpx);">
<!-- Uses the documented Taobao component, only after camera permission. -->
<ar-camera a:if="{{running}}" id="test-ar-camera-{{cameraSession}}" data-session="{{cameraSession}}" class="cam" mode="faceDetection2D" useCapturedImage="{{true}}" devicePosition="{{devicePosition}}"
onInit="onARInit" onARFrame="onARFrame" onStop="onARStop" onError="onError" />
<view class="diag-toolbar" style="top:{{toolbarTop}}px;">
<view class="toolbar-version">AR 相机诊断 · {{probeVersion}}</view>
<button class="native-probe-entry" onTap="onOpenNativeProbe" disabled="{{busy || exporting}}">进入普通前置相机测试</button>
<view class="toolbar-actions">
<button class="copy-diagnostics" onTap="onCopyDiagnostics">复制诊断信息</button>
<button class="export-photo" onTap="onSnapshot" disabled="{{!running || busy || exporting}}">导出照片</button>
</view>
<view class="copy-status" a:if="{{copyStatus}}">{{copyStatus}}</view>
<view class="copy-fallback" a:if="{{diagnosticText}}">
<view class="copy-help">长按文字可选择复制;再次点击上方按钮可更新。仅含安全诊断,不含照片或账号信息。</view>
<scroll-view class="copy-text-scroll" scroll-y><text class="copy-text" selectable="{{true}}">{{diagnosticText}}</text></scroll-view>
</view>
</view>
<view class="probe">
<view class="title">AR 相机真机验收</view>
<view class="version">诊断版本 {{probeVersion}}</view>
<view class="note">只检测本机实时帧与照片导出;不登录账号、不上传、不生成报告。</view>
<view class="diagnostics"><text>{{environmentText}}</text></view>
<view class="note">{{capabilityText}}</view>
<view class="facing">请求{{devicePosition === 'front' ? '前置' : '后置'}}镜头 · 请核对实际画面</view>
<view class="note">切换将重新挂载相机;方向属性是否生效需在手机核验。原始人脸坐标未作镜像,请分别向左右转头并核对提示。</view>
<view class="actions">
<button onTap="onStart" disabled="{{running || busy}}">开启 AR 相机</button>
<button onTap="onFlip" disabled="{{!running}}">切换镜头</button>
<button onTap="onStop" disabled="{{!running}}">停止</button>
</view>
<view class="calibration">
<view class="note">高级可选:校验照片颜色。可直接使用顶部“导出照片”。</view>
<button class="calibration-optional" onTap="onCalibrate" disabled="{{!running || busy || exporting}}">高级:校验照片颜色</button>
<view class="note">此功能只在本机生成同一帧的候选预览,不上传、不保存,可根据实际肤色、衣物和背景选择。</view>
<view class="calibration-confirmed" a:if="{{calibrationConfirmed}}">本次运行已确认格式:{{calibrationConfirmed}}</view>
<view class="note" a:if="{{calibrationText}}">{{calibrationText}}</view>
<view class="calibration-grid" a:if="{{calibrationItems.length}}">
<view class="calibration-card" a:for="{{calibrationItems}}" a:key="format">
<view class="calibration-format">{{item.format}}</view>
<image class="calibration-image" src="{{item.url}}" mode="aspectFit" />
<button data-format="{{item.format}}" data-batch="{{item.batchId}}" onTap="onConfirmCalibration" disabled="{{!calibrationReady}}">这张颜色正常</button>
</view>
</view>
</view>
<view class="note">只读取 context 公开方法的属性描述符,不执行 getter 或未知方法。复制内容不包含照片、人脸坐标或账号信息。</view>
<view class="diagnostics"><text>Context 方法:{{contextText}}</text></view>
<view class="diagnostics"><text>{{eventText}}</text></view>
<view class="targets">
<view class="target {{target === 'front' ? 'active' : ''}}" data-target="front" onTap="onTarget">正脸</view>
<view class="target {{target === 'right' ? 'active' : ''}}" data-target="right" onTap="onTarget">右脸(向左转头)</view>
<view class="target {{target === 'left' ? 'active' : ''}}" data-target="left" onTap="onTarget">左脸(向右转头)</view>
</view>
<view class="status">{{status}}</view>
<view class="note">{{frameText}} · 回调 {{frameCount}} 次 · 标准帧 {{standardFrameCount}} 次</view>
<view class="note" a:if="{{poseText}}">{{poseText}}</view>
<view class="error" a:if="{{errorText}}">{{errorText}}</view>
<scroll-view class="fields" scroll-y a:if="{{fieldText}}"><text>{{fieldText}}</text></scroll-view>
<view class="note">收到人脸 anchor 不代表能够导出照片。未知帧格式将明确报错;请核对预览是否为本次本人、左右方向和肤色是否正常。</view>
<image class="snapshot" a:if="{{snapshotUrl}}" src="{{snapshotUrl}}" mode="aspectFit" />
<view class="note" a:if="{{snapshotText}}">{{snapshotText}}</view>
</view>
<!-- Keep this canvas mounted and laid out for actual device exports. -->
<canvas id="compress-canvas" canvas-id="compress-canvas" width="{{compressWidth}}" height="{{compressHeight}}" class="compress-canvas" style="width:{{compressWidth}}px;height:{{compressHeight}}px" />
</view>
+313 -6
View File
@@ -1,10 +1,317 @@
// Standalone device probe. No account API, upload, server call or saved photo.
var AR = require('../../utils/ar-camera');
var image = require('../../utils/image');
var permissions = require('../../utils/permissions');
var config = require('../../config');
var PROBE_VERSION = 'AR-DIAG-20261102-05';
var CALIBRATION_FORMATS = ['RGBA', 'BGRA', 'ARGB', 'ABGR'];
function confirmedPixelFormat() {
try {
var format = image.confirmedFramePixelFormat();
return CALIBRATION_FORMATS.indexOf(format) >= 0 ? format : '';
} catch (_) { return ''; }
}
Page({
data: {},
data: { running: false, busy: false, exporting: false, devicePosition: config.arFrontPosition === 'back' ? 'back' : 'front', cameraSession: 0, status: '请点击开启,验证淘宝真实 AR 帧与照片导出。',
probeVersion: PROBE_VERSION, toolbarTop: 112, environmentText: '', capabilityText: '', contextText: '等待 onInit 后读取公开方法名', eventText: '尚未收到原生事件', copyStatus: '', diagnosticText: '',
target: 'front', frameCount: 0, standardFrameCount: 0, frameText: '尚未收到帧事件',
calibrationItems: [], calibrationReady: false, calibrationText: '', calibrationConfirmed: '',
poseText: '', fieldText: '', errorText: '', snapshotUrl: '', snapshotText: '', compressWidth: 1, compressHeight: 1 },
onLoad: function () {
this.alive = true;
this.alive = true; this.visible = true; this.sequence = 0;
var capability = AR.capability(my);
this.diagnosticSystem = AR.diagnostics.system(my); this.contextMethodNames = []; this.nativeEventStats = {};
this.diagnosticCapability = 'ar-camera=' + capability.componentSignal + '; createArCameraContext=' + capability.contextFactory;
this.setData({ toolbarTop: AR.diagnostics.toolbarTop(my), calibrationConfirmed: confirmedPixelFormat(), environmentText: this.diagnosticSystem.join('\n') || '系统未返回可读取的设备/宿主版本;请手动补充淘宝版本。',
capabilityText: 'ar-camera 声明:' + capability.componentSignal + ';上下文工厂:' + (capability.contextFactory ? '存在' : '不存在') + '。声明结果不代表真机已支持。' });
},
onUnload: function () { this.alive = false; },
onError: function (err) {
my.showToast({ content: '相机组件报错: ' + JSON.stringify(err || {}), type: 'none', duration: 6000 });
}
onShow: function () { this.visible = true; },
flushFrameDiagnostic: function () {
var pending = this.pendingFrameDiagnostic;
if (!pending) return;
this.pendingFrameDiagnostic = null;
var record = this.nativeEventStats[pending.key], summary = AR.diagnostics.event(pending.event);
record.type = summary.type || '(未提供 type)'; record.metadata = summary.metadata;
this.lastDiagnosticMetadata = summary.metadata;
},
nativeEventView: function () {
var that = this;
return { eventText: Object.keys(this.nativeEventStats).map(function (item) {
var value = that.nativeEventStats[item];
return item + ' · type=' + value.type + ' · 次数 ' + value.count;
}).join('\n'), fieldText: (this.lastDiagnosticMetadata || []).join('\n') || '本事件未返回白名单元信息;不会输出未知字段值或人脸坐标。' };
},
recordNativeEvent: function (name, event, deferFrame) {
var key = name + ':request=' + this.data.devicePosition;
var record = this.nativeEventStats[key] || { count: 0, type: '(未提供 type)', metadata: [] };
record.count++;
this.nativeEventStats[key] = record;
// Count every frame in JS, but do not scan descriptors or cross the UI
// bridge until the 400ms refresh. Only the latest frame is retained.
if (deferFrame) { this.pendingFrameDiagnostic = { key: key, event: event }; return; }
this.flushFrameDiagnostic();
var summary = AR.diagnostics.event(event);
record.type = summary.type || '(未提供 type)'; record.metadata = summary.metadata;
this.lastDiagnosticMetadata = summary.metadata;
this.setData(this.nativeEventView());
return summary;
},
diagnosticReport: function () {
// A user-requested copy gets up-to-date counters and last-frame metadata
// without introducing another UI refresh on the high-rate native stream.
this.flushFrameDiagnostic();
var that = this, lines = [PROBE_VERSION].concat(this.diagnosticSystem || []);
lines.push(this.diagnosticCapability || 'capability=unavailable');
lines.push('requestedPosition=' + this.data.devicePosition + '; actualPosition=需核对实际画面');
lines.push('context.methods=' + ((this.contextMethodNames || []).join(', ') || '(none observed)'));
lines.push('pixelFormat.default=' + (CALIBRATION_FORMATS.indexOf(config.arPixelFormat) >= 0 ? config.arPixelFormat : '(none)') + '; defaultIsUserConfirmed=false');
lines.push('pixelFormat.confirmed=' + (confirmedPixelFormat() || '(unconfirmed)'));
lines.push('currentSession.frameEvents=' + (this.frames || 0) + '; parsableFrames=' + (this.standardFrames || 0));
Object.keys(this.nativeEventStats || {}).forEach(function (key) {
var record = that.nativeEventStats[key];
lines.push(key + '; type=' + record.type + '; count=' + record.count);
record.metadata.forEach(function (item) { lines.push(' ' + item); });
});
return lines.join('\n');
},
onCopyDiagnostics: function () {
if (!this.alive) return;
// Always reveal the same privacy-filtered text first, so clipboard failure
// or an absent API still leaves a native selectable-text fallback.
var report = this.diagnosticReport();
this.setData({ diagnosticText: report, copyStatus: '安全诊断已生成;下方文字可长按复制。' });
if (typeof my.setClipboard !== 'function') { this.setData({ copyStatus: '当前淘宝未提供剪贴板 API,请长按下方诊断文字复制。' }); return; }
var that = this;
try {
my.setClipboard({ text: report, success: function () { if (that.alive) that.setData({ copyStatus: '诊断信息已复制。也可长按下方文字复制。' }); },
fail: function () { if (that.alive) that.setData({ copyStatus: '自动复制失败,请长按下方诊断文字复制。' }); } });
} catch (_) { this.setData({ copyStatus: '自动复制失败,请长按下方诊断文字复制。' }); }
},
onOpenNativeProbe: function () {
if (this.data.busy || this.data.exporting) return;
my.redirectTo({ url: '/pages/camnative/camnative' });
},
currentCalibration: function (batch) {
return !!(batch && this.calibrationBatch === batch && batch.sequence === this.sequence && this.alive && this.visible && this.data.running);
},
invalidateCalibration: function () {
if (this.calibrationBatch) this.calibrationBatch.frame = null;
this.calibrationBatch = null;
if (this.alive) this.setData({ calibrationItems: [], calibrationReady: false, calibrationText: '' });
},
onCalibrate: function () {
if (!this.alive || !this.visible || !this.data.running) return Promise.resolve();
if (this.exportInFlight || this.data.busy) {
this.setData({ errorText: '上一批照片仍在导出,请稍候再校验颜色。' }); return Promise.resolve();
}
if (!this.lastFrame) { this.setData({ errorText: '尚未收到真实相机帧,无法校验照片颜色。' }); return Promise.resolve(); }
var frozen, formats;
try {
// Freeze once: every preview and the eventual explicit confirmation refer
// to the exact same image, not a later native/reused camera buffer.
frozen = image.freezeCalibrationFrame(this.lastFrame);
formats = image.frameCalibrationFormats(frozen).filter(function (format) { return CALIBRATION_FORMATS.indexOf(format) >= 0; });
} catch (error) { this.setData({ errorText: error.message || '当前帧无法进行本地颜色校验。' }); return Promise.resolve(); }
if (!formats.length) {
this.setData({ errorText: '当前帧不是可校验的无格式四通道图像,请等待新帧或直接导出照片。' }); return Promise.resolve();
}
this.invalidateCalibration();
var that = this, operation = {}, batch = { sequence: this.sequence, id: this.sequence + '-' + ((this.calibrationSerial || 0) + 1),
frame: frozen, formats: formats, items: [], settled: false };
this.calibrationSerial = (this.calibrationSerial || 0) + 1;
this.calibrationBatch = batch; this.exportInFlight = operation;
this.setData({ busy: true, exporting: true, errorText: '', snapshotUrl: '', snapshotText: '',
calibrationText: '正在按顺序生成本地颜色预览;全部完成后,请选择颜色正常的一张。' });
return formats.reduce(function (chain, format) {
return chain.then(function () {
if (!that.currentCalibration(batch)) return;
return image.captureFrameToBase64(batch.frame, that, { pixelFormat: format, maxSide: 480 }).then(function (url) {
if (!that.currentCalibration(batch)) return;
batch.items.push({ format: format, url: url, batchId: batch.id });
that.setData({ calibrationItems: batch.items.slice(), calibrationText: '已生成 ' + batch.items.length + '/' + batch.formats.length + ' 张,完成后可确认。' });
});
});
}, Promise.resolve()).then(function () {
batch.settled = true;
if (that.currentCalibration(batch)) that.setData({ calibrationReady: true, calibrationText: '请比较肤色和背景,点击颜色正常照片下方的按钮;尚未确认任何新格式。' });
}).catch(function (error) {
batch.settled = true;
if (that.currentCalibration(batch)) {
that.invalidateCalibration();
that.setData({ errorText: error.message || '颜色预览导出失败,请重试。', calibrationText: '本批颜色预览导出失败,未确认新格式,请重试。' });
}
}).then(function () {
if (!that.currentCalibration(batch)) batch.frame = null;
if (that.exportInFlight !== operation) return;
that.exportInFlight = null;
if (that.alive) {
var patch = { exporting: false };
if (batch.sequence === that.sequence) patch.busy = false;
that.setData(patch);
}
});
},
onConfirmCalibration: function (event) {
var batch = this.calibrationBatch, dataset = event && event.currentTarget && event.currentTarget.dataset || {}, format = dataset.format;
if (!this.currentCalibration(batch) || !batch.settled || String(dataset.batch) !== batch.id || batch.formats.indexOf(format) < 0
|| !batch.items.some(function (item) { return item.format === format; })) return;
try {
var confirmed = image.confirmFramePixelFormat(batch.frame, format);
if (confirmed !== format) throw new Error('未能确认相机格式,请重新校验。');
this.invalidateCalibration();
this.setData({ calibrationConfirmed: confirmed, calibrationText: '本次相机格式已确认:' + confirmed + ',已记录本次颜色选择。', errorText: '' });
} catch (error) { this.setData({ errorText: error.message || '格式确认失败,请重新校验。' }); }
},
acceptsCameraEvent: function (event) {
if (!this.alive || !this.visible || !this.data.running) return false;
var target = event && (event.currentTarget || event.target), dataset = target && target.dataset;
// Native callbacks from an unmounted camera must not enter a new session.
// Events without a marker remain compatible with older host callbacks.
if (dataset && dataset.session !== undefined && String(dataset.session) !== String(this.data.cameraSession)) return false;
var id = target && target.id;
return typeof id !== 'string' || id.indexOf('test-ar-camera-') !== 0 || id === 'test-ar-camera-' + this.data.cameraSession;
},
mountCamera: function (sequence) {
if (!this.alive || !this.visible || sequence !== this.sequence) return;
var that = this;
clearTimeout(this.frameTimer);
this.frames = 0; this.standardFrames = 0; this.lastFrame = null; this.context = null; this.lastUpdate = 0; this.frameUiShown = false; this.pendingFrameDiagnostic = null; this.contextMethodNames = [];
this.setData({ running: true, busy: false, cameraSession: sequence, frameCount: 0, standardFrameCount: 0, frameText: '尚未收到帧事件', contextText: '等待 onInit 后读取公开方法名',
status: '已请求' + (this.data.devicePosition === 'front' ? '前置' : '后置') + '镜头,等待真实帧;请核对实际画面…' });
this.frameTimer = setTimeout(function () {
if (that.alive && that.visible && sequence === that.sequence && !that.frames) that.setData({ status: '10秒内未收到帧事件(onARFrame)。请复制诊断,不能视为能力已通过。' });
}, 10000);
},
onStart: function () {
if (this.data.running || this.data.busy) return Promise.resolve();
var that = this, sequence = ++this.sequence;
this.setData({ busy: true, errorText: '', snapshotUrl: '', snapshotText: '', frameCount: 0, poseText: '', fieldText: '' });
// Camera permission only. Neither Taobao userInfo nor album is needed.
return permissions.ensure('scope.camera').then(function () {
if (!that.alive || !that.visible || sequence !== that.sequence) return;
that.mountCamera(sequence);
}).catch(function (error) {
if (that.alive && sequence === that.sequence) that.setData({ errorText: error.message || '相机授权失败' });
}).then(function () { if (that.alive && sequence === that.sequence) that.setData({ busy: false }); });
},
onARInit: function (event) {
if (!this.acceptsCameraEvent(event)) return;
this.recordNativeEvent('onInit', event);
try {
this.context = AR.context(my, 'test-ar-camera-' + this.data.cameraSession);
// Enumerate descriptors only. This diagnostic never probes unknown methods
// or executes property getters/automatic context actions.
this.contextMethodNames = AR.diagnostics.methods(this.context);
this.setData({ contextText: this.contextMethodNames.join(', ') || '未发现可读取的公开方法;不代表原生能力不存在。', status: 'AR 初始化完成,等待实时人脸数据…' });
} catch (error) { this.setData({ errorText: error.message || 'AR 上下文初始化失败' }); }
},
onARFrame: function (event) {
if (!this.acceptsCameraEvent(event)) return;
this.frames = (this.frames || 0) + 1; this.lastFrame = event && event.detail || event;
this.recordNativeEvent('onARFrame', event, true);
clearTimeout(this.frameTimer);
// Facing selects hardware, not the native landmark coordinate convention.
// Do not reverse yaw solely because a front camera was requested.
var verdict;
try { verdict = AR.evaluate(event, this.data.target, false); } catch (_) { verdict = { frameReceived: false }; }
if (verdict.frameReceived) this.standardFrames = (this.standardFrames || 0) + 1;
var now = Date.now();
if (this.frameUiShown && now - this.lastUpdate < 400) return;
this.lastUpdate = now; this.frameUiShown = true;
this.flushFrameDiagnostic();
var patch = this.nativeEventView();
patch.frameCount = this.frames; patch.standardFrameCount = this.standardFrames || 0;
if (!verdict.frameReceived) {
patch.status = '已收到 onARFrame 回调,但格式不符合 width/height/anchors;请复制诊断。';
patch.frameText = '已收到原生回调,尚无可解析的标准帧'; patch.poseText = '';
this.setData(patch);
return;
}
var observation = verdict.observation;
patch.status = verdict.ok ? '实时人脸判断通过;仍需点击导出照片验证原始图像。' : verdict.hint;
patch.frameText = verdict.width + '×' + verdict.height + ' · 人脸 ' + verdict.faceCount + ' · 已收到真实帧';
patch.poseText = observation ? '几何姿态 yaw ' + observation.yaw.toFixed(1) + '° / pitch ' + observation.pitch.toFixed(1) + '° / roll ' + observation.roll.toFixed(1) + '°' : '';
this.setData(patch);
},
onTarget: function (event) {
var target = event.currentTarget.dataset.target;
if (['front', 'left', 'right'].indexOf(target) >= 0) this.setData({ target: target });
},
onFlip: function () {
if (!this.alive || !this.visible || !this.data.running) return Promise.resolve();
var that = this, sequence = ++this.sequence;
clearTimeout(this.frameTimer);
this.flushFrameDiagnostic();
this.invalidateCalibration();
this.lastFrame = null; this.context = null; this.frames = 0; this.lastUpdate = 0;
// devicePosition is shared-component compatibility evidence. Its actual
// effect in the Taobao host must be checked on a phone; no context method
// or successful callback is treated as proof that hardware changed.
return new Promise(function (resolve) {
that.setData({ running: false, busy: true, devicePosition: that.data.devicePosition === 'front' ? 'back' : 'front',
cameraSession: sequence, frameCount: 0, frameText: '尚未收到真实帧', poseText: '', fieldText: '', errorText: '',
snapshotUrl: '', snapshotText: '', status: '正在重新挂载相机;切换效果以实际画面为准…' }, function () {
that.mountCamera(sequence);
resolve();
});
});
},
onSnapshot: function () {
// Image decoding/draw callbacks may still use the shared canvas after a
// camera session changes. Release this lock only when that export settles.
if (this.exportInFlight) {
this.setData({ errorText: '上一帧照片仍在导出,请稍候再试。' });
return Promise.resolve();
}
if (!this.lastFrame || this.data.busy) {
if (!this.lastFrame) this.setData({ errorText: '尚未收到真实相机帧,无法验证照片导出。' });
return Promise.resolve();
}
var that = this, sequence = this.sequence, operation = {};
this.exportInFlight = operation;
this.setData({ busy: true, exporting: true, errorText: '', snapshotUrl: '', snapshotText: '' });
var work;
try { work = image.captureFrameToBase64(this.lastFrame, this, { defaultPixelFormat: config.arPixelFormat }); } catch (error) { work = Promise.reject(error); }
return Promise.resolve(work).then(function (value) {
if (!that.alive || !that.visible || sequence !== that.sequence) return;
that.setData({ snapshotUrl: value, snapshotText: 'JPEG ' + that.data.compressWidth + '×' + that.data.compressHeight + ',仅本页预览,未上传、未保存到相册。', status: '已导出真实帧照片,请核对方向和颜色。' });
}).catch(function (error) {
if (that.alive && that.visible && sequence === that.sequence) that.setData({ errorText: error.message || '照片导出失败,请记录帧字段与实际错误。' });
}).then(function () {
if (that.exportInFlight !== operation) return;
that.exportInFlight = null;
if (that.alive) {
var patch = { exporting: false };
if (sequence === that.sequence) patch.busy = false;
that.setData(patch);
}
});
},
onARStop: function (event) {
if (!this.acceptsCameraEvent(event)) return;
this.recordNativeEvent('onStop', event);
this.invalidateCalibration();
this.sequence++;
this.setData({ status: 'AR相机已停止。请重新开启后验证。', running: false, busy: false });
this.lastFrame = null; this.context = null; clearTimeout(this.frameTimer);
},
onError: function (event) {
if (!this.acceptsCameraEvent(event)) return;
var summary = this.recordNativeEvent('onError', event);
this.invalidateCalibration();
this.sequence++;
this.lastFrame = null; this.context = null; clearTimeout(this.frameTimer);
this.setData({ running: false, busy: false, status: 'AR 相机未通过真机验证。', errorText: '收到 AR 错误事件。' + (summary.metadata.join(';') || '未返回可安全展示的错误码,请复制诊断。') });
},
onStop: function () {
this.flushFrameDiagnostic();
this.invalidateCalibration();
this.sequence++; this.lastFrame = null; this.context = null; clearTimeout(this.frameTimer);
if (this.alive) this.setData({ running: false, busy: false, status: '已停止,帧缓存已清空。' });
},
onHide: function () {
this.visible = false; this.onStop();
if (this.alive) this.setData({ snapshotUrl: '', snapshotText: '', poseText: '', fieldText: '' });
},
onUnload: function () { this.onHide(); this.alive = false; }
});
+3 -2
View File
@@ -1,5 +1,6 @@
{
"defaultTitle": "相机组件测试",
"defaultTitle": "AR 相机真机验收",
"titleBarColor": "#101823",
"backgroundColor": "#000000"
"backgroundColor": "#000000",
"gestureBack": "NO"
}
+4
View File
@@ -31,6 +31,7 @@ page { background: #EFEFEF; }
position: relative;
z-index: 1;
padding: 56rpx 34rpx 72rpx; /* calc(28*u) calc(17*u) calc(36*u) */
padding-top: max(56rpx, env(safe-area-inset-top));
box-sizing: border-box;
}
@@ -157,6 +158,7 @@ page { background: #EFEFEF; }
background: #F5F078;
border-radius: 10rpx; /* calc(5 * var(--u)) */
width: 160rpx; /* calc(80 * var(--u)) */
height: 46rpx; /* 覆盖小程序 button 默认高度,与 H5 23 设计像素一致 */
min-height: 46rpx; /* calc(23 * var(--u)) */
font-size: 12px;
color: #282828;
@@ -206,3 +208,5 @@ page { background: #EFEFEF; }
box-sizing: border-box;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.session-guard { position: relative; z-index: 10; padding: 120rpx 40rpx; text-align: center; font-size: 13px; line-height: 1.8; color: #707070; }
.session-guard .btn-primary { margin: 32rpx auto 0; }
+10 -3
View File
@@ -1,10 +1,16 @@
<import src="../../components/brand-header.axml" />
<template is="brand-header" data="{{brandHeader: brandHeader}}" />
<!-- ═════ 历史报告 —— 对照 H5 index.html #s-history(L32-42) 与 app.js renderHistoryList()(L1956-1985) ═════ -->
<view class="history">
<view class="history" style="padding-top:{{brandHeader.height}}px;">
<view class="session-guard" a:if="{{identityChecking || identityError}}">
<view>{{identityChecking ? '正在确认当前淘宝账号…' : identityError}}</view>
<button a:if="{{identityError}}" class="btn-primary" onTap="onRetryIdentity">重新验证</button>
</view>
<!-- H5 #s-history{background:#efefef url(bg-history-h5) center/cover fixed} -->
<image class="history-bg" src="{{bgUrl}}" mode="aspectFill" />
<!-- H5 .history-content -->
<view class="history-content">
<view class="history-content" hidden="{{identityChecking || identityError}}">
<view class="report-title">您的报告</view>
<!-- H5 .account-status.account-status-history -->
@@ -31,7 +37,8 @@
<view class="history-list-note" a:if="{{!loading && error}}">{{error}},请刷新列表重试。</view>
<view class="history-empty" a:if="{{!loading && !error && !items.length}}">
<view>暂无检测报告</view>
<view>完成一次检测后,报告会自动保存在这里。</view>
<view>{{guest ? '游客报告不保存,淘宝登录后可保存之后生成的报告。' : '完成一次检测后,报告会自动保存在这里。'}}</view>
<button a:if="{{guest}}" class="history-retry" onTap="onLogin">淘宝账号登录</button>
</view>
<view class="card history-item" a:for="{{items}}" a:key="id" data-id="{{item.id}}" onTap="onOpen">
<view class="history-item-line"><text class="history-item-label">测试时间</text> {{item.date}}</view>
+57 -16
View File
@@ -10,21 +10,49 @@ function formatDate(value) {
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
Page({
Page(require('../../utils/page-header').page({
onShareAppMessage: function () { return require('../../utils/share').message(); },
data: {
bgUrl: api.assetUrl('img/ui-20261009/bg-history-h5.jpg'),
items: [], loading: false, error: '',
signedIn: false, accountLabel: '尚未登录',
signedIn: false, guest: false, accountLabel: '尚未登录',
countText: '正在读取报告…', // H5 #history-count 初值
identityNote: '',
toastText: '', toastOn: false
toastText: '', toastOn: false, identityChecking: true, identityError: ''
},
onShow: function () {
if (!api.user()) { this.setData({ items: [] }); my.redirectTo({ url: '/pages/splash/splash' }); return; }
this.setData({ signedIn: true, accountLabel: '淘宝已登录' });
this.load();
this.visible = true;
if (!api.user()) { this.setData({ items: [] }); my.redirectTo({ url: '/pages/login/login' }); return; }
this.ownerId = api.user().id;
if (api.user().kind === 'taobao_guest') { this.showGuest(); return; }
var that = this, owner = this.ownerId, seq = this.identitySeq = (this.identitySeq || 0) + 1;
this.identityPending = true; this.setData({ identityChecking: true, identityError: '' });
return (typeof api.restore === 'function' ? api.restore() : Promise.resolve(api.user())).then(function (user) {
if (!that.visible || seq !== that.identitySeq) return;
if (!user || user.id !== owner) { that.setData({ items: [], countText: '', identityNote: '' }); my.redirectTo({ url: '/pages/login/login' }); return; }
that.identityPending = false;
that.setData({ identityChecking: false, signedIn: true, guest: false, accountLabel: '淘宝已登录' });
that.load();
}).catch(function (err) {
if (!that.visible || seq !== that.identitySeq) return;
if (err.status === 401 || !api.user() || api.user().id !== owner) {
that.setData({ items: [], countText: '', identityNote: '' }); my.redirectTo({ url: '/pages/login/login' }); return;
}
that.setData({ identityChecking: false, identityError: '网络异常,暂时无法确认当前账号,请重试。' });
});
},
onUnload: function () { this.closed = true; },
onHide: function () {
this.visible = false; this.closed = true; this.loadSeq = (this.loadSeq || 0) + 1; this.identitySeq = (this.identitySeq || 0) + 1;
if (api.user() && api.user().kind !== 'taobao_guest') { this.identityPending = true; this.setData({ identityChecking: true, identityError: '' }); }
},
onUnload: function () { this.onHide(); clearTimeout(this.toastTimer); },
showGuest: function () {
this.identityPending = false;
this.setData({ guest: true, signedIn: false, accountLabel: '游客体验', items: [], loading: false, error: '',
identityChecking: false, identityError: '', countText: '游客报告不计入历史记录', identityNote: '游客报告仅供本次查看,不保存照片、档案或历史报告。' });
},
onRetryIdentity: function () { return this.onShow(); },
onLogin: function () { my.navigateTo({ url: '/pages/login/login?switch=1' }); },
// H5 .toast
toast: function (msg) {
@@ -37,11 +65,16 @@ Page({
},
load: function () {
if (this.identityPending) return;
var that = this;
if (!api.user()) { my.redirectTo({ url: '/pages/login/login' }); return; }
if (api.user().kind === 'taobao_guest') { this.showGuest(); return; }
var owner = api.user().id, seq = this.loadSeq = (this.loadSeq || 0) + 1;
this.closed = false;
this.setData({ loading: true, error: '' });
this.setData({ guest: false, loading: true, error: '', items: [] });
function current() { return !that.closed && seq === that.loadSeq && api.user() && api.user().id === owner; }
api.request('reports').then(function (data) {
if (that.closed) return;
if (!current()) return;
that.setData({
countText: '您最近已进行过' + data.total + '次面部问题检测',
identityNote: '保留最近 5 份报告,同一淘宝账号可跨手机查看。',
@@ -50,24 +83,32 @@ Page({
})
});
}).catch(function (err) {
if (!that.closed) that.setData({ error: err.message, countText: '暂时无法读取' });
}).then(function () { if (!that.closed) that.setData({ loading: false }); });
if (current()) that.setData({ error: err.message, countText: '暂时无法读取' });
else if (!api.user() && err.status === 401 && that.visible) my.redirectTo({ url: '/pages/login/login' });
}).then(function () { if (current()) that.setData({ loading: false }); });
},
onOpen: function (e) {
if (this.identityPending) return;
if (this.data.loading) return;
if (!api.user() || api.user().kind === 'taobao_guest') return;
var id = e.currentTarget.dataset.id;
if (!id) return;
var that = this;
var owner = api.user().id, seq = this.loadSeq = (this.loadSeq || 0) + 1;
this.closed = false;
this.setData({ loading: true });
api.request('reports', { id: id }).then(function (data) {
if (that.closed) return;
if (that.closed || seq !== that.loadSeq || !api.user() || api.user().id !== owner) return;
getApp().globalData.photos = data.report.photos;
getApp().globalData.payload = Object.assign({}, data.report.payload, { vm: data.report.vm, presentation: data.report.presentation });
my.navigateTo({ url: '/pages/report/report' });
}).catch(function (err) { that.toast(err.message); })
.then(function () { if (!that.closed) that.setData({ loading: false }); });
}).catch(function (err) {
if (that.closed || seq !== that.loadSeq) return;
if (err.status === 401) { that.setData({ items: [] }); my.redirectTo({ url: '/pages/login/login' }); }
else that.toast(err.message);
})
.then(function () { if (!that.closed && seq === that.loadSeq) that.setData({ loading: false }); });
},
// H5 data-action="logout-wechat"(历史页右上角)
@@ -75,7 +116,7 @@ Page({
var that = this;
api.logout().then(function () {
that.setData({ signedIn: false, accountLabel: '尚未登录', items: [], countText: '', identityNote: '' });
my.redirectTo({ url: '/pages/splash/splash' });
my.redirectTo({ url: '/pages/login/login' });
}).catch(function (err) { that.toast(err.message); });
}
});
}, typeof my === 'undefined' ? {} : my));
+9 -1
View File
@@ -1 +1,9 @@
{ "defaultTitle": "历史报告", "titleBarColor": "#FFFFFF", "backgroundColor": "#EFEFEF" }
{
"defaultTitle": "​",
"titleBarColor": "#FFFFFF",
"backgroundColor": "#EFEFEF",
"navigationBarForceEnable": true,
"transparentTitle": "always",
"navigationBarTextStyle": "black",
"gestureBack": "NO"
}
+19
View File
@@ -0,0 +1,19 @@
@import "../splash/splash.acss";
page { background: #142632; }
.login { position: relative; width: 100%; height: 100vh; overflow: hidden; background: #142632; }
.login-bg { position: absolute; inset: 0; width: 100%; height: 100%; }
.login-brand { position: absolute; width: 290rpx; left: 50%; top: 14%; transform: translateX(-50%); }
.login-bottom { position: absolute; left: 0; right: 0; bottom: max(5%, env(safe-area-inset-bottom)); display: flex; flex-direction: column; align-items: center; gap: 24rpx; }
.login-button { width: 540rpx; height: 80rpx; padding: 0; display: flex; align-items: center; justify-content: center; gap: 28rpx; border-radius: 10rpx; font-size: 14px; line-height: 1; background: transparent; }
.login-button::after { border: none; }
.login-taobao { background: #F5F078; color: #000; border: none; box-shadow: none; }
.login-taobao::before, .login-taobao::after { border: none; box-shadow: none; }
.login-browser { color: #fff; border: 1px solid rgba(255,255,255,.5); }
.login-icon { width: 46rpx; height: 40rpx; }
.login-taobao-mark { font-size: 19px; font-weight: 700; width: 46rpx; text-align: center; }
.login-consent { display: flex; align-items: center; gap: 16rpx; max-width: 90%; color: #eee; font-size: 10px; margin-top: 18rpx; }
.login-consent-box { width: 13px; height: 13px; border: 1px solid #eee; box-sizing: border-box; flex-shrink: 0; }
.login-consent-box.checked { background: #F5F078; border-color: #F5F078; }
.login-policy-link { color: #F5F078; text-decoration: underline; }
.login-message { min-height: 56rpx; max-width: 84%; color: #eee; text-align: center; font-size: 10px; line-height: 1.5; }
@media (max-height: 560px) { .login-brand { top: 8%; } .login-bottom { bottom: 2%; } }
+55
View File
@@ -0,0 +1,55 @@
<import src="../../components/brand-header.axml" />
<template is="brand-header" data="{{brandHeader: brandHeader}}" />
<view class="login" style="margin-top:{{brandHeader.height}}px;height:calc(100vh - {{brandHeader.height}}px);">
<image class="login-bg" src="{{bgUrl}}" mode="aspectFill" />
<image class="login-brand" src="{{brandUrl}}" mode="widthFix" />
<view class="login-bottom">
<button class="login-button login-taobao" disabled="{{busy || checking}}" onTap="onTaobaoLogin"><text class="login-taobao-mark">淘</text><text>淘宝账号登录</text></button>
<button class="login-button login-browser" disabled="{{busy}}" onTap="onGuest"><image class="login-icon" src="{{guestIconUrl}}" mode="aspectFit" /><text>游客体验</text></button>
<view class="login-consent" onTap="onToggleAgree"><view class="login-consent-box {{agreed ? 'checked' : ''}}" /><view>同意<text class="login-policy-link" catchTap="onOpenPolicy">用户协议、隐私政策和儿童保护政策</text></view></view>
<view class="login-message">{{message}}</view>
</view>
<view class="overlay overlay-policy" a:if="{{showPolicy}}">
<view class="policy-page">
<button class="nav-back policy-float-back" onTap="onClosePolicy">
<view class="chev left"></view>
</button>
<scroll-view scroll-y class="policy-body">
<view class="policy-title">Face⁺⁺个人信息与隐私保护政策</view>
<view class="policy-h2">前言</view>
<view class="policy-p">北京旷视科技有限公司及其关联公司(以下简称"我们"或"旷视")尊重并致力于保护个人信息与隐私。Face⁺⁺人工智能开放平台(以下简称"本平台")和/或Face⁺⁺人工智能开放平台服务(以下简称"本服务")作为旷视面向开发者的视觉智能开放平台与服务,严格参照《旷视个人信息与隐私保护政策》中的统一要求和标准处理相关个人信息;为进一步明确在本平台和本服务中可能涉及的个人信息处理实践情况,我们特制定了Face⁺⁺个人信息与隐私保护政策(以下简称"Face⁺⁺政策"),以介绍本平台和本服务中收集、使用、留存等个人信息的具体处理。</view>
<view class="policy-p">Face⁺⁺政策拟帮助客户和用户了解:我们如何收集使用个人信息、如何保留和披露、如何保护、个人信息在全球范围内的转移、您的个人信息权利、如何处理儿童的个人信息、如何联系我们、政策如何更新。</view>
<view class="policy-h2">一、我们如何收集使用个人信息</view>
<view class="policy-p">旷视将与客户一道,在符合《个人信息保护法》第十三条规定的合法性基础的前提下,确保所收集和处理的个人信息已取得您的完整、有效授权同意。</view>
<view class="policy-p">为提供人脸和人体图像识别相关技术的能力,我们将基于客户主动提供的原始图片、视频等数据进一步识别和提取面部识别特征、人体区域、人体特征和关键点、手势等生物识别信息,用于实现人脸比对、人脸检测、人脸搜索、人脸美颜、人脸融合等具体功能。请您理解,如您拒绝提供原始图片、视频等数据,前述功能应用将无法实现。</view>
<view class="policy-p">提请您注意的是,该等功能项下处理的面部识别特征等生物识别信息属于个人敏感信息,只有在具有特定的目的和充分的必要性、并采取严格保护措施的情形下,我们才会进行处理。我们将依法向您单独告知处理该等敏感个人信息的必要性以及对您个人权益的影响,并在您通过弹窗、勾选等明示方式单独同意后,方进行该等处理;您拒绝单独同意的,我们将不处理您的上述生物识别信息,但不影响您使用本平台和本服务的其他功能。</view>
<view class="policy-h2">二、我们如何保留和披露您的个人信息</view>
<view class="policy-p">除法律法规另有规定外,我们将在实现处理目的所必要的最短时间内保留您的个人信息,并通过本政策或相关服务规则向您明确告知各类个人信息的具体保存期限。在本平台和本服务下,我们主要提供技术能力的具体应用,因此在具体功能实现后,对于客户提供的原始图片、视频等数据以及基于该等原始数据形成的服务成果,我们将尽快删除或进行匿名化处理。此外,根据《网络安全法》等强制性法律法规要求,我们将切实保留网络日志不少于六个月。</view>
<view class="policy-h2">三、我们如何保护您的个人信息</view>
<view class="policy-p">我们已采取了合理、可行的管理措施和技术措施,以保护所处理的个人信息并应对个人信息安全事件,包括:及时对个人信息进行去标识化处理;定期审查个人信息处理方式并不断强化 API 和 SDK 等技术工具的安全性;实施传输全程安全加密等保障手段。如发生或者可能发生个人信息泄露、篡改、丢失的,我们将立即采取补救措施,并按照法律法规要求及时通知您。</view>
<view class="policy-h2">四、个人信息在全球范围内的转移</view>
<view class="policy-p">我们的服务器位于中国和加拿大。由于中国法律法规要求,原则上,在中国境内收集和产生的个人信息将统一在中国境内服务器进行处理。对于中国境外的业务,根据客户的选择,您的个人信息可能转移至位于中国或加拿大的服务器上进行处理。如确有必要向境外提供您的个人信息,我们将遵守《个人信息保护法》及数据出境相关法律法规的规定。</view>
<view class="policy-h2">五、您的个人信息权利</view>
<view class="policy-p">您依法享有知情权、访问权、更正权、删除权、注销账户权、拒绝自动决策权、解释权、可携权、查询权、撤销权。您可以通过具体产品或服务中设置的权限管理或隐私设置功能直接撤回相应授权,也可以通过发送邮件至 business@megvii.com 提交相关请求,我们将在核实您的身份后及时处理。</view>
<view class="policy-h2">六、我们如何处理儿童的个人信息</view>
<view class="policy-p">我们的所有产品、网站和服务主要面向企业客户,并不会自行主动收集、处理儿童个人信息。如我们发现任何客户或用户在未经父母或监护人同意的情况下提供包含儿童个人信息的原始数据,我们将立即删除该等原始数据并不会就该等数据提供任何服务。为免歧义,我们将不满14周岁的任何人均视为儿童。</view>
<view class="policy-h2">七、如何联系我们</view>
<view class="policy-p">如您对本政策及您的个人信息存在任何相关问题、申/投诉,或您需要任何个人信息权利相关的协助,请及时联系我们。您可以通过向 business@megvii.com 发送邮件联系我们,并提供您的姓名(名称)和联系方式、详细请求及相应链接。一般而言,我们会在15个工作日内或法律法规规定的期限内尽快回复。</view>
<view class="policy-h2">八、Face⁺⁺政策如何更新</view>
<view class="policy-p">我们保留不时更新或修改 Face⁺⁺政策的权利。但未经您明确同意,我们不会削减您按照 Face⁺⁺政策所应享有的权利。对于重大变更,我们会提供更为显著的通知。您可通过本页面查看最新版本的 Face⁺⁺政策。</view>
<view class="policy-end">—— 全文完 ——</view>
</scroll-view>
</view>
</view>
</view>
+50
View File
@@ -0,0 +1,50 @@
var api = require('../../utils/api');
Page(require('../../utils/page-header').page({
onShareAppMessage: function () { return require('../../utils/share').message(); },
data: {
// Keep the original H5 pixels, but avoid public-network requests for the
// first screen's background, brand and guest icon.
bgUrl: '../../assets/startup/bg-login-h5.webp',
brandUrl: '../../assets/startup/brand-login.png',
guestIconUrl: '../../assets/startup/icon-guest.png',
agreed: false, busy: false, checking: true, showPolicy: false,
message: '正在检查登录状态…'
},
onLoad: function (query) { this.chooseAccount = !!(query && query.switch === '1'); this.alive = true; this.entrySeq = 0; },
onShow: function () {
var that = this;
this.alive = true;
if (this.data.busy) return;
var seq = this.entrySeq = (this.entrySeq || 0) + 1;
if (this.chooseAccount) { this.setData({ checking: false, message: '淘宝登录可保存报告;游客仅限本次体验。' }); return; }
this.setData({ checking: true });
api.restore().then(function (user) {
if (!that.alive || seq !== that.entrySeq) return;
if (user) { my.redirectTo({ url: '/pages/splash/splash' }); return; }
that.setData({ message: '淘宝登录可保存报告;游客仅限本次体验。' });
}).catch(function (err) {
if (that.alive && seq === that.entrySeq) that.setData({ message: err.status === 401 ? '登录已过期,请重新登录或选择游客体验。' : '暂时无法检查登录状态,请重试或选择游客体验。' });
}).then(function () { if (that.alive && seq === that.entrySeq) that.setData({ checking: false }); });
},
onUnload: function () { this.alive = false; this.entrySeq++; },
onToggleAgree: function () { if (!this.data.busy) this.setData({ agreed: !this.data.agreed }); },
onOpenPolicy: function () { this.setData({ showPolicy: true }); },
onClosePolicy: function () { this.setData({ showPolicy: false }); },
enter: function (guest) {
if (this.data.busy || (!guest && this.data.checking)) return;
if (!this.data.agreed) { this.setData({ message: '请先阅读并同意用户协议和隐私政策' }); return; }
var that = this, seq = this.entrySeq = (this.entrySeq || 0) + 1;
this.setData({ busy: true, checking: false, message: guest ? '正在开始游客体验…' : '正在登录淘宝账号…' });
var task;
try { task = guest ? api.loginGuest(true) : api.login(true); } catch (error) { task = Promise.reject(error); }
Promise.resolve(task).then(function () {
if (that.alive && seq === that.entrySeq) my.redirectTo({ url: '/pages/splash/splash' });
}).catch(function (err) { if (that.alive && seq === that.entrySeq && !(err && err.canceled)) {
that.setData({ message: err && err.message || '暂时无法进入,请重试' });
} })
.then(function () { if (that.alive && seq === that.entrySeq) that.setData({ busy: false }); });
},
onTaobaoLogin: function () { this.enter(false); },
onGuest: function () { this.enter(true); },
noop: function () {}
}, typeof my === 'undefined' ? {} : my, { nativeTitleFallback: true }));
+10
View File
@@ -0,0 +1,10 @@
{
"defaultTitle": "​",
"titleBarColor": "#FFFFFF",
"backgroundColor": "#142632",
"disableScroll": true,
"navigationBarTextStyle": "black",
"navigationBarForceEnable": true,
"transparentTitle": "always",
"gestureBack": "NO"
}
+2 -1
View File
@@ -1,5 +1,6 @@
{
"defaultTitle": "SAE 连通性 MVP",
"titleBarColor": "#101823",
"backgroundColor": "#101823"
"backgroundColor": "#101823",
"gestureBack": "NO"
}
+13 -2
View File
@@ -19,7 +19,7 @@ page { background: #0e0f18; }
overflow: hidden;
overscroll-behavior: none;
background: #0e0f18;
--profile-space: min(2rpx, max(0.6rpx, calc((100vh - 420px) / 306)));
--profile-space: min(2rpx, max(0.6rpx, calc((100vh - var(--brand-header-height, 64px) - 420px) / 306)));
}
/* H5 .profile-bg(ui-20261009 L31:bg-profile-h5,cover) */
@@ -110,7 +110,6 @@ page { background: #0e0f18; }
margin-top: -8rpx; /* calc(-4 * var(--u)) */
border-width: 0 1.5px 1.5px 0;
}
.profile .pick-host { display: block; width: 100%; }
/* ─── H5 .skin-card(style.css L144-148 + ui-20261012 L189) ─── */
.profile .skin-card {
@@ -198,6 +197,7 @@ page { background: #0e0f18; }
min-height: 0;
transition: transform .32s cubic-bezier(.22, .61, .36, 1);
}
.profile .skin-track.dragging { transition: none; }
/* H5 .skin-item / .skin-item img / .skin-name / .skin-desc */
.profile .skin-item {
flex: 0 0 100%;
@@ -317,6 +317,15 @@ page { background: #0e0f18; }
text-align: center;
line-height: 44px;
}
/* H5 mobile-select: single wheel, 90% inner width and 44px yellow selection. */
.gender-sheet { animation: gender-sheet-in .4s ease; }
.gender-sheet .picker-btn-bar { padding: 0 5%; }
.gender-picker { width: 90%; margin: 0 auto; }
.gender-picker .picker-cell { height: 44px; }
@keyframes gender-sheet-in {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
/* ═══ 拍照引导弹窗 H5 #p-photoguide(L311-327) ═══ */
.profile .overlay-guide {
@@ -492,3 +501,5 @@ page { background: #0e0f18; }
box-sizing: border-box;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.session-guard { position: relative; z-index: 10; padding: 120rpx 40rpx; text-align: center; font-size: 13px; line-height: 1.8; color: #707070; }
.session-guard .btn-primary { margin: 32rpx auto 0; }
+24 -15
View File
@@ -1,10 +1,16 @@
<import src="../../components/brand-header.axml" />
<template is="brand-header" data="{{brandHeader: brandHeader}}" />
<!-- ═════ 03 档案填写 —— 对照 H5 index.html #s-profile(L61-108) + #p-photoguide(L311-327) ═════ -->
<view class="profile">
<view class="profile" style="--brand-header-height:{{brandHeader.height}}px;margin-top:{{brandHeader.height}}px;height:calc(100vh - {{brandHeader.height}}px);">
<view class="session-guard" a:if="{{identityChecking || identityError}}">
<view>{{identityChecking ? '正在确认当前淘宝账号…' : identityError}}</view>
<button a:if="{{identityError}}" class="btn-primary" onTap="onRetryIdentity">重新验证</button>
</view>
<!-- H5 .profile-bg -->
<image class="profile-bg" src="{{bgUrl}}" mode="aspectFill" />
<!-- H5 .profile-body -->
<view class="profile-body">
<view class="profile-body" hidden="{{identityChecking || identityError}}">
<view class="page-title">您的肌肤状态</view>
<!-- H5 .form-row.two:出生年月 + 性别 -->
@@ -18,12 +24,10 @@
</view>
<view class="field">
<view class="field-label">您的性别</view>
<picker class="pick-host" mode="selector" range="{{genderRange}}" onChange="onGenderChange">
<view class="pick">
<text class="pick-value">{{genderText}}</text>
<view class="chev down"></view>
</view>
</picker>
<view class="pick" onTap="onOpenGender">
<text class="pick-value">{{genderText}}</text>
<view class="chev down"></view>
</view>
</view>
</view>
@@ -49,8 +53,8 @@
<view class="skin-arrow prev {{skinIndex === 0 ? 'disabled' : ''}}" onTap="onSkinPrev">
<view class="chev left"></view>
</view>
<view class="skin-carousel" onTouchStart="onSkinTouchStart" onTouchEnd="onSkinTouchEnd">
<view class="skin-track" style="transform: translateX(-{{skinPercent}}%);">
<view class="skin-carousel" onTouchStart="onSkinTouchStart" onTouchMove="onSkinTouchMove" onTouchEnd="onSkinTouchEnd" onTouchCancel="onSkinTouchCancel">
<view class="skin-track {{skinDragging ? 'dragging' : ''}}" style="transform: translateX(-{{skinPercent}}%) translateX({{skinDragOffset}}px);">
<view class="skin-item" a:for="{{skins}}" a:key="name">
<image class="skin-img" src="{{item.img}}" mode="aspectFit" />
<text class="skin-name">{{item.name}}</text>
@@ -75,15 +79,20 @@
<button class="btn-primary btn-submit" hover-class="btn-press" onTap="onSubmit">提交</button>
</view>
<!-- ═══ 弹层:出生年月/地区滚轮(保留现有 picker-view 自绘实现,仅统一外观) ═══ -->
<view class="overlay overlay-sheet" a:if="{{picker}}" onTap="onPickerClose">
<view class="picker-sheet" catchTap="noop">
<!-- ═══ 弹层:出生年月/性别/地区滚轮 ═══ -->
<view class="overlay overlay-sheet" a:if="{{picker && !identityChecking && !identityError}}" onTap="onPickerClose">
<view class="picker-sheet {{picker === 'gender' ? 'gender-sheet' : ''}}" catchTap="noop">
<view class="picker-btn-bar">
<view class="picker-cancel" onTap="onPickerClose">取消</view>
<view class="picker-title">{{pickerTitle}}</view>
<view class="picker-ensure" onTap="onPickerConfirm">确定</view>
</view>
<picker-view class="native-picker" value="{{pickerValue}}" onChange="onPickerChange">
<picker-view a:if="{{picker === 'gender'}}" class="native-picker gender-picker" value="{{pickerValue}}" onChange="onPickerChange" indicator-style="height:44px; border-top:1px solid #EFEFEF; border-bottom:1px solid #EFEFEF; background-color:rgba(245,240,120,.18);">
<picker-view-column>
<view class="picker-cell" a:for="{{genderRange}}" a:key="*this">{{item}}</view>
</picker-view-column>
</picker-view>
<picker-view a:else class="native-picker" value="{{pickerValue}}" onChange="onPickerChange">
<picker-view-column>
<view class="picker-cell" a:for="{{picker === 'birth' ? birthRange[0] : regionRange[0]}}" a:key="*this">{{item}}</view>
</picker-view-column>
@@ -95,7 +104,7 @@
</view>
<!-- ═══ 弹窗:拍照引导(H5 #p-photoguide:L311-327) ═══ -->
<view class="overlay overlay-guide" a:if="{{showGuide}}" onTap="onCloseGuide">
<view class="overlay overlay-guide" a:if="{{showGuide && !identityChecking && !identityError}}" onTap="onCloseGuide">
<view class="guide-bg">
<image class="guide-bg-img" src="{{guideBgUrl}}" mode="aspectFill" />
<!-- 背景只保留启动页底图 + Slogan -->
+84 -23
View File
@@ -1,9 +1,10 @@
/* ═════ 档案填写页:出生年月/性别/地区 + 日常肤质轮播 → 拍照
对照 H5 index.html #s-profile(L61-108)、#p-photoguide(L311-327)
档案字段与 H5 一致:{ real_age, gender, region, daily_skin_type }
选择器弹层保持现有实现(自绘 picker-view 滚轮 + 原生 selector),只调整触发控件外观。 ═════ */
三个选择器均用底部滚轮弹层,滚动只修改草稿,确定后写回字段。 ═════ */
var api = require('../../utils/api.js');
var REGION_DATA = require('../../utils/region-data.js');
var swipe = require('../../utils/swipe-gesture');
// 肤质选项(与 H5 SKINS 一致,图片远程加载;第一条描述在 H5 里含 <br>,拆成两行)
var SKINS = [
@@ -21,7 +22,8 @@ for (var m = 1; m <= 12; m++) MONTHS.push(m + '月');
function ageFromBirth(y, m) { return NOW_Y - y - (NOW_M < m ? 1 : 0); } // 未过生日月份按少一岁(与 H5 一致)
Page({
Page(require('../../utils/page-header').page({
onShareAppMessage: function () { return require('../../utils/share').message(); },
data: {
bgUrl: api.assetUrl('img/ui-20261009/bg-profile-h5.jpg'),
guideBgUrl: api.assetUrl('img/ui-20261009/bg-splash-h5.jpg'),
@@ -52,16 +54,16 @@ Page({
return { img: api.assetUrl(s.img), name: s.name, desc: s.desc, desc2: s.desc2 || '' };
}),
skinIndex: 0,
skinPercent: 0,
skinPercent: 0, skinDragOffset: 0, skinDragging: false,
skinLastIndex: SKINS.length - 1,
showGuide: false, picker: '', pickerTitle: '', pickerValue: [0, 0],
toastText: '', toastOn: false
toastText: '', toastOn: false, identityChecking: true, identityError: ''
},
onLoad: function () {
if (!api.user()) { my.redirectTo({ url: '/pages/splash/splash' }); return; }
if (!api.user()) { my.redirectTo({ url: '/pages/login/login' }); return; }
this.ownerId = api.user().id;
try {
if (api.user().kind !== 'taobao_guest') try {
var saved = my.getStorageSync({ key: 'juzi.taobao.profile.' + api.user().id }).data;
if (saved) {
saved.age = ageFromBirth(Number(saved.birth.split('-')[0]), Number(saved.birth.split('-')[1]));
@@ -72,11 +74,57 @@ Page({
this.setData({ skinIndex: this.data.skinIndex || 0, skinPercent: (this.data.skinIndex || 0) * 100 });
},
onShow: function () {
this.disableSwipeBack();
this.visible = true;
if (!api.user() || this.ownerId !== api.user().id) {
this.setData({ birthText: '', genderText: '', regionText: '', showGuide: false });
my.redirectTo({ url: '/pages/splash/splash' });
this.clearPrivateProfile(); return Promise.resolve(false);
}
if (api.user().kind === 'taobao_guest') {
this.identityPending = false; var guestPage = this;
this.setData({ identityChecking: false, identityError: '' }, function () { guestPage.measureSkin(); }); return Promise.resolve(true);
}
var that = this, seq = this.identitySeq = (this.identitySeq || 0) + 1;
this.identityPending = true; this.setData({ identityChecking: true, identityError: '' });
return (typeof api.restore === 'function' ? api.restore() : Promise.resolve(api.user())).then(function (user) {
if (!that.visible || seq !== that.identitySeq) return false;
if (!user || user.id !== that.ownerId) { that.clearPrivateProfile(); return false; }
that.identityPending = false; that.setData({ identityChecking: false }, function () { that.measureSkin(); }); return true;
}).catch(function (err) {
if (!that.visible || seq !== that.identitySeq) return false;
if (err.status === 401 || !api.user() || api.user().id !== that.ownerId) { that.clearPrivateProfile(); return false; }
that.setData({ identityChecking: false, identityError: '网络异常,暂时无法确认当前账号,请重试。' }); return false;
});
},
onRetryIdentity: function () { return this.onShow(); },
disableSwipeBack: function () {
// Disable the native page-exit gesture, leaving skin carousel touches intact.
try {
if (my.tb && typeof my.tb.enableSwipeBack === 'function') {
var pending = my.tb.enableSwipeBack({ enable: 0 });
if (pending && typeof pending.catch === 'function') pending.catch(function () {});
}
} catch (_) {}
},
onReady: function () { this.disableSwipeBack(); this.measureSkin(); },
measureSkin: function () {
if (typeof my.createSelectorQuery !== 'function') return;
var that = this;
try { my.createSelectorQuery().select('.skin-carousel').boundingClientRect().exec(function (rows) {
if (that.visible && rows && rows[0] && rows[0].width > 0) that.skinWidth = rows[0].width;
}); } catch (_) {}
},
onResize: function () { this.onSkinTouchCancel(); this.skinWidth = 0; this.measureSkin(); },
clearPrivateProfile: function () {
this.identityPending = true;
this.setData({ birthText: '', birth: '', age: 0, genderText: '', regionText: '', showGuide: false, picker: '', identityChecking: true, identityError: '' });
my.redirectTo({ url: '/pages/login/login' });
},
onHide: function () {
this.visible = false; this.identitySeq = (this.identitySeq || 0) + 1;
this.onSkinTouchCancel();
if (api.user() && api.user().kind !== 'taobao_guest') { this.identityPending = true; this.setData({ identityChecking: true, identityError: '' }); }
},
onUnload: function () { this.onHide(); clearTimeout(this.toastTimer); },
// H5 .toast
toast: function (msg) {
@@ -92,6 +140,10 @@ Page({
// H5 的 mobile-select title 为「出生年月」/「所在地区」
this.setData({ picker: 'birth', pickerTitle: '出生年月', pickerValue: this.data.birthIndex.slice() });
},
onOpenGender: function () {
var index = this.data.genderRange.indexOf(this.data.genderText);
this.setData({ picker: 'gender', pickerTitle: '性别', pickerValue: [index < 0 ? 0 : index] });
},
onOpenRegion: function () {
var index = this.data.regionIndex.slice();
// A canceled draft may have changed the cities while the saved province stayed put.
@@ -107,7 +159,9 @@ Page({
},
onPickerConfirm: function () {
var e = { detail: { value: this.data.pickerValue } };
if (this.data.picker === 'birth') this.onBirthChange(e); else this.onRegionChange(e);
if (this.data.picker === 'birth') this.onBirthChange(e);
else if (this.data.picker === 'gender') this.onGenderChange(e);
else if (this.data.picker === 'region') this.onRegionChange(e);
this.onPickerClose();
},
onPickerClose: function () { this.setData({ picker: '' }); },
@@ -127,7 +181,8 @@ Page({
/* ─── 性别 ─── */
onGenderChange: function (e) {
this.setData({ genderText: this.data.genderRange[e.detail.value] });
var value = e.detail.value, index = Array.isArray(value) ? value[0] : value;
if (index === 0 || index === 1) this.setData({ genderText: this.data.genderRange[index] });
},
/* ─── 地区:省份列滚动时联动城市列 ─── */
@@ -148,25 +203,30 @@ Page({
/* ─── 肤质轮播(H5 goSkin 的等价实现:轨道 translateX) ─── */
applySkin: function (index) {
var i = Math.max(0, Math.min(SKINS.length - 1, index));
this.setData({ skinIndex: i, skinPercent: i * 100 });
this.skinGesture = null;
this.setData({ skinIndex: i, skinPercent: i * 100, skinDragOffset: 0, skinDragging: false });
},
onSkinPrev: function () { this.applySkin(this.data.skinIndex - 1); },
onSkinNext: function () { this.applySkin(this.data.skinIndex + 1); },
onSkinTouchStart: function (e) {
var t = (e.changedTouches || e.touches || [])[0];
this.touchX = t ? (t.clientX != null ? t.clientX : t.pageX) : 0;
if (this.identityPending) return;
var info = typeof my.getSystemInfoSync === 'function' ? my.getSystemInfoSync() : {};
this.skinGesture = swipe.start(e, this.data.skinIndex, this.skinWidth || (info.windowWidth || 375) * 191 / 375, SKINS.length);
},
onSkinTouchEnd: function (e) {
var t = (e.changedTouches || e.touches || [])[0];
if (!t) return;
var x = t.clientX != null ? t.clientX : t.pageX;
var dx = x - (this.touchX || 0);
if (Math.abs(dx) < 40) return;
this.applySkin(this.data.skinIndex + (dx < 0 ? 1 : -1));
onSkinTouchMove: function (e) {
var value = swipe.move(this.skinGesture, e);
if (!value) { this.onSkinTouchCancel(); return; }
if (value.dragging) this.setData({ skinDragOffset: value.offset, skinDragging: true });
},
onSkinTouchEnd: function (e) { var value = swipe.end(this.skinGesture, e, false); if (value) this.applySkin(value.index); },
onSkinTouchCancel: function () {
this.skinGesture = null;
if (this.data.skinDragging || this.data.skinDragOffset) this.setData({ skinDragOffset: 0, skinDragging: false });
},
/* ─── 提交 → 拍照引导弹窗(H5 data-action="submit-profile") ─── */
onSubmit: function () {
if (this.identityPending) return;
var d = this.data;
if (!d.birth || !(d.age >= 1 && d.age <= 99)) {
this.toast('请选择出生年月');
@@ -189,7 +249,8 @@ Page({
// H5 data-action="start-camera":档案入全局态,进入拍照页
onStartCamera: function () {
if (!api.user()) { my.redirectTo({ url: '/pages/splash/splash' }); return; }
if (this.identityPending) return;
if (!api.user()) { my.redirectTo({ url: '/pages/login/login' }); return; }
var d = this.data;
getApp().globalData.profile = {
real_age: d.age,
@@ -197,7 +258,7 @@ Page({
region: d.regionText,
daily_skin_type: SKINS[d.skinIndex].name
};
my.setStorageSync({ key: 'juzi.taobao.profile.' + api.user().id, data: {
if (api.user().kind !== 'taobao_guest') my.setStorageSync({ key: 'juzi.taobao.profile.' + api.user().id, data: {
birth: d.birth, birthIndex: d.birthIndex, birthText: d.birthText, genderText: d.genderText,
regionIndex: d.regionIndex, regionText: d.regionText, regionRange: d.regionRange, skinIndex: d.skinIndex
} });
@@ -206,4 +267,4 @@ Page({
},
noop: function () {}
});
}, typeof my === 'undefined' ? {} : my));
+7 -3
View File
@@ -1,5 +1,9 @@
{
"defaultTitle": "1'me在线肌肤测试",
"titleBarColor": "#101823",
"backgroundColor": "#101823"
"defaultTitle": "​",
"titleBarColor": "#FFFFFF",
"backgroundColor": "#101823",
"navigationBarTextStyle": "black",
"navigationBarForceEnable": true,
"transparentTitle": "always",
"gestureBack": "NO"
}
+21 -9
View File
@@ -17,6 +17,7 @@
(<image> 会额外解码约 32MB,并把页面滚动高度撑到 2608px)。
.report-body { padding: calc(28u) calc(17u) calc(42u) } */
.report { position: relative; }
/* 原生不透明页眉已预留系统顶部区域,正文恢复 H5 原有的 28u 上间距。 */
.report-body { position: relative; z-index: 1; padding: 56rpx 34rpx 84rpx; }
/* .report-title { font-size:23px; font-weight:600(覆盖 700); letter-spacing:calc(.5u);
@@ -318,14 +319,23 @@
background: #F5F078;
}
/* .detail-viewport { display:block; overflow:hidden } / .detail-panels { display:flex } →
acss 只渲染当前 Tab 的面板,视觉效果等价(H5 用 transform 横滑切换) */
.detail-viewport { overflow: hidden; }
.detail-panels { display: block; }
/* H5: follow the finger horizontally, leave vertical gestures to the report. */
.detail-viewport { overflow: hidden; touch-action: pan-y; transition: height .32s ease; }
.detail-viewport.dragging { transition: none; }
.detail-panels { display: flex; align-items: flex-start; transition: transform .32s cubic-bezier(.22,.61,.36,1); }
.detail-panels.dragging { transition: none; }
/* .detail-panels .dpanel { display:block; flex:0 0 100%; padding-top:calc(10u) } */
.dpanel { display: block; padding-top: 20rpx; }
.dpanel { display: block; flex: 0 0 100%; min-width: 0; padding-top: 20rpx; box-sizing: border-box; pointer-events: none; }
.dpanel.active { pointer-events: auto; }
/* .ditem { display:block(覆盖 flex) } */
.ditem { display: block; }
.report-zoom { position: fixed; inset: 0; z-index: 80; background: rgba(0,0,0,.92); display: flex; align-items: center; justify-content: center; }
.report-zoom-enter { animation: report-zoom-in .22s ease both; }
.report-zoom-frame { position: relative; overflow: hidden; background: #F6F6F6; transform-origin: center; }
.report-zoom-side { position: absolute; }
.report-zoom-bitmap { left:0; top:0; width:100%; height:100%; }
.report-zoom-canvas { position:fixed; left:-10000px; top:0; width:835px; height:738px; pointer-events:none; }
@keyframes report-zoom-in { from { opacity: 0; transform: scale(.88); } to { opacity: 1; transform: none; } }
/* ─── 检测图框(.viz-frame + .contour-front-frame 的 90% 高裁切)
.viz-frame { position:relative; width:calc(215u); height:calc(278u); margin:0 auto calc(12u);
@@ -429,11 +439,14 @@
}
/* H5 grid: figure[data-view="right"] { grid-column:2 } → 只剩右脸时仍占第二列 */
.contour-side.right { margin-left: auto; }
/* 轮廓和皱纹统一交换两张侧脸的位置,照片、标注及点击预览仍绑定原侧。 */
.contour-sides-reversed { flex-direction: row-reverse; }
.contour-sides-reversed .contour-side.right { margin-left: 0; margin-right: auto; }
.side-img {
position: absolute;
left: 0; top: 0;
width: 100%; height: 100%;
transform: translateY(-25%) scale(2.8);
transform: translateY(-25%) scale(2.52);
transform-origin: center;
}
.side-dim {
@@ -628,6 +641,5 @@
}
.report-disclaimer-line { display: block; }
/* ─── 小程序独有:历史报告入口(H5 报告页无此按钮)
沿用 H5 style.css 里已有的 .btn-more { margin: calc(26u) auto 0 },保持既有跳转不丢失 ─── */
.btn-more { margin: 52rpx auto 0; }
.session-guard { padding: 120rpx 40rpx; text-align: center; font-size: 13px; line-height: 1.8; color: #707070; }
.session-guard .btn-primary { margin: 32rpx auto 0; }
+38 -13
View File
@@ -1,15 +1,21 @@
<import src="../../components/brand-header.axml" />
<template is="brand-header" data="{{brandHeader: brandHeader}}" />
<!-- ═════ 08 报告页 —— 对照 H5 index.html #s-report L174-291 ═════
结构逐块对应:.report-title → .card.card-summary(环形评分 + 生物可视年龄 + 三维度)
→ 三组 .dimension-title + .card.card-detail(紧塑力 2 视图 / 透亮感 3 视图 / 稳韧力 2 视图)
→ .prod-title → .card.report-care-products(日常护理 + 推荐产品)→ .report-disclaimer。
样式见 report.acss;数据仍由 utils/report-model.js 的 model()/layers()/marks() 产出。 -->
<view class="report">
<view class="report" style="padding-top:{{brandHeader.height}}px;">
<view class="session-guard" a:if="{{identityChecking || identityError}}">
<view>{{identityChecking ? '正在确认当前淘宝账号…' : identityError}}</view>
<button a:if="{{identityError}}" class="btn-primary" onTap="onRetryIdentity">重新验证</button>
</view>
<!-- H5 #s-report: background:#efefef url(bg-report-h5.webp) center top/100% auto。
该 webp 为 1080×7512 的纯 #EFEFEF 单色图(全图仅一种颜色 239,239,239),
与 app.acss 里 page 的底色完全等价;acss 侧不再引 32MB 解码量的空图,
直接用 page 底色(否则 <image> 还会把页面滚动高度撑到 2608px)。 -->
<view class="report-body">
<view class="report-body" hidden="{{identityChecking || identityError}}">
<!-- H5 h1.report-title -->
<view class="report-title">您的肌肤状态表现</view>
@@ -71,13 +77,14 @@
<text class="dtab-tx">{{tab.name}}</text>
</view>
</view>
<view class="detail-viewport">
<view class="detail-panels">
<view class="detail-viewport {{card.dragging ? 'dragging' : ''}}" style="{{card.height ? 'height:' + card.height + 'px;' : ''}}">
<view class="detail-panels {{card.dragging ? 'dragging' : ''}}" style="transform:translateX(-{{card.index * 100}}%) translateX({{card.dragOffset || 0}}px);">
<block a:for="{{card.views}}" a:for-item="view" a:for-index="vi" a:key="key">
<!-- H5 用 transform 横滑切换;acss 只渲染当前面板,视觉等价 -->
<view class="dpanel" a:if="{{card.index === vi}}">
<!-- Keep all panels mounted for the H5 finger-following track. -->
<view class="dpanel {{card.index === vi ? 'active' : ''}}" aria-hidden="{{card.index !== vi}}">
<view class="ditem">
<view class="viz-frame {{view.frameCls}}">
<view class="viz-frame {{view.frameCls}}" data-card="{{card.key}}" data-view="{{view.key}}"
onTouchStart="onPanelTouchStart" onTouchMove="onPanelTouchMove" onTouchEnd="onPanelTouchEnd" onTouchCancel="onPanelTouchCancel" onTap="onZoomPhoto">
<!-- hasVisual=false 时整块画框只留占位文案(H5 renderViz 的降级:轮廓/肤色/平滑度/痘痘
必须有人脸照片,皱纹/水分/泛红可只挂 face_maps 图层) -->
<view class="viz-stage" a:if="{{view.hasVisual}}">
@@ -94,13 +101,15 @@
</view>
<!-- H5 .contour-sides(轮廓=原图;皱纹=原图压暗 + 侧脸皱纹图层 / 加载态) -->
<view class="contour-sides {{view.dim ? 'wrinkle-sides' : ''}}" a:if="{{sidesShown && (view.dim || view.key === 'contour')}}">
<view class="contour-sides contour-sides-reversed {{view.dim ? 'wrinkle-sides' : ''}}" a:if="{{sidesShown && (view.dim || view.key === 'contour')}}">
<block a:for="{{sides}}" a:for-item="side" a:key="key">
<view class="contour-side {{side.key === 'right' ? 'right' : ''}}" a:if="{{side.photo}}">
<image class="side-img" src="{{side.photo}}" mode="aspectFill" />
<view class="contour-side {{side.key === 'right' ? 'right' : ''}}" a:if="{{side.photo}}"
data-card="{{card.key}}" data-view="{{view.key}}" data-side="{{side.key}}" onTap="onZoomPhoto">
<image class="side-img" src="{{side.photo}}" mode="{{side.photoStyle ? 'scaleToFill' : 'aspectFill'}}" style="{{side.photoStyle}}"
data-side="{{side.key}}" data-photo="{{side.photo}}" onLoad="onSidePhotoLoad" />
<block a:if="{{view.dim}}">
<view class="side-dim"></view>
<image class="side-img" a:if="{{side.layer}}" src="{{side.layer}}" mode="aspectFill" />
<image class="side-img" a:if="{{side.layer}}" src="{{side.layer}}" mode="{{side.photoStyle ? 'scaleToFill' : 'aspectFill'}}" style="{{side.photoStyle}}" />
<view class="side-loading" a:if="{{side.loading}}"><view>皱纹标注</view><view>加载中…</view></view>
</block>
</view>
@@ -157,7 +166,23 @@
<view class="report-disclaimer-line">*仅根据您最近的测肤记录推荐。</view>
</view>
<!-- 小程序独有入口(H5 报告页无此按钮):保留既有的历史报告跳转 -->
<button class="btn-primary btn-more" onTap="onHistory">查看历史报告</button>
</view>
<!-- H5: tap an inspection image to enlarge; tap again to return. -->
<view class="report-zoom" a:if="{{zoom && !identityChecking && !identityError}}" onTap="onCloseZoom" catchTouchMove="noop">
<view class="report-zoom-enter">
<view class="report-zoom-frame" style="{{zoom.frameStyle}}">
<view class="viz-stage" a:if="{{!zoom.side}}">
<image class="viz-photo" a:if="{{zoom.photo}}" src="{{zoom.photo}}" mode="{{zoom.fit}}" style="{{zoom.photoStyle}}" />
<view class="viz-dim" a:if="{{zoom.dark}}"></view>
<image class="viz-layer {{zoom.blend ? 'viz-blend' : ''}}" a:for="{{zoom.layers}}" a:for-item="layer" a:key="*this" src="{{layer}}" mode="scaleToFill" style="opacity:{{zoom.alpha}};{{zoom.photoStyle}}" />
<view class="viz-mark" a:for="{{zoom.marks}}" a:for-item="mark" a:key="id" style="left:{{mark.left}}%;top:{{mark.top}}%;width:{{mark.width}}%;height:{{mark.height}}%;border-color:{{mark.color}};"></view>
</view>
<block a:else>
<image class="report-zoom-side report-zoom-bitmap" src="{{zoom.bakedPhoto || zoom.photo}}" mode="aspectFit"
data-photo="{{zoom.photo}}" data-generation="{{zoom.generation}}" onLoad="onZoomSideLoad" />
</block>
</view>
</view>
</view>
<canvas id="report-side-zoom-canvas" canvas-id="report-side-zoom-canvas" width="835" height="738" class="report-zoom-canvas" />
</view>
+279 -27
View File
@@ -8,6 +8,8 @@
4) 痘/斑标记坐标:接口像素坐标 → 画框百分比(含 H5 的最小框放大规则);
5) 侧脸皱纹标注:payload.wrinkle_job + api.wrinkleViews,pending/unavailable 文案同 H5。 */
var api = require('../../utils/api'), report = require('../../utils/report-model'), config = require('../../config');
var swipe = require('../../utils/swipe-gesture');
var sideZoom = require('../../utils/report-side-zoom');
/* H5 检测图框 .viz-frame:calc(215 * var(--u)) × calc(278 * var(--u)) → 430×556rpx(report.acss 同值) */
var FRAME_W = 430, FRAME_H = 556, FRAME_RATIO = 215 / 278;
@@ -34,6 +36,25 @@ var MELANIN_COLOR = '#9F641F';
function numeric(value) { return typeof value === 'number' && isFinite(value); }
function px(value) { return (Math.round(value * 100) / 100) + 'rpx'; }
// H5 thumbnail framing with fill reduced by 10%: 2.8 * .9 = 2.52.
// Keep the existing vertical anchor while showing more of the photograph.
// Resolve the object-fit and transform together so native image components do
// not reinterpret the percentage translation or scale their internal crop.
function sideThumbnailStyle(width, height) {
if (!numeric(width) || !numeric(height) || width <= 0 || height <= 0) return '';
var w = 208.7, h = 184.5, scale = Math.max(w / width, h / height) * 2.52;
var imageW = width * scale, imageH = height * scale;
return 'left:' + px((w - imageW) / 2) + ';top:' + px(h / 4 - imageH / 2) +
';width:' + px(imageW) + ';height:' + px(imageH) + ';transform:none;';
}
function sideZoomStyle(width, height) {
var rect = sideZoom.rectangle(width, height);
if (!rect) return '';
// Express the side-photo crop relative to the final viewport, rather than magnifying
// a tiny native image/overflow container with a parent CSS transform.
return 'left:' + pct(rect.left / 208.7) + '%;top:' + pct(rect.top / 184.5) +
'%;width:' + pct(rect.width / 208.7) + '%;height:' + pct(rect.height / 184.5) + '%;';
}
function pct(ratio) { return Math.round(ratio * 10000) / 100; }
/* H5 SVG 环形评分:#ring-fg 的 stroke-dashoffset = 326.73 × (1 - 总分/100),顺时针、从正上方开始。
@@ -131,20 +152,23 @@ function smoothCaption(result) {
: '本次未获取毛孔、黑头增强图;此处仅展示拍摄照片,不绘制色斑、痣或痤疮框。';
}
Page({
Page(require('../../utils/page-header').page({
onShareAppMessage: function () { return require('../../utils/share').message(); },
data: {
// 图片统一走 api.assetUrl(与 H5 h5/assets/ 同相对路径)
productBadgeUrl: api.assetUrl('img/ui-20261015/product-badge.png'),
// report-model.model() 产出(接口不变)
total: null, skinAge: null, ageNote: '', dims: [], focusName: '', focusSlogan: '',
views: [], viewIndex: 0, frontPhoto: '', activeLayers: [], careAdvice: [], product: null,
views: [], viewIndex: 0, frontPhoto: '', activeLayers: [], careAdvice: [], product: null, guest: false,
identityChecking: true, identityError: '',
// 版面对齐 H5 所需的派生数据
cards: [], sides: [], sidesShown: false, wrinkleNote: '',
ringDeg: 0, ringCap: false, ringCapEnd: ''
ringDeg: 0, ringCap: false, ringCapEnd: '', zoom: null
},
onLoad: function () {
this.alive = true; this.visible = true;
this.ownerId = api.user() && api.user().id;
this.setData({ guest: !!(api.user() && api.user().kind === 'taobao_guest') });
var gd = getApp().globalData;
if (!gd.payload || !gd.payload.result) { my.redirectTo({ url: '/pages/splash/splash' }); return; }
this.payload = gd.payload; this.photos = gd.photos || [];
@@ -159,17 +183,63 @@ Page({
}));
this.renderSides(this.payload.wrinkle_views);
},
onReady: function () { this.ready = true; this.measurePhoto(); this.loadSides(); },
onShow: function () {
if (!api.user() || this.ownerId !== api.user().id) {
this.onHide();
this.setData({ frontPhoto: '', views: [], cards: [], sides: [], careAdvice: [], product: null });
my.redirectTo({ url: '/pages/splash/splash' }); return;
}
this.visible = true; if (this.ready) this.loadSides();
disableSwipeBack: function () {
// Taobao's container gesture is separate from page gestureBack config.
// Official contract: enable is numeric 0/1, not a boolean.
try {
if (my.tb && typeof my.tb.enableSwipeBack === 'function') {
var pending = my.tb.enableSwipeBack({ enable: 0 });
if (pending && typeof pending.catch === 'function') pending.catch(function () {});
}
} catch (_) {}
},
onHide: function () { this.visible = false; if (this.signal) this.signal.canceled = true; },
onUnload: function () { this.onHide(); this.alive = false; this.photos = []; this.payload = null; },
onReady: function () { this.disableSwipeBack(); this.ready = true; this.measurePhoto(); this.loadSides(); this.measurePanels(); },
onShow: function () {
this.disableSwipeBack();
this.openingProduct = false; this.productGeneration = (this.productGeneration || 0) + 1; clearTimeout(this.productTimer);
if (!api.user() || this.ownerId !== api.user().id) {
this.clearPrivateReport(); return Promise.resolve(false);
}
this.visible = true;
if (api.user().kind === 'taobao_guest') {
this.identityPending = false;
var guestPage = this;
this.setData({ identityChecking: false, identityError: '' }, function () { guestPage.measurePanels(); });
if (this.ready) this.loadSides(); return Promise.resolve(true);
}
var that = this, seq = this.identitySeq = (this.identitySeq || 0) + 1;
this.identityPending = true; this.setData({ identityChecking: true, identityError: '' });
return (typeof api.restore === 'function' ? api.restore() : Promise.resolve(api.user())).then(function (user) {
if (!that.alive || !that.visible || seq !== that.identitySeq) return false;
if (!user || user.id !== that.ownerId) { that.clearPrivateReport(); return false; }
that.identityPending = false; that.setData({ identityChecking: false }, function () { that.measurePanels(); });
if (that.ready) that.loadSides(); return true;
}).catch(function (err) {
if (!that.alive || !that.visible || seq !== that.identitySeq) return false;
if (err.status === 401 || !api.user() || api.user().id !== that.ownerId) { that.clearPrivateReport(); return false; }
that.identityPending = true;
that.setData({ identityChecking: false, identityError: '网络异常,暂时无法确认当前账号,请重试。' });
return false;
});
},
onRetryIdentity: function () { return this.onShow(); },
clearPrivateReport: function () {
this.onHide(); this.identityPending = true;
this.setData({ total: null, skinAge: null, ageNote: '', dims: [], focusName: '', focusSlogan: '',
frontPhoto: '', activeLayers: [], views: [], cards: [], sides: [], careAdvice: [], product: null,
identityChecking: true, identityError: '' });
this.photos = []; this.payload = null;
my.redirectTo({ url: '/pages/login/login' });
},
onHide: function () {
this.visible = false; this.identitySeq = (this.identitySeq || 0) + 1;
this.cancelGestures(); this.onCloseZoom(); this.measureGeneration = (this.measureGeneration || 0) + 1;
if (this.signal) this.signal.canceled = true;
if (api.user() && api.user().kind !== 'taobao_guest') {
this.identityPending = true; this.setData({ identityChecking: true, identityError: '' });
}
},
onUnload: function () { this.onHide(); clearTimeout(this.productTimer); this.alive = false; this.photos = []; this.payload = null; },
/* ─── H5 三张维度卡的视图分组(七个检测视角各带图层/标记/框型) ─── */
buildCards: function (model, result) {
@@ -194,7 +264,8 @@ Page({
caption: key === 'smooth' ? smoothCaption(result) : ''
});
});
return { key: group.key, title: group.title, titleCls: group.titleCls, cardCls: group.cardCls, index: 0, views: views };
return { key: group.key, title: group.title, titleCls: group.titleCls, cardCls: group.cardCls,
index: 0, dragOffset: 0, dragging: false, height: 0, views: views };
});
},
@@ -210,6 +281,7 @@ Page({
onPhotoLoad: function (event) {
var detail = (event && event.detail) || {};
this.applyGeometry(detail.width, detail.height);
this.measurePanels();
},
applyGeometry: function (width, height) {
if (!(width > 0 && height > 0) || this.geoDone) return;
@@ -231,16 +303,24 @@ Page({
patch[key + '.marks'] = placeMarks(marks, width, height, box);
});
});
if (that.alive !== false) this.setData(patch);
if (that.alive !== false) this.setData(patch, function () { that.measurePanels(); });
},
/* ─── 视角切换:保持既有 viewIndex / activeLayers 语义 ─── */
onTabTap: function (event) {
var dataset = (event && event.currentTarget && event.currentTarget.dataset) || {};
var key = dataset.card, index = Number(dataset.index), patch = {}, selected = null;
this.selectTab(dataset.card, Number(dataset.index));
},
selectTab: function (key, index) {
if (this.identityPending || !isFinite(index) || Math.floor(index) !== index) return;
var patch = {}, selected = null, that = this;
this.data.cards.forEach(function (card, ci) {
if (card.key !== key) return;
if (card.key !== key || !card.views[index]) return;
patch['cards[' + ci + '].index'] = index;
patch['cards[' + ci + '].dragOffset'] = 0;
patch['cards[' + ci + '].dragging'] = false;
var size = that.panelSizes && that.panelSizes[key];
if (size && size.heights[index]) patch['cards[' + ci + '].height'] = size.heights[index];
selected = card.views[index] || null;
});
if (!selected) return;
@@ -248,14 +328,146 @@ Page({
this.data.views.forEach(function (view, i) { if (view.key === selected.key) flat = i; });
patch.viewIndex = flat;
patch.activeLayers = this.payload && this.payload.result ? report.layers(this.payload.result, selected.key) : [];
this.setData(patch);
this.setData(patch, function () { that.measurePanels(); });
},
measurePanels: function () {
if (!this.ready || !this.visible || this.identityPending || typeof my.createSelectorQuery !== 'function') return;
var that = this, generation = this.measureGeneration = (this.measureGeneration || 0) + 1;
try { my.createSelectorQuery().selectAll('.detail-viewport').boundingClientRect()
.selectAll('.dpanel').boundingClientRect().exec(function (rows) {
if (!that.alive || !that.visible || that.identityPending || generation !== that.measureGeneration || !rows || !rows[0] || !rows[1]) return;
var patch = {}, cursor = 0;
that.panelSizes = that.panelSizes || {};
that.data.cards.forEach(function (card, ci) {
var heights = card.views.map(function () { var rect = rows[1][cursor++]; return rect && rect.height > 0 ? rect.height : 0; });
var viewport = rows[0][ci];
if (!viewport || !(viewport.width > 0)) return;
that.panelSizes[card.key] = { width: viewport.width, heights: heights };
if (!card.dragging && heights[card.index]) patch['cards[' + ci + '].height'] = heights[card.index];
});
if (Object.keys(patch).length) that.setData(patch);
}); } catch (_) {}
},
reportCard: function (event) {
var key = event && event.currentTarget && event.currentTarget.dataset && event.currentTarget.dataset.card;
for (var i = 0; i < this.data.cards.length; i++) if (this.data.cards[i].key === key) return { card: this.data.cards[i], index: i };
return null;
},
onPanelTouchStart: function (event) {
if (this.identityPending) return;
var row = this.reportCard(event); if (!row) return;
this.cancelGestures(); this.suppressImageTapUntil = 0;
var width = this.panelSizes && this.panelSizes[row.card.key] && this.panelSizes[row.card.key].width;
if (!width) { var info = typeof my.getSystemInfoSync === 'function' ? my.getSystemInfoSync() : {}; width = (info.windowWidth || 375) * 263 / 375; }
this.panelGesture = { key: row.card.key, index: row.index, state: swipe.start(event, row.card.index, width, row.card.views.length,
{ startThreshold: 12, directionRatio: 2 / 3, maxOverscroll: 16 }) };
},
onPanelTouchMove: function (event) {
var gesture = this.panelGesture; if (!gesture || this.identityPending) return;
var value = swipe.move(gesture.state, event);
if (!value) { this.onPanelTouchCancel(); return; }
if (!value.dragging) return;
var patch = {}; patch['cards[' + gesture.index + '].dragOffset'] = value.offset;
patch['cards[' + gesture.index + '].dragging'] = true; this.setData(patch);
},
onPanelTouchEnd: function (event) { this.finishPanelGesture(event, false); },
onPanelTouchCancel: function (event) { this.finishPanelGesture(event, true); },
finishPanelGesture: function (event, canceled) {
var gesture = this.panelGesture; this.panelGesture = null; if (!gesture) return;
var value = swipe.end(gesture.state, event, canceled);
if (value && value.dragged) this.suppressImageTapUntil = Date.now() + 350;
if (value) this.selectTab(gesture.key, value.index);
},
cancelGestures: function () {
this.panelGesture = null;
var patch = {};
this.data.cards.forEach(function (card, ci) { if (card.dragging || card.dragOffset) {
patch['cards[' + ci + '].dragging'] = false; patch['cards[' + ci + '].dragOffset'] = 0;
} });
if (Object.keys(patch).length) this.setData(patch);
},
onResize: function () { this.cancelGestures(); this.onCloseZoom(); this.measurePanels(); },
onZoomPhoto: function (event) {
if (this.identityPending || !this.visible || Date.now() < (this.suppressImageTapUntil || 0)) return;
var row = this.reportCard(event), dataset = event.currentTarget.dataset || {};
if (!row) return;
var view = row.card.views[row.card.index];
if (!view || dataset.view !== view.key) return;
this.onCloseZoom();
var zoom = { side: false, photo: this.data.frontPhoto, fit: view.fit, photoStyle: view.photoStyle,
dark: view.dark, blend: view.blend, alpha: view.alpha, layers: view.layers, marks: view.marks,
width: FRAME_W, height: view.frameCls ? 500.4 : FRAME_H };
var that = this, generation = this.zoomGeneration, owner = this.ownerId;
function show() {
if (!that.alive || !that.visible || that.identityPending || generation !== that.zoomGeneration || owner !== (api.user() && api.user().id)) return;
var info = typeof my.getSystemInfoSync === 'function' ? my.getSystemInfoSync() : {};
var w = info.windowWidth || 375, h = info.windowHeight || 726;
var scale = Math.min(w / (zoom.width * w / 750), h / (zoom.height * w / 750));
zoom.frameStyle = zoom.side
? 'width:' + (zoom.width * w / 750 * scale) + 'px;height:' + (zoom.height * w / 750 * scale) + 'px;'
: 'width:' + zoom.width + 'rpx;height:' + zoom.height + 'rpx;transform:scale(' + scale + ');';
that.setData({ zoom: zoom });
}
if (!dataset.side) { if (view.hasVisual) show(); return; }
var side = null;
this.data.sides.forEach(function (item) { if (item.key === dataset.side) side = item; });
if (!side || !side.photo) return;
zoom.side = true; zoom.photo = side.photo; zoom.width = 208.7; zoom.height = 184.5;
zoom.sideKey = side.key; zoom.generation = generation; zoom.fit = 'aspectFit';
zoom.dark = view.dim ? .55 : 0; zoom.layers = view.dim && side.layer ? [side.layer] : [];
function sideSize(info) {
if (!that.alive || !that.visible || generation !== that.zoomGeneration || owner !== (api.user() && api.user().id)) return;
zoom.photoStyle = sideZoomStyle(info && info.width, info && info.height) || 'left:0;top:0;width:100%;height:100%;';
show();
that.bakeSideZoom(info);
}
function fallbackSize() {
sideSize({ width: side.naturalWidth, height: side.naturalHeight });
}
// Read the source file, not dimensions reported by an already enlarged
// native image. Only fall back to thumbnail metadata when this API fails.
if (typeof my.getImageInfo !== 'function') { fallbackSize(); return; }
try { my.getImageInfo({ src: side.photo, success: sideSize, fail: fallbackSize }); }
catch (_) { fallbackSize(); }
},
onZoomSideLoad: function (event) {
var zoom = this.data.zoom, data = event && event.currentTarget && event.currentTarget.dataset || {};
if (!zoom || !zoom.side || !this.alive || !this.visible || this.identityPending ||
this.ownerId !== (api.user() && api.user().id) || Number(data.generation) !== this.zoomGeneration ||
zoom.generation !== this.zoomGeneration || data.photo !== zoom.photo) return;
if (zoom.bakedPhoto || this.zoomCanvasJob) return;
var info = event && event.detail, style = sideZoomStyle(info && info.width, info && info.height);
if (!style) return;
this.applySidePhotoSize(zoom.sideKey, zoom.photo, info);
this.setData({ zoom: Object.assign({}, zoom, { photoStyle: style }) });
this.bakeSideZoom(info);
},
bakeSideZoom: function (info) {
var zoom = this.data.zoom, that = this;
if (!zoom || !zoom.side || zoom.bakedPhoto || this.zoomCanvasJob || !sideZoom.rectangle(info && info.width, info && info.height) ||
typeof my.createCanvasContext !== 'function') return;
var generation = zoom.generation, owner = this.ownerId;
var job = {}; this.zoomCanvasJob = job;
// A full, uncropped photo is the safe preview while baking or on export
// failure. Never fall back to the problematic native overflow crop.
sideZoom.bake(my, { photo: zoom.photo, width: info.width, height: info.height, dark: zoom.dark, layers: zoom.layers }).then(function (src) {
if (!that.alive || !that.visible || that.identityPending || that.zoomGeneration !== generation ||
owner !== (api.user() && api.user().id) || !that.data.zoom || that.data.zoom.photo !== zoom.photo) return;
that.setData({ zoom: Object.assign({}, that.data.zoom, { bakedPhoto: src }) });
}, function () {}).then(function () { if (that.zoomCanvasJob === job) that.zoomCanvasJob = null; });
},
onCloseZoom: function () {
this.zoomGeneration = (this.zoomGeneration || 0) + 1;
if (this.data.zoom) this.setData({ zoom: null });
},
noop: function () {},
/* ─── 侧脸照片 + 皱纹标注(H5 renderWrinkleSides / renderContourSides) ───
pending:未标注一侧显示「皱纹标注加载中…」
unavailable:显示「部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。」 */
renderSides: function (views) {
var payload = this.payload || {}, photos = this.photos || [];
var previous = this.data.sides || [];
views = views || payload.wrinkle_views || {};
var pending = !!(payload.wrinkle_job && payload.wrinkle_job.status === 'pending');
var sides = ['left', 'right'].map(function (key, i) {
@@ -264,16 +476,44 @@ Page({
if (!item || !item.result) item = (payload.side || {})[key];
var source = (item && (item.result || item)) || {};
var layer = report.mapURL(source.face_maps && source.face_maps.texture_enhanced_lines);
return { key: key, photo: photos[i + 1] || '', layer: layer, loading: pending && !layer };
var photo = photos[i + 1] || '', photoStyle = '', naturalWidth = 0, naturalHeight = 0;
previous.forEach(function (old) { if (old.key === key && old.photo === photo) {
photoStyle = old.photoStyle || ''; naturalWidth = old.naturalWidth || 0; naturalHeight = old.naturalHeight || 0;
} });
return { key: key, photo: photo, photoStyle: photoStyle, naturalWidth: naturalWidth, naturalHeight: naturalHeight, layer: layer, loading: pending && !layer };
});
var shown = sides.filter(function (side) { return !!side.photo; });
var plain = pending ? false : shown.some(function (side) { return !side.layer; });
var that = this;
this.setData({ sides: sides, sidesShown: shown.length > 0,
wrinkleNote: plain ? '部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。' : '' });
wrinkleNote: plain ? '部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。' : '' }, function () {
that.measurePanels();
sides.forEach(function (side) { if (side.photo && !side.photoStyle) that.readSidePhotoSize(side.key, side.photo); });
});
},
applySidePhotoSize: function (key, photo, info) {
if (!this.alive || !this.visible || this.ownerId !== (api.user() && api.user().id)) return;
var style = sideThumbnailStyle(info && info.width, info && info.height);
if (!style) return;
var changed = false;
var sides = this.data.sides.map(function (side) {
if (side.key !== key || side.photo !== photo || side.photoStyle === style && side.naturalWidth === info.width && side.naturalHeight === info.height) return side;
changed = true; return Object.assign({}, side, { photoStyle: style, naturalWidth: info.width, naturalHeight: info.height });
});
if (changed) this.setData({ sides: sides });
},
readSidePhotoSize: function (key, photo) {
if (typeof my.getImageInfo !== 'function') return;
var that = this;
try { my.getImageInfo({ src: photo, success: function (info) { that.applySidePhotoSize(key, photo, info); }, fail: function () {} }); } catch (_) {}
},
onSidePhotoLoad: function (event) {
var data = event && event.currentTarget && event.currentTarget.dataset || {};
this.applySidePhotoSize(data.side, data.photo, event && event.detail);
},
loadSides: function () {
var that = this, job = this.payload && this.payload.wrinkle_job;
if (!job || job.status !== 'pending' || !this.visible || this.loadingSides) return;
if (!job || job.status !== 'pending' || !this.visible || this.loadingSides || this.identityPending) return;
this.loadingSides = true; this.signal = { canceled: false };
var signal = this.signal;
api.wrinkleViews(job.token, signal).then(function (data) {
@@ -286,11 +526,23 @@ Page({
}).then(function () { that.loadingSides = false; if (signal.canceled && that.alive && that.visible) that.loadSides(); });
},
/* 推荐产品 → 淘宝商品详情(config.productItemIds 未配置时为 no-op,不伪造链接) */
/* 推荐文案保持 H5;三个档位统一进入用户确认的天猫商品。 */
onMore: function () {
var id = this.data.product && config.productItemIds[this.data.product.tier];
if (!id) return;
if (my.tb && my.tb.openDetail) my.tb.openDetail({ itemId: String(id) });
if (this.openingProduct || this.identityPending) return;
var id = this.data.product && (config.productItemId || (config.productItemIds || {})[this.data.product.tier]);
if (!id || !/^\d+$/.test(String(id))) { my.showToast({ content: '商品信息暂未就绪,请稍后重试', type: 'none' }); return; }
if (!my.tb || typeof my.tb.openDetail !== 'function') { my.showToast({ content: '当前环境暂不支持打开商品,请使用最新版手机淘宝', type: 'none' }); return; }
var that = this;
var generation = this.productGeneration = (this.productGeneration || 0) + 1;
this.openingProduct = true;
this.productTimer = setTimeout(function () { if (generation === that.productGeneration) that.openingProduct = false; }, 10000);
function failed() {
if (generation !== that.productGeneration) return;
clearTimeout(that.productTimer); that.openingProduct = false;
if (that.alive !== false) my.showToast({ content: '暂时无法打开商品,请稍后重试', type: 'none' });
}
try { my.tb.openDetail({ itemId: String(id), fail: failed }); } catch (_) { failed(); }
},
onHistory: function () { my.navigateTo({ url: '/pages/history/history' }); }
});
onHistory: function () { my.navigateTo({ url: '/pages/history/history' }); },
onEndExperience: function () { api.endGuest().then(function () { my.redirectTo({ url: '/pages/login/login' }); }); }
}, typeof my === 'undefined' ? {} : my));
+6 -2
View File
@@ -1,5 +1,9 @@
{
"defaultTitle": "1'me在线肌肤测试",
"defaultTitle": "​",
"titleBarColor": "#FFFFFF",
"backgroundColor": "#EFEFEF"
"backgroundColor": "#EFEFEF",
"navigationBarForceEnable": true,
"transparentTitle": "always",
"gestureBack": "NO",
"navigationBarTextStyle": "black"
}
-48
View File
@@ -404,51 +404,3 @@ page { background: #101823; }
box-sizing: border-box;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ═══ 开发版入口(H5 无;保持低调) ═══ */
.dev-entries {
position: absolute;
z-index: 3;
top: max(10px, env(safe-area-inset-top));
left: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.dev-entry {
font-size: 10px;
line-height: 1.5;
color: rgba(255, 255, 255, .42);
}
.diag-panel {
position: absolute;
z-index: 20;
left: 32rpx;
right: 32rpx;
top: 240rpx;
bottom: 160rpx;
background: rgba(6, 16, 26, .96);
border: 1rpx solid rgba(120, 200, 255, .35);
border-radius: 20rpx;
padding: 20rpx;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.diag-scroll { flex: 1; min-height: 0; }
.diag-text {
color: #9fd0ff;
font-size: 22rpx;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
}
.diag-close {
margin-top: 16rpx;
text-align: center;
color: #0b1622;
background: #ffd94d;
border-radius: 12rpx;
padding: 16rpx 0;
font-size: 26rpx;
}
+3 -16
View File
@@ -1,20 +1,7 @@
<import src="../../components/brand-header.axml" />
<template is="brand-header" data="{{brandHeader: brandHeader}}" />
<!-- ═════ 01 启动页 —— 对照 H5 index.html #s-splash(L43-58) + #p-privacy(L294-307) + #p-policy(L335-466) ═════ -->
<view class="splash">
<!-- 开发版入口(H5 无此入口,保持现有实现,样式低调) -->
<view class="dev-entries" a:if="{{showCameraDebug || showMvpDebug || showDiagnose}}">
<view class="dev-entry" a:if="{{showCameraDebug}}" onTap="onCameraDebug">开发调试 · 相机验收</view>
<view class="dev-entry" a:if="{{showMvpDebug}}" onTap="onMvpDebug">SAE MVP 连通性</view>
<view class="dev-entry" a:if="{{showDiagnose}}" onTap="onDiagnose">权限诊断</view>
<view class="dev-entry" a:if="{{showDiagnose}}" onTap="onCamTest">相机组件测试</view>
</view>
<!-- 诊断结果浮层:真机上唯一能看到相机能力/授权返回值的地方 -->
<view class="diag-panel" a:if="{{diagText}}">
<scroll-view class="diag-scroll" scroll-y>
<text class="diag-text">{{diagText}}</text>
</scroll-view>
<view class="diag-close" onTap="onCloseDiagnose">关闭</view>
</view>
<view class="splash" style="margin-top:{{brandHeader.height}}px;height:calc(100vh - {{brandHeader.height}}px);">
<!-- H5 .splash-bg -->
<image class="splash-bg" src="{{bgUrl}}" mode="aspectFill" />
+29 -45
View File
@@ -1,11 +1,10 @@
/* ═════ 启动页:品牌底图 + Slogan + 开始检测/我的报告 + 隐私授权弹窗
对照 H5 index.html #s-splash(L43-58)、#p-privacy(L294-307)、#p-policy(L335-466)
交互保持:登录(必须勾选协议)、restore、历史入口、退出登录、政策全文、开发版入口。 ═════ */
交互保持:登录(必须勾选协议)、restore、历史入口、退出登录、政策全文。 ═════ */
var api = require('../../utils/api.js');
var development = require('../../utils/development');
var config = require('../../config');
Page({
Page(require('../../utils/page-header').page({
onShareAppMessage: function () { return require('../../utils/share').message(); },
data: {
// 图片全部走 api.assetUrl(与 H5 h5/assets/ 同相对路径)
bgUrl: api.assetUrl('img/ui-20261009/bg-splash-h5.jpg'),
@@ -20,20 +19,27 @@ Page({
showPolicy: false,
busy: false,
signedIn: false,
guest: false,
accountLabel: '尚未登录', // H5 renderAccountStatus()
toastText: '', toastOn: false,
showCameraDebug: development.cameraDebugEnabled(),
showMvpDebug: config.mvpLocal === true || (config.transport === 'cloud' && config.environment !== 'production'),
showDiagnose: config.environment !== 'production',
diagText: ''
toastText: '', toastOn: false
},
onShow: function () {
var that = this;
api.restore().then(function (user) { that.setData({ signedIn: !!user, accountLabel: user ? '淘宝已登录' : '尚未登录' }); })
.catch(function () {});
this.alive = true;
api.restore().then(function (user) {
if (!that.alive) return;
if (!user) { my.redirectTo({ url: '/pages/login/login' }); return; }
var guest = user.kind === 'taobao_guest';
that.setData({ signedIn: !guest, guest: guest, accountLabel: guest ? '游客模式' : '淘宝已登录' });
}).catch(function (err) {
if (!that.alive) return;
that.setData({ signedIn: false, guest: false, accountLabel: '登录状态待确认' });
if (err.status === 401) my.redirectTo({ url: '/pages/login/login' });
else that.toast('网络异常,请重新登录或稍后重试');
});
},
onUnload: function () { this.alive = false; clearTimeout(this.toastTimer); },
// H5 .toast(自定义轻提示,替代系统 toast 以获得一致的视觉)
toast: function (msg) {
@@ -47,7 +53,8 @@ Page({
// 「开始检测」→ 隐私授权弹窗(H5 data-action="open-privacy")
onOpenPrivacy: function () {
if (api.user()) { my.navigateTo({ url: '/pages/profile/profile' }); return; }
if (!api.user()) { my.redirectTo({ url: '/pages/login/login' }); return; }
this.setData({ agreed: false });
this.setData({ showPrivacy: true });
},
onClosePrivacy: function () {
@@ -75,45 +82,22 @@ Page({
return;
}
this.setData({ busy: true });
api.login(true).then(function () {
that.setData({ showPrivacy: false, signedIn: true, accountLabel: '淘宝已登录' });
my.navigateTo({ url: '/pages/profile/profile' });
}).catch(function (err) {
that.toast(err.message);
}).then(function () { that.setData({ busy: false }); });
if (!api.user()) { this.setData({ busy: false }); my.redirectTo({ url: '/pages/login/login' }); return; }
this.setData({ showPrivacy: false, busy: false });
my.navigateTo({ url: '/pages/profile/profile' });
},
// H5 data-action="history" / 「我的报告」
onHistory: function () {
if (!api.user()) { this.onOpenPrivacy(); return; }
if (!api.user()) { my.redirectTo({ url: '/pages/login/login' }); return; }
my.navigateTo({ url: '/pages/history/history' });
},
onLogout: function () {
var that = this;
api.logout().then(function () {
that.setData({ signedIn: false, agreed: false, accountLabel: '尚未登录' });
(api.isGuest() ? api.endGuest() : api.logout()).then(function () {
that.setData({ signedIn: false, guest: false, agreed: false, accountLabel: '尚未登录' });
my.redirectTo({ url: '/pages/login/login' });
}).catch(function (err) { that.toast(err.message); });
},
onCameraDebug: function () {
if (!development.cameraDebugEnabled()) return;
my.navigateTo({ url: '/pages/camera/camera?debug=camera' });
},
onMvpDebug: function () {
if (!config.mvpLocal && !(config.transport === 'cloud' && config.environment !== 'production')) return;
my.navigateTo({ url: '/pages/mvp/mvp' });
},
// 相机/相册权限诊断:相机页可能整页崩掉,所以在启动页也能点
onDiagnose: function () {
var that = this;
this.setData({ diagText: '检测中…' });
require('../../utils/permissions').diagnose().then(function (info) {
that.setData({ diagText: info.join('\n') });
}).catch(function (err) {
that.setData({ diagText: 'diagnose error: ' + (err && err.message ? err.message : String(err)) });
});
},
onCloseDiagnose: function () { this.setData({ diagText: '' }); },
// 相机组件冒烟测试:单独一页只放 <camera>,用来判断淘宝真机是否支持该组件
onCamTest: function () { my.navigateTo({ url: '/pages/camtest/camtest' }); },
onLogin: function () { my.navigateTo({ url: '/pages/login/login?switch=1' }); },
noop: function () {} // 弹窗卡片内点击拦截冒泡用
});
}, typeof my === 'undefined' ? {} : my));
+7 -3
View File
@@ -1,5 +1,9 @@
{
"defaultTitle": "1'me在线肌肤测试",
"titleBarColor": "#101823",
"backgroundColor": "#101823"
"defaultTitle": "​",
"titleBarColor": "#FFFFFF",
"backgroundColor": "#101823",
"navigationBarTextStyle": "black",
"navigationBarForceEnable": true,
"transparentTitle": "always",
"gestureBack": "NO"
}
+60
View File
@@ -0,0 +1,60 @@
'use strict';
// Reproducible CPU/no-DOM benchmark. Prints timings/counts, never pixels/points.
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'), zlib = require('node:zlib');
const root = path.resolve(__dirname, '..'), cache = {};
const context = vm.createContext({ console: { log() {}, warn() {}, error() {} }, setTimeout, clearTimeout });
function load(file) {
file = path.resolve(file); if (cache[file]) return cache[file].exports;
const module = { exports: {} }; cache[file] = module;
const run = vm.runInContext('(function(exports,require,module){' + fs.readFileSync(file, 'utf8') + '\n})', context, { filename: file });
run(module.exports, name => { if (!name.startsWith('.')) throw new Error('Unexpected runtime dependency'); return load(path.resolve(path.dirname(file), name + '.js')); }, module);
return module.exports;
}
const fixture = JSON.parse(fs.readFileSync(path.join(root, 'test/fixtures/native-face-c3.json'), 'utf8'));
const source = new Uint8Array(zlib.gunzipSync(Buffer.from(fixture.gzipBase64, 'base64')));
const portrait = { width: 540, height: 960, data: new Uint8Array(540 * 960 * 4) };
for (let at = 3; at < portrait.data.length; at += 4) portrait.data[at] = 255;
for (let y = 0; y < 405; y++) for (let x = 0; x < 540; x++) {
const at = (Math.floor(y * fixture.height / 405) * fixture.width + Math.floor(x * fixture.width / 540)) * 4;
portrait.data.set(source.subarray(at, at + 4), ((y + 277) * 540 + x) * 4);
}
const repeatArg = process.argv.find(value => value.startsWith('--repeats='));
const repeats = repeatArg ? Math.max(1, Math.min(10, Number(repeatArg.split('=')[1]) || 2)) : 2;
const cpuOptimization = !process.argv.includes('--baseline-cpu');
const maxSideArg = process.argv.find(value => value.startsWith('--max-side='));
const maxSide = maxSideArg ? Number(maxSideArg.split('=')[1]) : 320;
const sizesArg = process.argv.find(value => value.startsWith('--input-sizes='));
const sizes = sizesArg ? sizesArg.split('=')[1].split(',').map(Number) : [256, 192, 160, 128];
const api = load(path.join(root, 'utils/native-face.js'));
(async () => {
if (process.argv.includes('--guided')) {
const seed = api.create({ inputSize: 256, landmarkModel: 'full' });
const originalPortrait = await seed.detect(portrait);
const reference = await seed.detect({ width: fixture.width, height: fixture.height, data: source });
seed.dispose();
const box = reference.detections[0].detection.box, scale = 960 * 0.45 / box.height;
const centerX = box.x + box.width / 2, centerY = box.y + box.height / 2;
for (let y = 0; y < 960; y++) for (let x = 0; x < 540; x++) {
const sx = Math.floor((x - 270) / scale + centerX), sy = Math.floor((y - 480) / scale + centerY), to = (y * 540 + x) * 4;
portrait.data.fill(0, to, to + 3); portrait.data[to + 3] = 255;
if (sx >= 0 && sx < fixture.width && sy >= 0 && sy < fixture.height) portrait.data.set(source.subarray((sy * fixture.width + sx) * 4, (sy * fixture.width + sx) * 4 + 4), to);
}
process.stdout.write(JSON.stringify({ setup: 'guided-portrait', originalPortraitFaceHeightRatio: originalPortrait.detections[0].detection.box.height / 960, requestedFaceHeightRatio: 0.45 }) + '\n');
}
for (const landmarkModel of ['full', 'tiny']) for (const inputSize of sizes) {
const engine = api.create({ defaultPixelFormat: 'RGBA', maxSide, inputSize, landmarkModel, cpuOptimization });
const initialized = Date.now(); await engine.init(); const initMs = Date.now() - initialized;
const samples = [];
for (let index = 0; index < repeats; index++) {
const result = await engine.detect(portrait);
samples.push({ ...result.timings, faces: result.faceCount, landmarks: result.detections[0] ? result.detections[0].landmarks.positions.length : 0,
faceHeightRatio: result.detections[0] ? result.detections[0].detection.box.height / result.height : 0 });
}
const mean = {};
for (const key of ['snapshotMs', 'modelMs', 'tensorMs', 'detectorMs', 'landmarkMs', 'totalMs']) mean[key] = Math.round(samples.reduce((sum, item) => sum + item[key], 0) / samples.length);
const memory = engine.stats(); engine.dispose();
process.stdout.write(JSON.stringify({ landmarkModel, inputSize, maxSide, cpuOptimization, frame: '540x960', initMs, mean,
allSamplesDetected: samples.every(item => item.faces > 0 && item.landmarks === 68), samples,
modelBytes: memory.numBytes, modelTensors: memory.numTensors, afterDisposeTensors: engine.stats().numTensors }) + '\n');
}
})().catch(error => { process.stderr.write(error.message + '\n'); process.exitCode = 1; });
+44
View File
@@ -0,0 +1,44 @@
'use strict';
// Rasterize the existing H5 vector geometry for the native image component.
// No remote assets or generated face shapes are used.
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
const { chromium } = require('playwright');
const root = path.resolve(__dirname, '..'), h5 = path.join(root, '../h5');
const out = path.join(root, 'assets/camera');
async function main() {
fs.mkdirSync(out, { recursive: true });
const sandbox = { window: {} };
vm.runInNewContext(fs.readFileSync(path.join(h5, 'js/camera-geometry.js'), 'utf8'), sandbox);
const app = fs.readFileSync(path.join(h5, 'js/app.js'), 'utf8');
vm.runInNewContext(app.split('/* camera-outline-pure-begin */')[1].split('/* camera-outline-pure-end */')[0], sandbox);
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, deviceScaleFactor: 2 });
for (const [index, name] of ['front', 'left', 'right'].entries()) {
const geometry = sandbox.window.CameraGeometry[index];
for (const live of [false, true]) {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="375" height="726" viewBox="0 86 375 726">' +
'<defs><mask id="cut"><rect x="0" y="86" width="375" height="726" fill="white"/><path d="' + sandbox.smoothCameraOutline(geometry.outline) + '" fill="black"/></mask></defs>' +
'<rect x="0" y="86" width="375" height="726" fill="black" fill-opacity=".85" mask="url(#cut)"/>' +
'<g fill="none" stroke="' + (live ? '#fff' : '#111') + '" stroke-width="2.5" stroke-dasharray="5 5">' + geometry.guides.map(d => '<path d="' + d + '"/>').join('') + '</g></svg>';
await page.setContent('<style>html,body{margin:0;background:transparent}svg{display:block}</style>' + svg);
await page.screenshot({ path: path.join(out, 'mask-' + name + (live ? '-live' : '') + '.png'), omitBackground: true });
}
await page.setContent('<style>html,body{margin:0;background:transparent}svg{display:block}</style><svg xmlns="http://www.w3.org/2000/svg" width="375" height="726" viewBox="0 86 375 726"><path fill="white" d="' + sandbox.smoothCameraOutline(geometry.outline) + '"/></svg>');
await page.screenshot({ path: path.join(out, 'hole-' + name + '.png'), omitBackground: true });
}
for (const name of ['icon-album.png', 'icon-shutter.png', 'icon-switch-camera.png']) {
fs.copyFileSync(path.join(h5, 'assets/img/ui-20261009', name), path.join(out, name));
}
fs.copyFileSync(path.join(h5, 'assets/img/ui-20261012/scan-face-mask.png'), path.join(out, 'scan-face-mask.png'));
const data = fs.readFileSync(path.join(h5, 'assets/img/ui-20261012/scan-base.webp')).toString('base64');
const png = await page.evaluate(async data => {
const image = new Image(); image.src = 'data:image/webp;base64,' + data; await image.decode();
const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height;
canvas.getContext('2d').drawImage(image, 0, 0); return canvas.toDataURL('image/png').split(',')[1];
}, data);
fs.writeFileSync(path.join(out, 'scan-base.png'), Buffer.from(png, 'base64'));
} finally { await browser.close(); }
console.log('Built H5 camera geometry, icons and scan artwork in taobao/assets/camera');
}
main().catch(error => { console.error(error.message); process.exitCode = 1; });
+58
View File
@@ -0,0 +1,58 @@
'use strict';
// Build-time only. The miniapp runtime never reads files or uses Buffer.
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
const source = path.resolve(__dirname, '../../h5/vendor/faceapi');
const target = path.resolve(__dirname, '../vendor/native-face');
const modelSources = path.resolve(__dirname, '../../reference/native-face-models');
const models = [['tiny', 'tiny_face_detector_model', path.join(source, 'models')], ['landmark', 'face_landmark_68_model', path.join(source, 'models')],
['landmarkTiny', 'face_landmark_68_tiny_model', modelSources]];
const output = {};
const sourceFiles = [];
function record(file, data) { sourceFiles.push({ path: path.relative(path.resolve(__dirname, '../..'), file).replace(/\\/g, '/'), bytes: data.length,
sha256: crypto.createHash('sha256').update(data).digest('hex') }); }
let bytes = 0;
for (const [key, name, directory] of models) {
const manifestPath = path.join(directory, name + '-weights_manifest.json');
const manifestBytes = fs.readFileSync(manifestPath); record(manifestPath, manifestBytes);
const manifest = JSON.parse(manifestBytes.toString('utf8'));
if (manifest.length !== 1) throw new Error('Expected one weight group: ' + name);
const data = Buffer.concat(manifest[0].paths.map(file => {
if (path.basename(file) !== file) throw new Error('Unexpected model path');
const fullPath = path.join(directory, file), content = fs.readFileSync(fullPath); record(fullPath, content); return content;
}));
bytes += data.length;
output[key] = { weights: manifest[0].weights, base64: data.toString('base64') };
}
const bundle = fs.readFileSync(path.join(source, 'face-api.js'));
record(path.join(source, 'face-api.js'), bundle);
// Old bundled TFJS needs a registry named global/window/self. Give this copy a
// module-local registry and prevent Node environment branches in the miniapp.
const adapter = '// Miniapp CPU adapter: private TFJS registry; no Node or DOM environment.\nvar global = {}; var process; var Buffer;\n' +
'function nativeFaceNodeUnsupported(name) { throw new Error("Node-only module unavailable in local miniapp CPU engine: " + name); }\n';
// The IDE statically resolves require(), even inside unreachable Node branches.
// Stub these optional Node-only paths rather than bundling Node polyfills. The
// tfjs-node occurrence is only a console-help string; remove it there too.
const nodeOnlyModules = ['crypto', 'node-fetch', 'util', 'fs', '@tensorflow/tfjs-node'];
const counts = {};
const adaptedBundle = bundle.toString('utf8').replace(/\brequire\s*\(\s*(['"])(crypto|node-fetch|util|fs|@tensorflow\/tfjs-node)\1\s*\)/g, (expression, quote, name) => {
counts[name] = (counts[name] || 0) + 1;
return 'nativeFaceNodeUnsupported(' + quote + name + quote + ')';
});
for (const name of nodeOnlyModules) if (counts[name] !== 1) throw new Error('Upstream Node branch audit changed: ' + name);
if (/\brequire\s*\(/.test(adaptedBundle)) throw new Error('Unreviewed runtime require in face-api bundle');
const weightModule = '// Generated by scripts/build-native-face.cjs; quantized source weights, no runtime filesystem.\nmodule.exports = ' + JSON.stringify(output) + ';\n';
const provenance = { source: 'Existing h5/vendor/faceapi (unchanged) plus official upstream tiny 68 model in reference/native-face-models', upstream: 'https://github.com/justadudewhohacks/face-api.js',
build: 'node taobao/scripts/build-native-face.cjs', verify: 'node taobao/scripts/build-native-face.cjs --check',
modifications: ['Module-local TFJS registry', 'Shadow process and Buffer to keep Node branches inactive', 'Node-only require calls replaced with explicit unsupported stubs'],
nodeOnlyStubs: counts, quantizedBytes: bytes, sourceFiles, downloadedSources: JSON.parse(fs.readFileSync(path.join(modelSources, 'sources.json'), 'utf8')) };
const files = { 'face-api.js': adapter + adaptedBundle, 'weights.js': weightModule, 'provenance.json': JSON.stringify(provenance, null, 2) + '\n' };
const check = process.argv.includes('--check');
if (!check) fs.mkdirSync(target, { recursive: true });
for (const [name, content] of Object.entries(files)) {
const destination = path.join(target, name), expected = Buffer.from(content);
if (check) {
if (!fs.existsSync(destination) || !fs.readFileSync(destination).equals(expected)) throw new Error('Generated model bundle differs: ' + name);
} else fs.writeFileSync(destination, expected);
}
process.stdout.write(JSON.stringify({ checked: check, quantizedBytes: bytes, bundleBytes: bundle.length,
sourceSHA256: crypto.createHash('sha256').update(bundle).digest('hex') }) + '\n');
+4 -3
View File
@@ -28,9 +28,10 @@ http.createServer(async (req, res) => {
chunks.push(chunk);
}
const raw = Buffer.concat(chunks).toString('utf8');
if (url.pathname === '/api/taobao/session') {
url.search = new URLSearchParams({ app_key: process.env.TAOBAO_APP_KEY,
open_id: 'local-development-user', sign_method: 'md5' }).toString();
if (['/api/taobao/session', '/api/taobao/guest-session', '/api/taobao/me'].includes(url.pathname)) {
const query = { app_key: process.env.TAOBAO_APP_KEY, sign_method: 'md5' };
if (url.pathname !== '/api/taobao/guest-session') query.open_id = 'local-development-user';
url.search = new URLSearchParams(query).toString();
const params = Object.fromEntries(url.searchParams);
url.searchParams.set('sign', auth.signature(params, raw, process.env.TAOBAO_APP_SECRET));
}
+2 -2
View File
@@ -1,7 +1,7 @@
'use strict';
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
const root = path.resolve(__dirname, '..'), check = process.argv.includes('--check');
const files = ['skin-score.js', 'report-ui.js', 'report-copy.js'];
const files = ['skin-score.js', 'report-ui.js', 'report-copy.js', 'head-pose.js', 'capture-quality.js'];
const manifest = {};
for (const name of files) {
const source = fs.readFileSync(path.join(root, '../h5/js', name));
@@ -15,4 +15,4 @@ for (const name of files) {
const dest = path.join(root, 'vendor/manifest.json'), expected = JSON.stringify(manifest, null, 2) + '\n';
if (check) { if (fs.readFileSync(dest, 'utf8') !== expected) throw Error('Shared manifest drift'); }
else fs.writeFileSync(dest, expected);
console.log(check ? 'PASS: scoring, report UI and copy match H5 byte for byte' : 'Synced shared logic from H5 (source files unchanged)');
console.log(check ? 'PASS: scoring, report UI, copy and capture gates match H5 byte for byte' : 'Synced shared logic from H5 (source files unchanged)');

Some files were not shown because too many files have changed in this diff Show More