fix(recommendation): use displayed skin age across reports and admin

This commit is contained in:
xsl
2026-10-16 19:03:25 +08:00
parent 571d78a2ed
commit bb6da17f54
12 changed files with 106 additions and 35 deletions
+4 -4
View File
@@ -427,8 +427,8 @@ function renderDetail(data) {
if (r.detect_status === '成功') {
h += dSec('综合', '<div class="d-kv">' +
'<span>综合得分 <b>' + fmtTotal(r.total_score) + '</b></span>' +
'<span>原始肌龄(新推荐依据) <b>' + ageText(r.skin_age) + '</b></span>' +
'<span>前端展示肌龄 <b>' + ageText(r.skin_age_display) + '</b></span>' +
'<span>原始肌龄(接口返回) <b>' + ageText(r.skin_age) + '</b></span>' +
'<span>报告展示肌龄(推荐依据) <b>' + ageText(r.skin_age_display) + '</b></span>' +
'<span>六分类均分S <b>' + fmtScore(r.avg_s) + '</b></span>' +
'<span>左下颌线角 <b>' + (r.jawline_angle_l != null ? r.jawline_angle_l + '°' : '-') + '</b></span>' +
'<span>右下颌线角 <b>' + (r.jawline_angle_r != null ? r.jawline_angle_r + '°' : '-') + '</b></span>' +
@@ -439,7 +439,7 @@ function renderDetail(data) {
var tone = r.tone_scoring;
h += '<p class="sub">肤色项=色素扣斑分×80%+黑眼圈评分×20%;' + (r.vita_score_rule === 'independent-color-water-smooth-min-v1' ? '该历史版本透亮感取独立肤色、水分、平滑度三项最低分。' : '肤色项与水分项各占50%合成肤色水润分,透亮感取肤色水润与平滑度的最低分。') + '色素扣斑分:' + esc(fmtToneScore(tone.pigment_adjusted_score)) + ',黑眼圈评分:' + esc(fmtToneScore(tone.dark_circle_score)) + ',独立肤色项:' + esc(fmtToneScore(tone.color_score)) + ',水分项:' + esc(fmtToneScore(tone.water_adjusted_score)) + '。肤色缺项按有效权重归一。</p>';
}
h += '<p class="sub">前端展示肌龄限制在实际年龄±5岁;推荐仍使用原始肌龄。历史报告保留当时的推荐。</p>';
h += '<p class="sub">展示肌龄限制在实际年龄±5岁;当前推荐使用报告展示肌龄及边界规则。原始检测数据和历史快照保留。</p>';
/* 6 分类表格 */
var catRows = '';
@@ -532,7 +532,7 @@ function renderDetail(data) {
var score = rec.scores[sk];
scoreParts.push(DIM_NAMES[sk] + ':' + (score == null ? '未获取' : String(score)));
}
h += dSec('推荐依据', '<div class="d-kv"><span>使用原始肌龄 <b>' + ageText(rec.skinAge) + '</b></span>' +
h += dSec('推荐依据', '<div class="d-kv"><span>' + (rec.ageBasis === 'display' ? '使用展示肌龄' : '使用原始肌龄') + ' <b>' + ageText(rec.skinAge) + '</b></span>' +
'<span>基础档 <b>' + esc(tierText(rec.baseTier)) + '</b></span>' +
'<span>最终档 <b>' + esc(tierText(rec.tier)) + '</b></span></div>' +
'<div class="d-copy">' + esc(rec.reason) + '</div><div class="sub">判定分(未舍入S,不使用前端一位小数):' + esc(scoreParts.join(';')) + '</div>');
+1
View File
@@ -1669,6 +1669,7 @@
refresh();
}
function renderReport(vm, frontPhoto, sidePhoto, payload, reportPhotos) {
vm=SkinScore.withDisplayRecommendation(vm,payload && payload.result);
activeWrinkleReport={payload:payload,photos:reportPhotos || [frontPhoto,sidePhoto,null],owner:AccountClient.user()&&AccountClient.user().id,started:false};
renderContourSides(reportPhotos || [frontPhoto, sidePhoto, null]);
reportOwner = AccountClient.user() && AccountClient.user().id;
+4
View File
@@ -181,6 +181,10 @@
// Refresh only presentation for old snapshots: scores, selected care order,
// product and original stored objects are never rewritten/recomputed.
function forDisplay(vm, result, saved) {
if (saved && vm.recommendation && vm.recommendation.ageBasis === 'display') {
var product=products.filter(function(p){return p.tier === vm.recommendation.tier;})[0] || null;
if(saved.product!==product)saved = Object.assign({}, saved, { product: product });
}
if (!saved || (saved.gradeRule === gradeRule && saved.copyRule === copyRule)) return saved || build(vm, result);
var current = build(vm, result);
var care = (saved.care || []).map(function(item) {
+22 -7
View File
@@ -628,12 +628,12 @@
var firm = isNum(scores.firm) ? scores.firm : null;
var vita = isNum(scores.vita) ? scores.vita : null;
var stab = isNum(scores.stab) ? scores.stab : null;
var rec = { version: '2026-10-12-skin-age-v1', skinAge: skinAge,
var rec = { version: '2026-10-16-display-skin-age-v2', ageBasis: 'display', skinAge: skinAge,
baseTier: null, tier: null, boundary: null, adjustment: 'none',
scores: { firm: firm, vita: vita, stab: stab }, reason: '', triggers: [] };
if (!isNum(skinAge) || skinAge <= 0) {
rec.adjustment = 'unavailable';
rec.reason = '未获取有效原始肌龄,暂不推荐具体产品;不以实际年龄代替。';
rec.reason = '未获取有效展示肌龄,暂不推荐具体产品;不以实际年龄代替。';
return rec;
}
rec.baseTier = skinAge < 35 ? 1 : (skinAge < 50 ? 2 : 3);
@@ -653,11 +653,11 @@
rec.reason = '处于' + rec.boundary + '岁上侧边界,紧塑力≥9且透亮感或稳韧力≥9,降一档。';
rec.triggers.push(rec.boundary + '岁边界高分降档');
} else if (skinAge < 32) {
rec.reason = '原始肌龄小于32岁,固定推荐25+,轮廓或皱纹低分不升档。';
rec.reason = '展示肌龄小于32岁,固定推荐25+,轮廓或皱纹低分不升档。';
} else if (rec.boundary != null) {
rec.reason = '处于' + rec.boundary + '岁边界,未满足升降档条件,保持肤龄基础档。';
} else {
rec.reason = '不在35±3或50±3岁边界内,仅按原始肌龄推荐。';
rec.reason = '不在35±3或50±3岁边界内,仅按展示肌龄推荐。';
}
return rec;
}
@@ -777,7 +777,7 @@
if (!worst || cP < worst.P) worst = { catKey: def.key, name: def.name, P: cP, S: sShow(cP) };
}
var recommendation = recommendProduct(skinAge, {
var recommendation = recommendProduct(skinAgeDisplay, {
firm: dims.firm.P == null ? null : sRaw(dims.firm.P),
vita: dims.vita.P == null ? null : sRaw(dims.vita.P),
stab: dims.stab.P == null ? null : sRaw(dims.stab.P)
@@ -934,7 +934,7 @@
};
// Apply the current display rule to new and historical reports without
// modifying their raw age, stored snapshot, copy or recommendation.
// modifying their raw age or stored snapshot. Recommendation uses this age.
function reportSkinAge(vm, result) {
var raw = vm && Object.prototype.hasOwnProperty.call(vm, 'skinAgeRaw') ? val(vm.skinAgeRaw) : val(result && result.skin_age);
if (!isNum(raw) || raw < 0) return null;
@@ -942,7 +942,22 @@
if (!isNum(actual) || actual < 0) return raw;
return Math.max(actual - 5, Math.min(actual + 5, raw));
}
function withDisplayRecommendation(vm, result) {
if (!vm) return vm;
var age = reportSkinAge(vm, result), scores = {};
['firm','vita','stab'].forEach(function(key) {
var d = vm.dims && vm.dims[key];
var saved = vm.recommendation && vm.recommendation.scores && vm.recommendation.scores[key];
scores[key] = isNum(saved) ? saved : d && isNum(d.P) ? sRaw(d.P) : d && isNum(d.S) ? d.S : null;
});
var recommendation = recommendProduct(age, scores), tier = recommendation.tier;
return Object.assign({}, vm, { skinAgeDisplay: age, recommendation: recommendation,
group: tier == null ? null : ['25+组','35+组','50+组'][tier-1],
essence: { name: tier == null ? '暂无法推荐' : ESSENCE[tier].name,
text: tier == null ? recommendation.reason : ESSENCE[tier].text, tier: tier, triggers: recommendation.triggers } });
}
return {
withDisplayRecommendation: withDisplayRecommendation,
capDimensionScore: capDimensionScore,
weightedTotal: weightedTotal,
reportSkinAge: reportSkinAge,
@@ -1020,7 +1035,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
ok(vm2.dims.firm.P === Math.min(vm2.cats.contour.P, vm2.cats.wrinkles.P)
&& vm2.dims.vita.P === Math.min(vm2.cats.smooth.P, vm2.cats.tone.P)
&& vm2.dims.stab.P === Math.min(vm2.cats.sens.P, vm2.cats.acne.P), '三维度 P = min(旗下两分类)');
ok(vm2.group === '25+组', '实际年龄40不影响推荐,原始肌龄20仍为25+组');
ok(vm2.group === '35+组', '实际年龄40、原始肌龄20、展示肌龄35,按展示肌龄推荐35+组');
console.log('== T3 原始肌龄展示 ==');
var r3 = clone(SS.MOCK_RESULT);
+8 -1
View File
@@ -3,6 +3,8 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const net = require('net');
const SkinScore = require('../js/skin-score');
const ReportUI = require('../js/report-ui');
const random = () => crypto.randomBytes(32).toString('hex');
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
@@ -162,7 +164,12 @@ module.exports = function createAccounts(root, env = process.env) {
else if (/^R[\w-]+$/.test(id) && user.reports.some(r => r.id === id)) {
const file = path.join(snapshots, user.id, id + '.json');
if (!fs.existsSync(file)) json(res, 404, { ok: false, message: '报告文件暂不可用' });
else json(res, 200, { ok: true, report: JSON.parse(fs.readFileSync(file, 'utf8')) });
else {
const snapshot=JSON.parse(fs.readFileSync(file,'utf8'));
snapshot.vm=SkinScore.withDisplayRecommendation(snapshot.vm,snapshot.payload && snapshot.payload.result);
if(snapshot.presentation)snapshot.presentation=Object.assign({},snapshot.presentation,{product:ReportUI.products.find(p=>p.tier===snapshot.vm.recommendation.tier)||null});
json(res,200,{ok:true,report:snapshot});
}
} else json(res, 404, { ok: false, message: '报告不存在' });
} else json(res, 404, { ok: false, message: '接口不存在' });
} catch (_) { if (!res.headersSent) json(res, 400, { ok: false, message: '请求处理失败,请稍后重试' }); }
+14 -5
View File
@@ -587,6 +587,7 @@ function displayScoreLevels(scores, capDimensions = false) {
Object.assign({}, value, { S:capDimensions ? SkinScore.capDimensionScore(value && value.S) : value && value.S, level: ReportUI.internalLevel(value && value.S) })])) : scores;
}
function listItem(rec) {
rec=displayRecommendationRecord(rec);
return {
record_id: rec.record_id,
created_at: rec.created_at,
@@ -606,6 +607,13 @@ function listItem(rec) {
}
/* Dates mean Beijing calendar days; fully zoned ISO timestamps retain their instant. */
function displayRecommendationRecord(rec) {
if(!rec || rec.detect_status!=='成功')return rec;
const vm=SkinScore.withDisplayRecommendation({skinAgeRaw:rec.skin_age,realAge:rec.real_age,dims:rec.dims,recommendation:rec.recommendation});
const presentation=rec.presentation?Object.assign({},rec.presentation,{product:ReportUI.products.find(p=>p.tier===vm.recommendation.tier)||null}):rec.presentation;
return Object.assign({},rec,{skin_age_display:vm.skinAgeDisplay,recommendation:vm.recommendation,group:vm.group,essence:vm.essence,essence_line:vm.essence.text,
trigger:vm.recommendation.triggers.join('、'),presentation});
}
function matchQuery(rec, q) {
if (q.timeRange && (q.from || q.to)) {
const timestamp = BeijingTime.milliseconds(rec.created_at);
@@ -650,7 +658,7 @@ function handleRecordsQuery(req, res, url) {
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
const page = Math.max(1, parseInt(sp.get('page') || '1', 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(sp.get('page_size') || '20', 10) || 20));
const filtered = readAllRecords().filter(r => matchQuery(r, q))
const filtered = readAllRecords().map(displayRecommendationRecord).filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); // created_at 倒序
const total = filtered.length;
const items = filtered.slice((page - 1) * pageSize, page * pageSize).map(listItem);
@@ -658,7 +666,7 @@ function handleRecordsQuery(req, res, url) {
}
function handleRecordDetail(req, res, id) {
const rec = findRecord(id);
const rec = displayRecommendationRecord(findRecord(id));
if (!rec) return sendJson(res, 404, { ok: false, message: '记录不存在' });
// 原始 JSON 全文:读文件失败给 null。
// face_maps 是整幅 base64 叠加图(单文件可达数 MB),详情页直接渲染会卡死浏览器:
@@ -695,6 +703,7 @@ function handleRecordDetail(req, res, id) {
if (rec.sub_scores && rec.sub_scores.color) displayVm.cats.tone.vars.color_score = rec.sub_scores.color.P;
presentation = ReportUI.forDisplay(displayVm, rawJson.result, presentation);
}
if(presentation && rec.recommendation && rec.recommendation.ageBasis==='display')presentation=Object.assign({},presentation,{product:ReportUI.products.find(p=>p.tier===rec.recommendation.tier)||null});
sendJson(res, 200, {
ok: true,
record: Object.assign({}, rec, { total_score: displayTotalScore(rec), total_score_rule: 'dimensions-433-v1', skin_age_display: displaySkinAge(rec),
@@ -738,7 +747,7 @@ function handlePhoto(req, res, id, which) {
}
function handleStats(req, res) {
const all = readAllRecords();
const all = readAllRecords().map(displayRecommendationRecord);
const now = Date.now(), today = BeijingTime.day(now);
const finished = all.filter(r => r.detect_status === '成功' || r.detect_status === '失败');
const succeeded = finished.filter(r => r.detect_status === '成功');
@@ -853,13 +862,13 @@ function handleExport(req, res, url) {
};
try { q.timeRange = BeijingTime.range(q.from, q.to); }
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
const filtered = readAllRecords().filter(r => matchQuery(r, q))
const filtered = readAllRecords().map(displayRecommendationRecord).filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
const dimP = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].P : null;
const dimS = (r, d) => (r.dims && r.dims[d]) ? SkinScore.capDimensionScore(r.dims[d].S) : null;
const dimLv = (r, d) => (r.dims && r.dims[d]) ? ReportUI.levelForScore(r.dims[d].S) : null;
const header = ['记录ID', '创建时间(北京时间)', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
'失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄(推荐依据)', '前端展示肌龄',
'失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄', '前端展示肌龄(推荐依据)',
'紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档',
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本'];
const lines = [header.join(',')];
+19
View File
@@ -0,0 +1,19 @@
'use strict';
const assert=require('node:assert/strict'),SS=require('../js/skin-score'),UI=require('../js/report-ui');
// The reported case: raw34, actual44 => displayed39, product II.
const old={skinAgeRaw:34,realAge:44,skinAgeDisplay:39,dims:{firm:{P:76,S:9.28},vita:{P:76.2,S:9.29},stab:{P:95.5,S:9.87}},
recommendation:{version:'old',scores:{firm:9.28,vita:9.286,stab:9.865}},essence:{name:'25+束紧精华'},summary:'原结论',cats:{},careAdvice:[]};
const bytes=JSON.stringify(old),updated=SS.withDisplayRecommendation(old);
assert.equal(updated.recommendation.skinAge,39);assert.equal(updated.recommendation.ageBasis,'display');assert.equal(updated.essence.tier,2);
assert.equal(updated.skinAgeRaw,34);assert.equal(updated.summary,old.summary);assert.strictEqual(updated.dims,old.dims);assert.equal(JSON.stringify(old),bytes);
const saved={gradeRule:UI.gradeRule,copyRule:UI.copyRule,product:UI.products[0],views:[],care:[]},before=JSON.stringify(saved);
const presentation=UI.forDisplay(updated,{},saved);assert.equal(presentation.product.tier,2);assert.equal(JSON.stringify(saved),before);assert.strictEqual(presentation.views,saved.views);
for(const [raw,actual,display,tier] of [[24,60,55,3],[55,30,35,1],[34,44,39,2],[null,44,null,null],[34,null,34,1]]){
const vm=SS.withDisplayRecommendation({...old,skinAgeRaw:raw,realAge:actual});assert.equal(vm.skinAgeDisplay,display);assert.equal(vm.essence.tier,tier);
}
// New reports also use the exact same age displayed by the report renderer.
for(const actual of [24,40,44,60]){
const raw=JSON.parse(JSON.stringify(SS.MOCK_RESULT));raw.skin_age={value:34};const vm=SS.buildViewModel(raw,SS.MOCK_SIDE,{realAge:actual});
assert.equal(vm.recommendation.skinAge,SS.reportSkinAge(vm,raw));assert.equal(vm.skinAgeRaw,34);
}
console.log('PASS: real case raw34/display39 => II; new/history identical display-age basis; boundary/missing age; scores/copy/saved snapshots unchanged');
+1 -1
View File
@@ -35,7 +35,7 @@ old.product={tier:3,title:'历史推荐不改'};
const oldBytes=JSON.stringify(old), vmBytes=JSON.stringify(vm);
const display=UI.forDisplay(vm,r,old);
assert.equal(display.views[0].level,'优秀');
assert.deepEqual(display.product,old.product);
assert.deepEqual(display.product,ui.product);
assert.deepEqual(display.care.map(v=>v.key),old.care.map(v=>v.key));
assert.equal(JSON.stringify(old),oldBytes);assert.equal(JSON.stringify(vm),vmBytes);
assert.equal(UI.forDisplay(vm,r,ui),ui);
+8 -7
View File
@@ -7,7 +7,7 @@ function check(age, firm, vita, stab, tier, adjustment = 'none') {
const r = SS.recommendProduct(age, { firm, vita, stab });
assert.equal(r.tier, tier, `age=${age}; scores=${firm}/${vita}/${stab}`);
assert.equal(r.adjustment, adjustment);
assert.equal(r.version, '2026-10-12-skin-age-v1');
assert.equal(r.version, '2026-10-16-display-skin-age-v2');
if (tier) assert(Math.abs(r.tier - r.baseTier) <= 1);
count++;
}
@@ -50,11 +50,12 @@ const raw = JSON.parse(JSON.stringify(SS.MOCK_RESULT));
raw.skin_age = { value: 24 };
for (const realAge of [24, 40, 60, undefined]) {
const vm = SS.buildViewModel(raw, SS.MOCK_SIDE, { realAge });
assert.equal(vm.essence.tier, 1); // Weak wrinkles never upgrade under32.
assert.equal(vm.recommendation.skinAge, 24);
const age=realAge==null?24:Math.max(realAge-5,Math.min(realAge+5,24));
assert.equal(vm.essence.tier, SS.recommendProduct(age,vm.recommendation.scores).tier);
assert.equal(vm.recommendation.skinAge, age);
assert(Math.abs(vm.recommendation.scores.firm - 7.69) < 1e-12);
assert.equal(UI.build(vm, raw).product.tier, 1);
assert.equal(vm.skinAgeDisplay, realAge == null ? 24 : Math.max(realAge - 5, Math.min(realAge + 5, 24))); // Display clamp does not change raw-age recommendation.
assert.equal(UI.build(vm, raw).product.tier,vm.essence.tier);
assert.equal(vm.skinAgeDisplay, realAge == null ? 24 : Math.max(realAge - 5, Math.min(realAge + 5, 24)));
count++;
}
delete raw.skin_age;
@@ -62,6 +63,6 @@ const missing = SS.buildViewModel(raw, SS.MOCK_SIDE, { realAge: 60 });
assert.equal(missing.group, null);
assert.equal(missing.essence.tier, null);
assert.equal(UI.build(missing, raw).product, null);
assert(missing.recommendation.reason.includes('未获取有效原始肌龄'));
assert(missing.recommendation.reason.includes('未获取有效展示肌龄'));
count++;
console.log(`PASS: ${count} recommendation cases; boundary/threshold/missing/raw-age/actual-age isolation`);
console.log(`PASS: ${count} recommendation cases; unchanged boundary/threshold rules, display-age input and missing-age handling`);
+10 -10
View File
@@ -73,8 +73,8 @@ try {
assert(result.vm.skinAgeRaw < 32);
const expectedAge = Math.max(55, Math.min(65, result.vm.skinAgeRaw));
assert.equal(result.vm.skinAgeDisplay, expectedAge);
assert.equal(result.vm.essence.tier, 1); // Raw age, never actual60.
assert.equal(result.presentation.product.tier, 1);
assert.equal(result.vm.essence.tier, 3); // Display age 55, not raw age below32.
assert.equal(result.presentation.product.tier, 3);
const adminRecord = (await (await api.get(base + 'api/admin/records/' + result.record_id)).json()).record;
assert.deepEqual(adminRecord.presentation, result.presentation);
assert.deepEqual(adminRecord.recommendation, result.vm.recommendation);
@@ -101,7 +101,7 @@ try {
assert.deepEqual(list.items[0].recommendation, result.vm.recommendation);
assert.equal(list.items[0].total_score, expectedTotal);
const csv = await (await api.get(base + 'api/admin/export')).text();
assert(csv.includes('原始肌龄(推荐依据),前端展示肌龄'));
assert(csv.includes('原始肌龄,前端展示肌龄(推荐依据)'));
assert(csv.includes(result.vm.recommendation.version));
assert(csv.includes('综合得分(4:3:3)'));
assert.equal(csv.trim().split('\n')[1].split(',')[14],String(expectedAge));
@@ -125,7 +125,7 @@ try {
assert.equal(await row.locator('td').nth(6).textContent(), String(expectedTotal));
assert((await row.textContent()).includes('原始 ' + result.vm.skinAgeRaw + '岁'));
assert((await row.textContent()).includes('展示 ' + expectedAge + '岁'));
assert((await row.textContent()).includes("1'me 主场精华 I(25+)"));
assert((await row.textContent()).includes("1'me 主场精华 III(50+)"));
for (const key of ['firm', 'vita', 'stab']) {
assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + ReportUI.dimensionScore(result.vm.dims[key].S) + ' ' + ReportUI.levelForScore(result.vm.dims[key].S)));
}
@@ -154,11 +154,11 @@ try {
}
if (process.env.H5_ARTIFACT_DIR) await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-weighted-detail.png') });
assert(detailText.includes(result.vm.recommendation.reason));
assert(detailText.includes('前端展示肌龄 ' + expectedAge + '岁'));
assert(detailText.includes('报告展示肌龄(推荐依据) ' + expectedAge + '岁'));
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) {
await desktop.locator('.d-sec').filter({ hasText: '使用原始肌龄' }).scrollIntoViewIfNeeded();
await desktop.locator('.d-sec').filter({ hasText: '使用展示肌龄' }).scrollIntoViewIfNeeded();
await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-recommendation.png') });
}
await desktop.click('#btn-close');
@@ -249,15 +249,15 @@ try {
const legacyCsv=(await (await api.get(base + 'api/admin/export')).text()).trim().split('\n')[1].split(',');
assert.equal(legacyCsv[12], String(expectedTotal));
assert.equal(legacyCsv[14], String(expectedAge));
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.equal(await page.locator('#product-title').textContent(), ReportUI.products[2].title);
assert.equal(await page.locator('#product-image').getAttribute('src'), ReportUI.products[2].image, 'historical product refreshes to current displayed age without rewriting stored snapshot');
assert((await page.locator('.ditem-para').allTextContents()).includes(ReportUI.forDisplay(legacy.vm,legacy.payload.result,legacy.presentation).views[0].text));
assert(!(await page.locator('#care-list').textContent()).includes('旧护理待刷新'));
await desktop.goto(base + 'admin.html');
await desktop.locator('#tbl-records tbody tr').first().click();
await desktop.waitForFunction(text => document.querySelector('#d-body').textContent.includes(text), legacyDetail.record.presentation.views[0].text);
assert((await desktop.locator('#d-body').textContent()).includes('旧版规则(历史记录)'));
assert((await desktop.locator('#d-body').textContent()).includes('前端展示肌龄 ' + expectedAge + '岁'));
assert((await desktop.locator('#d-body').textContent()).includes('2026-10-16-display-skin-age-v2'));
assert((await desktop.locator('#d-body').textContent()).includes('报告展示肌龄(推荐依据) ' + expectedAge + '岁'));
assert.equal(fs.readFileSync(snapshotFile, 'utf8'), storedBytes);
assert.equal(fs.readFileSync(recordFile, 'utf8'), recordBytes);
// An incomplete set must show no total everywhere, not fall back to the API score.