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
+245
View File
@@ -0,0 +1,245 @@
/* ═════ 报告页样式(灰底白卡,对齐设计稿 08) ═════ */
page { background: #EFEFEF; }
.report { padding-bottom: 64rpx; }
.report-title {
font-size: 40rpx;
font-weight: 600;
text-align: center;
padding: 32rpx 0 8rpx;
}
/* ─── 总分卡 ─── */
.card-summary {
display: flex;
align-items: center;
justify-content: space-around;
}
.ring-box {
position: relative;
width: 240rpx;
height: 240rpx;
}
.ring-canvas {
width: 240rpx;
height: 240rpx;
}
.ring-center {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.sum-age {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-label {
font-size: 24rpx;
color: #868686;
}
.stat-value { display: flex; align-items: baseline; }
.stat-num {
font-size: 64rpx;
font-weight: 700;
color: #282828;
}
.stat-unit {
font-size: 24rpx;
color: #868686;
margin-left: 4rpx;
}
.age-pill {
margin-top: 12rpx;
font-size: 22rpx;
color: #282828;
background: #F5F078;
border-radius: 20rpx;
padding: 4rpx 20rpx;
}
/* ─── 三维度 ─── */
.dims-row {
display: flex;
justify-content: space-between;
}
.dim {
width: 31%;
position: relative;
}
.dim-name {
font-size: 26rpx;
color: #4A4A4A;
}
.dim-score { margin: 8rpx 0; }
.dim-int {
font-size: 48rpx;
font-weight: 700;
}
.dim-dec {
font-size: 28rpx;
color: #868686;
}
.dim.worst .dim-int, .dim.worst .dim-dec { color: #D30C0C; }
.dim-bar {
height: 8rpx;
background: #EFEFEF;
border-radius: 4rpx;
overflow: hidden;
}
.dim-bar-fill {
height: 100%;
background: #E5E5E5;
border-radius: 4rpx;
}
.dim.best .dim-bar-fill { background: #DBF6D6; }
.dim.worst .dim-bar-fill { background: #F5C6C6; }
.dim-flag {
position: absolute;
top: 0;
right: 0;
font-size: 20rpx;
color: #D30C0C;
}
.dims-note { margin-top: 24rpx; }
.dims-note-l1 {
font-size: 26rpx;
color: #868686;
}
.dims-focus {
color: #282828;
font-weight: 600;
text-decoration: underline;
}
.dims-note-l2 {
font-size: 24rpx;
color: #868686;
margin-top: 4rpx;
}
/* ─── 6 分类 Tab ─── */
.detail-tabs {
display: flex;
border-bottom: 2rpx solid #E5E5E5;
margin-bottom: 24rpx;
}
.dtab {
flex: 1;
text-align: center;
font-size: 28rpx;
color: #868686;
padding: 16rpx 0;
}
.dtab.active {
color: #282828;
font-weight: 600;
border-bottom: 4rpx solid #F5F078;
}
.ditem { margin-bottom: 32rpx; }
.ditem-title {
font-size: 28rpx;
font-weight: 600;
margin-bottom: 16rpx;
}
.ditem-img {
width: 100%;
border-radius: 16rpx;
margin-bottom: 16rpx;
background: #EFEFEF;
}
.ditem-para {
font-size: 26rpx;
color: #4A4A4A;
line-height: 1.7;
}
/* ─── 降级标注 + 免责 ─── */
.vm-notes {
font-size: 22rpx;
color: #868686;
padding: 16rpx 32rpx 0;
line-height: 1.6;
}
.report-disclaimer {
font-size: 22rpx;
color: #868686;
text-align: center;
line-height: 1.8;
padding: 24rpx 32rpx;
}
/* ─── 护理建议 / 精华 ─── */
.care-item {
font-size: 26rpx;
color: #4A4A4A;
line-height: 1.7;
margin-bottom: 16rpx;
}
.care-summary {
font-size: 24rpx;
color: #868686;
margin-top: 16rpx;
}
.essence-name {
font-size: 32rpx;
font-weight: 600;
}
.essence-triggers {
font-size: 24rpx;
color: #868686;
margin-top: 12rpx;
}
/* ─── 产品推荐 ─── */
.prod-title {
text-align: center;
font-size: 36rpx;
font-weight: 700;
padding: 40rpx 0 8rpx;
}
.prod-title text { display: block; line-height: 1.5; }
.prod-card {
display: flex;
align-items: center;
justify-content: space-between;
}
.prod-info { flex: 1; padding-right: 24rpx; }
.prod-badge { height: 40rpx; margin-bottom: 16rpx; }
.prod-name {
font-size: 30rpx;
font-weight: 600;
line-height: 1.4;
}
.prod-sub {
font-size: 22rpx;
color: #868686;
margin-top: 8rpx;
}
.prod-img {
width: 240rpx;
height: 240rpx;
border-radius: 16rpx;
flex-shrink: 0;
}
.btn-more {
margin: 40rpx 32rpx 0;
}
.detail-tabs { display: block; white-space: nowrap; }
.detail-tabs-inner { display: flex; width: max-content; }
.dtab { flex-shrink: 0; padding: 16rpx 20rpx; }
.analysis-image { position: relative; width: 100%; overflow: hidden; border-radius: 16rpx; background: #202020; }
.analysis-photo { display: block; width: 100%; }
.dark-photo { opacity: .45; }
.analysis-layer { position: absolute; left: 0; top: 0; width: 100%; height: 100%; }
.mark-box { position: absolute; box-sizing: border-box; border: 2rpx solid; border-radius: 4rpx; }
.side-views { display: flex; gap: 16rpx; margin: 24rpx 0; }
.side-view { flex: 1; min-width: 0; font-size: 24rpx; line-height: 1.8; }
.side-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.4); color: white; }
.side-note { color: #777; }
.detail-grade { color: #666; font-size: 24rpx; }
.care-name { font-weight: 600; margin-bottom: 8rpx; }
.dim-level { font-size: 22rpx; margin-top: 8rpx; color: #777; }
.ring-box { width: 120px; height: 120px; }
+53
View File
@@ -0,0 +1,53 @@
<view class="report">
<view class="report-title">您的肌肤状态表现</view>
<view class="card card-summary">
<view class="sum-score"><view class="ring-box">
<canvas id="score-ring" width="120" height="120" class="ring-canvas" style="width:120px;height:120px;" />
<view class="ring-center"><view class="stat-value"><text class="stat-num">{{total != null ? total : '--'}}</text><text class="stat-unit">分</text></view><view class="stat-label">综合评分</view></view>
</view></view>
<view class="sum-age"><view class="stat-label">肌肤年龄</view><view class="stat-value"><text class="stat-num">{{skinAge != null ? skinAge : '--'}}</text><text class="stat-unit">岁</text></view><view class="age-pill" a:if="{{ageNote}}">{{ageNote}}</view></view>
</view>
<view class="card card-dims">
<view class="dims-row"><view class="dim {{item.rank}}" a:for="{{dims}}" a:key="key">
<view class="dim-name">{{item.name}}</view><view class="dim-score"><text class="dim-int">{{item.int}}</text><text class="dim-dec" a:if="{{item.dec}}">.{{item.dec}}</text></view>
<view class="dim-bar"><view class="dim-bar-fill" style="width:{{item.barWidth}}%;"></view></view><view class="dim-level">{{item.level}}</view>
<view class="dim-flag" a:if="{{item.rank === 'worst'}}">优先关注</view>
</view></view>
<view class="dims-note" a:if="{{focusName}}"><view>近期优先关注:{{focusName}}</view><view>{{focusSlogan}}</view></view>
</view>
<view class="card card-detail" a:if="{{views.length}}">
<scroll-view class="detail-tabs" scroll-x="{{true}}"><view class="detail-tabs-inner">
<view class="dtab {{index === viewIndex ? 'active' : ''}}" a:for="{{views}}" a:key="key" data-tab="{{index}}" onTap="onTabChange">{{item.name}}</view>
</view></scroll-view>
<block a:for="{{views}}" a:key="key">
<view class="ditem" a:if="{{index === viewIndex}}">
<view class="ditem-title">{{item.name}} <text class="detail-grade">{{item.score}} · {{item.level}}</text></view>
<view class="analysis-image" a:if="{{frontPhoto}}">
<image class="analysis-photo {{item.dim ? 'dark-photo' : ''}}" src="{{frontPhoto}}" mode="widthFix" data-index="{{index}}" onLoad="onPhotoLoad" />
<image class="analysis-layer" a:for="{{activeLayers}}" a:for-item="layer" a:key="*this" src="{{layer}}" mode="scaleToFill" style="opacity:{{item.alpha}};" />
<view class="mark-box" a:for="{{item.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>
<view class="side-views" a:if="{{item.key === 'wrinkles'}}">
<view class="side-view" a:for="{{sideViews}}" a:for-item="side" a:key="key">
<view>{{side.title}}</view><view class="analysis-image" a:if="{{side.photo}}">
<image class="analysis-photo dark-photo" src="{{side.photo}}" mode="widthFix" />
<image a:if="{{side.layer}}" class="analysis-layer" src="{{side.layer}}" mode="scaleToFill" />
<view class="side-loading" a:if="{{side.loading}}">标注生成中…</view>
</view><view class="side-note" a:if="{{side.note}}">{{side.note}}</view>
</view>
</view>
<view class="ditem-para">{{item.text}}</view>
</view>
</block>
</view>
<view class="report-disclaimer">您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。本次检测仅供当前肌肤状态参考。</view>
<view class="card card-care"><view class="card-heading">护理建议</view>
<view class="care-item" a:for="{{careAdvice}}" a:key="key"><view class="care-name">{{item.name}}</view>{{item.text}}</view>
<view class="care-summary">好状态,是内外同调的长期节奏。</view>
</view>
<view class="card card-product" a:if="{{product}}"><view class="prod-card">
<view class="prod-info"><view class="prod-name">{{product.title}}</view><view class="prod-sub">{{product.name}}</view></view>
<image class="prod-img" src="{{product.image}}" mode="aspectFit" />
</view></view>
<button class="btn-primary btn-more" onTap="onHistory">查看历史报告</button>
</view>
+71
View File
@@ -0,0 +1,71 @@
var api = require('../../utils/api'), report = require('../../utils/report-model'), config = require('../../config');
Page({
data: { total: null, skinAge: null, ageNote: '', dims: [], focusName: '', focusSlogan: '',
views: [], viewIndex: 0, frontPhoto: '', activeLayers: [], careAdvice: [], product: null, sideViews: [] },
onLoad: function () {
this.alive = true; this.visible = true;
this.ownerId = api.user() && api.user().id;
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 || [];
try { this.setData(report.model(this.payload, this.photos, gd.profile || {})); }
catch (_) { my.showToast({ content: '报告生成失败,请重新检测', type: 'none' }); }
this.renderSides(this.payload.wrinkle_views);
},
onReady: function () { this.ready = true; this.drawRing(this.data.total); this.loadSides(); },
onShow: function () {
if (!api.user() || this.ownerId !== api.user().id) {
this.onHide(); this.setData({ frontPhoto: '', views: [], sideViews: [], careAdvice: [], product: null });
my.redirectTo({ url: '/pages/splash/splash' }); return;
}
this.visible = true; if (this.ready) this.loadSides();
},
onHide: function () { this.visible = false; if (this.signal) this.signal.canceled = true; },
onUnload: function () { this.onHide(); this.alive = false; this.photos = []; this.payload = null; },
onTabChange: function (e) {
var i = Number(e.currentTarget.dataset.tab), view = this.data.views[i];
if (!view) return;
this.setData({ viewIndex: i, activeLayers: report.layers(this.payload.result, view.key) });
},
onPhotoLoad: function (e) {
var i = Number(e.currentTarget.dataset.index), view = this.data.views[i];
if (!view || !this.payload) return;
var points = report.marks(this.payload.result, view.key, e.detail.width, e.detail.height);
var patch = {}; patch['views[' + i + '].marks'] = points; this.setData(patch);
},
renderSides: function (views) {
var that = this;
this.setData({ sideViews: ['left', 'right'].map(function (key, i) {
var item = views && views[key] || {}, layer = report.mapURL(item.result && item.result.face_maps && item.result.face_maps.texture_enhanced_lines);
return { key: key, title: i ? '右脸皱纹' : '左脸皱纹', photo: that.photos[i + 1] || '', layer: layer,
loading: item.status === 'pending', note: item.status === 'unavailable' ? '侧脸标注暂不可用' : '' };
}) });
},
loadSides: function () {
var that = this, job = this.payload && this.payload.wrinkle_job;
if (!job || job.status === 'complete' || !this.visible || this.loadingSides) return;
this.loadingSides = true; this.signal = { canceled: false }; var signal = this.signal;
api.wrinkleViews(job.token, signal).then(function (data) {
if (!that.alive || !that.visible || signal.canceled) return;
that.payload.wrinkle_views = data.wrinkle_views; job.status = 'complete'; that.renderSides(data.wrinkle_views);
}).catch(function () {
if (that.alive && that.visible && !signal.canceled) that.renderSides({ left: { status: 'unavailable' }, right: { status: 'unavailable' } });
}).then(function () { that.loadingSides = false; if (signal.canceled && that.alive && that.visible) that.loadSides(); });
},
drawRing: function (total) {
var ctx = my.createCanvasContext('score-ring'), ratio = total == null ? 0 : Math.max(0, Math.min(100, total)) / 100;
ctx.clearRect(0, 0, 120, 120); ctx.setLineWidth(8);
ctx.beginPath(); ctx.arc(60, 60, 52, 0, 2 * Math.PI); ctx.setStrokeStyle('#E5E5E5'); ctx.stroke();
if (total != null) {
ctx.beginPath(); ctx.arc(60, 60, 52, -Math.PI / 2, -Math.PI / 2 + ratio * 2 * Math.PI);
ctx.setStrokeStyle('#F5F078'); ctx.setLineCap('round'); ctx.stroke();
}
ctx.draw();
},
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) });
},
onHistory: function () { my.navigateTo({ url: '/pages/history/history' }); }
});
+5
View File
@@ -0,0 +1,5 @@
{
"defaultTitle": "1'me在线肌肤测试",
"titleBarColor": "#FFFFFF",
"backgroundColor": "#EFEFEF"
}