549 lines
32 KiB
JavaScript
549 lines
32 KiB
JavaScript
/* ═════ 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));
|