feat(h5): align third feedback with latest PSD and scanning screen
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,89 @@
|
||||
/* Third-round PSD, 2026-10-12. Measurements include text transforms.
|
||||
Native 248px WeChat header is excluded. Fixed text sizes/line breaks are
|
||||
intentional; flexible photo backgrounds and safe areas accommodate phones. */
|
||||
.splash-slogan {
|
||||
left: calc(50% - 1.041667px); right: auto;
|
||||
width: 249.305556px; height: 75.347222px;
|
||||
transform: translateX(-50%); text-shadow: none;
|
||||
top: max(6.838833%, calc(env(safe-area-inset-top) + 48px));
|
||||
}
|
||||
#s-splash .splash-slogan { top: max(6.838833%, calc(env(safe-area-inset-top) + 48px)); }
|
||||
.splash-bottom { bottom: max(5.882353%, env(safe-area-inset-bottom)); gap: calc(7.291667 * var(--u)); padding-bottom: 0; }
|
||||
.brand-row { margin-bottom: calc(10.416667 * var(--u)); gap: 0; }
|
||||
.brand-lockup { width: 179.513889px; height: 20.486111px; }
|
||||
#s-splash .btn-primary { display: flex; align-items: center; justify-content: center; width: calc(181.25 * var(--u)); height: calc(38.888889 * var(--u)); line-height: calc(38.888889 * var(--u)); border-radius: calc(6.944444 * var(--u)); }
|
||||
.splash-button-label { width: 55.902778px; height: 13.888889px; }
|
||||
.btn-history .splash-button-label { width: 56.597222px; height: 13.541667px; }
|
||||
|
||||
/* These two fixed-content dialogs retain the PSD text column on every phone.
|
||||
308px fits a 320px viewport without shrinking fonts or changing line breaks. */
|
||||
#p-privacy .popup, #p-photoguide .popup {
|
||||
width: 307.986111px; height: 313.194444px; min-height: 313.194444px;
|
||||
padding: 0; top: -42.881944px; border-radius: 8.680556px;
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
#p-privacy .popup-title, #p-photoguide .popup-title {
|
||||
position: absolute; margin: 0; font-size: 23.148148px; line-height: 28.935185px;
|
||||
letter-spacing: 0; white-space: nowrap;
|
||||
}
|
||||
#p-privacy .popup-title { left: 31.944444px; top: 35px; }
|
||||
#p-photoguide .popup-title { left: 37.5px; top: 61.388889px; text-align: left; }
|
||||
#p-privacy .popup-body, .guide-description {
|
||||
position: absolute; margin: 0;
|
||||
font-size: 11.574074px; line-height: 17.361111px; font-weight: 400; letter-spacing: 0;
|
||||
}
|
||||
#p-privacy .popup-body { left: 31.944444px; top: 109.027778px; width: 243.75px; }
|
||||
/* Text-frame advance includes side bearings beyond the PSD visible-ink bbox. */
|
||||
.guide-description { left: 37.847222px; top: 135.069444px; width: 237px; }
|
||||
.guide-description span { white-space: nowrap; }
|
||||
#p-privacy .privacy-row { position: absolute; left: 29.861111px; top: 184.375px; gap: 10.069444px; }
|
||||
#p-privacy .privacy-text { font-size: 10.127315px; line-height: 18.807870px; white-space: nowrap; }
|
||||
#p-privacy .privacy-link { font-family: "MiSans Demibold", var(--font); font-weight: 700; }
|
||||
#p-privacy .checkbox { width: 11.805556px; height: 12.152778px; }
|
||||
#p-privacy .popup-btn, #p-photoguide .popup-btn {
|
||||
position: absolute; left: 63.194444px; margin: 0;
|
||||
width: 181.25px; height: 38.888889px; line-height: 38.888889px;
|
||||
font-family: "MiSans Demibold", var(--font); font-size: 14.467593px; font-weight: 700;
|
||||
letter-spacing: 0; border-radius: 6.944444px;
|
||||
}
|
||||
#p-privacy .popup-btn { top: 239.930556px; }
|
||||
#p-photoguide .popup-btn { top: 219.791667px; }
|
||||
#p-privacy .popup-close, #p-photoguide .popup-close { top: 10.055556px; right: 10.055556px; width: 32px; height: 32px; }
|
||||
#p-privacy .popup-close img, #p-photoguide .popup-close img { width: 17.708333px; height: 17.361111px; }
|
||||
@media (max-height: 440px) {
|
||||
#p-privacy .popup, #p-photoguide .popup { top: 0; }
|
||||
}
|
||||
|
||||
/* PSD 984x503 card; text starts 77/78px in, image starts at x480.
|
||||
Fixed typography is preserved even at 320px; never center-crop the artwork. */
|
||||
.prod-title { font-size: 23.148148px; line-height: 31.828704px; margin-top: calc(19.583333 * var(--u)); letter-spacing: 0; }
|
||||
.card-product { width: calc(341.666667 * var(--u)); margin-left: calc(-0.333333 * var(--u)); border-radius: calc(5.208333 * var(--u)); }
|
||||
.prod-card { position: relative; height: calc(174.652778 * var(--u)); min-height: 0; text-rendering: geometricPrecision; }
|
||||
.prod-info { flex: 0 0 48.780488%; padding: 0; }
|
||||
#product-title {
|
||||
position: absolute; left: 26.736111px; top: calc(34.027778 * var(--u));
|
||||
margin: 0; font-size: 14.467593px; line-height: 15.071903px; font-weight: 600;
|
||||
}
|
||||
.prod-name {
|
||||
position: absolute; left: 26.736111px; top: calc(57.638889 * var(--u));
|
||||
margin: 0; font-size: 11.574074px; line-height: 17.361111px; font-weight: 600;
|
||||
}
|
||||
.prod-badge { position: absolute; left: 27.430556px; top: calc(115.277778 * var(--u)); width: calc(35.069444 * var(--u)); margin: 0; }
|
||||
.prod-scroll { flex: 0 0 51.219512%; }
|
||||
.prod-scroll img { width: 100%; height: 100%; aspect-ratio: auto; object-fit: fill; }
|
||||
|
||||
/* Exact static PSD waiting image. No fake progress or added scanning animation.
|
||||
Contain the original composition instead of stretching/cropping faces/icons. */
|
||||
#p-analyzing { background: #000; overflow: hidden; }
|
||||
.scan-artwork { width: 100%; height: 100%; object-fit: contain; }
|
||||
#p-analyzing:not(.scan-artwork-ready) .scan-artwork { visibility: hidden; }
|
||||
/* A failed/stalled image must not leave an unexplained black screen. The live
|
||||
status is visually hidden only once the exact PSD artwork has loaded. */
|
||||
.scan-status { position: absolute; top: 10.5%; left: 0; right: 0; color: #fff; text-align: center; font: 700 23.148148px/1.4 "MiSans Demibold",var(--font); }
|
||||
.scan-artwork-ready .scan-status { width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.scan-cancel {
|
||||
position: absolute; left: 50%; bottom: max(10px, env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%); padding: 10px 24px;
|
||||
color: #bbb; font: 12px/1.5 "PingFang SC","Microsoft YaHei",sans-serif;
|
||||
}
|
||||
.scan-cancel:focus-visible { outline: 1px solid var(--yellow); border-radius: 4px; }
|
||||
@@ -15,6 +15,7 @@
|
||||
<link rel="stylesheet" href="vendor/mobile-select/mobile-select.css">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<link rel="stylesheet" href="css/ui-20261009.css">
|
||||
<link rel="stylesheet" href="css/ui-20261012.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -42,14 +43,13 @@
|
||||
<section class="screen" id="s-splash">
|
||||
<div class="splash-bg"></div>
|
||||
<button class="account-status account-status-home" data-action="switch-login" aria-label="切换登录方式"><span data-auth-status></span><small>切换 ›</small></button>
|
||||
<p class="splash-slogan"><span class="splash-slogan-en">ONE ME,<br>FROM THE INSIDE OUT</span><br><span class="splash-slogan-zh">以我为始 自有表现</span></p>
|
||||
<img class="splash-slogan" src="assets/img/ui-20261012/splash-slogan.png" width="718" height="217" loading="lazy" alt="ONE ME,FROM THE INSIDE OUT;以我为始 自有表现">
|
||||
<div class="splash-bottom">
|
||||
<div class="brand-row">
|
||||
<img src="assets/img/ui-20261009/brand-logo.png" alt="1'me" class="brand-logo">
|
||||
<span class="brand-text">肌肤状态检测</span>
|
||||
<img src="assets/img/ui-20261012/brand-lockup.png" width="517" height="59" loading="lazy" alt="1'me 肌肤状态检测" class="brand-lockup">
|
||||
</div>
|
||||
<button class="btn btn-primary" data-action="open-privacy">开始检测</button>
|
||||
<button class="btn btn-primary btn-history" data-action="history">我的报告</button>
|
||||
<button class="btn btn-primary" data-action="open-privacy"><img class="splash-button-label" src="assets/img/ui-20261012/button-start-label.png" width="161" height="40" loading="lazy" alt="开始检测"></button>
|
||||
<button class="btn btn-primary btn-history" data-action="history"><img class="splash-button-label" src="assets/img/ui-20261012/button-history-label.png" width="163" height="39" loading="lazy" alt="我的报告"></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -289,7 +289,7 @@
|
||||
<img src="assets/img/ui-20261009/icon-close.png" alt="关闭">
|
||||
</button>
|
||||
<h2 class="popup-title">欢迎体验1'me<br>肌肤状态评估服务!</h2>
|
||||
<p class="popup-body">我们将采集您的面部影像,用于本次肌肤分析,并将报告保存至服务端,供您在“我的报告”中回看。</p>
|
||||
<p class="popup-body">我们将采集您的面部影像,为您生成专属肌肤分析与状态管理方案。所有数据严格保密,仅用于本次检测分析。</p>
|
||||
<label class="privacy-row" id="privacy-row">
|
||||
<span class="checkbox" id="privacy-check"></span>
|
||||
<span class="privacy-text">我已阅读并同意<a class="privacy-link" id="privacy-policy-link">相关隐私协议</a></span>
|
||||
@@ -301,13 +301,13 @@
|
||||
<!-- ═══════════ 弹窗:拍照引导 ═══════════ -->
|
||||
<!-- 背景只保留启动页底图 + Slogan(不叠档案表单),对齐设计稿 04 -->
|
||||
<div class="overlay overlay-guide" id="p-photoguide">
|
||||
<div class="guide-bg"><p class="splash-slogan">ONE ME,<br>FROM THE INSIDE OUT<br>以我为始 自有表现</p></div>
|
||||
<div class="guide-bg"><img class="splash-slogan" src="assets/img/ui-20261012/splash-slogan.png" width="718" height="217" loading="lazy" alt="ONE ME,FROM THE INSIDE OUT;以我为始 自有表现"></div>
|
||||
<div class="popup">
|
||||
<button class="popup-close" data-action="close-photoguide" aria-label="关闭">
|
||||
<img src="assets/img/ui-20261009/icon-close.png" alt="关闭">
|
||||
</button>
|
||||
<h2 class="popup-title popup-title-center">请拍摄或上传<br>正面及左右侧面照片</h2>
|
||||
<p class="guide-description">为使测试结果更准确,请关闭美颜/滤镜、并确保光线充足均匀。</p>
|
||||
<p class="guide-description"><span>为使测试结果更准确,请关闭美颜/滤镜、并确</span><br><span>保光线充足均匀。</span></p>
|
||||
<button class="btn btn-primary popup-btn" data-action="start-camera">开始</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -452,12 +452,12 @@
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ 分析中过渡 ═══════════ -->
|
||||
<div class="overlay overlay-dark" id="p-analyzing">
|
||||
<div class="analyzing">
|
||||
<div class="spinner"></div>
|
||||
<p>正在生成您的肌肤报告…</p>
|
||||
<p class="analyzing-tip">点击空白处取消</p>
|
||||
</div>
|
||||
<div class="overlay" id="p-analyzing" role="dialog" aria-modal="true" aria-label="正在生成肌肤报告">
|
||||
<!-- Original visible PSD layers, including decorative album/flip icons.
|
||||
Not camera controls: no action attributes, focus targets or file inputs. -->
|
||||
<img id="scan-artwork" class="scan-artwork" data-src="assets/img/ui-20261012/scan-waiting.webp" width="1080" height="2091" alt="" aria-hidden="true" draggable="false">
|
||||
<p class="scan-status" role="status">检测中…</p>
|
||||
<button class="scan-cancel" type="button" data-action="cancel-analysis">取消</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Toast ═══════════ -->
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
function showScreen(id, nav) { // nav: 'push' 新增历史 | 'replace' 替换当前历史 | 缺省不动历史
|
||||
if (id !== 's-login' && !AccountClient.user() && !(/[?&]mock=1/.test(location.search))) id = 's-login';
|
||||
if (['s-profile', 's-cam', 's-report'].includes(id)) loadAppFont();
|
||||
if (id === 's-profile' || id === 's-cam') prepareScanArtwork();
|
||||
document.querySelectorAll('.screen').forEach(function (s) {
|
||||
s.classList.toggle('active', s.id === id);
|
||||
});
|
||||
@@ -140,9 +141,19 @@
|
||||
}
|
||||
|
||||
/* ─── 弹窗管理 ─── */
|
||||
function prepareScanArtwork() {
|
||||
var image = $('#scan-artwork');
|
||||
if (!image.getAttribute('src') || (image.complete && !image.naturalWidth)) {
|
||||
image.onload = function () { $('#p-analyzing').classList.add('scan-artwork-ready'); };
|
||||
image.onerror = function () { $('#p-analyzing').classList.remove('scan-artwork-ready'); };
|
||||
image.src = image.dataset.src;
|
||||
}
|
||||
}
|
||||
function openOverlay(id) {
|
||||
if (id === 'p-privacy' || id === 'p-photoguide') loadAppFont();
|
||||
if (id === 'p-analyzing') prepareScanArtwork();
|
||||
$('#' + id).classList.add('show');
|
||||
if (id === 'p-analyzing') $('.scan-cancel').focus({ preventScroll: true });
|
||||
}
|
||||
function closeOverlay(id) { $('#' + id).classList.remove('show'); }
|
||||
|
||||
@@ -164,6 +175,10 @@
|
||||
if (e.key === 'Tab') { e.preventDefault(); $('[data-action="login-confirm"]').focus(); }
|
||||
return;
|
||||
}
|
||||
if ($('#p-analyzing').classList.contains('show')) {
|
||||
if (e.key === 'Tab') { e.preventDefault(); $('.scan-cancel').focus(); }
|
||||
return; // Preserve existing Escape behavior; do not focus camera controls.
|
||||
}
|
||||
if (e.key !== 'Escape' && e.key !== 'Esc') return;
|
||||
var order = ['p-zoom', 'p-policy', 'p-privacy', 'p-photoguide'];
|
||||
for (var i = 0; i < order.length; i++) {
|
||||
@@ -181,7 +196,7 @@
|
||||
}
|
||||
// 分析中弹层点遮罩取消:回档案页(mock 路径同样经 seq 丢弃)
|
||||
$('#p-analyzing').addEventListener('click', function (e) {
|
||||
if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片
|
||||
if (e.target !== this && !e.target.closest('[data-action="cancel-analysis"]')) return;
|
||||
leaveCamera();
|
||||
showScreen('s-profile');
|
||||
popHistorySilently();
|
||||
@@ -1352,7 +1367,10 @@
|
||||
var span = document.createElement('span'); span.className = 'product-line'; span.textContent = line;
|
||||
$('#product-name').appendChild(span);
|
||||
});
|
||||
$('#product-image').src = product.image;
|
||||
// Presentation snapshots retain historical copy/recommendations. Only the
|
||||
// artwork crop is refreshed by tier, without mutating stored report data.
|
||||
var productArt = ReportUI.products.filter(function (p) { return p.tier === product.tier; })[0];
|
||||
$('#product-image').src = productArt ? productArt.image : product.image;
|
||||
$('#product-image').alt = product.name + '双仓精华液';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-20261009/product-25.png' },
|
||||
{ tier: 2, match: '35+支撑精华', title: "1'me 主场精华 II", name: '以我自在有势挺括', image: 'assets/img/ui-20261009/product-35.png' },
|
||||
{ tier: 3, match: '50+重塑精华', title: "1'me 主场精华 III", name: '以我从容有态构塑', image: 'assets/img/ui-20261009/product-50.png' }
|
||||
{ tier: 1, match: '25+束紧精华', title: "1'me 主场精华 I", name: '以我本然有光蓄活', image: 'assets/img/ui-20261012/product-25.webp' },
|
||||
{ tier: 2, match: '35+支撑精华', title: "1'me 主场精华 II", name: '以我自在有势挺括', image: 'assets/img/ui-20261012/product-35.webp' },
|
||||
{ tier: 3, match: '50+重塑精华', title: "1'me 主场精华 III", name: '以我从容有态构塑', image: 'assets/img/ui-20261012/product-50.webp' }
|
||||
];
|
||||
var levelMap = { '预警': '改善', '改善': '良好', '良好': '优秀' };
|
||||
var dimNames = { firm: '紧塑力', vita: '透亮感', stab: '稳韧力' };
|
||||
|
||||
@@ -21,31 +21,27 @@ try {
|
||||
for (const width of [320, 375, 390, 414, 480]) {
|
||||
await page.setViewportSize({ width, height: 726 });
|
||||
const home = await page.evaluate(() => {
|
||||
const en = getComputedStyle(document.querySelector('.splash-slogan-en'));
|
||||
const zh = getComputedStyle(document.querySelector('.splash-slogan-zh'));
|
||||
const text = document.querySelector('#s-splash .brand-text');
|
||||
const style = getComputedStyle(text);
|
||||
const logo = document.querySelector('#s-splash .brand-logo').getBoundingClientRect();
|
||||
const lockup = document.querySelector('#s-splash .brand-lockup');
|
||||
const logo = lockup.getBoundingClientRect();
|
||||
const row = document.querySelector('#s-splash .brand-row').getBoundingClientRect();
|
||||
const slogan = document.querySelector('.splash-slogan').getBoundingClientRect();
|
||||
const sloganImage = document.querySelector('#s-splash .splash-slogan');
|
||||
const slogan = sloganImage.getBoundingClientRect();
|
||||
const badge = document.querySelector('.account-status-home').getBoundingClientRect();
|
||||
const ctx = document.createElement('canvas').getContext('2d');
|
||||
ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
||||
const metrics = ctx.measureText(text.textContent);
|
||||
return { en: en.fontWeight, zh: zh.fontWeight, size: style.fontSize, weight: style.fontWeight,
|
||||
logoHeight: logo.height, inkHeight: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent,
|
||||
return { logoHeight: logo.height, source: lockup.getAttribute('src'), alt: lockup.alt,
|
||||
logoRatio: logo.width / logo.height, rowX: row.x, rowRight: row.right,
|
||||
badgeBottom: badge.bottom, sloganTop: slogan.top,
|
||||
sloganLines: slogan.height / parseFloat(zh.lineHeight), overflow: document.documentElement.scrollWidth > innerWidth };
|
||||
sloganRatio: slogan.width / slogan.height, sloganAlt: sloganImage.alt,
|
||||
overflow: document.documentElement.scrollWidth > innerWidth };
|
||||
});
|
||||
assert.equal(home.en, '400'); assert.equal(home.zh, '700');
|
||||
assert.equal(home.size, '24px'); assert.equal(home.weight, '700');
|
||||
assert.equal(home.logoHeight, 26);
|
||||
assert(Math.abs(home.inkHeight - home.logoHeight) <= 4, 'logo/text optical height mismatch: ' + JSON.stringify(home));
|
||||
assert(Math.abs(home.logoRatio - 132 / 59) < .01, 'logo must retain original aspect ratio');
|
||||
// Third-round PSD replaces platform-dependent glyphs with the exact lockup.
|
||||
assert(home.source.endsWith('ui-20261012/brand-lockup.png'));
|
||||
assert.equal(home.alt, "1'me 肌肤状态检测");
|
||||
assert(Math.abs(home.logoHeight - 59 / 2.88) < .02);
|
||||
assert(Math.abs(home.logoRatio - 517 / 59) < .01, 'lockup must retain PSD aspect ratio');
|
||||
assert(Math.abs(home.sloganRatio - 718 / 217) < .01, 'three-line PSD slogan must retain its aspect ratio');
|
||||
assert(home.sloganAlt.includes('以我为始 自有表现'));
|
||||
assert(home.rowX >= 0 && home.rowRight <= width && !home.overflow, 'brand row overflows at ' + width);
|
||||
assert(home.badgeBottom <= home.sloganTop, 'status badge overlaps slogan at ' + width);
|
||||
assert(Math.abs(home.sloganLines - 3) < .1, 'slogan must retain three lines at ' + width);
|
||||
}
|
||||
await page.setViewportSize({ width: 375, height: 726 });
|
||||
if (artifacts) {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// Local UI + in-memory API fixtures only. No account/report writes or paid calls.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
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 base = process.env.H5_BASE_URL || 'http://127.0.0.1:8080/';
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const artifacts = process.env.H5_ARTIFACT_DIR;
|
||||
const widths = [320, 375, 390, 414, 480];
|
||||
const consent = '我们将采集您的面部影像,为您生成专属肌肤分析与状态管理方案。所有数据严格保密,仅用于本次检测分析。';
|
||||
const guideLines = ['为使测试结果更准确,请关闭美颜/滤镜、并确', '保光线充足均匀。'];
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'design/ui-20261012/manifest.json')));
|
||||
for (const asset of manifest.assets) {
|
||||
const bytes = fs.readFileSync(path.join(root, asset.file));
|
||||
assert.equal(crypto.createHash('sha256').update(bytes).digest('hex'), asset.sha256, asset.file);
|
||||
}
|
||||
const styleOf = key => manifest.text_styles.find(row => row.path === key);
|
||||
assert.equal(styleOf('0/4').runs[0].font, 'MiSans-Demibold');
|
||||
assert.equal(styleOf('0/5').runs[0].font, 'MiSans-Regular');
|
||||
assert.equal(styleOf('8/1/0/5').runs[0].font, 'MiSans-Semibold');
|
||||
assert.equal(styleOf('1/3/1').text, consent);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
|
||||
page.setDefaultTimeout(60000);
|
||||
const errors = [], requests = [], writes = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
page.on('request', request => { requests.push(new URL(request.url()).pathname); if (request.method() === 'POST') writes.push(request.url()); });
|
||||
await page.route('**/api/auth/session', route => route.fulfill({ json: { ok: true, user: { id: 'third-batch-fixture', kind: 'wechat' } } }));
|
||||
await page.route('**/vendor/faceapi/face-api.js', route => route.abort());
|
||||
await page.addInitScript(() => {
|
||||
navigator.mediaDevices.getUserMedia = () => Promise.reject(new DOMException('fixture denial', 'NotAllowedError'));
|
||||
});
|
||||
async function screenshot(name, selector) {
|
||||
if (!artifacts) return;
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
await (selector ? page.locator(selector) : page).screenshot({ path: path.join(artifacts, name + '.png'), animations: 'disabled' });
|
||||
}
|
||||
async function loadFonts() {
|
||||
await page.waitForFunction(() => [...document.fonts].some(font => /MiSans Demibold/.test(font.family)));
|
||||
await page.evaluate(async () => { await Promise.all([document.fonts.load('400 12px MiSans'), document.fonts.load('600 14px MiSans'), document.fonts.load('700 23px "MiSans Demibold"')]); });
|
||||
}
|
||||
async function rect(selector) { return page.locator(selector).evaluate(e => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; }); }
|
||||
async function actualLines(selector) {
|
||||
return page.locator(selector).evaluate(el => {
|
||||
const lines = [], walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
while ((node = walker.nextNode())) for (let i = 0; i < node.length; i++) {
|
||||
const range = document.createRange(); range.setStart(node, i); range.setEnd(node, i + 1);
|
||||
const r = range.getBoundingClientRect(); if (!r.width || !r.height) continue;
|
||||
let row = lines.find(row => Math.abs(row.y - r.y) < 1);
|
||||
if (!row) { row = { y: r.y, text: '' }; lines.push(row); }
|
||||
row.text += node.textContent[i];
|
||||
}
|
||||
return lines.map(row => row.text);
|
||||
});
|
||||
}
|
||||
try {
|
||||
await page.goto(base);
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.waitForLoadState('networkidle');
|
||||
assert(!requests.some(p => /\.woff2$|faceapi|scan-waiting|product-\d+\.webp/.test(p)), 'heavy/scanning assets must not compete with entry');
|
||||
await page.waitForFunction(() => document.querySelector('#s-splash .splash-slogan').naturalWidth === 718 && document.querySelector('.brand-lockup').naturalWidth === 517);
|
||||
await screenshot('01-home');
|
||||
await page.click('[data-action="open-privacy"]');
|
||||
await loadFonts();
|
||||
await page.waitForTimeout(350);
|
||||
assert.equal(await page.locator('#p-privacy .popup-body').textContent(), consent);
|
||||
const bodyLines = await actualLines('#p-privacy .popup-body');
|
||||
assert.deepEqual(bodyLines, ['我们将采集您的面部影像,为您生成专属肌肤分', '析与状态管理方案。所有数据严格保密,仅用于', '本次检测分析。']);
|
||||
for (const width of widths) {
|
||||
await page.setViewportSize({ width, height: 726 });
|
||||
const popup = await rect('#p-privacy .popup');
|
||||
assert(popup.x >= 0 && popup.right <= width);
|
||||
assert.deepEqual(await actualLines('#p-privacy .popup-body'), bodyLines, 'consent wrapping at ' + width);
|
||||
}
|
||||
const dialogRadius = await page.locator('#p-privacy .popup').evaluate(e => parseFloat(getComputedStyle(e).borderRadius));
|
||||
assert(Math.abs(dialogRadius - manifest.shape_metrics.find(row => row.path === '1/1').corner_radius_psd_px / 2.88) < .001);
|
||||
await page.setViewportSize({ width: 375, height: 726 });
|
||||
await screenshot('02-consent');
|
||||
await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
|
||||
await page.waitForSelector('#s-profile.active');
|
||||
assert(requests.some(p => p.endsWith('scan-waiting.webp')), 'preload waiting image only after profile entry');
|
||||
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
|
||||
await page.click(selector); await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(400);
|
||||
}
|
||||
await page.click('[data-action="submit-profile"]');
|
||||
await page.waitForTimeout(350);
|
||||
for (const width of widths) {
|
||||
await page.setViewportSize({ width, height: 726 });
|
||||
assert.deepEqual(await actualLines('.guide-description'), guideLines, 'exact PSD guide lines at ' + width);
|
||||
const popup = await rect('#p-photoguide .popup'), description = await rect('.guide-description');
|
||||
assert(popup.x >= 0 && popup.right <= width && description.right <= popup.right);
|
||||
assert.equal(await page.locator('.guide-description').evaluate(e => e.scrollWidth <= e.clientWidth), true);
|
||||
await screenshot('03-guide-' + width);
|
||||
}
|
||||
// The real capture/upload progression with held, intercepted analysis response.
|
||||
await page.setViewportSize({ width: 375, height: 726 });
|
||||
let pending;
|
||||
await page.route('**/api/skin-analyze', route => { pending = route; });
|
||||
async function captureThree() {
|
||||
await page.click('[data-action="start-camera"]');
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await page.waitForFunction(n => document.querySelector('#cam-step').textContent.includes(n + '/3'), i);
|
||||
await page.waitForTimeout(1600);
|
||||
await page.setInputFiles('#cam-file', path.join(root, 'reference/facepp-skinanalyze/examples/images/C3_180.jpg'));
|
||||
}
|
||||
await page.waitForSelector('#p-analyzing.show');
|
||||
await page.waitForFunction(() => document.querySelector('#scan-artwork').naturalWidth === 1080);
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!pending && Date.now() < deadline) await page.waitForTimeout(20);
|
||||
assert(pending, 'analysis request must be intercepted within 5 seconds');
|
||||
}
|
||||
await captureThree();
|
||||
assert.equal(await page.locator('.scan-status').evaluate(e => getComputedStyle(e).clipPath), 'inset(50%)', 'loaded PSD text must not be duplicated');
|
||||
const scanPixels = await page.locator('#scan-artwork').evaluate(image => {
|
||||
const canvas = document.createElement('canvas'); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight;
|
||||
const context = canvas.getContext('2d'); context.drawImage(image, 0, 0);
|
||||
return [[540, 720], [540, 1200]].map(([x, y]) => Array.from(context.getImageData(x, y, 1, 1).data));
|
||||
});
|
||||
assert(scanPixels[0].slice(0, 3).every(value => value > 245), 'visible PSD face must not be lost to clipping');
|
||||
assert(scanPixels[1][0] > 220 && scanPixels[1][1] > 220 && scanPixels[1][2] < 150, 'yellow lower face must be visible');
|
||||
assert.equal(await page.locator('#p-analyzing [data-action]').count(), 1, 'only cancel is actionable; PSD icons decorative');
|
||||
for (const [width, height] of [[320, 726], [375, 726], [390, 844], [414, 896], [480, 726], [375, 440]]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
const art = await rect('#scan-artwork');
|
||||
assert.equal(art.width, width); assert.equal(art.height, height);
|
||||
assert.equal(await page.locator('#scan-artwork').evaluate(e => getComputedStyle(e).objectFit), 'contain');
|
||||
assert(await page.locator('.scan-cancel').isVisible());
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
}
|
||||
await page.setViewportSize({ width: 375, height: 726 });
|
||||
await screenshot('04-waiting');
|
||||
for (const point of [[70, 655], [306, 655], [180, 350]]) await page.mouse.click(...point);
|
||||
assert(await page.locator('#p-analyzing').isVisible(), 'icons and face must not cancel/open camera');
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.evaluate(() => document.activeElement.dataset.action), 'cancel-analysis');
|
||||
await page.keyboard.press('Escape');
|
||||
assert(await page.locator('#p-analyzing').isVisible(), 'Escape must retain existing waiting behavior');
|
||||
await page.click('[data-action="cancel-analysis"]');
|
||||
await page.waitForSelector('#s-profile.active');
|
||||
await pending.fulfill({ json: { ok: false, message: '迟到的失败不应影响页面' } }); pending = null;
|
||||
await page.waitForTimeout(1400);
|
||||
assert.equal(await page.locator('#p-analyzing.show').count(), 0);
|
||||
assert(!(await page.locator('#toast').textContent()).includes('迟到'));
|
||||
assert(await page.locator('#f-birth').inputValue());
|
||||
// Re-entry/failure must still dismiss the new artwork and retain the profile.
|
||||
await page.click('[data-action="submit-profile"]'); await captureThree();
|
||||
await pending.fulfill({ json: { ok: false, message: '未检测到人脸' } }); pending = null;
|
||||
await page.waitForSelector('#s-profile.active');
|
||||
assert.equal(await page.locator('#p-analyzing.show').count(), 0);
|
||||
assert((await page.locator('#toast').textContent()).includes('未检测到人脸'));
|
||||
// Successful completion follows the same request callback into the report.
|
||||
await page.click('[data-action="submit-profile"]'); await captureThree();
|
||||
const success = await page.evaluate(() => ({ ok: true, result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE, report_saved: true }));
|
||||
await pending.fulfill({ json: success }); pending = null;
|
||||
await page.waitForSelector('#s-report.active');
|
||||
assert.equal(await page.locator('#p-analyzing.show').count(), 0);
|
||||
assert.notEqual(await page.locator('#stat-age b').textContent(), '--');
|
||||
await page.unroute('**/api/skin-analyze');
|
||||
assert.equal(writes.length, 3, 'only three intercepted analysis POSTs; no auth/report mutations');
|
||||
|
||||
for (const [age, tier] of [[24, 1], [40, 2], [60, 3]]) {
|
||||
await page.route('**/assets/mock/report-fixture.js', async route => {
|
||||
const response = await route.fetch();
|
||||
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]) + '.webp'));
|
||||
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);
|
||||
const card = await rect('.card-product');
|
||||
assert(Math.abs(card.width - 984 / 1080 * width) < .1, 'exact PSD card width');
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
}
|
||||
await page.setViewportSize({ width: 375, height: 726 }); await screenshot('05-product-' + tier, '.card-product');
|
||||
await page.unroute('**/assets/mock/report-fixture.js');
|
||||
}
|
||||
// Font downloads may fail on a phone: explicit guide lines still must not
|
||||
// reflow or escape the dialog, and short viewports must keep controls visible.
|
||||
const fallback = await browser.newPage({ viewport: { width: 375, height: 726 } });
|
||||
await fallback.route('**/*.woff2', route => route.abort());
|
||||
await fallback.route('**/scan-waiting.webp', route => route.abort());
|
||||
await fallback.route('**/api/auth/session', route => route.fulfill({ json: { ok: true, user: { id: 'font-fallback-fixture', kind: 'browser' } } }));
|
||||
await fallback.goto(base); await fallback.click('[data-action="open-privacy"]');
|
||||
await fallback.waitForLoadState('networkidle');
|
||||
await fallback.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
document.querySelector('#p-privacy').classList.remove('show');
|
||||
document.querySelector('#p-photoguide').classList.add('show');
|
||||
});
|
||||
for (const width of widths) {
|
||||
await fallback.setViewportSize({ width, height: 440 });
|
||||
assert(await fallback.locator('#p-photoguide').evaluate(el => {
|
||||
const box = el.querySelector('.popup').getBoundingClientRect();
|
||||
return box.top >= 0 && box.bottom <= innerHeight && box.left >= 0 && box.right <= innerWidth &&
|
||||
[...el.querySelectorAll('.guide-description span')].every(span => span.getBoundingClientRect().right <= box.right);
|
||||
}), 'font fallback/short viewport overflow at ' + width);
|
||||
assert(await fallback.locator('[data-action="start-camera"]').isVisible());
|
||||
}
|
||||
await fallback.evaluate(() => { document.querySelector('#p-photoguide').classList.remove('show'); document.querySelector('#p-privacy').classList.add('show'); });
|
||||
await fallback.click('#privacy-check'); await fallback.click('[data-action="agree-continue"]');
|
||||
await fallback.waitForLoadState('networkidle');
|
||||
await fallback.evaluate(() => document.querySelector('#p-analyzing').classList.add('show'));
|
||||
assert.equal(await fallback.locator('#p-analyzing.scan-artwork-ready').count(), 0);
|
||||
assert.equal(await fallback.locator('.scan-status').evaluate(e => getComputedStyle(e).clipPath), 'none', 'failed image must leave a visible text status');
|
||||
assert.equal(await fallback.locator('#scan-artwork').evaluate(e => getComputedStyle(e).visibility), 'hidden');
|
||||
await fallback.click('[data-action="cancel-analysis"]');
|
||||
assert.equal(await fallback.locator('#p-analyzing.show').count(), 0, 'image failure must not disable cancel');
|
||||
await fallback.close();
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: PSD source hashes/fonts/copy; no heavy entry assets; fixed consent/guide wrapping in five widths; exact product crops/padding/Semibold/all tiers; actual three-photo waiting transition; visible clipped face; decorative icons/focus; cancel/stale/failure/retry/success; six viewport sizes; no backend writes or JS errors');
|
||||
} finally { await browser.close(); }
|
||||
@@ -146,7 +146,7 @@ try {
|
||||
const record = JSON.parse(fs.readFileSync(recordFile, 'utf8').trim());
|
||||
const snapshotFile = path.join(dataRoot, 'accounts/reports', record.owner_id, result.record_id + '.json');
|
||||
const legacy = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
|
||||
legacy.presentation.product = ReportUI.products[1];
|
||||
legacy.presentation.product = { ...ReportUI.products[1], image: 'assets/img/ui-20261009/product-35.png' };
|
||||
legacy.presentation.views[0].text = '历史报告原文,不应按新文案重新计算。';
|
||||
legacy.vm.essence = { name: '35+支撑精华', text: '旧版推荐', triggers: ['最差项约束'] };
|
||||
delete legacy.vm.recommendation;
|
||||
@@ -162,6 +162,7 @@ try {
|
||||
await page.click('[data-report-id]');
|
||||
await page.waitForSelector('#s-report.active');
|
||||
assert.equal(await page.locator('#product-title').textContent(), ReportUI.products[1].title);
|
||||
assert.equal(await page.locator('#product-image').getAttribute('src'), ReportUI.products[1].image, 'refresh artwork crop, never the historical recommendation/copy');
|
||||
assert((await page.locator('.ditem-para').allTextContents()).includes(legacy.presentation.views[0].text));
|
||||
await desktop.goto(base + 'admin.html');
|
||||
await desktop.locator('#tbl-records tbody tr').first().click();
|
||||
|
||||