fix(h5): show raw skin age, support Harmony touch pickers and add WeChat logout
This commit is contained in:
+2
-2
@@ -91,10 +91,10 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
|
||||
3. 评分由 `js/skin-score.js` 引擎完成,公式/锁值/话术/产品推荐以 `docs/三维度评分与话术生成需求文档_V2.3.docx` 为最高契约(V2.3 口径,含 §8.5 护理建议五段):
|
||||
- 6 分类:轮廓 = min(眼袋映射分, 下颌线角度分);皱纹(12 分区);光滑(毛孔/黑头/纹理);肤色水润(色素×水润);敏感(泛红面积取 `red_spot.red_spot_area`/红区分);痘痘(分级映射×丘疹 `acne.count` 计数扣分);分组按实际年龄;
|
||||
- 三维度木桶 min:紧塑力(轮廓+皱纹)、活力感(光滑+肤色水润)、稳定力(敏感+痘痘);
|
||||
- S = 7 + 3×P/100;档位:良好(P≥90)/改善(80–90)/预警(<80);肤龄钳制到实际年龄 ±3 并留痕;三视图版 18 条话术;
|
||||
- S = 7 + 3×P/100;档位:良好(P≥90)/改善(80–90)/预警(<80);生物可视年龄直接展示算法原始肌龄,不再按实际年龄 ±3 修正;三视图版 18 条话术;
|
||||
- 精华推荐三重判据(均分档/最差项约束/年龄组档)取最强档,triggers 记录命中判据。
|
||||
4. 前端 `SkinScore.buildViewModel(data.result, data.side, { realAge, skinType })` → viewModel 直渲报告页:
|
||||
- 头部:综合评分(`vm.total`,缺失回退三维度 S 均分×10)、肤龄 `vm.skinAgeDisplay` + 钳制标记 `vm.ageClampNote`;三维度 S(1 位小数)+ 档位配色(良好绿/改善琥珀/预警红);
|
||||
- 头部:综合评分(`vm.total`,缺失回退三维度 S 均分×10)、肤龄读取 `SkinScore.reportSkinAge(vm, result)`;历史报告同样读取原始肌龄而非旧修正展示值,原值缺失显示 `--`;三维度 S(1 位小数)+ 档位配色(良好绿/改善琥珀/预警红);
|
||||
- 三个 Tab 面板各展示所辖两分类:分类名、S + 档位徽章、话术正文(`vm.cats.*.text`);P 值不直接展示,`P=null` 的分类显示"本次未检出有效数据";
|
||||
- 报告底部:降级标注(`vm.notes` 中与侧脸/下颌线/补拍相关的条目,去掉接口质量码后展示)、护理建议五段(`vm.careAdvice`)、精华推荐卡(`vm.essence.name` + 命中判据;推荐正文已在护理建议第 4 段,卡内不重复);
|
||||
- 近期优先关注 = 最差维度(`vm.focus`);6 分类各配检测可视化图:轮廓=正脸实拍、皱纹=正脸压暗+`texture_enhanced_lines` 皱纹增强线(线图层几乎全透明,直挂白底不可读,故与压暗沉脸合成)、光滑=正脸+色斑/痣/痤疮标记叠加、肤色=`brown_area` 色沉图、敏感=`red_area` 红区图、痘痘=正脸+闭口粉刺/痤疮标记叠加(marks 按原像素坐标叠加到正脸实拍)。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* 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. */
|
||||
.account-logout { font-size: 11px; opacity: .8; white-space: nowrap; }
|
||||
.splash-slogan {
|
||||
left: calc(50% - 1.041667px); right: auto;
|
||||
width: 249.305556px; height: 75.347222px;
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@
|
||||
<section class="screen" id="s-history">
|
||||
<div class="history-content">
|
||||
<h1 class="report-title">您的报告</h1>
|
||||
<div class="account-status account-status-history"><span data-auth-status></span></div>
|
||||
<div class="account-status account-status-history"><span data-auth-status></span><button type="button" class="account-logout" data-action="logout-wechat" hidden>退出登录</button></div>
|
||||
<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>
|
||||
@@ -42,7 +42,7 @@
|
||||
</section>
|
||||
<section class="screen" id="s-splash">
|
||||
<div class="splash-bg"></div>
|
||||
<div class="account-status account-status-home"><span data-auth-status></span></div>
|
||||
<div class="account-status account-status-home"><span data-auth-status></span><button type="button" class="account-logout" data-action="logout-wechat" hidden>退出登录</button></div>
|
||||
<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">
|
||||
|
||||
@@ -56,5 +56,10 @@ window.AccountClient = (function () {
|
||||
return v;
|
||||
});
|
||||
}
|
||||
return { request: request, init: init, browser: browser, guest: guest, ensureGuest: ensureGuest, user: function () { return user; } };
|
||||
function logout() {
|
||||
return request('api/auth/logout', {}).then(function (v) {
|
||||
user = null; guestReady = false; rememberKind(); return v;
|
||||
});
|
||||
}
|
||||
return { request: request, init: init, browser: browser, guest: guest, ensureGuest: ensureGuest, logout: logout, user: function () { return user; } };
|
||||
})();
|
||||
|
||||
+30
-4
@@ -64,7 +64,7 @@
|
||||
}
|
||||
|
||||
/* ─── 屏幕管理:页面内不放返回按钮,前进时 pushState,微信/浏览器返回键经 popstate 回到上一屏 ─── */
|
||||
var currentScreen = 's-login';
|
||||
var currentScreen = 's-login', reportOwner = null, logoutBusy = false;
|
||||
var fontStylesLoaded = false;
|
||||
function loadAppFont() {
|
||||
if (fontStylesLoaded) return;
|
||||
@@ -79,6 +79,11 @@
|
||||
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : '游客模式') : '尚未登录';
|
||||
label.parentElement.dataset.kind = user ? user.kind : '';
|
||||
});
|
||||
document.querySelectorAll('[data-action="logout-wechat"]').forEach(function (button) {
|
||||
button.hidden = !user || user.kind !== 'wechat';
|
||||
button.disabled = logoutBusy;
|
||||
button.textContent = logoutBusy ? '正在退出…' : '退出登录';
|
||||
});
|
||||
}
|
||||
function loginSucceeded() {
|
||||
showScreen('s-splash', 'replace');
|
||||
@@ -94,6 +99,7 @@
|
||||
}
|
||||
function showScreen(id, nav) { // nav: 'push' 新增历史 | 'replace' 替换当前历史 | 缺省不动历史
|
||||
if (id !== 's-login' && !AccountClient.user() && !(/[?&]mock=1/.test(location.search))) id = 's-login';
|
||||
if (id === 's-report' && !IS_MOCK && reportOwner !== (AccountClient.user() && AccountClient.user().id)) id = 's-splash';
|
||||
if (id === 's-profile' || profileOwner !== ProfileStore.key(AccountClient.user())) restoreProfile();
|
||||
if (['s-profile', 's-cam', 's-report'].includes(id)) loadAppFont();
|
||||
if (id === 's-profile' || id === 's-cam') prepareScanArtwork();
|
||||
@@ -1401,6 +1407,7 @@
|
||||
}
|
||||
|
||||
function renderReport(vm, frontPhoto, sidePhoto, payload) {
|
||||
reportOwner = AccountClient.user() && AccountClient.user().id;
|
||||
// 头部:综合评分(vm.total 缺失时回退三维度 S 均分×10)
|
||||
var total = vm.total;
|
||||
if (total == null) {
|
||||
@@ -1412,10 +1419,11 @@
|
||||
$('#stat-score').innerHTML = total != null ? '<b>' + total + '</b><i>分</i>' : '<b>--</b><i>分</i>';
|
||||
var ring = $('#ring-fg');
|
||||
ring.style.strokeDashoffset = total != null ? 326.73 * (1 - Math.max(0, Math.min(100, total)) / 100) : 326.73;
|
||||
$('#stat-age').innerHTML = vm.skinAgeDisplay != null ? '<b>' + vm.skinAgeDisplay + '</b><i>岁</i>' : '<b>--</b><i>岁</i>';
|
||||
var displayedAge = SkinScore.reportSkinAge(vm, payload && payload.result);
|
||||
$('#stat-age').innerHTML = displayedAge != null ? '<b>' + displayedAge + '</b><i>岁</i>' : '<b>--</b><i>岁</i>';
|
||||
// 肌龄对比胶囊(新版设计「比实际年龄年轻2岁」);缺实际年龄或肌龄时不展示
|
||||
var pill = $('#stat-age-note');
|
||||
var diff = vm.skinAgeDisplay != null && vm.realAge ? vm.skinAgeDisplay - vm.realAge : null;
|
||||
var diff = displayedAge != null && vm.realAge ? displayedAge - vm.realAge : null;
|
||||
pill.hidden = diff == null;
|
||||
pill.textContent = diff == null ? '' : diff < 0 ? '比实际年龄年轻' + (-diff) + '岁' : diff > 0 ? '比实际年龄大' + diff + '岁' : '与实际年龄相当';
|
||||
|
||||
@@ -1618,6 +1626,7 @@
|
||||
},
|
||||
'history': function () { openHistory(); },
|
||||
'history-retry': function () { renderHistoryList(); },
|
||||
'logout-wechat': logoutWechat,
|
||||
'open-wechat-login': function () {
|
||||
historySeq++;
|
||||
$('#login-consent').checked = false;
|
||||
@@ -1668,8 +1677,9 @@
|
||||
var btn = event.target.closest('[data-report-id]');
|
||||
if (!btn || btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
var seq = historySeq, owner = AccountClient.user() && AccountClient.user().id;
|
||||
AccountClient.request('api/reports/' + encodeURIComponent(btn.dataset.reportId)).then(function (data) {
|
||||
if (currentScreen !== 's-history') return;
|
||||
if (currentScreen !== 's-history' || seq !== historySeq || owner !== (AccountClient.user() && AccountClient.user().id)) return;
|
||||
var entry = data.report;
|
||||
renderReport(entry.vm, entry.photos[0], entry.photos[1], Object.assign({}, entry.payload, { presentation: entry.presentation }));
|
||||
showScreen('s-report', 'push');
|
||||
@@ -1677,6 +1687,22 @@
|
||||
});
|
||||
|
||||
var loginBusy = false;
|
||||
function logoutWechat() {
|
||||
var user = AccountClient.user();
|
||||
if (logoutBusy || !user || user.kind !== 'wechat') return;
|
||||
if (!window.confirm('确认退出微信登录?\n历史报告将保留,重新微信登录后可查看。')) return;
|
||||
logoutBusy = true; renderAccountStatus();
|
||||
AccountClient.logout().then(function () {
|
||||
historySeq++; reportOwner = null;
|
||||
$('#history-list').textContent = ''; $('#history-count').textContent = ''; $('#identity-note').textContent = '';
|
||||
$('#login-consent').checked = false;
|
||||
$('#login-message').textContent = '已退出微信登录,历史报告已保留。';
|
||||
showScreen('s-login', 'replace');
|
||||
$('[data-action="login-wechat"]').focus({ preventScroll: true });
|
||||
}).catch(function (e) { toast('退出失败:' + e.message); }).finally(function () {
|
||||
logoutBusy = false; renderAccountStatus();
|
||||
});
|
||||
}
|
||||
function loginAllowed() {
|
||||
if (loginBusy) return false;
|
||||
if (!$('#login-consent').checked) { toast('请先阅读并同意用户协议和隐私政策'); return false; }
|
||||
|
||||
+12
-8
@@ -689,14 +689,11 @@
|
||||
var notes = [];
|
||||
var realAge = val(profile.realAge);
|
||||
|
||||
// 肌肤年龄:原值不改写,展示值钳制到 realAge±3 并留痕
|
||||
// 生物可视年龄直接使用算法原始肌龄,不再按实际年龄钳制。
|
||||
var skinAge = val(r.skin_age);
|
||||
var skinAgeDisplay = skinAge, ageClampNote = '';
|
||||
if (realAge == null) {
|
||||
notes.push('未提供实际年龄:不修正展示肌龄;产品推荐仍使用接口原始肌龄');
|
||||
} else if (skinAge != null) {
|
||||
skinAgeDisplay = Math.min(Math.max(skinAge, realAge - 3), realAge + 3);
|
||||
if (skinAgeDisplay !== skinAge) ageClampNote = '触发(原' + skinAge + '→' + skinAgeDisplay + ')';
|
||||
notes.push('未提供实际年龄:肌龄展示与产品推荐均使用接口原始肌龄');
|
||||
}
|
||||
|
||||
var skinTypeCn = skinTypeCnOf(r, profile); // {skin_type_cn}:接口枚举优先,回退用户自选肤质
|
||||
@@ -898,7 +895,14 @@
|
||||
right: { right_jawline_info: { right_jawline_angle: 113.4, right_jawline_angle_level: 1, right_jawline_quality: 1 } } // 偏离 2.6° → 94.8
|
||||
};
|
||||
|
||||
// Historical snapshots may retain an old clamped display value. Read their
|
||||
// original age without modifying the stored snapshot or recalculating copy.
|
||||
function reportSkinAge(vm, result) {
|
||||
if (vm && Object.prototype.hasOwnProperty.call(vm, 'skinAgeRaw')) return val(vm.skinAgeRaw);
|
||||
return val(result && result.skin_age);
|
||||
}
|
||||
return {
|
||||
reportSkinAge: reportSkinAge,
|
||||
buildViewModel: buildViewModel,
|
||||
recommendProduct: recommendProduct,
|
||||
MOCK_RESULT: MOCK_RESULT,
|
||||
@@ -941,7 +945,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
|
||||
ok(vm.cats.acne.P === 85, '痘痘P = min(分级映射85, 100−14.5=85.5) = 85');
|
||||
ok(vm.cats.wrinkles.S === 7.69 && vm.cats.smooth.S === 9.45 && vm.cats.tone.S === 9.45, 'S=7+3×P/100 两位小数(含 9.445→9.45 浮点修正)');
|
||||
ok(vm.dims.firm.P === 23 && vm.dims.vita.P === 81.5 && vm.dims.stab.P === 85, '维度木桶 min:紧塑力23 / 活力感81.5 / 稳定力85');
|
||||
ok(vm.skinAgeRaw === 20 && vm.skinAgeDisplay === 22 && vm.ageClampNote === '触发(原20→22)', '肌肤年龄钳制 20→22 留痕,原值不改写');
|
||||
ok(vm.skinAgeRaw === 20 && vm.skinAgeDisplay === 20 && vm.ageClampNote === '', '肌肤年龄直接展示原值20,不按实际年龄钳制');
|
||||
ok(vm.group === '25+组', '原始肌龄20 → 25+组');
|
||||
ok(vm.essence.name === '25+束紧精华' && vm.recommendation.tier === 1,
|
||||
'肌龄小于32,即使皱纹低分也固定25+,不再使用最差项升档');
|
||||
@@ -974,14 +978,14 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
|
||||
&& vm2.dims.stab.P === Math.min(vm2.cats.sens.P, vm2.cats.acne.P), '三维度 P = min(旗下两分类)');
|
||||
ok(vm2.group === '25+组', '实际年龄40不影响推荐,原始肌龄20仍为25+组');
|
||||
|
||||
console.log('== T3 钳制边界 ==');
|
||||
console.log('== T3 原始肌龄展示 ==');
|
||||
var r3 = clone(SS.MOCK_RESULT);
|
||||
r3.skin_age = { value: 24 };
|
||||
var vm3 = SS.buildViewModel(r3, SS.MOCK_SIDE, { realAge: 25 });
|
||||
ok(vm3.skinAgeDisplay === 24 && vm3.ageClampNote === '', '±3 以内不钳制、不留痕');
|
||||
r3.skin_age = { value: 40 };
|
||||
var vm3b = SS.buildViewModel(r3, SS.MOCK_SIDE, { realAge: 25 });
|
||||
ok(vm3b.skinAgeDisplay === 28 && vm3b.ageClampNote === '触发(原40→28)' && vm3b.skinAgeRaw === 40, '上边界钳制 40→28');
|
||||
ok(vm3b.skinAgeDisplay === 40 && vm3b.ageClampNote === '' && vm3b.skinAgeRaw === 40, '原始肌龄40不因实际年龄25而改变');
|
||||
|
||||
console.log('== T4 最差项不再覆盖肤龄推荐 ==');
|
||||
var r4 = clone(SS.MOCK_RESULT);
|
||||
|
||||
+2
-4
@@ -514,10 +514,8 @@ function photoUrls(rec) {
|
||||
|
||||
/* 列表用的字段子集(不含 raw_json 与文案全文) */
|
||||
function displaySkinAge(rec) {
|
||||
if (Object.prototype.hasOwnProperty.call(rec, 'skin_age_display')) return rec.skin_age_display;
|
||||
// 旧记录没有展示值,沿用生成时的规则,不重算推荐或改写文件。
|
||||
if (rec.skin_age == null || rec.real_age == null) return rec.skin_age;
|
||||
return Math.min(Math.max(rec.skin_age, rec.real_age - 3), rec.real_age + 3);
|
||||
// Match H5 even for old records; do not rewrite historical files.
|
||||
return SkinScore.reportSkinAge({ skinAgeRaw: rec.skin_age });
|
||||
}
|
||||
function listItem(rec) {
|
||||
return {
|
||||
|
||||
@@ -69,6 +69,7 @@ try {
|
||||
assert.equal(await page.locator('#p-login-success.show').count(), 0, 'guest entry never shows login success');
|
||||
assert.equal(await page.locator('[data-action="switch-login"]').count(), 0, 'switch-login controls are removed');
|
||||
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), '游客模式');
|
||||
assert.equal(await page.locator('[data-action="logout-wechat"]:visible').count(), 0, 'guests have no logout control');
|
||||
assert.equal(guestRequests.length, 0, 'guest entry performs no authentication request');
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.identity.v1')), null);
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.reports.v1')), null);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Real app + actual touch/mouse dispatch with varying UA/capabilities.
|
||||
// Chromium UA regression, not a substitute for HarmonyOS hardware verification.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
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 h5 = fileURLToPath(new URL('../', import.meta.url));
|
||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-harmony-test-'));
|
||||
let child, browser;
|
||||
let base = process.env.H5_BASE_URL;
|
||||
try {
|
||||
if (!base) {
|
||||
base = 'http://127.0.0.1:18116/';
|
||||
child = spawn(process.execPath, ['server.js'], { cwd: h5, env: { ...process.env, PORT: '18116', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
|
||||
PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(Error('test startup timeout')), 10000);
|
||||
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timer); resolve(); } });
|
||||
child.once('exit', code => { clearTimeout(timer); reject(Error('server exited ' + code)); });
|
||||
});
|
||||
}
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const android = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36';
|
||||
const desktop = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36';
|
||||
const cases = [
|
||||
{ name: 'Android touch', ua: android, touch: true },
|
||||
{ name: 'OpenHarmony 5 touch', ua: 'Mozilla/5.0 (Phone; OpenHarmony 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 ArkWeb/4.1.6.1 Mobile', touch: true },
|
||||
{ name: 'OpenHarmony 6 WeChat touch', ua: 'Mozilla/5.0 (Phone;OpenHarmony 6.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 ArkWeb/6.0.0.42 Mobile MicroMessenger/8.0', touch: true },
|
||||
{ name: 'Desktop UA on touch hardware', ua: desktop, touch: true },
|
||||
{ name: 'Desktop mouse without TouchEvent', ua: desktop, touch: false },
|
||||
{ name: 'Android UA without touch hardware', ua: android, touch: false }
|
||||
];
|
||||
for (const fixture of cases) {
|
||||
const context = await browser.newContext({ viewport: { width: 375, height: 726 }, isMobile: fixture.touch, hasTouch: fixture.touch, userAgent: fixture.ua });
|
||||
const page = await context.newPage(), errors = [], writes = [];
|
||||
page.setDefaultTimeout(30000);
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
if (!fixture.touch) await page.addInitScript(() => { delete window.TouchEvent; });
|
||||
await page.route('**/*', route => {
|
||||
const req = route.request();
|
||||
if (req.method() !== 'GET') { writes.push(req.url()); return route.abort(); }
|
||||
if (/\.woff2?(\?|$)/.test(req.url())) return route.abort();
|
||||
if (req.url().endsWith('/api/auth/session')) return route.fulfill({ json: { ok: true, user: { id: 'harmony-picker-fixture', kind: 'wechat' } } });
|
||||
return route.continue();
|
||||
});
|
||||
await page.route('**/mobile-select.iife.min.js', async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, body: await response.text() + ';var OriginalMobileSelect=MobileSelect;window.__pickers={};MobileSelect=function(c){var p=new OriginalMobileSelect(c);window.__pickers[c.trigger]=p;return p;};' });
|
||||
});
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#s-splash.active');
|
||||
await page.click('[data-action="open-privacy"]'); await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
|
||||
await page.waitForSelector('#s-profile.active');
|
||||
const cdp = await context.newCDPSession(page);
|
||||
for (const trigger of ['#pick-birth', '#pick-region']) {
|
||||
await page.click(trigger); await page.waitForTimeout(350);
|
||||
const events = await page.evaluate(t => __pickers[t].eventHandleMap.panel.event, trigger);
|
||||
assert.deepEqual(events, fixture.touch ? ['touchstart', 'touchend', 'touchmove'] : ['mousedown', 'mousemove', 'mouseup'], fixture.name);
|
||||
assert.equal(await page.locator('.ms-mobile-select.ms-show').evaluate(el => !!el.closest('#s-profile')), false, 'profile scroll lock cannot intercept picker touch');
|
||||
for (const column of [0, 1]) {
|
||||
if (trigger === '#pick-region' && column === 1) await page.evaluate(() => __pickers['#pick-region'].locatePosition(0, REGION_DATA.findIndex(p => p[0] === '浙江省')));
|
||||
const before = await page.evaluate(t => JSON.stringify(__pickers[t].getCurValue()), trigger);
|
||||
const wheel = page.locator('.ms-mobile-select.ms-show .ms-wheel').nth(column), box = await wheel.boundingBox();
|
||||
const x = box.x + box.width / 2, y = box.y + box.height / 2;
|
||||
if (fixture.touch) {
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] });
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: y - i * 20, id: 1 }] });
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
} else {
|
||||
await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.move(x, y - 80, { steps: 4 }); await page.mouse.up();
|
||||
}
|
||||
await page.waitForTimeout(250);
|
||||
assert.notEqual(await page.evaluate(t => JSON.stringify(__pickers[t].getCurValue()), trigger), before, fixture.name + '/' + trigger + '/' + column + ' must move');
|
||||
}
|
||||
const chosen = await page.evaluate(t => __pickers[t].getCurValue(), trigger);
|
||||
await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(250);
|
||||
if (trigger === '#pick-birth') {
|
||||
const value = parseInt(chosen[0]) + '-' + String(parseInt(chosen[1])).padStart(2, '0');
|
||||
assert.equal(await page.locator('#f-birth').inputValue(), value);
|
||||
} else {
|
||||
const value = await page.locator('#f-region').inputValue();
|
||||
assert(value.includes(chosen[0].value) && value.includes(chosen[1].value));
|
||||
}
|
||||
await page.click(trigger); await page.waitForTimeout(300);
|
||||
assert.deepEqual(await page.evaluate(t => __pickers[t].getCurValue(), trigger), chosen, 'reopening retains confirmed values');
|
||||
await page.click('.ms-mobile-select.ms-show .ms-cancel'); await page.waitForTimeout(250);
|
||||
}
|
||||
await page.click('#pick-gender'); await page.click('.ms-mobile-select.ms-show .ms-ensure');
|
||||
await page.click('[data-action="submit-profile"]'); await page.waitForSelector('#p-photoguide.show');
|
||||
assert.deepEqual(errors, []); assert.deepEqual(writes, []);
|
||||
console.log('PASS ' + fixture.name + ': year/month/province/city drag, cascade, confirm/reopen, profile validation');
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (child && child.exitCode == null) { const stopped = once(child, 'exit'); child.kill(); await stopped; }
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const SS = require('../js/skin-score');
|
||||
for (const raw of [20, 24, 28, 40, 50, 24.6, null]) {
|
||||
for (const realAge of [25, 30, 60, undefined]) {
|
||||
const result = structuredClone(SS.MOCK_RESULT);
|
||||
if (raw == null) delete result.skin_age; else result.skin_age = { value: raw };
|
||||
const before = JSON.stringify(result);
|
||||
const vm = SS.buildViewModel(result, SS.MOCK_SIDE, { realAge });
|
||||
assert.equal(vm.skinAgeRaw, raw); assert.equal(vm.skinAgeDisplay, raw);
|
||||
assert.equal(vm.ageClampNote, ''); assert.equal(SS.reportSkinAge(vm, result), raw);
|
||||
assert.equal(JSON.stringify(result), before);
|
||||
}
|
||||
}
|
||||
const legacy = { skinAgeRaw: 24, skinAgeDisplay: 57, realAge: 60 };
|
||||
assert.equal(SS.reportSkinAge(legacy), 24);
|
||||
assert.equal(legacy.skinAgeDisplay, 57, 'historical VM is not mutated');
|
||||
assert.equal(SS.reportSkinAge({ skinAgeDisplay: 57 }, { skin_age: { value: 24 } }), 24);
|
||||
assert.equal(SS.reportSkinAge({ skinAgeDisplay: 57 }), null, 'clamped display is never a raw-age fallback');
|
||||
assert.equal(SS.reportSkinAge({ skinAgeRaw: null, skinAgeDisplay: 57 }), null);
|
||||
assert.equal(SS.reportSkinAge({}, { skin_age: '24.6' }), 24.6);
|
||||
console.log('PASS: raw age across actual-age/decimal/missing cases; old display ignored; legacy payload fallback; no mutation or invented age');
|
||||
@@ -54,7 +54,7 @@ for (const realAge of [24, 40, 60, undefined]) {
|
||||
assert.equal(vm.recommendation.skinAge, 24);
|
||||
assert(Math.abs(vm.recommendation.scores.firm - 7.69) < 1e-12);
|
||||
assert.equal(UI.build(vm, raw).product.tier, 1);
|
||||
if (realAge === 60) assert.equal(vm.skinAgeDisplay, 57); // Display isn't the recommendation input.
|
||||
assert.equal(vm.skinAgeDisplay, 24); // Display and recommendation both use raw skin age.
|
||||
count++;
|
||||
}
|
||||
delete raw.skin_age;
|
||||
|
||||
@@ -47,8 +47,8 @@ try {
|
||||
assert.equal(result.report_saved, true);
|
||||
assert(result.vm && result.presentation);
|
||||
assert(result.vm.skinAgeRaw < 32);
|
||||
assert.equal(result.vm.skinAgeDisplay, 57);
|
||||
assert.equal(result.vm.essence.tier, 1); // Raw age, not actual60 or display57.
|
||||
assert.equal(result.vm.skinAgeDisplay, result.vm.skinAgeRaw);
|
||||
assert.equal(result.vm.essence.tier, 1); // Raw age, never actual60.
|
||||
assert.equal(result.presentation.product.tier, 1);
|
||||
const adminRecord = (await (await api.get(base + 'api/admin/records/' + result.record_id)).json()).record;
|
||||
assert.deepEqual(adminRecord.presentation, result.presentation);
|
||||
@@ -58,7 +58,7 @@ try {
|
||||
for (const key of ['firm', 'vita', 'stab']) assert.equal(adminRecord.dims[key].S, result.vm.dims[key].S);
|
||||
const list = await (await api.get(base + 'api/admin/records')).json();
|
||||
assert.equal(list.items[0].skin_age, result.vm.skinAgeRaw);
|
||||
assert.equal(list.items[0].skin_age_display, 57);
|
||||
assert.equal(list.items[0].skin_age_display, result.vm.skinAgeRaw);
|
||||
assert.deepEqual(list.items[0].recommendation, result.vm.recommendation);
|
||||
const csv = await (await api.get(base + 'api/admin/export')).text();
|
||||
assert(csv.includes('原始肌龄(推荐依据),前端展示肌龄'));
|
||||
@@ -74,7 +74,7 @@ try {
|
||||
const row = desktop.locator('#tbl-records tbody tr').first();
|
||||
await row.waitFor();
|
||||
assert((await row.textContent()).includes('原始 ' + result.vm.skinAgeRaw + '岁'));
|
||||
assert((await row.textContent()).includes('展示 57岁'));
|
||||
assert((await row.textContent()).includes('展示 ' + result.vm.skinAgeRaw + '岁'));
|
||||
assert((await row.textContent()).includes("1'me 主场精华 I(25+)"));
|
||||
for (const key of ['firm', 'vita', 'stab']) {
|
||||
assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + result.vm.dims[key].S.toFixed(1) + ' ' + ReportUI.levelMap[result.vm.dims[key].level]));
|
||||
@@ -93,7 +93,7 @@ try {
|
||||
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('推荐依据'));
|
||||
const detailText = await desktop.locator('#d-body').textContent();
|
||||
assert(detailText.includes(result.vm.recommendation.reason));
|
||||
assert(detailText.includes('前端展示肌龄 57岁'));
|
||||
assert(detailText.includes('前端展示肌龄 ' + result.vm.skinAgeRaw + '岁'));
|
||||
for (const view of result.presentation.views) assert(detailText.includes(view.text));
|
||||
for (const care of result.presentation.care) assert(detailText.includes(care.text));
|
||||
if (process.env.H5_ARTIFACT_DIR) {
|
||||
@@ -146,19 +146,21 @@ try {
|
||||
const legacy = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
|
||||
legacy.presentation.product = { ...ReportUI.products[1], image: 'assets/img/ui-20261009/product-35.png' };
|
||||
legacy.presentation.views[0].text = '历史报告原文,不应按新文案重新计算。';
|
||||
legacy.vm.skinAgeDisplay = 57; // Old snapshot retains the retired display clamp.
|
||||
legacy.vm.essence = { name: '35+支撑精华', text: '旧版推荐', triggers: ['最差项约束'] };
|
||||
delete legacy.vm.recommendation;
|
||||
fs.writeFileSync(snapshotFile, JSON.stringify(legacy));
|
||||
record.essence = legacy.vm.essence;
|
||||
delete record.presentation;
|
||||
delete record.recommendation;
|
||||
delete record.skin_age_display;
|
||||
record.skin_age_display = 57;
|
||||
fs.writeFileSync(recordFile, JSON.stringify(record) + '\n');
|
||||
const storedBytes = fs.readFileSync(snapshotFile, 'utf8');
|
||||
const recordBytes = fs.readFileSync(recordFile, 'utf8');
|
||||
await page.click('[data-action="history"]');
|
||||
await page.click('[data-report-id]');
|
||||
await page.waitForSelector('#s-report.active');
|
||||
assert.equal(await page.locator('#stat-age b').textContent(), String(result.vm.skinAgeRaw), 'old saved display clamp must not override original skin age');
|
||||
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));
|
||||
@@ -166,6 +168,7 @@ try {
|
||||
await desktop.locator('#tbl-records tbody tr').first().click();
|
||||
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('历史报告原文'));
|
||||
assert((await desktop.locator('#d-body').textContent()).includes('旧版规则(历史记录)'));
|
||||
assert((await desktop.locator('#d-body').textContent()).includes('前端展示肌龄 ' + result.vm.skinAgeRaw + '岁'));
|
||||
assert.equal(fs.readFileSync(snapshotFile, 'utf8'), storedBytes);
|
||||
assert.equal(fs.readFileSync(recordFile, 'utf8'), recordBytes);
|
||||
// Missing skin age must not leave the previous product visible in the H5.
|
||||
@@ -181,6 +184,7 @@ try {
|
||||
await page.waitForSelector('#s-report.active');
|
||||
assert.equal(await page.locator('.card-product').isVisible(), false);
|
||||
assert.equal(await page.locator('#product-unavailable').isVisible(), true);
|
||||
assert.equal(await page.locator('#stat-age b').textContent(), '--');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: raw-age recommendation; admin/H5 scores, ages, product, seven-view copy, care, CSV, filters; saved/reloaded and legacy immutable snapshots; missing-age UI; owner isolation/private files/back');
|
||||
} finally {
|
||||
|
||||
@@ -72,6 +72,7 @@ try {
|
||||
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);
|
||||
assert.equal(await page.locator('#s-splash [data-action="logout-wechat"]').isVisible(), true);
|
||||
}
|
||||
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 } });
|
||||
@@ -165,6 +166,48 @@ try {
|
||||
assert.equal(await page.evaluate(() => AccountClient.user()), null);
|
||||
assert.equal(await page.locator('#p-login-success.show').count(), 0, 'session restore must not repeatedly open a success dialog');
|
||||
}
|
||||
if (device === 1) {
|
||||
const reportFile = path.join(root, 'accounts/reports', user.id, saved.id + '.json');
|
||||
const originalBytes = fs.readFileSync(reportFile, 'utf8');
|
||||
const logout = page.locator('#s-history [data-action="logout-wechat"]');
|
||||
assert.equal(await logout.textContent(), '退出登录');
|
||||
page.once('dialog', dialog => { assert(dialog.message().includes('历史报告将保留')); dialog.dismiss(); });
|
||||
await logout.click();
|
||||
assert.equal((await api(page, 'auth/session')).data.user.id, user.id, 'cancel leaves session intact');
|
||||
await page.route('**/api/auth/logout', route => route.fulfill({ status: 503, json: { ok: false, message: '退出测试失败' } }));
|
||||
page.once('dialog', dialog => dialog.accept()); await logout.click();
|
||||
await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('退出失败'));
|
||||
assert.equal(await page.evaluate(() => AccountClient.user().id), user.id, 'failure does not fake local logout');
|
||||
assert.equal((await api(page, 'auth/session')).data.user.id, user.id);
|
||||
await page.unroute('**/api/auth/logout');
|
||||
await page.waitForFunction(() => !document.querySelector('#s-history [data-action="logout-wechat"]').disabled);
|
||||
let heldReport, markHeld;
|
||||
const reportHeld = new Promise(resolve => { markHeld = resolve; });
|
||||
await page.route('**/api/reports/' + saved.id, route => { heldReport = route; markHeld(); }, { times: 1 });
|
||||
await page.click('[data-report-id="RwechatCrossDevice"]'); await reportHeld;
|
||||
page.once('dialog', dialog => dialog.accept()); await logout.click();
|
||||
await page.waitForSelector('#s-login.active');
|
||||
assert.equal(await page.evaluate(() => AccountClient.user()), null);
|
||||
assert.equal((await api(page, 'auth/session')).data.user, null);
|
||||
assert.equal((await api(page, 'reports/' + saved.id)).status, 401);
|
||||
assert.equal(await page.locator('#login-consent').isChecked(), false);
|
||||
assert.equal(fs.readFileSync(reportFile, 'utf8'), originalBytes, 'logout retains exact historical file');
|
||||
await page.goBack(); await page.waitForSelector('#s-login.active');
|
||||
await page.click('[data-action="guest-experience"]'); await page.waitForSelector('#s-splash.active');
|
||||
await page.click('[data-action="history"]'); await page.waitForSelector('#s-history.active');
|
||||
await heldReport.fulfill({ json: { ok: true, report: JSON.parse(originalBytes) } });
|
||||
await page.waitForTimeout(250);
|
||||
assert.equal(await page.locator('#s-report.active').count(), 0, 'late WeChat report response cannot open under guest identity');
|
||||
await page.unroute('**/api/reports/' + saved.id);
|
||||
await page.click('[data-action="open-wechat-login"]');
|
||||
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), user.id);
|
||||
await page.click('[data-action="history"]'); await page.waitForSelector('[data-report-id="RwechatCrossDevice"]');
|
||||
assert.deepEqual((await api(page, 'reports/' + saved.id)).data.report, JSON.parse(originalBytes));
|
||||
assert.equal(fs.readFileSync(reportFile, 'utf8'), originalBytes);
|
||||
console.log('PASS WeChat logout: confirm/cancel/failure, server session revoked, back guarded, re-login retains exact report');
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
Vendored
+3
-1
@@ -1,4 +1,6 @@
|
||||
# mobile-select 1.4.0
|
||||
|
||||
来源:npm `mobile-select@1.4.0`(MIT,见同目录 `LICENSE`),取 `dist/mobile-select.iife.min.js` 与 `dist/style/mobile-select.css`,未改动。
|
||||
来源:npm `mobile-select@1.4.0`(MIT,见同目录 `LICENSE`),取 `dist/mobile-select.iife.min.js` 与 `dist/style/mobile-select.css`。
|
||||
|
||||
2026-10-13 本地兼容补丁:不再按 UA 名称判断触摸设备,改用 `navigator.maxTouchPoints > 0` 或 `ontouchstart` 能力;解决不带 Android 标识的 OpenHarmony/ArkWeb 被错误绑定鼠标事件的问题。事件坐标按 `touches/changedTouches` 读取,不依赖全局 `TouchEvent` 构造函数存在。上游版本和 MIT 声明保留,升级依赖时须保留此补丁并运行 `h5/test/harmony-picker.mjs` 回归。
|
||||
档案页的出生年月 / 性别 / 地区滚轮由它渲染,样式覆盖写在 `css/style.css` 的 mobile-select 段。
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user