feat: 完善淘宝小程序拍照、报告与登录流程
接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+798
-67
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"defaultTitle": "普通相机诊断",
|
||||
"pullRefresh": false,
|
||||
"gestureBack": "NO"
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"defaultTitle": "相机组件测试",
|
||||
"defaultTitle": "AR 相机真机验收",
|
||||
"titleBarColor": "#101823",
|
||||
"backgroundColor": "#000000"
|
||||
"backgroundColor": "#000000",
|
||||
"gestureBack": "NO"
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
{ "defaultTitle": "历史报告", "titleBarColor": "#FFFFFF", "backgroundColor": "#EFEFEF" }
|
||||
{
|
||||
"defaultTitle": "",
|
||||
"titleBarColor": "#FFFFFF",
|
||||
"backgroundColor": "#EFEFEF",
|
||||
"navigationBarForceEnable": true,
|
||||
"transparentTitle": "always",
|
||||
"navigationBarTextStyle": "black",
|
||||
"gestureBack": "NO"
|
||||
}
|
||||
|
||||
@@ -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%; } }
|
||||
@@ -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>
|
||||
@@ -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 }));
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"defaultTitle": "",
|
||||
"titleBarColor": "#FFFFFF",
|
||||
"backgroundColor": "#142632",
|
||||
"disableScroll": true,
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarForceEnable": true,
|
||||
"transparentTitle": "always",
|
||||
"gestureBack": "NO"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"defaultTitle": "SAE 连通性 MVP",
|
||||
"titleBarColor": "#101823",
|
||||
"backgroundColor": "#101823"
|
||||
"backgroundColor": "#101823",
|
||||
"gestureBack": "NO"
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"defaultTitle": "1'me在线肌肤测试",
|
||||
"titleBarColor": "#101823",
|
||||
"backgroundColor": "#101823"
|
||||
"defaultTitle": "",
|
||||
"titleBarColor": "#FFFFFF",
|
||||
"backgroundColor": "#101823",
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarForceEnable": true,
|
||||
"transparentTitle": "always",
|
||||
"gestureBack": "NO"
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"defaultTitle": "1'me在线肌肤测试",
|
||||
"defaultTitle": "",
|
||||
"titleBarColor": "#FFFFFF",
|
||||
"backgroundColor": "#EFEFEF"
|
||||
"backgroundColor": "#EFEFEF",
|
||||
"navigationBarForceEnable": true,
|
||||
"transparentTitle": "always",
|
||||
"gestureBack": "NO",
|
||||
"navigationBarTextStyle": "black"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"defaultTitle": "1'me在线肌肤测试",
|
||||
"titleBarColor": "#101823",
|
||||
"backgroundColor": "#101823"
|
||||
"defaultTitle": "",
|
||||
"titleBarColor": "#FFFFFF",
|
||||
"backgroundColor": "#101823",
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarForceEnable": true,
|
||||
"transparentTitle": "always",
|
||||
"gestureBack": "NO"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user