feat(auth): clarify login identity and speed up mobile entry

This commit is contained in:
xsl
2026-10-12 11:01:49 +08:00
parent 85790ef985
commit d2931a41e4
28 changed files with 1903 additions and 1046 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

+3
View File
@@ -0,0 +1,3 @@
/* Original MiSans files, unchanged; never needed for login/session restoration. */
@font-face { font-family: MiSans; src: url(../assets/fonts/misans/MiSans-Regular.woff2) format('woff2'); font-weight: 100 500; font-display: swap; }
@font-face { font-family: MiSans; src: url(../assets/fonts/misans/MiSans-Semibold.woff2) format('woff2'); font-weight: 600 900; font-display: swap; }
+22 -8
View File
@@ -1,9 +1,12 @@
/* 10.09 PSD: exclude the native WeChat header; retain flexible H5 viewport. */
@font-face { font-family: MiSans; src: url(../assets/fonts/misans/MiSans-Regular.woff2) format('woff2'); font-weight: 100 500; font-display: swap; }
@font-face { font-family: MiSans; src: url(../assets/fonts/misans/MiSans-Semibold.woff2) format('woff2'); font-weight: 600 900; font-display: swap; }
/* Font faces are registered lazily by app.js on entering profile/camera/report.
Hidden wheel-picker text can otherwise trigger font downloads at startup. */
:root { --font: "MiSans","PingFang SC","Microsoft YaHei",sans-serif; }
[hidden] { display: none !important; }
#s-login { background: #142632 url(../assets/img/ui-20261009/bg-login-h5.jpg) center/cover no-repeat; }
/* Keep the original licensed font intact. Entry/account screens use system fonts
so login never competes with the two full Chinese font downloads (~9.5 MB). */
#s-login, #s-splash, #s-history, #p-login-success, #toast { --font: "PingFang SC","Microsoft YaHei",system-ui,sans-serif; font-family: var(--font); }
#s-login { background: #142632 url(../assets/img/ui-20261009/bg-login-h5.webp) center/cover no-repeat; }
.login-brand { position: absolute; width: calc(145 * var(--u)); left: 50%; top: 14%; transform: translateX(-50%); }
.login-bottom { position: absolute; left: 0; right: 0; bottom: max(5%, env(safe-area-inset-bottom)); display: flex; flex-direction: column; align-items: center; gap: calc(12 * var(--u)); }
.login-button { width: calc(270 * var(--u)); height: calc(40 * var(--u)); display: flex; align-items: center; justify-content: center; gap: calc(14 * var(--u)); border-radius: calc(5 * var(--u)); font-size: calc(14 * var(--u)); }
@@ -14,16 +17,16 @@
.login-consent input { accent-color: var(--yellow); width: 13px; height: 13px; }
.login-consent button { color: var(--yellow); text-decoration: underline; font-size: inherit; }
.login-message { min-height: calc(28 * var(--u)); max-width: 84%; color: #eee; text-align: center; font-size: calc(10 * var(--u)); line-height: 1.5; }
.splash-bg, .guide-bg { background-image: url(../assets/img/ui-20261009/bg-splash-h5.jpg); }
.splash-bg, .guide-bg { background-image: url(../assets/img/ui-20261009/bg-splash-h5.webp); }
.splash-slogan { top: 7.2%; font-size: calc(23 * var(--u)); line-height: 1.16; letter-spacing: 0; font-weight: 400; }
.splash-bottom { bottom: 6.5%; gap: calc(9 * var(--u)); }
.brand-row { margin-bottom: calc(7 * var(--u)); gap: calc(5 * var(--u)); }
.brand-logo { width: calc(48 * var(--u)); }
.brand-text { font-size: calc(19 * var(--u)); letter-spacing: 0; }
.btn-history { background: #fff; }
.profile-bg { background: url(../assets/img/ui-20261009/bg-profile-h5.jpg) center/cover no-repeat; }
.profile-bg { background: url(../assets/img/ui-20261009/bg-profile-h5.webp) center/cover no-repeat; }
.skin-desc { max-width: calc(150 * var(--u)); font-size: calc(11.5 * var(--u)); }
#s-history { background: #efefef url(../assets/img/ui-20261009/bg-history-h5.jpg) center/cover fixed; }
#s-history { background: #efefef url(../assets/img/ui-20261009/bg-history-h5.webp) center/cover fixed; }
.history-content { padding: calc(28 * var(--u)) calc(17 * var(--u)) calc(36 * var(--u)); }
.history-count { text-align: center; padding: calc(16 * var(--u)) calc(12 * var(--u)) calc(21 * var(--u)); }
.history-count > span { color: #888; font-size: calc(12 * var(--u)); }
@@ -35,7 +38,7 @@
.history-item p span { color: #888; margin-right: calc(8 * var(--u)); }
.history-detail { display: block; margin: calc(7 * var(--u)) 0 0 auto; background: var(--yellow); border-radius: calc(5 * var(--u)); width: calc(80 * var(--u)); font-size: calc(12 * var(--u)); min-height: calc(23 * var(--u)); }
.history-retry { display: block; margin: 20px auto; color: #707070; text-decoration: underline; font-size: 12px; }
#s-report { background: #efefef url(../assets/img/ui-20261009/bg-report-h5.jpg) center top/100% auto; }
#s-report { background: #efefef url(../assets/img/ui-20261009/bg-report-h5.webp) center top/100% auto; }
.report-body { padding: calc(28 * var(--u)) calc(17 * var(--u)) calc(42 * var(--u)); }
.report-title { font-size: calc(23 * var(--u)); padding: 0; margin-bottom: calc(23 * var(--u)); }
.card { border-radius: calc(7 * var(--u)); }
@@ -74,11 +77,22 @@
.prod-badge { width: calc(36 * var(--u)); margin-top: calc(20 * var(--u)); }
.prod-scroll { flex-basis: 50%; }
.prod-scroll img { object-fit: cover; aspect-ratio: 1 / 1; }
#p-privacy { background: linear-gradient(#0008,#0008),url(../assets/img/ui-20261009/bg-splash-h5.jpg) center/cover; }
#p-privacy { background: linear-gradient(#0008,#0008),url(../assets/img/ui-20261009/bg-splash-h5.webp) center/cover; }
#p-photoguide .popup { padding: calc(62 * var(--u)) calc(30 * var(--u)) calc(40 * var(--u)); }
.popup-title-center { text-align: left; margin: 0 0 calc(18 * var(--u)); }
.guide-description { font-size: calc(12 * var(--u)); line-height: 1.6; margin-bottom: calc(35 * var(--u)); }
.cam-feature-lines { fill: none; stroke: #fff; stroke-width: 2.5; stroke-dasharray: 5 5; filter: drop-shadow(0 1px 1px #000); }
#s-cam:not(.cam-live) .cam-feature-lines { stroke: #111; filter: none; }
.font-credit { display: block; text-align: center; color: #888; font-size: 10px; margin: 24px auto 0; }
.account-status { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-radius: 8px; font-size: 12px; }
.account-status span::before { content: ''; display: inline-block; width: 7px; height: 7px; margin-right: 7px; border-radius: 50%; background: #b7bac0; }
.account-status[data-kind="wechat"] span::before { background: #34ca7b; }
.account-status small { font-size: 11px; opacity: .8; }
.account-status-home { position: absolute; top: max(10px, env(safe-area-inset-top)); right: 12px; z-index: 2; background: #0008; color: #fff; }
#s-splash .splash-slogan { top: max(7.2%, calc(env(safe-area-inset-top) + 52px)); }
.account-status-history { width: 100%; background: #fff; margin-bottom: 14px; text-align: left; }
.login-success-popup { min-height: 0; text-align: center; gap: 18px; align-items: center; padding: 30px 24px; }
.login-success-icon { width: 48px; height: 48px; border-radius: 50%; background: #e9f8ef; color: #17994f; font-size: 30px; line-height: 48px; }
.login-success-popup h2 { font-size: 20px; font-weight: 600; }
.login-success-popup p { font-size: 14px; line-height: 1.7; color: #666; white-space: pre-line; }
@media (max-height: 560px) { .login-brand { top: 8%; } .login-bottom { bottom: 2%; } .splash-slogan { font-size: calc(18 * var(--u)); } }
+12 -3
View File
@@ -11,7 +11,7 @@
<meta property="og:description" content="拍摄正面与左右侧面照片,即时获取六维肌肤状态分析与个性化护理建议。">
<title>1'me 在线肌肤测试</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="preload" as="image" href="assets/img/bg-splash.jpg">
<link rel="preload" as="image" href="assets/img/ui-20261009/bg-login-h5.webp" fetchpriority="high">
<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">
@@ -31,16 +31,17 @@
<section class="screen" id="s-history">
<div class="history-content">
<h1 class="report-title">您的报告</h1>
<button class="account-status account-status-history" data-action="switch-login" aria-label="切换登录方式"><span data-auth-status></span><small>切换登录方式 ›</small></button>
<div class="card history-count"><span>测试数量</span><p id="history-count">正在读取报告…</p></div>
<p class="identity-note" id="identity-note"></p>
<div id="history-list" aria-live="polite"></div>
<button class="history-retry" data-action="history-retry">刷新列表</button>
<button class="history-retry" data-action="switch-login">切换登录方式</button>
<small class="font-credit">本应用使用 MiSans 字体</small>
</div>
</section>
<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">ONE ME,<br>FROM THE INSIDE OUT<br>以我为始 自有表现</p>
<div class="splash-bottom">
<div class="brand-row">
@@ -461,7 +462,15 @@
<!-- ═══════════ Toast ═══════════ -->
<div class="toast" id="toast"></div>
<script src="vendor/faceapi/face-api.js" defer></script>
<div class="overlay" id="p-login-success" role="dialog" aria-modal="true" aria-labelledby="login-success-title" aria-describedby="login-success-message">
<div class="popup login-success-popup">
<div class="login-success-icon" aria-hidden="true">✓</div>
<h2 id="login-success-title">登录成功</h2>
<p id="login-success-message"></p>
<button class="btn btn-primary" data-action="login-confirm">我知道了</button>
</div>
</div>
<script src="js/head-pose.js" defer></script>
<script src="js/skin-score.js" defer></script>
<script src="vendor/mobile-select/mobile-select.iife.min.js" defer></script>
+9 -1
View File
@@ -5,11 +5,19 @@ window.AccountClient = (function () {
function read(name) { try { return localStorage.getItem(name || key); } catch (_) { return null; } }
function rememberKind() { try { if (user) localStorage.setItem(kindKey, user.kind); } catch (_) {} }
function request(url, data) {
var controller = new AbortController();
// Bound only authentication waits. Full report snapshots contain photos and
// must retain their existing download budget on a slow mobile connection.
var timer = url.indexOf('api/auth/') === 0 ? setTimeout(function () { controller.abort(); }, 12000) : null;
return fetch(url, { credentials: 'same-origin', method: data ? 'POST' : 'GET',
signal: controller.signal,
headers: data ? { 'Content-Type': 'application/json' } : {},
body: data ? JSON.stringify(data) : undefined }).then(function (r) {
return r.json().then(function (v) { if (!r.ok || !v.ok) throw new Error(v.message || '请求失败'); return v; });
});
}).catch(function (error) {
if (error.name === 'AbortError') throw new Error('网络响应超时,请检查网络后重试');
throw error;
}).finally(function () { clearTimeout(timer); });
}
function browser(consent) {
return request('api/auth/browser', { consent: consent, token: read() }).then(function (v) {
+75 -24
View File
@@ -32,8 +32,38 @@
/* ─── 屏幕管理:页面内不放返回按钮,前进时 pushState,微信/浏览器返回键经 popstate 回到上一屏 ─── */
var currentScreen = 's-login';
var fontStylesLoaded = false;
function loadAppFont() {
if (fontStylesLoaded) return;
fontStylesLoaded = true;
var link = document.createElement('link');
link.rel = 'stylesheet'; link.href = 'css/fonts.css';
document.head.appendChild(link);
}
function renderAccountStatus() {
var user = AccountClient.user();
document.querySelectorAll('[data-auth-status]').forEach(function (label) {
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : '本机浏览器已登录') : '尚未登录';
label.parentElement.dataset.kind = user ? user.kind : '';
});
}
function loginSucceeded() {
showScreen('s-splash', 'replace');
$('#login-success-message').textContent = AccountClient.user().kind === 'wechat'
? '当前使用:微信登录\n同一微信账号可跨手机查看报告。'
: '当前使用:本机浏览器登录\n报告仅能通过当前浏览器身份查看。';
openOverlay('p-login-success');
document.querySelectorAll('.screen').forEach(function (screen) { screen.inert = true; });
$('[data-action="login-confirm"]').focus();
}
function closeLoginSuccess() {
closeOverlay('p-login-success');
document.querySelectorAll('.screen').forEach(function (screen) { screen.inert = false; });
$('#s-splash [data-action="open-privacy"]').focus({ preventScroll: true });
}
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();
document.querySelectorAll('.screen').forEach(function (s) {
s.classList.toggle('active', s.id === id);
});
@@ -42,6 +72,7 @@
document.body.scrollTop = 0;
sc.scrollTop = 0;
currentScreen = id;
renderAccountStatus();
if (id === 's-report') resetReport(); // 每次进入报告页都还原初始状态
if (nav === 'push') history.pushState({ screen: id }, '');
else if (nav === 'replace') history.replaceState({ screen: id }, '');
@@ -52,6 +83,7 @@
window.addEventListener('popstate', function (e) {
if (Date.now() < popSilentUntil) { popSilentUntil = 0; return; }
var to = (e.state && e.state.screen) || 's-splash';
if ($('#p-login-success').classList.contains('show')) closeLoginSuccess();
['p-privacy', 'p-photoguide', 'p-policy', 'p-zoom'].forEach(closeOverlay);
if (currentScreen === 's-cam') leaveCamera();
if (to === 's-cam') { to = 's-profile'; history.replaceState({ screen: to }, ''); } // 「前进」回拍照页不自动开相机
@@ -86,6 +118,11 @@
bindBackdropClose('p-policy');
// ESC 关闭(分析中过渡不响应;放大图/协议层在最上,优先关)
document.addEventListener('keydown', function (e) {
if ($('#p-login-success').classList.contains('show')) {
if (e.key === 'Escape' || e.key === 'Esc') closeLoginSuccess();
if (e.key === 'Tab') { e.preventDefault(); $('[data-action="login-confirm"]').focus(); }
return;
}
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++) {
@@ -323,7 +360,7 @@
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框,快门出现
toast('已切换为系统相机拍摄');
// 系统相机拍回的照片也要校验(有脸、角度对),模型照常预加载
if (!IS_MOCK && typeof faceapi !== 'undefined') loadModels().catch(function () {});
if (!IS_MOCK) loadModels().catch(function () {});
}
// 从视频帧截取照片(完整帧、摄像头原始视角,压到最长边 1280 的 JPEG)
// 抓完整帧而非取景框可见区:新 UI 轮廓外全黑,用户只关注轮廓内人脸;按可见区裁切在侧脸/偏站位时会切掉人脸(Face++ INVALID_IMAGE_FACE)
@@ -545,7 +582,28 @@
var CENTER_TOL = 0.18; // 人脸中心偏离取景中线超过屏宽 18% → 提示移入取景框
var LUMA_DARK = 65, LUMA_BRIGHT = 235; // 脸部平均亮度(0–255):过暗/过曝提示调整光线
var modelsPromise = null, modelsOk = false, detectDisabled = false; // detectDisabled:face-api 缺失→全程手动;模型加载失败仅当次禁用,下次进拍照页重试
var modelsPromise = null, modelsOk = false, detectDisabled = false;
var faceApiPromise = null;
function loadFaceApi() {
if (window.faceapi) return Promise.resolve();
if (!faceApiPromise) {
faceApiPromise = new Promise(function (resolve, reject) {
var script = document.createElement('script');
var timer = setTimeout(function () { finish(new Error('检测引擎加载超时')); }, 20000);
function finish(error) {
clearTimeout(timer);
script.onload = script.onerror = null;
if (error) { script.remove(); faceApiPromise = null; reject(error); }
else resolve();
}
script.src = 'vendor/faceapi/face-api.js';
script.onload = function () { finish(window.faceapi ? null : new Error('检测引擎不可用')); };
script.onerror = function () { finish(new Error('检测引擎加载失败')); };
document.head.appendChild(script);
});
}
return faceApiPromise;
}
var detRunning = false, detTimer = null; // 检测循环
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
var nudgeTimer = null; // 12s 无进展提示
@@ -555,10 +613,10 @@
// 模型只加载一次(缓存 promise);失败清空缓存,下次进拍照页重试
function loadModels() {
if (!modelsPromise) {
modelsPromise = Promise.all([
modelsPromise = loadFaceApi().then(function () { return Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL)
]).then(function () { modelsOk = true; },
]); }).then(function () { modelsOk = true; },
function (err) { modelsPromise = null; throw err; });
}
return modelsPromise;
@@ -1206,12 +1264,6 @@
/* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */
function beginDetect() {
if (IS_MOCK || camFallback) { resetDetectState(); return; }
if (typeof faceapi === 'undefined') {
detectDisabled = true; // face-api 本身缺失:全程手动,不再重试
resetDetectState();
toast('自动识别不可用,已切换手动拍摄');
return;
}
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
resetDetectState();
if (!modelsOk) showCamStatus('检测引擎加载中…');
@@ -1265,6 +1317,7 @@
'open-policy': function () { openOverlay('p-policy'); },
'login-browser': function () { loginBrowser(); },
'login-wechat': function () { loginWechat(); },
'login-confirm': closeLoginSuccess,
'open-privacy': function () { if (!AccountClient.user()) { showScreen('s-login', 'replace'); return; } openOverlay('p-privacy'); },
'close-privacy': function () { closeOverlay('p-privacy'); },
'close-policy': function () { closeOverlay('p-policy'); },
@@ -1370,25 +1423,20 @@
loginBusy = true;
$('#login-message').textContent = '正在登录…';
AccountClient.browser(true).then(function () {
showScreen('s-splash', 'replace');
loginSucceeded();
}).catch(function (e) { $('#login-message').textContent = e.message; })
.finally(function () { loginBusy = false; });
}
function loginWechat() {
if (!loginAllowed()) return;
if (!/MicroMessenger/i.test(navigator.userAgent)) {
$('#login-message').textContent = '请在微信内打开本页面,或使用本机浏览器登录。';
return;
}
loginBusy = true;
AccountClient.request('api/auth/config').then(function (data) {
if (!data.wechat) {
$('#login-message').textContent = data.reason + '。报告仍会保存至服务端。';
return;
}
if (!/MicroMessenger/i.test(navigator.userAgent)) {
$('#login-message').textContent = '请在微信内打开本页面,或使用本机浏览器登录。';
return;
}
return AccountClient.request('api/auth/wechat', { consent: true }).then(function (data) {
location.assign(data.url);
});
$('#login-message').textContent = '正在前往微信授权…';
AccountClient.request('api/auth/wechat', { consent: true }).then(function (data) {
location.assign(data.url);
}).catch(function (e) { $('#login-message').textContent = e.message; })
.finally(function () { loginBusy = false; });
}
@@ -1446,7 +1494,10 @@
: '微信授权未完成,请重试;如仍失败,请将错误页面截图发给管理员。也可选择本机浏览器登录。';
} else if (loginSuccess && (!AccountClient.user() || AccountClient.user().kind !== 'wechat')) {
$('#login-message').textContent = '微信返回后登录会话未能保存,请在同一微信窗口重新登录。';
} else if (AccountClient.user()) showScreen('s-splash', 'replace');
} else if (AccountClient.user()) {
if (loginSuccess) loginSucceeded();
else showScreen('s-splash', 'replace');
}
else $('#login-message').textContent = '微信登录可跨手机查看报告;本机登录仅限当前浏览器。';
}).catch(function (e) { $('#login-message').textContent = e.message; })
.finally(function () {
+22 -5
View File
@@ -13,6 +13,8 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const zlib = require('zlib');
const PORT = Number(process.env.PORT) || 8080;
// 只绑回环:公网入口是 nginx(1me.xajuzi.com/state/),8080 不再对公网裸奔;需局域网调试时 HOST=0.0.0.0
@@ -84,11 +86,26 @@ function serveStatic(req, res, pathname) {
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); }
const ext = path.extname(file);
// html/css/js 不缓存(迭代期样式/脚本改动必须即时到端;图片/字体/音频走默认)
const headers = { 'Content-Type': MIME[ext] || 'application/octet-stream' };
if (['.html', '.css', '.js'].includes(ext)) headers['Cache-Control'] = 'no-cache';
res.writeHead(200, headers);
res.end(data);
// Revalidate every time: updates remain immediate; unchanged resources use
// 304 instead of downloading again. APIs/private reports never enter here.
const headers = { 'Content-Type': MIME[ext] || 'application/octet-stream',
'Cache-Control': 'no-cache', ETag: 'W/"' + crypto.createHash('sha256').update(data).digest('hex') + '"' };
const compressible = /\.(html|css|js|json|svg)$/.test(file);
if (compressible) headers.Vary = 'Accept-Encoding';
if ((req.headers['if-none-match'] || '').split(/\s*,\s*/).includes(headers.ETag)) {
res.writeHead(304, headers); return res.end();
}
// Compress static public text only, not authenticated JSON or photographs.
const gzipAccepted = (req.headers['accept-encoding'] || '').split(',').some(value => {
const parts = value.trim().split(';');
return parts[0] === 'gzip' && !parts.slice(1).some(p => /^\s*q=0(?:\.0*)?\s*$/.test(p));
});
if (compressible && data.length > 1024 && gzipAccepted) {
zlib.gzip(data, (error, compressed) => {
if (!error) headers['Content-Encoding'] = 'gzip';
res.writeHead(200, headers); res.end(error ? data : compressed);
});
} else { res.writeHead(200, headers); res.end(data); }
});
}
+3
View File
@@ -22,6 +22,7 @@ await page.addInitScript(() => {
});
try {
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.addScriptTag({ url: base + 'vendor/faceapi/face-api.js' });
// Real models from production paths, actual landmark inference on the sample image.
const detected = await page.evaluate(async data => {
await Promise.all([
@@ -35,11 +36,13 @@ try {
assert(detected && detected.landmarks === 68 && detected.score > 0.3);
console.log('PASS: real production models load and detect sample face with 68 landmarks');
// Keep subsequent error-flow uploads independent of angle validation.
await page.route('**/vendor/faceapi/face-api.js', route => route.abort());
await page.evaluate(() => { window.faceapi = undefined; });
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
await page.click('[data-action="open-privacy"]');
await page.click('#privacy-check');
await page.click('[data-action="agree-continue"]');
+1
View File
@@ -119,6 +119,7 @@ try {
await page.locator('#login-consent').check();
await page.locator('[data-action="login-browser"]').click();
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
await page.getByRole('button', { name: '开始检测' }).click();
await page.locator('#privacy-check').click();
await page.getByRole('button', { name: '继续' }).click();
+156
View File
@@ -0,0 +1,156 @@
// Isolated server/data; no production account, report or paid API call.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import http from 'node:http';
import zlib from 'node:zlib';
import { spawn, execSync } from 'node:child_process';
import { once } from 'node:events';
import { fileURLToPath, pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-login-perf-'));
const child = spawn(process.execPath, ['server.js'], {
cwd: fileURLToPath(new URL('../', import.meta.url)),
env: { ...process.env, PORT: '18091', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' },
stdio: ['ignore', 'pipe', 'pipe']
});
const base = 'http://127.0.0.1:18091/';
let browser;
async function raw(route, headers = {}) {
return new Promise((resolve, reject) => {
http.get(base + route, { headers }, response => {
const chunks = [];
response.on('data', data => chunks.push(data));
response.on('end', () => resolve({ status: response.statusCode, headers: response.headers, body: Buffer.concat(chunks) }));
}).on('error', reject);
});
}
try {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('test startup timeout')), 10000);
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timer); resolve(); } });
child.once('exit', code => { clearTimeout(timer); reject(new Error('server exited: ' + code)); });
});
for (const route of ['index.html', 'js/app.js', 'css/ui-20261009.css', 'vendor/faceapi/face-api.js']) {
const plain = await raw(route);
const compressed = await raw(route, { 'Accept-Encoding': 'gzip' });
assert.equal(compressed.status, 200);
assert.equal(compressed.headers['content-encoding'], 'gzip');
assert.equal(compressed.headers.vary, 'Accept-Encoding');
assert.equal(compressed.headers['cache-control'], 'no-cache');
assert.deepEqual(zlib.gunzipSync(compressed.body), plain.body);
assert(compressed.body.length < plain.body.length * .5, route);
const cached = await raw(route, { 'Accept-Encoding': 'gzip', 'If-None-Match': compressed.headers.etag });
assert.equal(cached.status, 304); assert.equal(cached.body.length, 0);
assert.equal((await raw(route, { 'Accept-Encoding': 'gzip;q=0' })).headers['content-encoding'], undefined);
}
const session = await raw('api/auth/session', { 'Accept-Encoding': 'gzip' });
assert.equal(session.headers['cache-control'], 'no-store');
assert.equal(session.headers.etag, undefined);
assert.equal(session.headers['content-encoding'], undefined);
assert.equal((await raw('data/accounts/identities.json')).status, 403);
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
const errors = [], requests = [];
page.on('pageerror', error => errors.push(error.message));
page.on('request', request => requests.push(new URL(request.url()).pathname));
// If the old eager script is reintroduced, it will fail instead of silently
// passing on a fast developer machine. Login must not depend on this request.
await page.route('**/vendor/faceapi/face-api.js', route => route.abort());
const cdp = await page.context().newCDPSession(page);
await cdp.send('Network.enable');
await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 150,
downloadThroughput: 250000, uploadThroughput: 125000 });
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
const start = Date.now();
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
const readyMs = Date.now() - start;
await page.waitForLoadState('networkidle');
assert(!requests.some(p => /faceapi|models|\.woff2$|bg-splash\.jpg$/.test(p)), JSON.stringify(requests));
const resourceBytes = await page.evaluate(() => performance.getEntriesByType('resource').reduce((sum, r) => sum + r.encodedBodySize, 0));
assert(resourceBytes < 350000, 'login resource budget exceeded: ' + resourceBytes);
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#p-login-success.show');
const artifacts = process.env.H5_ARTIFACT_DIR;
if (artifacts) {
fs.mkdirSync(artifacts, { recursive: true });
await page.screenshot({ path: path.join(artifacts, 'browser-login-success.png') });
}
await page.keyboard.press('Escape');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
assert.equal(await page.locator('#s-splash').evaluate(e => e.inert), false);
for (const width of [320, 375, 390, 480]) {
await page.setViewportSize({ width, height: 726 });
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
const badge = await page.locator('.account-status-home').boundingBox();
const slogan = await page.locator('#s-splash .splash-slogan').boundingBox();
assert(badge.y + badge.height <= slogan.y, 'account badge overlaps home slogan at ' + width);
}
await page.waitForLoadState('networkidle');
assert(!requests.some(p => /faceapi|models|\.woff2$/.test(p)), JSON.stringify(requests));
if (artifacts) await page.screenshot({ path: path.join(artifacts, 'browser-home-status.png') });
await page.click('[data-action="history"]');
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('次'));
assert.equal(await page.locator('#s-history [data-auth-status]').textContent(), '本机浏览器已登录');
if (artifacts) await page.screenshot({ path: path.join(artifacts, 'browser-history-status.png') });
await page.click('#s-history [data-action="switch-login"]');
assert.equal(await page.locator('#s-login.active').count(), 1);
assert.deepEqual(errors, []);
console.log('PASS: gzip/304/revalidation, no-store private API, login without fonts/face engine, success dialog/Escape/status/switching/layout; throttled cold login=' + readyMs + 'ms, resources=' + resourceBytes + ' bytes');
// Lazy engine loading must still support real models and retry after failure.
await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 });
await page.goBack();
await page.goBack();
await page.waitForSelector('#s-splash.active');
await page.evaluate(() => { navigator.mediaDevices.getUserMedia = () => Promise.reject(new DOMException('test denial', 'NotAllowedError')); });
await page.click('[data-action="open-privacy"]');
await page.click('#privacy-check');
await page.click('[data-action="agree-continue"]');
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.click('[data-action="start-camera"]');
await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('系统相机'));
await page.waitForTimeout(300);
assert.equal(requests.filter(p => p.endsWith('/face-api.js')).length, 1, 'engine must load only when entering camera');
await page.goBack();
await page.waitForSelector('#s-profile.active');
await page.unroute('**/vendor/faceapi/face-api.js');
await page.click('[data-action="submit-profile"]');
await page.click('[data-action="start-camera"]');
await page.waitForFunction(() => window.faceapi && faceapi.nets.tinyFaceDetector.isLoaded && faceapi.nets.faceLandmark68Net.isLoaded);
assert.equal(requests.filter(p => p.endsWith('/face-api.js')).length, 2, 'failed lazy engine should be retried');
const sample = fs.readFileSync(fileURLToPath(new URL('../../reference/facepp-skinanalyze/examples/images/C3_180.jpg', import.meta.url)));
const face = await page.evaluate(async data => {
const image = await faceapi.fetchImage(data);
const found = await faceapi.detectSingleFace(image, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .3 })).withFaceLandmarks();
return found ? found.landmarks.positions.length : 0;
}, 'data:image/jpeg;base64,' + sample.toString('base64'));
assert.equal(face, 68, 'lazy-loaded engine must perform real landmark inference');
console.log('PASS: no engine at login; camera loads engine/models; failed script retries; real 68-point inference works');
// A stalled session request must leave a recoverable message, not spin forever
// or discard the saved browser credential. Only this test shortens the timeout.
const token = await page.evaluate(() => localStorage.getItem('juzi.identity.v1'));
await page.addInitScript(() => {
const original = window.setTimeout;
window.setTimeout = (fn, ms, ...args) => original(fn, ms === 12000 ? 100 : ms, ...args);
});
await page.route('**/api/auth/session', () => {});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('网络响应超时'));
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.identity.v1')), token);
assert.equal(await page.locator('#p-login-success.show').count(), 0);
console.log('PASS: stalled session timeout is visible and preserves browser identity');
} finally {
if (browser) await browser.close();
const stopped = once(child, 'exit'); child.kill(); await stopped;
fs.rmSync(dataRoot, { recursive: true, force: true });
}
+1
View File
@@ -37,6 +37,7 @@ try {
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
const api = context.request;
assert.equal((await api.get(base + 'api/reports')).status(), 200);
const cookie = (await context.cookies()).find(c => c.httpOnly);
+1
View File
@@ -42,6 +42,7 @@ try {
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
const api = page.context().request;
const result = await (await api.post(base + 'api/skin-analyze', { data: { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0], profile: { real_age: 60, daily_skin_type: '干性肌肤' } } })).json();
assert.equal(result.ok, true);
+4
View File
@@ -24,6 +24,10 @@ await page.click('[data-action="login-wechat"]');
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('本机浏览器'));
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await page.waitForSelector('#p-login-success.show');
await page.screenshot({ path: new URL('02-login-success.png', root).pathname });
await page.click('[data-action="login-confirm"]');
await page.waitForTimeout(400);
await page.waitForFunction(() => !document.querySelector('#toast').classList.contains('show'));
await page.waitForTimeout(400);
await page.screenshot({ path: new URL('02-home.png', root).pathname });
+24
View File
@@ -56,6 +56,23 @@ try {
const saved = { id: 'RwechatCrossDevice', created_at: new Date().toISOString(), vm,
presentation: ReportUI.build(vm, SkinScore.MOCK_RESULT), photos: [null, null, null],
payload: { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE } };
async function confirmLogin(page, kind) {
await page.waitForSelector('#p-login-success.show');
assert.equal(await page.locator('#login-success-title').textContent(), '登录成功');
assert((await page.locator('#login-success-message').textContent()).includes(kind === 'wechat' ? '当前使用:微信登录' : '当前使用:本机浏览器登录'));
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), kind === 'wechat' ? '微信已登录' : '本机浏览器已登录');
if (process.env.H5_ARTIFACT_DIR) {
fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true });
await page.waitForLoadState('networkidle');
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, kind + '-login-success.png'), animations: 'disabled' });
}
assert.equal(await page.evaluate(() => document.activeElement.dataset.action), 'login-confirm');
await page.keyboard.press('Tab');
assert.equal(await page.evaluate(() => document.activeElement.dataset.action), 'login-confirm');
await page.click('[data-action="login-confirm"]');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
assert.equal(await page.locator('#s-splash').evaluate(e => e.inert), false);
}
for (let device = 0; device < 2; device++) {
const context = await browser.newContext({ ignoreHTTPSErrors: true, userAgent: 'Mozilla/5.0 MicroMessenger/8.0', viewport: { width: 375, height: 726 } });
const page = await context.newPage();
@@ -76,6 +93,7 @@ try {
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'browser');
browserId = await page.evaluate(() => AccountClient.user().id);
browserToken = await page.evaluate(() => localStorage.getItem('juzi.identity.v1'));
accounts.storeReport(browserId, { ...saved, id: 'RbrowserOriginal' });
@@ -89,12 +107,14 @@ try {
await page.click('[data-action="login-wechat"]');
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('微信授权未完成'));
assert.equal(await page.locator('#s-login.active').count(), 1, 'OAuth failure must not masquerade as browser login success');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
await page.waitForFunction(() => !location.search.includes('login_error'));
upstreamError = false;
}
await page.check('#login-consent');
await page.click('[data-action="login-wechat"]');
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'wechat');
await page.waitForFunction(() => !location.search.includes('login=success'));
const user = await page.evaluate(() => AccountClient.user());
assert.equal(user.kind, 'wechat');
@@ -104,6 +124,7 @@ try {
assert(cookie.secure && cookie.httpOnly && cookie.path === '/state/' && cookie.sameSite === 'Lax');
await page.click('[data-action="history"]');
await page.waitForFunction(() => document.querySelector('#identity-note').textContent.includes('同一微信账号'));
assert.equal(await page.locator('#s-history [data-auth-status]').textContent(), '微信已登录');
await page.waitForSelector('[data-report-id="RwechatCrossDevice"]');
assert.equal(await page.locator('[data-report-id="RbrowserOriginal"]').count(), 0);
assert.deepEqual((await api(page, 'reports/' + saved.id)).data.report, JSON.parse(JSON.stringify(saved)));
@@ -117,12 +138,14 @@ try {
await page.check('#login-consent');
await page.click('[data-action="login-wechat"]');
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'wechat');
assert.equal(await page.evaluate(() => AccountClient.user().id), firstId);
await page.click('[data-action="history"]');
await page.click('[data-action="switch-login"]');
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'browser');
assert.equal(await page.evaluate(() => AccountClient.user().id), browserId);
await page.click('[data-action="history"]');
await page.waitForSelector('[data-report-id="RbrowserOriginal"]');
@@ -137,6 +160,7 @@ try {
await page.reload(); // Ordinary browser restore is still supported.
await page.waitForSelector('#s-splash.active');
assert.equal(await page.evaluate(() => AccountClient.user().id), browserId);
assert.equal(await page.locator('#p-login-success.show').count(), 0, 'session restore must not repeatedly open a success dialog');
}
assert.deepEqual(errors, []);
await context.close();