Files
juzi/taobao/pages/report/report.js
T
xsl fed52930cc feat(miniapp): align UI with H5 and harden device debugging
五个页面按 h5/css 的 style.css → ui-20261009 → ui-20261012 覆盖顺序逐条重排,
换算规则固定为 calc(N * var(--u)) → 2N rpx,固定 px 的字号保持 px。

- app.acss:换回 H5 设计 token(方角小倒角按钮 10rpx、卡片 14rpx、.chev 折线箭头)
- splash/profile/history:底图与按钮文字图改用 H5 资源,隐私/政策弹窗按 H5 重排
- report:环形分用 conic-gradient、人脸裁切窗口叠加 face_maps、标记点百分比盒、
  侧脸皱纹 pending/unavailable 文案;用 Playwright 抓 H5 94 个选择器几何比对,
  整页高 2438.7 vs 2438.67
- camera:淘宝 <camera> 组件在真机整页崩溃(嵌了微信专有的 <cover-view>),
  改为默认系统相机 + `config.nativeCamera` 开关 + pages/camtest 冒烟页验证组件
- permissions:淘宝用带前缀的 scope.camera(裸名返回 5022 Scope:camera信息不存在),
  先试带前缀再退回裸名,拒绝后不再重复弹窗
- voice:非 HTTPS 资源导致的异常不再冒泡成整页崩溃;app.js 加开发版错误 toast
- 开发版入口:权限诊断(启动页/相机页)、相机组件测试
- 测试 20/20(新增权限与相机能力用例)
2026-11-01 23:30:38 +08:00

297 lines
16 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');
/* 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'; }
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({
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,
// 版面对齐 H5 所需的派生数据
cards: [], sides: [], sidesShown: false, wrinkleNote: '',
ringDeg: 0, ringCap: false, ringCapEnd: ''
},
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 || [];
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);
},
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();
},
onHide: function () { this.visible = false; if (this.signal) this.signal.canceled = true; },
onUnload: function () { this.onHide(); 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, 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);
},
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);
},
/* ─── 视角切换:保持既有 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.data.cards.forEach(function (card, ci) {
if (card.key !== key) return;
patch['cards[' + ci + '].index'] = 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);
},
/* ─── 侧脸照片 + 皱纹标注(H5 renderWrinkleSides / renderContourSides) ───
pending:未标注一侧显示「皱纹标注加载中…」
unavailable:显示「部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。」 */
renderSides: function (views) {
var payload = this.payload || {}, photos = this.photos || [];
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);
return { key: key, photo: photos[i + 1] || '', 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; });
this.setData({ sides: sides, sidesShown: shown.length > 0,
wrinkleNote: plain ? '部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。' : '' });
},
loadSides: function () {
var that = this, job = this.payload && this.payload.wrinkle_job;
if (!job || job.status !== 'pending' || !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 && 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(); });
},
/* 推荐产品 → 淘宝商品详情(config.productItemIds 未配置时为 no-op,不伪造链接) */
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' }); }
});