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(新增权限与相机能力用例)
This commit is contained in:
xsl
2026-11-01 23:30:38 +08:00
parent f5c416dd9d
commit fed52930cc
33 changed files with 3061 additions and 876 deletions
+260 -35
View File
@@ -1,67 +1,292 @@
/* ═════ 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: { total: null, skinAge: null, ageNote: '', dims: [], focusName: '', focusSlogan: '',
views: [], viewIndex: 0, frontPhoto: '', activeLayers: [], careAdvice: [], product: null, sideViews: [] },
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 || [];
try { this.setData(report.model(this.payload, this.photos, gd.profile || {})); }
catch (_) { my.showToast({ content: '报告生成失败,请重新检测', type: 'none' }); }
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.drawRing(this.data.total); this.loadSides(); },
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: [], sideViews: [], careAdvice: [], product: null });
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; },
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) });
/* ─── 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 };
});
},
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);
/* ─── 人脸裁切窗口 + 标记坐标:照片自然尺寸已知后计算一次 ─── */
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 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' ? '侧脸标注暂不可用' : '' };
}) });
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 === 'complete' || !this.visible || this.loadingSides) return;
this.loadingSides = true; this.signal = { canceled: false }; var signal = this.signal;
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.wrinkle_views; job.status = 'complete'; that.renderSides(data.wrinkle_views);
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) that.renderSides({ left: { status: 'unavailable' }, right: { status: 'unavailable' } });
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(); });
},
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();
},
/* 推荐产品 → 淘宝商品详情(config.productItemIds 未配置时为 no-op,不伪造链接) */
onMore: function () {
var id = this.data.product && config.productItemIds[this.data.product.tier];
if (!id) return;