Files
juzi/taobao/pages/report/report.js
T
xsl 774376fa16 feat: 完善淘宝小程序拍照、报告与登录流程
接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
2026-11-04 15:07:51 +08:00

549 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ═════ 08 报告页 —— 1:1 移植 H5 #s-report ═════
数据来源不变:utils/report-model.js 的 model()/layers()/marks()(接口未改),
本文件只做 H5 版面映射:
1) 七个检测视角按 H5 的三张维度卡分组(紧塑力 2 / 透亮感 3 / 稳韧力 2);
2) H5 SVG 环形评分 → conic-gradient 角度 + 圆头补点(acss 无 svg);
3) H5 app.js vizFaceCrop/vizCompose 的人脸裁切窗口 → 照片与 face_maps 图层共用的
「同一画框绝对定位」,保证叠加层位置与 H5 canvas 合成一致;
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;
/* H5 canvas 过小标记的最小框 = 画布宽 / 36 ≈ 6px;缩到画框后约 12rpx */
var MIN_MARK = 12;
/* H5 环形评分 viewBox 0 0 120 120、r=52 → 216rpx 画盘上半径 93.6rpx、圆心 108rpx */
var RING_R = 93.6, RING_C = 108;
/* 照片未拿到自然尺寸前的兜底:整框 + contain(H5 object-fit:contain 语义) */
var DEFAULT_BOX = 'left:0;top:0;width:' + FRAME_W + 'rpx;height:' + FRAME_H + 'rpx;';
/* H5 报告页的三张维度卡(index.html L209-264:紧塑力/透亮感/稳韧力) */
var GROUPS = [
{ key: 'firm', title: '紧塑力', titleCls: '', cardCls: '', views: ['contour', 'wrinkles'] },
{ key: 'vita', title: '透亮感', titleCls: '', cardCls: '', views: ['color', 'smooth', 'water'] },
{ key: 'stab', title: '稳韧力', titleCls: 'dimension-stability-title', cardCls: 'stability-card', views: ['sens', 'acne'] }
];
/* H5 app.js VIZ_CAT:blend=正片叠底(白底色沉/缺水/红区图层);wrinkles 需把照片压暗 0.55 */
var BLEND = { color: true, water: true, sens: true };
var DARK = { wrinkles: .55 };
/* H5 app.js vizCompose({contentDown}):轮廓/皱纹画框只露上部 90%,主体需下移 9% */
var CONTENT_DOWN = { contour: .09, wrinkles: .09 };
/* 有色沉图层时 H5 不再重复画黑色素框(VIZ_CAT.tone 的 filter) */
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),顺时针、从正上方开始。
acss 用 conic-gradient 画弧 + 两个圆点还原 stroke-linecap:round 的两端。 */
function ringOf(total) {
var value = total == null ? 0 : Math.max(0, Math.min(100, total));
var angle = Math.PI * 2 * value / 100;
var x = RING_C + RING_R * Math.sin(angle), y = RING_C - RING_R * Math.cos(angle);
return { ringDeg: Math.round(360 * value / 100 * 100) / 100, ringCap: total != null && value > 0,
ringCapEnd: 'left:' + px(x) + ';top:' + px(y) + ';' };
}
/* H5 style.css .dim.best(最高分浅绿条)+ 既有 .dim.worst(report-model 已按原始 P 判定的优先关注) */
function rankDims(dims) {
var worstKey = '', bestKey = '', best = -1, scored = 0;
dims.forEach(function (dim) {
if (dim.rank === 'worst') worstKey = dim.key;
if (typeof dim.int === 'string' && dim.int !== '-') scored++;
});
dims.forEach(function (dim) {
if (dim.key === worstKey || dim.int === '-') return;
if (dim.barWidth > best) { best = dim.barWidth; bestKey = dim.key; }
});
if (scored < 2) bestKey = '';
return dims.map(function (dim) {
var copy = Object.assign({}, dim);
copy.rank = dim.key === worstKey ? 'worst' : dim.key === bestKey ? 'best' : '';
return copy;
});
}
/* H5 app.js vizFaceCrop:人脸矩形 → 215:278 裁切窗口(矩形不可用时退回整幅) */
function faceCrop(width, height, rectangle) {
var full = { left: 0, top: 0, width: width, height: height, focused: false };
if (!rectangle) return full;
var keys = ['left', 'top', 'width', 'height'];
for (var i = 0; i < keys.length; i++) if (!numeric(rectangle[keys[i]])) return full;
if (rectangle.width <= 0 || rectangle.height <= 0 || rectangle.left < 0 || rectangle.top < 0 ||
rectangle.left + rectangle.width > width || rectangle.top + rectangle.height > height) return full;
var ch = Math.min(Math.max(rectangle.height * 1.55, rectangle.width * 1.18 / FRAME_RATIO), height, width / FRAME_RATIO);
var cw = ch * FRAME_RATIO;
if (cw < rectangle.width || ch < rectangle.height) return full;
var left = Math.max(0, Math.min(width - cw, rectangle.left + rectangle.width / 2 - cw / 2));
var top = Math.max(0, Math.min(height - ch, rectangle.top - rectangle.height * .3));
if (top + ch < rectangle.top + rectangle.height) top = rectangle.top + rectangle.height - ch;
return { left: left, top: top, width: cw, height: ch, focused: true };
}
/* H5 app.js vizShiftContentDown:把画面整体下移(源窗口上移) */
function shiftContentDown(crop, rectangle, fraction) {
if (!crop.focused || !rectangle || !fraction) return crop;
var minimumTop = Math.max(0, rectangle.top + rectangle.height - crop.height);
var copy = Object.assign({}, crop);
copy.top = Math.max(minimumTop, crop.top - crop.height * fraction);
return copy;
}
/* 画框(430×556rpx)内的照片/图层定位:left/top/width/height 均为 rpx,
照片与图层共用同一套值,图层因此与照片逐像素贴合 */
function frameBox(width, height, rectangle, contentDown) {
var crop = faceCrop(width, height, rectangle), scale, left, top, boxW, boxH;
if (crop.focused) {
crop = shiftContentDown(crop, rectangle, contentDown);
scale = FRAME_W / crop.width;
left = -crop.left * scale; top = -crop.top * scale;
boxW = width * scale; boxH = height * scale;
} else {
// 无可用人脸矩形:H5 canvas 保留整幅比例,再由 object-fit:contain 内嵌 → 这里等价等比内嵌
scale = Math.min(FRAME_W / width, FRAME_H / height);
boxW = width * scale; boxH = height * scale;
left = (FRAME_W - boxW) / 2; top = (FRAME_H - boxH) / 2;
}
return { left: left, top: top, width: boxW, height: boxH, scale: scale,
style: 'left:' + px(left) + ';top:' + px(top) + ';width:' + px(boxW) + ';height:' + px(boxH) + ';' };
}
/* 标记:接口像素坐标 → 画框(430×556rpx)百分比;过小标记按 H5 规则放大并保持中心。
照片/图层在画框内的定位是 box.left = -裁切窗口左边界 × scale,
所以原始像素 X 落在画框里的位置就是 box.left + X × scale。 */
function placeMarks(marks, width, height, box) {
var scale = box.scale;
return (marks || []).map(function (mark) {
var left = box.left + (mark.left / 100 * width) * scale;
var top = box.top + (mark.top / 100 * height) * scale;
var w = mark.width / 100 * width * scale, h = mark.height / 100 * height * scale;
if (w < MIN_MARK) { left -= (MIN_MARK - w) / 2; w = MIN_MARK; }
if (h < MIN_MARK) { top -= (MIN_MARK - h) / 2; h = MIN_MARK; }
return { id: mark.id, color: mark.color, left: pct(left / FRAME_W), top: pct(top / FRAME_H),
width: pct(w / FRAME_W), height: pct(h / FRAME_H) };
});
}
/* H5 app.js renderViz 的 #smooth-map-note 文案(毛孔=蓝、黑头=红) */
function smoothCaption(result) {
var maps = (result && result.face_maps) || {}, notes = [];
if (report.mapURL(maps.texture_enhanced_pores)) notes.push('蓝色为毛孔增强标记');
if (report.mapURL(maps.texture_enhanced_blackheads)) notes.push('红色为黑头增强标记');
return notes.length ? notes.join(',') + ';标记不代表严重程度,具体以检测计数和结果为准。'
: '本次未获取毛孔、黑头增强图;此处仅展示拍摄照片,不绘制色斑、痣或痤疮框。';
}
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, guest: false,
identityChecking: true, identityError: '',
// 版面对齐 H5 所需的派生数据
cards: [], sides: [], sidesShown: false, wrinkleNote: '',
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 || [];
var model;
try { model = report.model(this.payload, this.photos, gd.profile || {}); }
catch (_) { my.showToast({ content: '报告生成失败,请重新检测', type: 'none' }); return; }
var result = this.payload.result, ring = ringOf(model.total);
this.setData(Object.assign({}, model, {
dims: rankDims(model.dims),
cards: this.buildCards(model, result),
ringDeg: ring.ringDeg, ringCap: ring.ringCap, ringCapEnd: ring.ringCapEnd
}));
this.renderSides(this.payload.wrinkle_views);
},
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 (_) {}
},
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) {
var byKey = {};
model.views.forEach(function (view) { byKey[view.key] = view; });
var hasPhoto = !!model.frontPhoto;
return GROUPS.map(function (group) {
var views = [];
group.views.forEach(function (key) {
var view = byKey[key];
if (!view) return;
var layers = result ? report.layers(result, key) : [];
// 有水印图层即可出图;轮廓/肤色/平滑度/痘痘必须有人脸照片(H5 renderViz 的降级规则)
var needsPhotoOnly = key === 'contour' || key === 'color' || key === 'smooth' || key === 'acne';
views.push({
key: key, name: view.name, text: view.text, dim: !!view.dim, alpha: view.alpha,
layers: layers, marks: [],
blend: !!BLEND[key], dark: DARK[key] || 0,
frameCls: CONTENT_DOWN[key] ? 'contour-front-frame' : '',
fit: 'aspectFit', photoStyle: DEFAULT_BOX,
hasVisual: needsPhotoOnly ? hasPhoto : (hasPhoto || layers.length > 0),
caption: key === 'smooth' ? smoothCaption(result) : ''
});
});
return { key: group.key, title: group.title, titleCls: group.titleCls, cardCls: group.cardCls,
index: 0, dragOffset: 0, dragging: false, height: 0, views: views };
});
},
/* ─── 人脸裁切窗口 + 标记坐标:照片自然尺寸已知后计算一次 ─── */
measurePhoto: function () {
var that = this, src = this.data.frontPhoto;
if (!src || this.geoDone || typeof my.getImageInfo !== 'function') return;
my.getImageInfo({ src: src,
success: function (info) { that.applyGeometry(info && info.width, info && info.height); },
fail: function () {} });
},
// H5 img.onload 兜底(getImageInfo 不可用时也能拿到自然尺寸)
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;
this.geoDone = true;
var that = this, result = this.payload && this.payload.result;
var rectangle = this.payload && this.payload.face_rectangle;
var patch = {};
this.data.cards.forEach(function (card, ci) {
card.views.forEach(function (view, vi) {
var box = frameBox(width, height, rectangle, CONTENT_DOWN[view.key] || 0);
var marks = report.marks(result, view.key, width, height);
// H5:有色沉图层时不再画黑色素框(避免和 browning 图层重复)
if (view.key === 'color' && view.layers && view.layers.length) {
marks = marks.filter(function (mark) { return mark.color !== MELANIN_COLOR; });
}
var key = 'cards[' + ci + '].views[' + vi + ']';
patch[key + '.photoStyle'] = box.style;
patch[key + '.fit'] = 'scaleToFill';
patch[key + '.marks'] = placeMarks(marks, width, height, box);
});
});
if (that.alive !== false) this.setData(patch, function () { that.measurePanels(); });
},
/* ─── 视角切换:保持既有 viewIndex / activeLayers 语义 ─── */
onTabTap: function (event) {
var dataset = (event && event.currentTarget && event.currentTarget.dataset) || {};
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 || !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;
var flat = this.data.viewIndex;
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, 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) {
var item = views[key];
// 只使用该侧照片自己的接口数据:wrinkle_views → payload.side(H5 同序回退)
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);
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 ? '部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。' : '' }, 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 || this.identityPending) 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 && data.wrinkle_views) || {};
job.status = 'complete'; that.renderSides(that.payload.wrinkle_views);
}).catch(function () {
if (!that.alive || !that.visible || signal.canceled) return;
job.status = 'unavailable'; that.renderSides(null);
}).then(function () { that.loadingSides = false; if (signal.canceled && that.alive && that.visible) that.loadSides(); });
},
/* 推荐文案保持 H5;三个档位统一进入用户确认的天猫商品。 */
onMore: function () {
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' }); },
onEndExperience: function () { api.endGuest().then(function () { my.redirectTo({ url: '/pages/login/login' }); }); }
}, typeof my === 'undefined' ? {} : my));