feat(h5): align report care and single-product section with updated design

This commit is contained in:
xsl
2026-10-15 01:30:45 +08:00
parent 60ae6c97ef
commit 77493bdff5
18 changed files with 983 additions and 728 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+28 -1
View File
@@ -82,6 +82,33 @@
.prod-scroll { flex: 0 0 51.219512%; }
.prod-scroll img { width: 100%; height: 100%; aspect-ratio: auto; object-fit: fill; }
/* Figma 1:65, 1080px canvas. One algorithm-selected product, not a catalogue. */
/* User-confirmed: both lines centered; use the same font size/line height
as the stability heading rather than scaling text on wide previews. */
.prod-title { width:calc(206.597222 * var(--u)); max-width:100%; text-align:center; margin:calc(13 * var(--u)) auto calc(8.583333 * var(--u)); padding:0; transform:translateX(calc(.347222 * var(--u))); font-size:22.106597px; line-height:28.738542px; font-weight:700; white-space:nowrap; }
.report-care-products { width:calc(340.625 * var(--u)); margin-left:auto; margin-right:auto; padding:calc(21.180556 * var(--u)) calc(24.652778 * var(--u)) calc(7.986111 * var(--u)); }
.report-care-products .card-heading,
.product-section-heading { font-size:13.888889px; line-height:19.444444px; font-weight:700; text-align:left; }
.report-care-products .card-heading { margin-bottom:calc(5.208333 * var(--u)); }
.report-care-products .care-item { font-size:11.574074px; line-height:14.467593px; margin-bottom:calc(14.467593 * var(--u)); }
.report-care-products .care-item:last-child { margin-bottom:0; }
.product-section-heading { margin:calc(34.375 * var(--u)) 0 calc(5.555556 * var(--u)); }
.report-care-products .card-product { width:auto; margin:0 calc(-18.402778 * var(--u)); border-radius:calc(3.472222 * var(--u)); background:#F6F6F6; }
.report-care-products .prod-card { height:auto; aspect-ratio:945 / 511; }
.report-care-products .prod-info { flex-basis:49.100529%; }
.report-care-products .prod-scroll { flex-basis:50.899471%; }
.report-care-products #product-title { left:8.571429%; top:29.158513%; font-size:13.888889px; line-height:17.340278px; font-weight:700; white-space:nowrap; }
.report-care-products .prod-name { left:8.677249%; top:41.095890%; font-size:11.532639px; line-height:14.467593px; font-weight:700; color:#989898; white-space:nowrap; }
.report-care-products .prod-badge { left:8.783069%; top:56.751468%; width:10.687831%; }
.report-care-products + .report-disclaimer { margin:calc(12.152778 * var(--u)) calc(5.208333 * var(--u)) calc(20 * var(--u)); font-size:7.233681px; line-height:10.127431px; }
.dimension-stability-title { font-size:22.106597px; line-height:28.738542px; }
.stability-card .dtab-tx { font-size:11.055903px; }
.stability-card .ditem-para { font-size:11.574074px; line-height:14.467593px; }
@media (max-width:359px) {
.report-care-products #product-title { font-size:12px; }
.report-care-products .prod-name { font-size:10px; }
}
/* Fourth feedback adds a fast one-second round trip to original PSD pixels.
The stage/mask share one contain transform, including short/landscape screens. */
#p-analyzing { background: #000; overflow: hidden; }
@@ -134,7 +161,7 @@
#stat-score { display: flex; align-items: baseline; justify-content: center; }
#stat-score b { display: block; letter-spacing: 0; }
#stat-score i { position: static; margin-left: 2px; }
.card-care .card-heading { text-align: left; margin-bottom: calc(9 * var(--u)); }
.card-care:not(.report-care-products) .card-heading { text-align: left; margin-bottom: calc(9 * var(--u)); }
/* The profile is a fixed page, not a scroll container. Keep normal-height PSD
spacing; shrink only vertical whitespace/photo height on shorter viewports.
+10 -15
View File
@@ -238,8 +238,8 @@
</div>
</div></div>
</div>
<h2 class="dimension-title">稳韧力</h2>
<div class="card card-detail">
<h2 class="dimension-title dimension-stability-title">稳韧力</h2>
<div class="card card-detail stability-card">
<div class="detail-tabs"><button class="dtab active" data-tab="0"><span class="dtab-tx">泛红</span></button><button class="dtab" data-tab="1"><span class="dtab-tx">痘痘</span></button></div>
<div class="detail-viewport"><div class="detail-panels">
<div class="dpanel active" data-panel="0">
@@ -254,33 +254,28 @@
</div>
<p class="vm-notes" id="vm-notes" style="display:none"></p>
<p class="report-disclaimer">*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。<br>*本次检测仅代表当前肌肤状态参考。</p>
<div class="card card-care">
<h2 class="card-heading">护理建议</h2>
<h2 class="prod-title">感受高光时刻<br>从日常的好状态开始</h2>
<div class="card card-care report-care-products">
<h2 class="card-heading">日常护理</h2>
<div id="care-list">
<p class="care-item">检测完成后显示分析结果。</p>
</div>
<p class="care-summary">好状态,是内外同调的长期节奏。</p>
</div>
<!-- 产品推荐大标题独立于白卡之外,直接落在灰底上(Figma 08) -->
<h2 class="prod-title">感受高光时刻<br>从日常的好状态开始</h2>
<h2 class="product-section-heading">推荐产品</h2>
<p id="product-unavailable" class="care-item" hidden>本次未获取有效肌龄,暂无法推荐产品,建议重新检测。</p>
<div class="card card-product">
<div class="card-product">
<div class="prod-card">
<div class="prod-info">
<h3 id="product-title"></h3>
<p class="prod-name" id="product-name"></p>
<img class="prod-badge" src="assets/img/ui-20261009/product-badge.png" alt="1’me">
<img class="prod-badge" src="assets/img/ui-20261015/product-badge.png" alt="1’me">
</div>
<div class="prod-scroll" id="prod-scroll">
<img id="product-image" alt="算法推荐精华">
</div>
</div>
</div>
</div>
<p class="report-disclaimer">*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。<br>*本次检测仅代表当前肌肤状态参考。<br>*仅根据您最近的测肤记录推荐。</p>
</div>
</section>
+8 -9
View File
@@ -1556,20 +1556,19 @@
}).join('') || '<p class="care-item">本次有效数据不足,暂无个性化护理建议。</p>';
var product = presentation.product;
$('.card-product').hidden = !product;
$('.prod-title').hidden = !product;
$('#product-unavailable').hidden = !!product;
if (product) {
$('#product-title').textContent = product.title;
$('#product-name').replaceChildren();
[product.name, '双仓精华液'].forEach(function (line) {
var span = document.createElement('span'); span.className = 'product-line'; span.textContent = line;
$('#product-name').appendChild(span);
});
// Presentation snapshots retain historical copy/recommendations. Only the
// artwork crop is refreshed by tier, without mutating stored report data.
// product display/artwork is refreshed by selected tier, never re-selected.
var productArt = ReportUI.products.filter(function (p) { return p.tier === product.tier; })[0];
$('#product-title').textContent = productArt ? productArt.title : product.title;
$('#product-name').textContent = productArt ? productArt.name : product.name;
$('#product-image').src = productArt ? productArt.image : product.image;
$('#product-image').alt = product.name + '双仓精华液';
$('#product-image').alt = (productArt ? productArt.title : product.title) + '推荐产品';
} else {
$('#product-image').removeAttribute('src');
$('#product-title').textContent = '';
$('#product-name').textContent = '';
}
}
+3 -3
View File
@@ -10,9 +10,9 @@
['sens', '泛红', 'stab', 'sens'], ['acne', '痘痘', 'stab', 'acne']
];
var products = [
{ tier: 1, match: '25+束紧精华', title: "1'me 主场精华 I", name: '以我本然有光蓄活', image: 'assets/img/ui-20261012/product-25.jpg' },
{ tier: 2, match: '35+支撑精华', title: "1'me 主场精华 II", name: '以我自在有势挺括', image: 'assets/img/ui-20261012/product-35.jpg' },
{ tier: 3, match: '50+重塑精华', title: "1'me 主场精华 III", name: '以我从容有态构塑', image: 'assets/img/ui-20261012/product-50.jpg' }
{ tier: 1, match: '25+束紧精华', title: "1'me 主场精华 I", name: '焕活光泽 透亮紧致', image: 'assets/img/ui-20261015/product-25.png' },
{ tier: 2, match: '35+支撑精华', title: "1'me 主场精华 II", name: '恢复弹性 紧实利落', image: 'assets/img/ui-20261015/product-35.png' },
{ tier: 3, match: '50+重塑精华', title: "1'me 主场精华 III", name: '强韧屏障 轮廓向上', image: 'assets/img/ui-20261015/product-50.png' }
];
var levelMap = { '预警': '改善', '改善': '良好', '良好': '优秀' };
var dimNames = { firm: '紧塑力', vita: '透亮感', stab: '稳韧力' };
+6 -6
View File
@@ -171,18 +171,18 @@ try {
await route.fulfill({ response, body: await response.text() + '\nMOCK_FIXTURE.result.skin_age = { value: ' + age + ' };' });
});
await page.goto(base + '?mock=1&screen=report'); await loadFonts();
await page.waitForFunction(() => document.querySelector('#product-image').naturalWidth === 504);
assert((await page.locator('#product-image').getAttribute('src')).endsWith('product-' + ({ 1: 25, 2: 35, 3: 50 }[tier]) + '.jpg'));
await page.waitForFunction(() => document.querySelector('#product-image').naturalWidth === 481);
assert((await page.locator('#product-image').getAttribute('src')).endsWith('product-' + ({ 1: 25, 2: 35, 3: 50 }[tier]) + '.png'));
for (const width of widths) {
await page.setViewportSize({ width, height: 844 });
await page.locator('.card-product').scrollIntoViewIfNeeded();
const info = await rect('.prod-info'), name = await rect('#product-name'), title = await rect('#product-title');
assert(title.right <= info.right && name.right <= info.right, 'product text overlaps art at ' + width + ': ' + JSON.stringify({ info, name, title }));
assert(Math.abs(title.x - info.x - 26.736111) < .1);
assert.equal(await page.locator('#product-name').evaluate(e => getComputedStyle(e).fontWeight), '600');
assert.equal((await actualLines('#product-name')).length, 2);
assert(Math.abs(title.x - info.x - 81 / 1080 * width) < .1);
assert.equal(await page.locator('#product-name').evaluate(e => getComputedStyle(e).fontWeight), '700');
assert.equal((await actualLines('#product-name')).length, 1);
const card = await rect('.card-product');
assert(Math.abs(card.width - 984 / 1080 * width) < .1, 'exact PSD card width');
assert(Math.abs(card.width - 945 / 1080 * width) < .1, 'exact current Figma card width');
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
}
await page.setViewportSize({ width: 375, height: 726 }); await screenshot('05-product-' + tier, '.card-product');
+2 -2
View File
@@ -129,10 +129,10 @@ try {
const heading = el.querySelector('.card-heading'), body = el.querySelector('#care-list');
return { margin: parseFloat(getComputedStyle(heading).marginBottom), gap: body.getBoundingClientRect().top - heading.getBoundingClientRect().bottom };
});
assert(Math.abs(spacing.margin - 9) < .1 && Math.abs(spacing.gap - 9) < 1, 'care title gap is halved from 18px to 9px');
assert(Math.abs(spacing.margin - 5.208333) < .1 && Math.abs(spacing.gap - 5.208333) < 1, 'daily care title gap follows current Figma');
if (artifacts) await page.locator('.card-care').screenshot({ path: path.join(artifacts, 'care-spacing.png') });
assert.deepEqual(errors, []);
console.log('PASS: four-character bold/underlined privacy link, half care gap; guest success/failure no disk writes or saved history/identity; WeChat retains snapshots/photos/admin records; screenshots; no paid calls');
console.log('PASS: four-character bold/underlined privacy link, current Figma care gap; guest success/failure no disk writes or saved history/identity; WeChat retains snapshots/photos/admin records; screenshots; no paid calls');
} finally {
if (browser) await browser.close();
if (child.exitCode == null) { const stopped = once(child, 'exit'); child.kill(); await stopped; }
+70
View File
@@ -0,0 +1,70 @@
// Figma 1:65 visual layout, synthetic reports; no backend writes or paid calls.
import assert from 'node:assert/strict';import fs from 'node:fs';import path from 'node:path';
import {execSync} from 'node:child_process';import{fileURLToPath,pathToFileURL}from'node:url';
const{chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href);
const browser=await chromium.launch();const artifacts=process.env.H5_ARTIFACT_DIR;
if(artifacts)fs.mkdirSync(artifacts,{recursive:true});
try{
const p=await browser.newPage({viewport:{width:375,height:1400},isMobile:true,hasTouch:true});const errors=[];
p.on('pageerror',e=>errors.push(e.message));
await p.route('**/api/**',r=>{assert.equal(r.request().method(),'GET');return r.fulfill({json:{ok:true,user:{kind:'browser'}}});});
const source=fs.readFileSync(fileURLToPath(new URL('../js/app.js',import.meta.url)),'utf8');
await p.route('**/js/app.js*',r=>r.fulfill({contentType:'text/javascript',body:source.replace(/\}\)\(\);\s*$/,`
window.__bottomTest=function(age,legacy){
var raw=JSON.parse(JSON.stringify(MOCK_FIXTURE.result));
if(age==null)delete raw.skin_age;else raw.skin_age={value:age};
var vm=SkinScore.buildViewModel(raw,MOCK_FIXTURE.side,{realAge:30});
var presentation=JSON.parse(JSON.stringify(ReportUI.build(vm,raw)));
if(legacy&&presentation.product){presentation.product.name='旧版名称';presentation.product.image='missing-legacy.png';}
var before=JSON.stringify(presentation);
renderReport(vm,MOCK_FIXTURE.photos[0],null,{result:raw,presentation:presentation});showScreen('s-report');
return {before:before,after:JSON.stringify(presentation),tier:presentation.product&&presentation.product.tier};
};
})();`)}));
await p.goto((process.env.H5_BASE_URL||'http://127.0.0.1:8080/')+'?mock=1&screen=report');
await p.evaluate(()=>document.fonts.ready);
const names=['焕活光泽 透亮紧致','恢复弹性 紧实利落','强韧屏障 轮廓向上'];
for(const[age,tier]of[[24,1],[40,2],[60,3]]){
const state=await p.evaluate(age=>window.__bottomTest(age,true),age);assert.equal(state.tier,tier);assert.equal(state.before,state.after);
await p.waitForFunction(()=>document.querySelector('#product-image').complete&&document.querySelector('#product-image').naturalWidth===481);
assert.equal(await p.locator('#product-name').textContent(),names[tier-1]);
assert.equal(await p.locator('.card-product:visible').count(),1);
assert.equal(await p.locator('.report-care-products .card-product').count(),1);
assert.equal(await p.locator('.report-care-products .card-heading').textContent(),'日常护理');
assert.equal(await p.locator('.product-section-heading').textContent(),'推荐产品');
assert.equal(await p.locator('.care-summary').count(),0);
assert((await p.locator('.report-disclaimer').textContent()).includes('仅根据您最近的测肤记录推荐'));
for(const width of[320,375,414,480]){
await p.setViewportSize({width,height:1400});await p.locator('.card-product').scrollIntoViewIfNeeded();
const geo=await p.evaluate(()=>{
const rect=s=>{const r=document.querySelector(s).getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height,right:r.right,bottom:r.bottom};};
return{heading:rect('.prod-title'),care:rect('.card-care'),product:rect('.card-product'),info:rect('.prod-info'),title:rect('#product-title'),name:rect('#product-name'),image:rect('#product-image'),badge:rect('.prod-badge'),disclaimer:rect('.report-disclaimer'),align:getComputedStyle(document.querySelector('.prod-title')).textAlign,headingFont:parseFloat(getComputedStyle(document.querySelector('.prod-title')).fontSize)};
});
assert.equal(geo.align,'center');assert(geo.heading.bottom<=geo.care.y+1);
assert(Math.abs(geo.heading.w-595/1080*width)<.1,'Figma text group width scales with preview');
const stabilityFont=await p.locator('.dimension-stability-title').evaluate(e=>parseFloat(getComputedStyle(e).fontSize));
assert(Math.abs(geo.headingFont-stabilityFont)<.01,'bottom title matches stability heading font size at every width');
assert(Math.abs((geo.heading.x+geo.heading.w/2)-(geo.care.x+geo.care.w/2)-width/1080)<.1,'whole title group centered, retaining 1px design offset');
assert(geo.product.bottom<=geo.care.bottom);assert(geo.disclaimer.y>=geo.care.bottom);
assert(geo.title.right<=geo.info.right+1&&geo.name.right<=geo.info.right+1,'text must not overlap image');
assert(Math.abs(geo.product.w/geo.product.h-945/511)<.01);
assert(Math.abs(geo.image.w/geo.image.h-481/511)<.01);
assert(Math.abs(geo.badge.w/geo.badge.h-101/77)<.01);
assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
}
await p.setViewportSize({width:375,height:1400});await p.locator('.card-product').scrollIntoViewIfNeeded();
if(artifacts){
const top=await p.locator('.prod-title').evaluate(e=>e.getBoundingClientRect().top);
const end=await p.locator('.report-disclaimer').evaluate(e=>e.getBoundingClientRect().bottom);
await p.screenshot({path:path.join(artifacts,'report-bottom-'+tier+'.png'),clip:{x:0,y:Math.max(0,top-8),width:375,height:end-Math.max(0,top-8)+8}});
}
}
await p.evaluate(()=>window.__bottomTest(null,false));
assert.equal(await p.locator('.card-product').isVisible(),false);assert(await p.locator('#product-unavailable').isVisible());
assert(await p.locator('.card-care').isVisible());assert(await p.locator('.prod-title').isVisible());
assert.equal(await p.locator('#product-image').getAttribute('src'),null);
// Long actual advice must push the product down rather than overlap it.
await p.evaluate(()=>{window.__bottomTest(24,false);document.querySelector('#care-list').textContent='测试长建议。'.repeat(300);});
const safe=await p.evaluate(()=>document.querySelector('#care-list').getBoundingClientRect().bottom<=document.querySelector('.product-section-heading').getBoundingClientRect().top);assert(safe);
assert.deepEqual(errors,[]);console.log('PASS bottom: 3 selected products, 4 widths, exact image ratios, merged card, titles/disclaimer order, legacy recommendation immutable, missing/long content');
}finally{await browser.close();}