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
+597 -209
View File
@@ -1,28 +1,106 @@
/* ═════ 报告页样式(灰底白卡,对齐设计稿 08) ═════ */
page { background: #EFEFEF; }
.report { padding-bottom: 64rpx; }
/* ═════ 08 报告页 —— 1:1 移植 H5 #s-report ═════
对照来源(逐条核对,后者覆盖前者):
index.html #s-report L174-291
css/style.css L347-503 (基础)
css/ui-20261009.css L45-87 (第二轮 PSD)
css/ui-20261012.css L71-172 (第三轮 PSD,最终生效值)
换算:H5 的 calc(N * var(--u))(375 设计宽基准)→ 2N rpx;
H5 里固定的 px 字号、1px 描边、百分比、transform 原样保留;
注释用「基础 → ui-20261009 → ui-20261012」标出覆盖关系。
限制:只能用 class/id 选择器;acss 无 @font-face,字体沿用 app.acss 的系统栈,
故 H5 的 "MiSans Demibold"(700) 退化为系统字体 + font-weight:700。 */
/* ─── 页面容器(H5 #s-report / .report-body) ───
#s-report 还有一条 background:#efefef url(bg-report-h5.webp) center top/100% auto。
该 webp 实测为 1080×7512 的纯 #EFEFEF 单色图(全图只有一种颜色 239,239,239),
与 app.acss 里 page 的底色等价,所以这里不再引这张空图
(<image> 会额外解码约 32MB,并把页面滚动高度撑到 2608px)。
.report-body { padding: calc(28u) calc(17u) calc(42u) } */
.report { position: relative; }
.report-body { position: relative; z-index: 1; padding: 56rpx 34rpx 84rpx; }
/* .report-title { font-size:23px; font-weight:600(覆盖 700); letter-spacing:calc(.5u);
text-align:center; margin-bottom:calc(23u) };行高固定 30px = H5 实测行盒 */
.report-title {
font-size: 40rpx;
font-size: 23px;
font-weight: 600;
color: #282828;
text-align: center;
padding: 32rpx 0 8rpx;
letter-spacing: 1rpx;
line-height: 30px;
margin-bottom: 46rpx;
}
/* ─── 总分卡 ─── */
/* .card { background:#fff; border-radius:calc(7u)(覆盖 8u); margin-bottom:calc(12u) } */
.card { margin-bottom: 24rpx; }
/* ─── 总分卡:H5 是 display:grid 两列 1fr + row-gap calc(15u),padding calc(16u) calc(14u)
acss 用 flex-wrap 等价实现:两个 50% 列 + 整行 .card-dims ─── */
.card-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-around;
padding: 32rpx 28rpx;
box-sizing: border-box;
}
.ring-box {
/* .sum-score { flex:none; border-right:1px solid #EFEFEF; flex-direction:column;
align-items:center; justify-content:center } */
.sum-score {
flex: 0 0 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
width: 240rpx;
height: 240rpx;
border-right: 1px solid #EFEFEF;
box-sizing: border-box;
}
.ring-canvas {
width: 240rpx;
height: 240rpx;
/* .score-dial { position:relative; width:calc(108u); height:calc(108u) } */
.score-dial { position: relative; width: 216rpx; height: 216rpx; }
/* H5 用 <svg viewBox="0 0 120 120"> 画环形:r=52、stroke-width=9
→ 在 108px 画盘上外径 50.85、内径 42.75。
acss 无 svg,改成等价的三层几何:轨道圆环 + conic-gradient 进度弧 + 圆头补点。 */
.ring-track {
position: absolute;
left: 50%; top: 50%;
width: 203.4rpx; height: 203.4rpx;
margin: -101.7rpx 0 0 -101.7rpx;
border-radius: 50%;
background: #EFEFEF;
}
/* 进度弧:report.js 按总分写入 conic-gradient(#F5F078 0 deg, 透明 deg)。
conic-gradient 的 0deg 在正上方、顺时针递增,与 H5 SVG rotate(-90deg) 的进度方向一致。
圆的直径必须与 .ring-track 一致(203.4rpx):若铺满 216rpx 画盘,
环会变成 11.25rpx 厚、外径 54(H5 是 8.1rpx 厚、外径 50.85)。 */
.ring-arc {
position: absolute;
left: 50%; top: 50%;
width: 203.4rpx; height: 203.4rpx;
margin: -101.7rpx 0 0 -101.7rpx;
border-radius: 50%;
}
/* 圆形内孔(卡片底色为白):半径 42.75 → 直径 171rpx */
.ring-hole {
position: absolute;
left: 50%; top: 50%;
width: 171rpx; height: 171rpx;
margin: -85.5rpx 0 0 -85.5rpx;
border-radius: 50%;
background: #FFFFFF;
}
/* stroke-linecap:round 的等价物:半径 8.1rpx 的圆点落在弧的两端 */
.ring-cap {
position: absolute;
width: 16.2rpx; height: 16.2rpx;
margin: -8.1rpx 0 0 -8.1rpx;
border-radius: 50%;
background: #F5F078;
}
/* .ring-center { position:absolute; inset:0; flex-direction:column; align-items:center;
justify-content:center; gap:0 } */
.ring-center {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
@@ -31,215 +109,525 @@ page { background: #EFEFEF; }
align-items: center;
justify-content: center;
}
/* .stat-value { color:var(--ink); line-height:1; white-space:nowrap; display:flex; align-items:baseline } */
.stat-value {
display: flex;
align-items: baseline;
color: #282828;
line-height: 1;
white-space: nowrap;
}
/* .ring-center .stat-value b { font-size:40px } + #stat-score b { letter-spacing:0; display:block } */
.stat-b { font-size: 40px; font-weight: 700; line-height: 1; letter-spacing: 0; }
/* #stat-score i { margin-left:2px } + .ring-center .stat-value i { font-size:14px } */
.stat-i { font-size: 14px; font-weight: 700; line-height: 1; margin-left: 2px; }
/* .ring-center .stat-label / .sum-age > .stat-label { margin:0; font-size:12px; line-height:1.5; color:var(--ink) } */
.stat-label { font-size: 12px; line-height: 1.5; color: #282828; margin: 0; }
/* .sum-age { flex:1; align-self:stretch; flex-direction:column; align-items:center;
justify-content:center; gap:0; padding-left:calc(16u)(覆盖 26u) } */
.sum-age {
flex: 0 0 50%;
align-self: stretch;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-left: 32rpx;
box-sizing: border-box;
}
.stat-label {
font-size: 24rpx;
color: #868686;
}
.stat-value { display: flex; align-items: baseline; }
.stat-num {
font-size: 64rpx;
font-weight: 700;
color: #282828;
}
.stat-unit {
font-size: 24rpx;
color: #868686;
margin-left: 4rpx;
}
.age-pill {
margin-top: 12rpx;
font-size: 22rpx;
color: #282828;
background: #F5F078;
border-radius: 20rpx;
padding: 4rpx 20rpx;
}
/* ─── 三维度 ─── */
.dims-row {
display: flex;
justify-content: space-between;
}
.dim {
width: 31%;
/* .age-dial { width:calc(108u); height:auto; display:flex; align-items:center; justify-content:center } */
.age-dial {
position: relative;
}
.dim-name {
font-size: 26rpx;
color: #4A4A4A;
}
.dim-score { margin: 8rpx 0; }
.dim-int {
font-size: 48rpx;
font-weight: 700;
}
.dim-dec {
font-size: 28rpx;
color: #868686;
}
.dim.worst .dim-int, .dim.worst .dim-dec { color: #D30C0C; }
.dim-bar {
height: 8rpx;
background: #EFEFEF;
border-radius: 4rpx;
overflow: hidden;
}
.dim-bar-fill {
height: 100%;
background: #E5E5E5;
border-radius: 4rpx;
}
.dim.best .dim-bar-fill { background: #DBF6D6; }
.dim.worst .dim-bar-fill { background: #F5C6C6; }
.dim-flag {
position: absolute;
top: 0;
right: 0;
font-size: 20rpx;
color: #D30C0C;
}
.dims-note { margin-top: 24rpx; }
.dims-note-l1 {
font-size: 26rpx;
color: #868686;
}
.dims-focus {
color: #282828;
font-weight: 600;
text-decoration: underline;
}
.dims-note-l2 {
font-size: 24rpx;
color: #868686;
margin-top: 4rpx;
}
/* ─── 6 分类 Tab ─── */
.detail-tabs {
display: flex;
border-bottom: 2rpx solid #E5E5E5;
margin-bottom: 24rpx;
}
.dtab {
flex: 1;
text-align: center;
font-size: 28rpx;
color: #868686;
padding: 16rpx 0;
}
.dtab.active {
color: #282828;
font-weight: 600;
border-bottom: 4rpx solid #F5F078;
}
.ditem { margin-bottom: 32rpx; }
.ditem-title {
font-size: 28rpx;
font-weight: 600;
margin-bottom: 16rpx;
}
.ditem-img {
width: 100%;
border-radius: 16rpx;
margin-bottom: 16rpx;
background: #EFEFEF;
}
.ditem-para {
font-size: 26rpx;
color: #4A4A4A;
line-height: 1.7;
}
/* ─── 降级标注 + 免责 ─── */
.vm-notes {
font-size: 22rpx;
color: #868686;
padding: 16rpx 32rpx 0;
line-height: 1.6;
}
.report-disclaimer {
font-size: 22rpx;
color: #868686;
text-align: center;
line-height: 1.8;
padding: 24rpx 32rpx;
}
/* ─── 护理建议 / 精华 ─── */
.care-item {
font-size: 26rpx;
color: #4A4A4A;
line-height: 1.7;
margin-bottom: 16rpx;
}
.care-summary {
font-size: 24rpx;
color: #868686;
margin-top: 16rpx;
}
.essence-name {
font-size: 32rpx;
font-weight: 600;
}
.essence-triggers {
font-size: 24rpx;
color: #868686;
margin-top: 12rpx;
}
/* ─── 产品推荐 ─── */
.prod-title {
text-align: center;
font-size: 36rpx;
font-weight: 700;
padding: 40rpx 0 8rpx;
}
.prod-title text { display: block; line-height: 1.5; }
.prod-card {
width: 216rpx;
height: auto;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: center;
}
.prod-info { flex: 1; padding-right: 24rpx; }
.prod-badge { height: 40rpx; margin-bottom: 16rpx; }
.prod-name {
font-size: 30rpx;
font-weight: 600;
line-height: 1.4;
/* .age-dial .stat-age { margin-top:0; display:flex; align-items:baseline } */
.stat-age { margin-top: 0; display: flex; align-items: baseline; }
/* .age-dial .stat-age b { font-size:54px(覆盖 58px); display:block; line-height:1 }
letter-spacing 继承 .stat-value b 的 calc(.5u) → 1rpx */
.stat-age .stat-b { font-size: 54px; line-height: 1; letter-spacing: 1rpx; }
/* .stat-age i { font-size:19px; margin-left:2px } */
.stat-age .stat-i { font-size: 19px; margin-left: 2px; }
/* H5 ui-20261009.css: .age-pill, .dims-note { display:none !important }
ui-20261012 只恢复了 #dims-note,肌龄对比胶囊仍是隐藏态。
ageNote 仍由 report-model 产出(接口不变),这里与 H5 一致:保留数据不展示。 */
.age-pill { display: none; }
/* ─── 三维度(.card-dims 并进总分卡:H5 grid-column:1/-1;acss 用 width:100% + row-gap)
.card-dims { padding:0(覆盖 calc(18u) calc(7u) calc(7u)) }
.dim { flex:1; align-items:flex-start; padding:0 0 calc(4u) calc(14u) }
.dim + .dim { border-left:1px solid #EFEFEF } */
.card-dims { width: 100%; margin-top: 30rpx; }
.dims-row { display: flex; }
.dim {
flex: 1;
min-width: 0;
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0 0 8rpx 28rpx;
box-sizing: border-box;
}
.prod-sub {
font-size: 22rpx;
color: #868686;
.dim + .dim { border-left: 1px solid #EFEFEF; }
/* .dim-name { font-size:11px(覆盖 12px); color:var(--ink) }
行高固定成 H5 实测行盒 14px:acss 是系统字体(H5 是 MiSans),
若放任 normal 行高,三维度卡会整体高 1px 并向下累积。 */
.dim-name { font-size: 11px; line-height: 14px; color: #282828; }
/* .dim-score { margin-top:calc(4u); font-size:27px(覆盖 30px); line-height:1.1; font-weight:700 }
.dim-score b { font-size:36px(覆盖 42px); font-weight:700 } */
.dim-score {
display: flex;
align-items: baseline;
margin-top: 8rpx;
font-size: 27px;
font-weight: 700;
color: #282828;
line-height: 1.1;
}
.prod-img {
width: 240rpx;
height: 240rpx;
.dim-int { font-size: 36px; font-weight: 700; line-height: 1.1; }
.dim-dec { font-size: 27px; font-weight: 700; line-height: 1.1; }
/* .dim-score.dim-bad { color:var(--bad) } */
.dim-bad { color: #D30C0C; }
/* .dim-bar { margin-top:calc(6u); width:calc(69u); height:calc(6u);
border-radius:calc(3u); background:#EFEFEF; overflow:hidden } */
.dim-bar {
display: block;
margin-top: 12rpx;
width: 138rpx;
height: 12rpx;
border-radius: 6rpx;
background: #EFEFEF;
overflow: hidden;
}
/* .dim-bar i { height:100%; background:var(--yellow); border-radius:inherit } */
.dim-fill { height: 100%; border-radius: 6rpx; background: #F5F078; }
.dim.best .dim-fill { background: #D7EE6A; }
.dim.worst .dim-fill { background: #F4B04F; }
/* .dim-flag { visibility:hidden; margin-top:calc(6u); font-size:8px(覆盖 9px); color:var(--ink);
background:#F8F4A6; border-radius:calc(8u); padding:calc(1u) calc(8u) }
→ 非最低分维度用 visibility 保留占位,三列高度与 H5 一致 */
.dim-flag {
visibility: hidden;
margin-top: 12rpx;
font-size: 8px;
line-height: 10px;
color: #282828;
background: #F8F4A6;
border-radius: 16rpx;
flex-shrink: 0;
padding: 2rpx 16rpx;
}
.dim.worst .dim-flag { visibility: visible; }
/* #dims-note { display:block !important; margin-top:12px; padding:10px 12px 10px 24px;
background:#F2F2F2; border-radius:calc(6u); position:relative }
#dims-note::before { left:12px; top:10px; bottom:10px; width:calc(3u); background:var(--yellow) } */
.dims-note {
position: relative;
margin-top: 24rpx;
background: #F2F2F2;
border-radius: 12rpx;
padding: 20rpx 24rpx 20rpx 48rpx;
text-align: left;
}
.dims-note::before {
content: '';
position: absolute;
left: 24rpx;
top: 20rpx;
bottom: 20rpx;
width: 6rpx;
border-radius: 4rpx;
background: #F5F078;
}
/* #dims-note .dims-note-l1 { font-size:12px; line-height:1.5; color:var(--para) }
.dims-note-l1 b { font-weight:500; color:var(--ink); text-decoration:underline } */
.dims-note-l1 { font-size: 12px; line-height: 1.5; color: #4A4A4A; }
.dims-focus {
font-weight: 500;
color: #282828;
text-decoration: underline;
}
/* #dims-note .dims-note-l2 { font-size:11px; line-height:1.5; margin-top:3px; color:var(--grey) } */
.dims-note-l2 { margin-top: 6rpx; font-size: 11px; line-height: 1.5; color: #868686; }
/* ─── 维度标题(H5 h2.dimension-title,落在灰底上的卡外标题)
.dimension-title { font-size:23px; font-weight:600; text-align:center;
margin:calc(18u) 0(覆盖 24u/12u) },行高固定 30px = H5 实测行盒
.dimension-stability-title { font-size:22.106597px; line-height:28.738542px } */
.dimension-title {
font-size: 23px;
font-weight: 600;
color: #282828;
line-height: 30px;
text-align: center;
margin: 36rpx 0;
}
.dimension-stability-title { font-size: 22.106597px; line-height: 28.738542px; }
/* ─── 维度详情卡(.card.card-detail)
.card-detail { margin-top:0; padding:calc(4u) calc(39u) calc(19u); border-radius:calc(7u) } */
.card-detail { margin-top: 0; padding: 8rpx 78rpx 38rpx; box-sizing: border-box; }
/* .detail-tabs { display:flex; border:none(覆盖 1px 底线) }
H5 高度由 .dtab 的 padding-top 13px + .dtab-tx 26px 撑成 40px → acss 固定 80rpx 更稳 */
.detail-tabs { display: flex; height: 80rpx; }
/* .dtab { flex:1; padding:calc(13u) 0 0; text-align:center; font-weight:500; color:var(--ink) }
容器 80rpx 内:上留白 28rpx(= H5 的 13px + 行盒基线偏移 1px),余下 52rpx 归 .dtab-tx;
用 flex 起点对齐消掉 inline-block 的基线偏移,让黄色下划线与 H5 一样停在
距标签卡顶 34px、底 41px 的位置。 */
.dtab {
flex: 1;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 28rpx;
text-align: center;
font-weight: 500;
color: #282828;
}
.dtab.active { font-weight: 700; }
/* .dtab-tx { position:relative; display:inline-block; font-size:12px;
padding:0 calc(5u) calc(10u) } */
.dtab-tx {
position: relative;
display: inline-block;
font-size: 12px;
line-height: 16px;
padding: 0 10rpx 20rpx;
}
/* .dtab.active .dtab-tx::after { left:50%; transform:translateX(-50%); bottom:-1px;
width:calc(76u); height:calc(7u);
border-radius:calc(3.5u); background:var(--yellow) } */
.dtab.active .dtab-tx::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: -1px;
width: 152rpx;
height: 14rpx;
border-radius: 7rpx;
background: #F5F078;
}
.btn-more {
margin: 40rpx 32rpx 0;
/* .detail-viewport { display:block; overflow:hidden } / .detail-panels { display:flex } →
acss 只渲染当前 Tab 的面板,视觉效果等价(H5 用 transform 横滑切换) */
.detail-viewport { overflow: hidden; }
.detail-panels { display: block; }
/* .detail-panels .dpanel { display:block; flex:0 0 100%; padding-top:calc(10u) } */
.dpanel { display: block; padding-top: 20rpx; }
/* .ditem { display:block(覆盖 flex) } */
.ditem { display: block; }
/* ─── 检测图框(.viz-frame + .contour-front-frame 的 90% 高裁切)
.viz-frame { position:relative; width:calc(215u); height:calc(278u); margin:0 auto calc(12u);
background:#f6f6f6; border-radius:calc(5u); overflow:hidden }
.contour-front-frame { height:calc(250.2u); margin-bottom:calc(7.56u) }
.contour-front-frame .ditem-img { height:calc(278u) } */
.viz-frame {
position: relative;
width: 430rpx;
height: 556rpx;
margin: 0 auto 24rpx;
background: #F6F6F6;
border-radius: 10rpx;
overflow: hidden;
}
.detail-tabs { display: block; white-space: nowrap; }
.detail-tabs-inner { display: flex; width: max-content; }
.dtab { flex-shrink: 0; padding: 16rpx 20rpx; }
.analysis-image { position: relative; width: 100%; overflow: hidden; border-radius: 16rpx; background: #202020; }
.analysis-photo { display: block; width: 100%; }
.dark-photo { opacity: .45; }
.analysis-layer { position: absolute; left: 0; top: 0; width: 100%; height: 100%; }
.mark-box { position: absolute; box-sizing: border-box; border: 2rpx solid; border-radius: 4rpx; }
.side-views { display: flex; gap: 16rpx; margin: 24rpx 0; }
.side-view { flex: 1; min-width: 0; font-size: 24rpx; line-height: 1.8; }
.side-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.4); color: white; }
.side-note { color: #777; }
.detail-grade { color: #666; font-size: 24rpx; }
.care-name { font-weight: 600; margin-bottom: 8rpx; }
.dim-level { font-size: 22rpx; margin-top: 8rpx; color: #777; }
.ring-box { width: 120px; height: 120px; }
.contour-front-frame { height: 500.4rpx; margin-bottom: 15.12rpx; }
/* 画框内部固定 430×556rpx 的「照片坐标系」:照片与每个 face_maps 图层共用同一套
left/top/width/height(report.js 按 H5 vizFaceCrop 算出),保证叠加层与人脸严格对齐 */
.viz-stage {
position: absolute;
left: 0; top: 0;
width: 430rpx; height: 556rpx;
overflow: hidden;
}
.viz-photo { position: absolute; display: block; }
.viz-layer { position: absolute; display: block; }
/* H5 vizCompose: ctx.globalCompositeOperation='multiply'(白底色沉/缺水/红区图层) */
.viz-blend { mix-blend-mode: multiply; }
/* H5 vizCompose({dim:.55}):照片先压暗再叠增强线(仅皱纹) */
.viz-dim {
position: absolute;
left: 0; top: 0;
width: 100%; height: 100%;
background: #000000;
opacity: .55;
}
/* H5 坐标标注 canvas:lineWidth = max(3, 画布宽/200),缩到 430rpx 画框后约 3rpx;
rectangle 用 strokeRect,所以只描边、不填充。 */
.viz-mark {
position: absolute;
box-sizing: border-box;
border-style: solid;
border-width: 3rpx;
}
/* .viz-missing { position:absolute; inset:0; display:flex; align-items:center;
justify-content:center; color:#888; font-size:12px } */
.viz-missing {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
display: flex;
align-items: center;
justify-content: center;
color: #888888;
font-size: 12px;
}
/* .ditem-text { border-top:1px solid #eee; padding-top:calc(10u) }
.ditem-para { font-size:12px(覆盖 10.1px); line-height:1.55; color:var(--para);
font-weight:400; white-space:pre-line } */
.ditem-text { border-top: 1px solid #EEEEEE; padding-top: 20rpx; }
.ditem-para {
font-size: 12px;
font-weight: 400;
color: #4A4A4A;
line-height: 1.55;
white-space: pre-line;
overflow-wrap: anywhere;
}
/* .viz-caption { color:#707070; font-size:11px; line-height:1.6; margin-top:10px } */
.viz-caption {
color: #707070;
font-size: 11px;
line-height: 1.6;
margin-top: 20rpx;
}
/* 稳韧力卡的标签/正文再小一号(.stability-card .dtab-tx / .ditem-para) */
.stability-card .dtab-tx { font-size: 11.055903px; }
.stability-card .ditem-para { font-size: 11.574074px; line-height: 14.467593px; }
/* ─── 侧脸照片(.contour-sides / .contour-side-photo / .wrinkle-side-photo)
.contour-sides { width:calc(215u); margin:0 auto calc(12u);
display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:calc(6.3u) }
.contour-side-photo { width:100%; height:calc(92.25u); border-radius:calc(5u);
background:#f6f6f6; overflow:hidden; position:relative }
.contour-side-photo img { position:absolute; inset:0; width:100%; height:100%;
object-fit:cover; transform:translateY(-25%) scale(2.8) } */
.contour-sides {
width: 430rpx;
margin: 0 auto 24rpx;
display: flex;
justify-content: space-between;
}
.contour-side {
position: relative;
flex: none;
width: 208.7rpx;
height: 184.5rpx;
border-radius: 10rpx;
overflow: hidden;
background: #F6F6F6;
}
/* H5 grid: figure[data-view="right"] { grid-column:2 } → 只剩右脸时仍占第二列 */
.contour-side.right { margin-left: auto; }
.side-img {
position: absolute;
left: 0; top: 0;
width: 100%; height: 100%;
transform: translateY(-25%) scale(2.8);
transform-origin: center;
}
.side-dim {
position: absolute;
left: 0; top: 0;
width: 100%; height: 100%;
background: #000000;
opacity: .55;
}
/* .wrinkle-loading { inset:0; background:rgba(0,0,0,.4); color:#fff; text-align:center;
font-size:calc(10u); line-height:1.6 } */
.side-loading {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, .4);
color: #FFFFFF;
text-align: center;
font-size: 20rpx;
line-height: 1.6;
}
/* .wrinkle-layer-note { width:calc(215u); margin:0 auto calc(12u); color:#888;
font-size:10px; line-height:1.4 } */
.wrinkle-layer-note {
width: 430rpx;
margin: 0 auto 24rpx;
color: #888888;
font-size: 10px;
line-height: 1.4;
}
/* ─── 产品区大标题(.prod-title,落在灰底)
ui-20261012 最终值:width:calc(206.597222u); margin:calc(18u) auto;
transform:translateX(calc(.347222u)); font-size:22.106597px; line-height:28.738542px;
font-weight:700; white-space:nowrap; letter-spacing:0 */
.prod-title {
width: 413.194444rpx;
max-width: 100%;
margin: 36rpx auto;
padding: 0;
text-align: center;
transform: translateX(0.694444rpx);
font-size: 22.106597px;
line-height: 28.738542px;
font-weight: 700;
color: #282828;
letter-spacing: 0;
white-space: nowrap;
}
/* ─── 日常护理 + 推荐产品(同一张白卡 .card.card-care.report-care-products)
.report-care-products { width:calc(340.625u); margin-left:auto; margin-right:auto;
padding:calc(21.180556u) calc(24.652778u) calc(7.986111u) } */
.report-care-products {
width: 681.25rpx;
margin-left: auto;
margin-right: auto;
padding: 42.361112rpx 49.305556rpx 15.972222rpx;
box-sizing: border-box;
}
/* .report-care-products .card-heading { font-size:13.888889px; line-height:19.444444px;
font-weight:700; text-align:left;
margin-bottom:calc(5.208333u) } */
.card-heading {
font-size: 13.888889px;
line-height: 19.444444px;
font-weight: 700;
text-align: left;
color: #000000;
margin-bottom: 10.416666rpx;
}
/* .report-care-products .care-item { font-size:11.574074px; line-height:14.467593px;
color:var(--para); margin-bottom:calc(14.467593u) }
.care-item:last-child { margin-bottom:0 } → acss 避开伪类,由 report.js 打 '.last' 标记 */
.care-item {
font-size: 11.574074px;
line-height: 14.467593px;
color: #4A4A4A;
margin-bottom: 28.935186rpx;
overflow-wrap: anywhere;
}
.care-item.last { margin-bottom: 0; }
/* .product-section-heading { position:relative; margin:calc(24.0625u) 0 calc(5.555556u);
font-size:13.888889px; line-height:19.444444px;
font-weight:700; text-align:left }
::before { left:0; right:0; top:calc(-12.03125u); border-top:1px solid #DDD } */
.product-section-heading {
position: relative;
margin: 48.125rpx 0 11.111112rpx;
font-size: 13.888889px;
line-height: 19.444444px;
font-weight: 700;
text-align: left;
color: #282828;
}
.product-section-heading::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: -24.0625rpx;
border-top: 1px solid #DDDDDD;
}
/* H5 #product-unavailable 也是 .care-item(文案见 report.axml,与 H5 index.html L274 一致) */
.product-unavailable { margin-bottom: 0; }
/* .report-care-products .card-product { width:auto; margin:0 calc(-18.402778u);
border-radius:calc(3.472222u);
background:#F6F6F6; overflow:hidden } */
.card-product {
width: auto;
margin: 0 -36.805556rpx;
border-radius: 6.944444rpx;
background: #F6F6F6;
overflow: hidden;
}
/* .report-care-products .prod-card { height:auto; aspect-ratio:945/511 }
→ 卡片内容宽 582.64rpx + 左右各 36.8rpx = 656.25rpx,
高 = 656.25 × 511 ÷ 945 = 354.84rpx(定值,不依赖 aspect-ratio 支持度) */
.prod-card {
position: relative;
height: 354.84rpx;
display: flex;
align-items: stretch;
}
/* .report-care-products .prod-info { flex-basis:49.100529%; padding:0 } */
.prod-info {
flex: 0 0 49.100529%;
min-width: 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
/* .report-care-products #product-title { position:absolute; left:8.571429%; top:29.158513%;
font-size:14.467593px; line-height:18.06279px;
font-weight:700; white-space:nowrap } */
.prod-card-title {
position: absolute;
left: 8.571429%;
top: 29.158513%;
margin: 0;
font-size: 14.467593px;
line-height: 18.06279px;
font-weight: 700;
color: #282828;
white-space: nowrap;
}
/* .report-care-products .prod-name { left:8.677249%; top:41.09589%; font-size:12.013166px;
line-height:15.070408px; font-weight:700; color:#989898;
white-space:nowrap } */
.prod-name {
position: absolute;
left: 8.677249%;
top: 41.09589%;
margin: 0;
font-size: 12.013166px;
line-height: 15.070408px;
font-weight: 700;
color: #989898;
white-space: nowrap;
}
/* .report-care-products .prod-badge { left:8.783069%; top:56.751468%; width:10.687831%; margin:0 } */
.prod-badge {
position: absolute;
left: 8.783069%;
top: 56.751468%;
width: 10.687831%;
height: auto;
margin: 0;
}
/* .report-care-products .prod-scroll { flex-basis:50.899471% }
.prod-scroll img { width:100%; height:100%; aspect-ratio:auto; object-fit:fill } */
.prod-scroll {
flex: 0 0 50.899471%;
display: flex;
overflow: hidden;
}
.prod-img { display: block; width: 100%; height: 100%; }
/* ─── 免责声明(.report-care-products + .report-disclaimer)
margin:calc(12.152778u) calc(5.208333u) calc(20u);
font-size:7.233681px; line-height:10.127431px */
.report-disclaimer {
margin: 24.305556rpx 10.416666rpx 40rpx;
font-size: 7.233681px;
line-height: 10.127431px;
color: #4A4A4A;
}
.report-disclaimer-line { display: block; }
/* ─── 小程序独有:历史报告入口(H5 报告页无此按钮)
沿用 H5 style.css 里已有的 .btn-more { margin: calc(26u) auto 0 },保持既有跳转不丢失 ─── */
.btn-more { margin: 52rpx auto 0; }
+155 -45
View File
@@ -1,53 +1,163 @@
<!-- ═════ 08 报告页 —— 对照 H5 index.html #s-report L174-291 ═════
结构逐块对应:.report-title → .card.card-summary(环形评分 + 生物可视年龄 + 三维度)
→ 三组 .dimension-title + .card.card-detail(紧塑力 2 视图 / 透亮感 3 视图 / 稳韧力 2 视图)
→ .prod-title → .card.report-care-products(日常护理 + 推荐产品)→ .report-disclaimer。
样式见 report.acss;数据仍由 utils/report-model.js 的 model()/layers()/marks() 产出。 -->
<view class="report">
<view class="report-title">您的肌肤状态表现</view>
<view class="card card-summary">
<view class="sum-score"><view class="ring-box">
<canvas id="score-ring" width="120" height="120" class="ring-canvas" style="width:120px;height:120px;" />
<view class="ring-center"><view class="stat-value"><text class="stat-num">{{total != null ? total : '--'}}</text><text class="stat-unit">分</text></view><view class="stat-label">综合评分</view></view>
</view></view>
<view class="sum-age"><view class="stat-label">肌肤年龄</view><view class="stat-value"><text class="stat-num">{{skinAge != null ? skinAge : '--'}}</text><text class="stat-unit">岁</text></view><view class="age-pill" a:if="{{ageNote}}">{{ageNote}}</view></view>
</view>
<view class="card card-dims">
<view class="dims-row"><view class="dim {{item.rank}}" a:for="{{dims}}" a:key="key">
<view class="dim-name">{{item.name}}</view><view class="dim-score"><text class="dim-int">{{item.int}}</text><text class="dim-dec" a:if="{{item.dec}}">.{{item.dec}}</text></view>
<view class="dim-bar"><view class="dim-bar-fill" style="width:{{item.barWidth}}%;"></view></view><view class="dim-level">{{item.level}}</view>
<view class="dim-flag" a:if="{{item.rank === 'worst'}}">优先关注</view>
</view></view>
<view class="dims-note" a:if="{{focusName}}"><view>近期优先关注:{{focusName}}</view><view>{{focusSlogan}}</view></view>
</view>
<view class="card card-detail" a:if="{{views.length}}">
<scroll-view class="detail-tabs" scroll-x="{{true}}"><view class="detail-tabs-inner">
<view class="dtab {{index === viewIndex ? 'active' : ''}}" a:for="{{views}}" a:key="key" data-tab="{{index}}" onTap="onTabChange">{{item.name}}</view>
</view></scroll-view>
<block a:for="{{views}}" a:key="key">
<view class="ditem" a:if="{{index === viewIndex}}">
<view class="ditem-title">{{item.name}} <text class="detail-grade">{{item.score}} · {{item.level}}</text></view>
<view class="analysis-image" a:if="{{frontPhoto}}">
<image class="analysis-photo {{item.dim ? 'dark-photo' : ''}}" src="{{frontPhoto}}" mode="widthFix" data-index="{{index}}" onLoad="onPhotoLoad" />
<image class="analysis-layer" a:for="{{activeLayers}}" a:for-item="layer" a:key="*this" src="{{layer}}" mode="scaleToFill" style="opacity:{{item.alpha}};" />
<view class="mark-box" a:for="{{item.marks}}" a:for-item="mark" a:key="id" style="left:{{mark.left}}%;top:{{mark.top}}%;width:{{mark.width}}%;height:{{mark.height}}%;border-color:{{mark.color}};"></view>
<!-- H5 #s-report: background:#efefef url(bg-report-h5.webp) center top/100% auto。
该 webp 为 1080×7512 的纯 #EFEFEF 单色图(全图仅一种颜色 239,239,239),
与 app.acss 里 page 的底色完全等价;acss 侧不再引 32MB 解码量的空图,
直接用 page 底色(否则 <image> 还会把页面滚动高度撑到 2608px)。 -->
<view class="report-body">
<!-- H5 h1.report-title -->
<view class="report-title">您的肌肤状态表现</view>
<!-- H5 .card.card-summary -->
<view class="card card-summary">
<view class="sum-score">
<view class="score-dial">
<!-- H5 <svg class="ring"><circle class="ring-bg">+<circle class="ring-fg">:
环轨道 + conic-gradient 进度弧 + 圆头补点(acss 无 svg,几何等价) -->
<view class="ring-track"></view>
<view class="ring-arc" style="background-image: conic-gradient(#F5F078 0deg {{ringDeg}}deg, rgba(0,0,0,0) {{ringDeg}}deg);"></view>
<view class="ring-cap" a:if="{{ringCap}}" style="left:108rpx;top:14.4rpx;"></view>
<view class="ring-cap" a:if="{{ringCap}}" style="{{ringCapEnd}}"></view>
<view class="ring-hole"></view>
<view class="ring-center">
<view class="stat-value"><text class="stat-b">{{total != null ? total : '--'}}</text><text class="stat-i">分</text></view>
<view class="stat-label">综合评分</view>
</view>
</view>
<view class="side-views" a:if="{{item.key === 'wrinkles'}}">
<view class="side-view" a:for="{{sideViews}}" a:for-item="side" a:key="key">
<view>{{side.title}}</view><view class="analysis-image" a:if="{{side.photo}}">
<image class="analysis-photo dark-photo" src="{{side.photo}}" mode="widthFix" />
<image a:if="{{side.layer}}" class="analysis-layer" src="{{side.layer}}" mode="scaleToFill" />
<view class="side-loading" a:if="{{side.loading}}">标注生成中…</view>
</view><view class="side-note" a:if="{{side.note}}">{{side.note}}</view>
</view>
<view class="sum-age">
<!-- H5 .sum-age > .stat-label -->
<view class="stat-label">生物可视年龄</view>
<view class="age-dial">
<view class="stat-value stat-age"><text class="stat-b">{{skinAge != null ? skinAge : '--'}}</text><text class="stat-i">岁</text></view>
</view>
<!-- H5 ui-20261009.css 把 .age-pill 置为 display:none !important(新版设计不再展示对比胶囊);
report-model 仍产出 ageNote,这里与 H5 一样保留数据但不展示 -->
<view class="age-pill" a:if="{{ageNote}}">{{ageNote}}</view>
</view>
<!-- H5 .card-dims:grid-column 1/-1,占满总分卡整行 -->
<view class="card-dims">
<view class="dims-row">
<view class="dim {{item.rank}}" a:for="{{dims}}" a:key="key">
<view class="dim-name">{{item.name}}</view>
<view class="dim-score {{item.rank === 'worst' ? 'dim-bad' : ''}}"><text class="dim-int">{{item.int}}</text><text class="dim-dec">.{{item.dec ? item.dec : '-'}}</text></view>
<view class="dim-bar"><view class="dim-fill" style="width:{{item.barWidth}}%;"></view></view>
<!-- H5 用 visibility 占位,非最低分维度也保留 12px 高度 -->
<view class="dim-flag">优先关注</view>
</view>
</view>
<!-- H5 #dims-note(ui-20261012 恢复了这块「近期优先关注」) -->
<view class="dims-note" a:if="{{focusName}}">
<view class="dims-note-l1">近期优先关注:<text class="dims-focus">{{focusName}}</text></view>
<view class="dims-note-l2">{{focusSlogan}}</view>
</view>
</view>
</view>
<!-- H5 三组 <h2 class="dimension-title"> + <div class="card card-detail"> -->
<block a:for="{{cards}}" a:for-item="card" a:for-index="ci" a:key="key">
<view class="dimension-title {{card.titleCls}}">{{card.title}}</view>
<view class="card card-detail {{card.cardCls}}">
<!-- H5 .detail-tabs > button.dtab > span.dtab-tx -->
<view class="detail-tabs">
<view class="dtab {{card.index === vi ? 'active' : ''}}" a:for="{{card.views}}" a:for-item="tab" a:for-index="vi" a:key="key"
data-card="{{card.key}}" data-index="{{vi}}" onTap="onTabTap">
<text class="dtab-tx">{{tab.name}}</text>
</view>
</view>
<view class="detail-viewport">
<view class="detail-panels">
<block a:for="{{card.views}}" a:for-item="view" a:for-index="vi" a:key="key">
<!-- H5 用 transform 横滑切换;acss 只渲染当前面板,视觉等价 -->
<view class="dpanel" a:if="{{card.index === vi}}">
<view class="ditem">
<view class="viz-frame {{view.frameCls}}">
<!-- hasVisual=false 时整块画框只留占位文案(H5 renderViz 的降级:轮廓/肤色/平滑度/痘痘
必须有人脸照片,皱纹/水分/泛红可只挂 face_maps 图层) -->
<view class="viz-stage" a:if="{{view.hasVisual}}">
<!-- 照片与 face_maps 图层共用 view.photoStyle(人脸裁切窗口),保证叠加层位置一致 -->
<image class="viz-photo" a:if="{{frontPhoto}}" src="{{frontPhoto}}" mode="{{view.fit}}" style="{{view.photoStyle}}" onLoad="onPhotoLoad" />
<view class="viz-dim" a:if="{{view.dark}}"></view>
<image class="viz-layer {{view.blend ? 'viz-blend' : ''}}" a:for="{{view.layers}}" a:for-item="layer" a:key="*this"
src="{{layer}}" mode="scaleToFill" style="opacity:{{view.alpha}};{{view.photoStyle}}" />
<!-- H5 canvas 里按矩形坐标描边的痘/斑标记 -->
<view class="viz-mark" a:for="{{view.marks}}" a:for-item="mark" a:key="id"
style="left:{{mark.left}}%;top:{{mark.top}}%;width:{{mark.width}}%;height:{{mark.height}}%;border-color:{{mark.color}};"></view>
</view>
<view class="viz-missing" a:if="{{!view.hasVisual}}">本次未获取{{view.name}}检测图</view>
</view>
<!-- H5 .contour-sides(轮廓=原图;皱纹=原图压暗 + 侧脸皱纹图层 / 加载态) -->
<view class="contour-sides {{view.dim ? 'wrinkle-sides' : ''}}" a:if="{{sidesShown && (view.dim || view.key === 'contour')}}">
<block a:for="{{sides}}" a:for-item="side" a:key="key">
<view class="contour-side {{side.key === 'right' ? 'right' : ''}}" a:if="{{side.photo}}">
<image class="side-img" src="{{side.photo}}" mode="aspectFill" />
<block a:if="{{view.dim}}">
<view class="side-dim"></view>
<image class="side-img" a:if="{{side.layer}}" src="{{side.layer}}" mode="aspectFill" />
<view class="side-loading" a:if="{{side.loading}}"><view>皱纹标注</view><view>加载中…</view></view>
</block>
</view>
</block>
</view>
<view class="wrinkle-layer-note" a:if="{{view.dim && wrinkleNote}}">{{wrinkleNote}}</view>
<!-- H5 .ditem-text > p.ditem-para -->
<view class="ditem-text">
<view class="ditem-para" a:if="{{view.text}}">{{view.text}}</view>
</view>
<!-- H5 #smooth-map-note(仅平滑度面板) -->
<view class="viz-caption" a:if="{{view.caption}}">{{view.caption}}</view>
</view>
</view>
</block>
</view>
</view>
<view class="ditem-para">{{item.text}}</view>
</view>
</block>
<!-- H5 h2.prod-title(两行固定文案) -->
<view class="prod-title">
<view>感受高光时刻</view>
<view>从日常的好状态开始</view>
</view>
<!-- H5 .card.card-care.report-care-products -->
<view class="card report-care-products">
<view class="card-heading">日常护理</view>
<view class="care-list">
<view class="care-item {{ci === careAdvice.length - 1 ? 'last' : ''}}" a:for="{{careAdvice}}" a:for-item="care" a:for-index="ci" a:key="key">{{ci + 1}}、{{care.text}}</view>
<view class="care-item last" a:if="{{!careAdvice.length}}">本次有效数据不足,暂无个性化护理建议。</view>
</view>
<view class="product-section-heading">推荐产品</view>
<view class="care-item product-unavailable" a:if="{{!product}}">本次未获取有效肌龄,暂无法推荐产品,建议重新检测。</view>
<view class="card-product" a:if="{{product}}" onTap="onMore">
<view class="prod-card">
<view class="prod-info">
<view class="prod-card-title">{{product.title}}</view>
<view class="prod-name">{{product.name}}</view>
<image class="prod-badge" src="{{productBadgeUrl}}" mode="widthFix" />
</view>
<view class="prod-scroll"><image class="prod-img" src="{{product.image}}" mode="scaleToFill" /></view>
</view>
</view>
</view>
<!-- H5 p.report-disclaimer(三行固定文案) -->
<view class="report-disclaimer">
<view class="report-disclaimer-line">*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。</view>
<view class="report-disclaimer-line">*本次检测仅代表当前肌肤状态参考。</view>
<view class="report-disclaimer-line">*仅根据您最近的测肤记录推荐。</view>
</view>
<!-- 小程序独有入口(H5 报告页无此按钮):保留既有的历史报告跳转 -->
<button class="btn-primary btn-more" onTap="onHistory">查看历史报告</button>
</view>
<view class="report-disclaimer">您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。本次检测仅供当前肌肤状态参考。</view>
<view class="card card-care"><view class="card-heading">护理建议</view>
<view class="care-item" a:for="{{careAdvice}}" a:key="key"><view class="care-name">{{item.name}}</view>{{item.text}}</view>
<view class="care-summary">好状态,是内外同调的长期节奏。</view>
</view>
<view class="card card-product" a:if="{{product}}"><view class="prod-card">
<view class="prod-info"><view class="prod-name">{{product.title}}</view><view class="prod-sub">{{product.name}}</view></view>
<image class="prod-img" src="{{product.image}}" mode="aspectFit" />
</view></view>
<button class="btn-primary btn-more" onTap="onHistory">查看历史报告</button>
</view>
+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;