feat(taobao): add native mini program with shared backend

Preserve H5 flows while adding Taobao identity, native capture, reports and isolated local development. Fix canceled province selection and expired-session logout. Record passing local regressions, two pre-existing H5 assertion failures and pending platform validation.
This commit is contained in:
xsl
2026-10-19 20:14:31 +08:00
parent d07f8cc1bb
commit 9eec4369da
61 changed files with 4827 additions and 17 deletions
+183
View File
@@ -0,0 +1,183 @@
/* ═════ 拍照页样式 ═════ */
.cam {
position: relative;
width: 100%;
height: 100vh;
background: #101823;
}
.cam-device {
width: 100%;
height: 100vh;
}
/* ─── 步骤文案 ─── */
.cam-step {
position: absolute;
top: 80rpx;
left: 0;
right: 0;
text-align: center;
}
.cam-step-no {
color: #FFFFFF;
font-size: 48rpx;
font-weight: 600;
}
.cam-step-tip {
color: #FFFFFF;
font-size: 40rpx;
font-weight: 600;
margin-top: 8rpx;
}
/* ─── 取景蒙版(椭圆线框) ─── */
.cam-mask {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
display: flex;
align-items: center;
justify-content: center;
}
.cam-oval {
width: 560rpx;
height: 720rpx;
border: 4rpx solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
box-sizing: border-box;
}
/* 侧脸步轮廓偏移提示(骨架版:向左/右微移示意朝向) */
.mask-1 { transform: translateX(-60rpx); } /* 左脸出镜:耳在画面左 */
.mask-2 { transform: translateX(60rpx); } /* 右脸出镜:耳在画面右 */
/* ─── 底部引导要点 ─── */
.cam-guide {
position: absolute;
bottom: 260rpx;
left: 0;
right: 0;
display: flex;
justify-content: center;
}
.cam-guide-item {
color: #FFFFFF;
font-size: 26rpx;
padding: 0 24rpx;
border-right: 2rpx solid rgba(255, 255, 255, 0.4);
}
.cam-guide-item:last-child { border-right: none; }
/* ─── 控制条 ─── */
.cam-controls {
position: absolute;
bottom: 80rpx;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-around;
}
.cam-side {
width: 120rpx;
height: 88rpx;
line-height: 88rpx;
text-align: center;
color: #FFFFFF;
font-size: 28rpx;
}
.cam-shutter {
width: 144rpx;
height: 144rpx;
border: 6rpx solid #FFFFFF;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.cam-shutter-dot {
width: 108rpx;
height: 108rpx;
border-radius: 50%;
background: #F5F078;
}
/* ─── 停顿卡 ─── */
.cam-done {
position: absolute;
top: 45%;
left: 60rpx;
right: 60rpx;
background: rgba(0, 0, 0, 0.65);
color: #FFFFFF;
font-size: 28rpx;
text-align: center;
border-radius: 16rpx;
padding: 24rpx;
}
/* ─── 拍后预览 ─── */
.cam-preview {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
background: #000000;
}
.cam-preview-img {
width: 100%;
height: 78%;
}
.cam-preview-btns {
position: absolute;
bottom: 80rpx;
left: 0;
right: 0;
display: flex;
justify-content: space-around;
}
.cam-preview-btn {
width: 280rpx;
height: 88rpx;
line-height: 88rpx;
text-align: center;
border-radius: 44rpx;
border: 2rpx solid #FFFFFF;
color: #FFFFFF;
font-size: 30rpx;
}
.cam-preview-btn.ok {
background: #F5F078;
border-color: #F5F078;
color: #282828;
font-weight: 600;
}
/* ─── 分析中 ─── */
.cam-analyzing {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
}
.cam-analyzing-text {
color: #FFFFFF;
font-size: 30rpx;
}
/* ─── 隐藏画布(压缩降级用) ─── */
.compress-canvas {
position: fixed;
left: -9999rpx;
top: -9999rpx;
width: 640px;
height: 640px;
}
.cam-analyzing { flex-direction: column; }
.cam-cancel { margin-top: 48rpx; color: #fff; font-size: 26rpx; padding: 24rpx; }
.camera-permission { padding: 56rpx 40rpx; color: #fff; line-height: 1.8; }
.camera-permission button { margin-top: 24rpx; }
.debug-banner { padding: 24rpx; background: #F5F078; color: #282828; font-size: 24rpx; }
.debug-results { padding: 32rpx; background: #101823; color: white; line-height: 1.8; }
.debug-title { font-size: 32rpx; font-weight: 600; }
.debug-result { margin: 24rpx 0; }
.debug-result image { display: block; width: 100%; height: 320rpx; }
.debug-results button { margin-top: 24rpx; }
+77
View File
@@ -0,0 +1,77 @@
<!-- ═════ 05-07 拍照页 ═════
覆盖在 camera 原生组件上的一切必须用 cover-view / cover-image -->
<view class="cam">
<view a:if="{{debugMode && !cameraEnabled}}" class="debug-banner">相机验收 · 免登录 · 照片只在本页处理,不上传</view>
<view a:if="{{debugComplete}}" class="debug-results">
<view class="debug-title">三步拍摄与 JPEG 压缩已完成</view>
<view>请核对实际照片方向是否为正脸、右脸、左脸;本页不判断姿态是否合格。</view>
<view class="debug-result" a:for="{{debugPhotos}}" a:key="key">
<image src="{{item.photo}}" mode="aspectFit" />
<view>{{item.title}} · JPEG {{item.width}}×{{item.height}} · 约{{item.kilobytes}}KB</view>
</view>
<button class="btn-primary" onTap="onDebugRestart">重新验收</button>
<button onTap="onCancel">退出并清空本页照片</button>
</view>
<view a:if="{{!cameraEnabled && !debugComplete}}" class="camera-permission">
<view>{{stepText}} · {{stepTip}}</view>
<view>{{permissionMessage}}</view>
<button class="btn-primary" onTap="onEnableCamera">开启相机</button>
<button onTap="onOpenSettings">授权设置</button>
<button onTap="onSystemCamera">使用系统相机</button>
<button onTap="onAlbum">从相册选择</button>
<view a:if="{{previewUrl}}">
<image src="{{previewUrl}}" mode="aspectFit" style="width:100%;height:400rpx;" />
<button onTap="onRetake">重新选择</button><button class="btn-primary" onTap="onConfirm">使用照片</button>
</view>
<view a:if="{{analyzing}}">{{debugMode ? '正在验证本地图片压缩…' : '正在生成您的肌肤报告…'}}</view>
</view>
<camera a:if="{{cameraEnabled}}" id="skin-camera" class="cam-device" device-position="{{devicePosition}}"
flash="off" onError="onCameraError" onStop="onCameraStop">
<!-- 顶部步骤文案 -->
<cover-view class="cam-step">
<cover-view a:if="{{debugMode}}">开发调试 · 不上传照片</cover-view>
<cover-view class="cam-step-no">{{stepText}}</cover-view>
<cover-view class="cam-step-tip">{{stepTip}}</cover-view>
</cover-view>
<!-- 取景蒙版:人脸轮廓(骨架版用椭圆线框,朝向提示随步切换;精确轮廓 TODO:换 cover-image 轮廓图) -->
<cover-view class="cam-mask">
<cover-view class="cam-oval mask-{{maskStep}}"></cover-view>
</cover-view>
<!-- 底部引导要点 -->
<cover-view class="cam-guide">
<cover-view class="cam-guide-item" a:for="{{guides}}" a:key="*this">{{item}}</cover-view>
</cover-view>
<!-- 控制条:相册 / 快门 / 翻转 -->
<cover-view class="cam-controls">
<cover-view class="cam-side" onTap="onAlbum">相册</cover-view>
<cover-view class="cam-shutter" onTap="onShutter">
<cover-view class="cam-shutter-dot"></cover-view>
</cover-view>
<cover-view class="cam-side" onTap="onFlip">翻转</cover-view>
</cover-view>
<!-- 本步完成停顿卡 -->
<cover-view class="cam-done" a:if="{{doneTip}}">{{doneTip}}</cover-view>
<!-- 拍后预览:确认使用 / 重拍 -->
<cover-view class="cam-preview" a:if="{{previewUrl}}" catchTap="noop">
<cover-image class="cam-preview-img" src="{{previewUrl}}"></cover-image>
<cover-view class="cam-preview-btns">
<cover-view class="cam-preview-btn" onTap="onRetake">重拍</cover-view>
<cover-view class="cam-preview-btn ok" onTap="onConfirm">使用照片</cover-view>
</cover-view>
</cover-view>
<!-- 分析中 -->
<cover-view class="cam-analyzing" a:if="{{analyzing}}">
<cover-view class="cam-analyzing-text">{{debugMode ? '正在验证本地图片压缩…' : '正在生成您的肌肤报告…'}}</cover-view>
<cover-view class="cam-cancel" onTap="onCancel">{{debugMode ? '退出验收' : '返回,稍后查看历史报告'}}</cover-view>
</cover-view>
</camera>
<!-- 压缩降级用的隐藏画布(canvas 重绘压缩,不可见) -->
<canvas id="compress-canvas" class="compress-canvas" width="{{compressWidth}}" height="{{compressHeight}}" style="width:{{compressWidth}}px;height:{{compressHeight}}px;" />
</view>
+186
View File
@@ -0,0 +1,186 @@
var api = require('../../utils/api'), image = require('../../utils/image'), voice = require('../../utils/voice');
var permissions = require('../../utils/permissions');
var development = require('../../utils/development');
var STEPS = [
{ target: 'front', tip: '请单人正脸出镜', turn: '正脸居中', mask: 0 },
{ target: 'right', tip: '请右脸出镜', turn: '请向左转头', mask: 2 },
{ target: 'left', tip: '请左脸出镜', turn: '请向右转头', mask: 1 }
];
Page({
data: { stepIndex: 0, stepText: '1/3', stepTip: STEPS[0].tip, guides: ['正脸居中', '取下眼镜', '光线充足'],
maskStep: 0, devicePosition: 'front', camError: false, previewUrl: '', doneTip: '', analyzing: false,
compressWidth: 1, compressHeight: 1, cameraEnabled: false, permissionMessage: '拍摄面部照片用于肌肤检测,请开启相机权限。',
debugMode: false, debugComplete: false, debugPhotos: [] },
onLoad: function (query) {
this.alive = true; this.visible = true; this.photos = []; this.busy = false; this.seq = 0;
this.debugMode = !!(query && query.debug === 'camera' && development.cameraDebugEnabled());
this.setData({ debugMode: this.debugMode });
if (this.debugMode) return;
if (!api.user() || !getApp().globalData.profile) my.redirectTo({ url: '/pages/splash/splash' });
},
onReady: function () {
this.pageReady = true;
if (this.cameraAuthorized) this.mountCamera();
},
onShow: function () {
this.visible = true;
if (!this.data.previewUrl && !this.data.analyzing && !this.data.debugComplete) this.renderStep();
var that = this;
permissions.settings().then(function (current) {
if (!that.alive || !that.visible) return;
if (permissions.state(current, 'scope.camera') === true) { that.cameraAuthorized = true; that.mountCamera(); }
else if (permissions.state(current, 'scope.camera') === false) that.setData({ cameraEnabled: false, permissionMessage: '相机权限未开启,可前往授权设置或使用相册。' });
});
},
mountCamera: function () {
if (!this.alive || !this.visible || !this.pageReady || this.data.cameraEnabled || this.data.debugComplete) return;
var that = this;
this.setData({ cameraEnabled: true, camError: false }, function () {
if (!that.alive || !that.visible) return;
try {
that.cameraCtx = my.createCameraContext('skin-camera');
if (!that.cameraCtx || !that.cameraCtx.takePhoto) that.onCameraError();
} catch (_) { that.onCameraError(); }
});
},
onEnableCamera: function () {
if (this.authorizing) return;
var that = this; this.authorizing = true;
permissions.ensure('scope.camera').then(function () { that.cameraAuthorized = true; that.mountCamera(); }).catch(function (err) {
if (that.alive && that.visible) that.setData({ permissionMessage: err.message });
}).then(function () { that.authorizing = false; });
},
onOpenSettings: function () {
var that = this;
permissions.open().then(function (current) {
if (permissions.state(current, 'scope.camera') === true) { that.cameraAuthorized = true; that.mountCamera(); }
}).catch(function (err) { if (that.alive) my.showToast({ content: err.message, type: 'none' }); });
},
onHide: function () { this.pause(); },
onUnload: function () { this.pause(); this.alive = false; this.photos = []; this.debugResults = []; },
pause: function () {
this.visible = false; this.seq++; this.busy = false;
clearTimeout(this.nextTimer);
if (this.signal) this.signal.canceled = true;
voice.stop(true);
this.cameraCtx = null;
if (this.alive) this.setData({ cameraEnabled: false });
if (this.alive && this.data.analyzing) this.setData({ analyzing: false, doneTip: '' });
},
renderStep: function () {
var cfg = STEPS[this.data.stepIndex];
this.setData({ stepText: (this.data.stepIndex + 1) + '/3', stepTip: cfg.tip,
guides: [cfg.turn, '取下眼镜', '光线充足'], maskStep: cfg.mask, doneTip: '' });
if (!this.debugMode) voice.play(voice.VOICE.step[this.data.stepIndex]);
},
onCameraError: function () {
this.setData({ camError: true, cameraEnabled: false, permissionMessage: '相机暂不可用,请检查淘宝的系统相机权限,或使用系统相机/相册。' });
my.showToast({ content: '相机不可用,请使用系统相机或相册', type: 'none' });
},
onCameraStop: function () { if (this.alive && this.visible) this.onCameraError(); },
onShutter: function () {
if (this.busy || this.data.previewUrl || this.data.analyzing) return;
if (this.data.camError || !this.cameraCtx) return this.choose(['camera']);
var that = this, seq = this.seq;
this.busy = true;
this.cameraCtx.takePhoto({ quality: 'high', success: function (res) {
if (!that.alive || !that.visible || seq !== that.seq) return;
var file = res.tempImagePath || res.tempFilePath || res.apFilePath;
if (file) that.setData({ previewUrl: file });
else my.showToast({ content: '拍摄失败,请重试', type: 'none' });
}, fail: function () {
if (that.alive && that.visible) my.showToast({ content: '拍摄失败,请重试', type: 'none' });
}, complete: function () { if (seq === that.seq) that.busy = false; } });
},
choose: function (sourceType) {
if (this.busy || this.data.previewUrl || this.data.analyzing) return;
var that = this;
this.busy = true;
// chooseImage has its own album scope; native capture also needs camera.
permissions.ensure('scope.album').then(function () {
if (sourceType[0] === 'camera') return permissions.ensure('scope.camera');
}).then(function () {
if (!that.alive || !that.visible) { that.busy = false; return; }
my.chooseImage({ count: 1, sourceType: sourceType,
success: function (res) {
var paths = res.apFilePaths || res.tempFilePaths || [];
if (that.alive && paths[0]) that.setData({ previewUrl: paths[0] });
}, fail: function () { if (that.alive && that.visible) my.showToast({ content: '未选取照片,可重试或检查系统权限', type: 'none' }); },
complete: function () { that.busy = false; } });
}).catch(function (err) {
that.busy = false;
if (that.alive && that.visible) { that.setData({ permissionMessage: err.message }); my.showToast({ content: err.message, type: 'none' }); }
});
},
onSystemCamera: function () { this.choose(['camera']); },
onAlbum: function () { this.choose(['album']); },
onFlip: function () {
if (this.busy || this.data.previewUrl || this.data.analyzing) return;
this.setData({ devicePosition: this.data.devicePosition === 'front' ? 'back' : 'front' });
},
onRetake: function () { if (!this.busy) this.setData({ previewUrl: '' }); },
onConfirm: function () {
if (this.busy || !this.data.previewUrl || this.data.analyzing) return;
var that = this, step = this.data.stepIndex;
this.busy = true; this.photos[step] = this.data.previewUrl;
if (!this.debugMode) voice.play(voice.VOICE.shot, { cut: true });
if (step === 2) { this.setData({ previewUrl: '' }); this.submitPhotos(); return; }
this.setData({ stepIndex: step + 1, previewUrl: '', doneTip: '拍摄完成。下一张:' + STEPS[step + 1].tip + ',' + STEPS[step + 1].turn });
this.nextTimer = setTimeout(function () {
if (!that.alive || !that.visible) return;
that.busy = false; that.renderStep();
}, 2000);
},
submitPhotos: function () {
if (!this.photos[0] || !this.photos[1] || !this.photos[2]) return;
var that = this, seq = ++this.seq, dataUrls = [];
var debugResults = [];
this.signal = { canceled: false }; var signal = this.signal;
this.busy = true; this.setData({ analyzing: true, doneTip: '' });
if (!this.debugMode) voice.play(voice.VOICE.analyzing);
// One shared native canvas: draw the photos sequentially.
this.photos.reduce(function (chain, photo) {
return chain.then(function () {
if (signal.canceled) throw { canceled: true };
return image.compressToBase64(photo, that);
}).then(function (value) {
dataUrls.push(value);
if (that.debugMode) debugResults.push({ key: STEPS[debugResults.length].target,
title: STEPS[debugResults.length].tip.replace(/^请/, ''), photo: photo,
width: that.data.compressWidth, height: that.data.compressHeight,
kilobytes: Math.round(value.replace(/^data:[^,]+,/, '').length * 3 / 4 / 1024) });
});
}, Promise.resolve()).then(function () {
if (signal.canceled) throw { canceled: true };
if (that.debugMode) {
if (!that.alive || !that.visible || seq !== that.seq) return;
that.busy = false; that.cameraCtx = null;
dataUrls = []; // Do not keep base64, upload, create an account or touch report state.
that.setData({ analyzing: false, cameraEnabled: false, debugComplete: true, debugPhotos: debugResults });
return;
}
var ordered = [dataUrls[0], dataUrls[2], dataUrls[1]];
return api.analyze(ordered, getApp().globalData.profile, signal).then(function (payload) {
if (!that.alive || !that.visible || seq !== that.seq) return;
getApp().globalData.photos = ordered; getApp().globalData.payload = payload;
voice.stop(true); my.redirectTo({ url: '/pages/report/report' });
});
}).catch(function (err) {
if (err.canceled || !that.alive || !that.visible || seq !== that.seq) return;
that.busy = false; that.setData({ analyzing: false });
if (!that.debugMode) voice.play(voice.VOICE.fail, { cut: true });
my.showToast({ content: err.message || '照片处理失败,请重新拍摄', type: 'none', duration: 3000 });
});
},
onDebugRestart: function () {
if (!this.debugMode || !development.cameraDebugEnabled()) return;
this.photos = []; this.busy = false; this.seq++;
this.setData({ debugComplete: false, debugPhotos: [], stepIndex: 0, previewUrl: '', analyzing: false });
this.renderStep(); this.onEnableCamera();
},
onCancel: function () {
if (this.debugMode) { this.photos = []; this.setData({ debugPhotos: [], previewUrl: '' }); }
this.pause(); my.navigateBack();
},
noop: function () {}
});
+6
View File
@@ -0,0 +1,6 @@
{
"defaultTitle": "1'me在线肌肤测试",
"titleBarColor": "#101823",
"backgroundColor": "#101823",
"disableScroll": true
}