feat(report): unify display grades and add contour side photos

This commit is contained in:
xsl
2026-10-15 17:14:05 +08:00
parent 60f0965c5a
commit 2d80f6dfd0
21 changed files with 557 additions and 49 deletions
+13 -1
View File
@@ -92,7 +92,8 @@
.report-care-products .card-heading { margin-bottom:calc(5.208333 * var(--u)); }
.report-care-products .care-item { font-size:11.574074px; line-height:14.467593px; margin-bottom:calc(14.467593 * var(--u)); }
.report-care-products .care-item:last-child { margin-bottom:0; }
.product-section-heading { margin:calc(34.375 * var(--u)) 0 calc(5.555556 * var(--u)); }
.product-section-heading { position:relative; margin:calc(24.0625 * var(--u)) 0 calc(5.555556 * var(--u)); }
.report-care-products .product-section-heading::before { content:""; position:absolute; left:0; right:0; top:calc(-12.03125 * var(--u)); border-top:1px solid #DDD; }
.report-care-products .card-product { width:auto; margin:0 calc(-18.402778 * var(--u)); border-radius:calc(3.472222 * var(--u)); background:#F6F6F6; }
.report-care-products .prod-card { height:auto; aspect-ratio:945 / 511; }
.report-care-products .prod-info { flex-basis:49.100529%; }
@@ -196,6 +197,17 @@
.detail-panels.dragging { transition: none; will-change: transform; }
.detail-panels .dpanel { display: block; flex: 0 0 100%; min-width: 0; padding-top: calc(10 * var(--u)); animation: none; }
.detail-panels .ditem-img { -webkit-user-drag: none; }
/* Keep the existing front-photo scale/top edge; clip only its bottom 10%.
The image source stays intact for the existing full-image zoom overlay. */
.contour-front-frame { height: calc(250.2 * var(--u)); margin-bottom: calc(7.56 * var(--u)); }
.contour-front-frame .ditem-img { height: calc(278 * var(--u)); }
.contour-sides { width: calc(215 * var(--u)); margin: 0 auto calc(12 * var(--u)); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: calc(6.3 * var(--u)); }
.contour-sides[hidden], .contour-sides figure[hidden] { display: none; }
.contour-sides figure { margin: 0; min-width: 0; }
.contour-sides figure[data-view="right"] { grid-column: 2; }
.contour-side-photo { display: block; position: relative; width: 100%; height: calc(92.25 * var(--u)); padding: 0; border: 0; border-radius: calc(5 * var(--u)); overflow: hidden; background: #f6f6f6; cursor: zoom-in; }
.contour-side-photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; transform: translateY(15%) scale(1.3); transform-origin: center; -webkit-user-drag: none; }
.contour-side-photo:focus-visible { outline: 2px solid #333; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.detail-panels, .detail-viewport { transition-duration: .01ms; }
}
+5 -1
View File
@@ -210,7 +210,11 @@
<div class="detail-tabs"><button class="dtab active" data-tab="0"><span class="dtab-tx">轮廓</span></button><button class="dtab" data-tab="1"><span class="dtab-tx">皱纹</span></button></div>
<div class="detail-viewport"><div class="detail-panels">
<div class="dpanel active" data-panel="0">
<div class="ditem"><div class="viz-frame"><img id="ditem-img1" class="ditem-img" alt="轮廓检测图" hidden><span class="viz-missing">本次未获取轮廓检测图</span></div>
<div class="ditem"><div class="viz-frame contour-front-frame"><img id="ditem-img1" class="ditem-img" alt="轮廓检测图" hidden><span class="viz-missing">本次未获取轮廓检测图</span></div>
<div class="contour-sides" id="contour-sides" hidden>
<figure data-view="left" hidden><button type="button" class="contour-side-photo" aria-label="查看左脸完整大图"><img id="contour-left" alt="左脸拍摄照片"></button></figure>
<figure data-view="right" hidden><button type="button" class="contour-side-photo" aria-label="查看右脸完整大图"><img id="contour-right" alt="右脸拍摄照片"></button></figure>
</div>
<div class="ditem-text"><p class="ditem-para" id="ditem-para1">检测完成后显示分析结果。</p></div></div>
</div>
<div class="dpanel" data-panel="1">
+8 -7
View File
@@ -69,9 +69,10 @@ function dimsHtml(dims) {
var h = '';
for (var k in DIM_NAMES) {
var d = dims[k];
h += '<div><span class="lv ' + (d && LV_CLASS[d.level] || '') + '" title="' + DIM_NAMES[k] + '">' +
var grade = ReportUI.internalLevel(d && d.S);
h += '<div><span class="lv ' + (LV_CLASS[grade] || '') + '" title="' + DIM_NAMES[k] + '">' +
DIM_NAMES[k] + ' ' + ReportUI.dimensionScore(d && d.S) + ' ' +
(d ? esc(ReportUI.levelMap[d.level] || '-') : '-') + '</span></div>';
esc(ReportUI.levelMap[grade] || '-') + '</span></div>';
}
return h;
}
@@ -428,7 +429,7 @@ function renderDetail(data) {
for (var i = 0; i < scoreOrder.length; i++) {
var ck = scoreOrder[i], cat = (r.sub_scores || r.cats || {})[ck];
var catName = ({ contour: '轮廓', wrinkles: '皱纹', smooth: '平滑度', color:'肤色', water:'水分', tone: '肤色 / 水分(历史)', sens: '泛红', acne: '痘痘' })[ck];
catRows += '<tr><td>' + esc(catName) + '</td><td>' + fmtScore(cat && cat.P) + '</td><td>' + fmtScore(cat && cat.S) + '</td><td>' + lvHtml(cat && cat.level) + '</td></tr>';
catRows += '<tr><td>' + esc(catName) + '</td><td>' + fmtScore(cat && cat.P) + '</td><td>' + fmtScore(cat && cat.S) + '</td><td>' + lvHtml(ReportUI.internalLevel(cat && cat.S)) + '</td></tr>';
}
h += dSec(r.sub_scores ? '七项独立评分' : '历史六分类评分(肤色 / 水分共用一项)', '<table class="d-table"><thead><tr><th>分类</th><th>P</th><th>S</th><th>档位</th></tr></thead><tbody>' + catRows + '</tbody></table>');
@@ -436,7 +437,7 @@ function renderDetail(data) {
var dimRows = '';
for (var dk in DIM_NAMES) {
var dim = r.dims && r.dims[dk];
dimRows += '<tr><td>' + DIM_NAMES[dk] + '</td><td>' + fmtScore(dim && dim.P) + '</td><td>' + fmtScore(dim && dim.S) + '</td><td>' + lvHtml(dim && dim.level) + '</td></tr>';
dimRows += '<tr><td>' + DIM_NAMES[dk] + '</td><td>' + fmtScore(dim && dim.P) + '</td><td>' + fmtScore(dim && dim.S) + '</td><td>' + lvHtml(ReportUI.internalLevel(dim && dim.S)) + '</td></tr>';
}
h += dSec('三维度评分', '<table class="d-table"><thead><tr><th>维度</th><th>P</th><th>S</th><th>档位</th></tr></thead><tbody>' + dimRows + '</tbody></table>');
if (r.acne_scoring && r.acne_scoring.version === 'three-view-type-max-v1') {
@@ -462,9 +463,9 @@ function renderDetail(data) {
if (txt) copyHtml += '<div class="d-copy"><b>' + esc((r.cats[CAT_ORDER[c2]] || {}).name || '') + ':</b>' + esc(txt) + '</div>';
}
}
if (r.copy_summary) copyHtml += '<div class="d-copy"><b>综合文案:</b>' + esc(r.copy_summary) + '</div>';
if (r.lifestyle) copyHtml += '<div class="d-copy"><b>生活方式:</b>' + esc(r.lifestyle) + '</div>';
if (copyHtml) h += dSec(presentation ? '前端报告结论(保存快照)与计算摘要' : '旧版生成文案(无前端快照)', copyHtml);
if (r.copy_summary) copyHtml += '<div class="d-copy"><b>旧版算法摘要(原始留档,不用于当前文案分档):</b>' + esc(r.copy_summary) + '</div>';
if (r.lifestyle) copyHtml += '<div class="d-copy"><b>旧版生活方式(原始留档):</b>' + esc(r.lifestyle) + '</div>';
if (copyHtml) h += dSec(presentation ? (presentation.copyRule === ReportUI.copyRule ? '前端报告结论(当前文案展示,原始快照保留)与计算摘要' : '前端报告结论(保存快照,未刷新文案)与计算摘要') : '旧版生成文案(无前端快照)', copyHtml);
// Technical region metrics stay in the protected admin, not customer prose.
var wrinkleView = presentation && presentation.views && presentation.views.filter(function(view) { return view.key === 'wrinkles'; })[0];
+46 -7
View File
@@ -1164,7 +1164,7 @@
popHistorySilently();
return;
}
renderReport(vm, photos[0], photos[1], payload); // img1=正脸实拍,payload 供检测可视化取 face_maps/marks
renderReport(vm, photos[0], photos[1], payload, photos); // photos 已统一为正/左/右
if (IS_MOCK) toast('演示报告,不保存至我的报告');
else if (AccountClient.user() && AccountClient.user().kind !== 'wechat') toast('游客报告仅供本次查看,不保存历史记录');
else if (!payload.report_saved) toast('报告生成成功,但保存失败,请勿关闭页面并联系管理员');
@@ -1315,6 +1315,13 @@
if (top + ch < rectangle.top + rectangle.height) top = rectangle.top + rectangle.height - ch;
return { left: left, top: top, width: cw, height: ch, focused: true };
}
function vizShiftContentDown(crop, rectangle, fraction) {
if (!crop.focused || !rectangle || !fraction) return crop;
// Move the source window upward, so its subject moves DOWN inside the
// fixed frame. Keep the full face in bounds and never create a blank edge.
var minimumTop = Math.max(0, rectangle.top + rectangle.height - crop.height);
return Object.assign({}, crop, { top: Math.max(minimumTop, crop.top - crop.height * fraction) });
}
// 从 result 提取某分类的标记集合:polygon 与 rectangle 都保留,缺该标记类型则跳过
function vizCollectMarks(markDefs, result) {
var out = [];
@@ -1389,6 +1396,8 @@
var finish = function (overlays) {
if (epoch !== vizEpoch) return;
var crop = vizFaceCrop(photo.naturalWidth, photo.naturalHeight, opts && opts.faceRectangle);
var originalTop = crop.top;
crop = vizShiftContentDown(crop, opts && opts.faceRectangle, opts && opts.contentDown);
var scale = Math.min(1, 960 / crop.width);
var w = Math.round(crop.width * scale), h = Math.round(crop.height * scale);
var cv = document.createElement('canvas');
@@ -1440,6 +1449,7 @@
try {
imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false;
imgEl.dataset.crop = crop.focused ? 'face' : 'full';
imgEl.dataset.contentOffset = String((originalTop - crop.top) / crop.height);
imgEl.dataset.overlayAlpha = String(opts && typeof opts.alpha === 'number' ? opts.alpha : 1);
} catch (e) { /* 合成失败保留占位 */ }
};
@@ -1501,11 +1511,39 @@
// 照片类(轮廓/光滑/痘痘):无照片 → 保留占位;有坐标则 canvas 叠加,无坐标纯照片
if (!frontPhoto) continue;
var marks = cfg.marks ? vizCollectMarks(cfg.marks, result) : [];
vizCompose(frontPhoto, null, marks, imgEl, { faceRectangle: rectangle });
// The visible contour frame is 90% of the source height: 9% of the
// source equals a 10% downward shift relative to that visible frame.
vizCompose(frontPhoto, null, marks, imgEl, { faceRectangle: rectangle, contentDown: key === 'contour' ? .09 : 0 });
}
}
function renderReport(vm, frontPhoto, sidePhoto, payload) {
var contourPhotoEpoch = 0;
function renderContourSides(reportPhotos) {
var epoch = ++contourPhotoEpoch, wrap = $('#contour-sides');
function refreshHeight() {
var card = wrap.closest('.card-detail'), state = detailSwipes.get(card);
if (state) state.height(Number(card.querySelector('.dtab.active').dataset.tab));
}
wrap.hidden = true;
['left', 'right'].forEach(function(view, i) {
var img = $('#contour-' + view), figure = img.closest('figure');
img.onload = img.onerror = null; img.removeAttribute('src'); figure.hidden = true;
var src = reportPhotos && reportPhotos[i + 1];
if (!src) return;
img.draggable = false;
img.onload = function() { if (epoch === contourPhotoEpoch) { figure.hidden = false; wrap.hidden = false; refreshHeight(); } };
img.onerror = function() {
if (epoch !== contourPhotoEpoch) return;
figure.hidden = true; img.removeAttribute('src');
wrap.hidden = !wrap.querySelector('figure:not([hidden])');
refreshHeight();
};
img.src = src;
});
refreshHeight();
}
function renderReport(vm, frontPhoto, sidePhoto, payload, reportPhotos) {
renderContourSides(reportPhotos || [frontPhoto, sidePhoto, null]);
reportOwner = AccountClient.user() && AccountClient.user().id;
// Historical snapshots also use their saved dimensions, never the old API total.
var total = SkinScore.weightedTotal(vm.dims);
@@ -1529,7 +1567,7 @@
['firm', 'vita', 'stab'].forEach(function (k) {
setDim(k, vm.dims[k], k === worstDimKey ? 'worst' : k === bestDimKey ? 'best' : '');
});
var presentation = payload && payload.presentation || ReportUI.build(vm, payload && payload.result);
var presentation = ReportUI.forDisplay(vm, payload && payload.result, payload && payload.presentation);
var focus = ReportUI.focusOf(vm), focusEl = $('#dims-note');
focusEl.hidden = !focus;
focusEl.replaceChildren();
@@ -1654,7 +1692,8 @@
card.addEventListener('click', function (e) {
var btn = e.target.closest('.dtab');
if (btn) { setDetailTab(card, btn.dataset.tab); return; }
var img = e.target.closest('.ditem-img');
var sideButton = e.target.closest('.contour-side-photo');
var img = sideButton ? sideButton.querySelector('img') : e.target.closest('.ditem-img');
if (img && img.src && !img.hidden && img.closest('.dpanel').classList.contains('active')) {
$('#zoom-img').src = img.src;
openOverlay('p-zoom');
@@ -1779,7 +1818,7 @@
AccountClient.request('api/reports/' + encodeURIComponent(btn.dataset.reportId)).then(function (data) {
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 }));
renderReport(entry.vm, entry.photos[0], entry.photos[1], Object.assign({}, entry.payload, { presentation: entry.presentation }), entry.photos);
showScreen('s-report', 'push');
}).catch(function (e) { toast(e.message); }).finally(function () { btn.disabled = false; });
});
@@ -1905,7 +1944,7 @@
renderReport(SkinScore.buildViewModel(mockRes, mockSide, {
realAge: +($('#f-age').value || 0) || 28, // 调试档案缺省 28 岁
skinType: SKINS[skinIndex].name
}), fx ? fx.photos[0] : null, null, fx ? { result: mockRes, side: mockSide, face_rectangle: fx.face_rectangle } : null);
}), fx ? fx.photos[0] : null, null, fx ? { result: mockRes, side: mockSide, face_rectangle: fx.face_rectangle } : null, fx && fx.photos);
} catch (e) { /* 调试入口异常不阻塞正式流程 */ }
}
})();
+1 -1
View File
@@ -10,7 +10,7 @@
"wrinkles": {
"改善": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。",
"良好": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面部整体平整,仅眼周或口周有细微的干纹,无明显静态深纹,纹路面积占比相对较低。",
"优秀": "面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。"
"优秀": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条;整体纹路状态较好,建议维持日常淡纹与保湿护理。"
},
"color": {
"改善": "检测到当前肌肤色素分 {melanin},存在色素沉着较显著,{dark_circle_note},整体透亮感不足。",
+35 -8
View File
@@ -21,6 +21,19 @@
function dimensionScore(value) {
return numeric(value) ? (Math.round((value + 1e-9) * 10) / 10).toFixed(1) : '-';
}
var gradeRule = 'display-score-8-9-v1';
var copyRule = 'wrinkles-excellent-acne-detected-v1';
function scoreOfP(P) {
return numeric(P) ? Math.round((Math.max(7, Math.min(10, 7 + 3 * P / 100)) + 1e-9) * 100) / 100 : null;
}
function levelForScore(S) {
if (!numeric(S)) return null;
var displayed = Number(dimensionScore(S));
return displayed < 8 ? '改善' : displayed < 9 ? '良好' : '优秀';
}
function internalLevel(S) {
return { '改善': '预警', '良好': '改善', '优秀': '良好' }[levelForScore(S)] || null;
}
// Read saved raw dimension values, never re-score historical reports or use
// the legacy focus.name (which contains the old dimension terminology).
function focusOf(vm) {
@@ -84,12 +97,11 @@
result = result || {};
var views = defs.map(function(d) {
var cat = vm.cats[d[3]], vars = Object.assign({}, cat.vars || {});
var level = levelMap[cat.level], valid = cat.P != null;
var level, valid = numeric(cat.P);
var viewP = cat.P;
if (d[0] === 'color') {
viewP = numeric(vars.color_score) ? vars.color_score : vars.tone_scoring && vars.tone_scoring.color_score;
valid = vars.melanin != null && numeric(viewP);
level = valid ? (viewP < 80 ? '改善' : viewP < 90 ? '良好' : '优秀') : null;
}
if (d[0] === 'water') {
// Water copy/advice is independent of pigment/dark circles. Reuse
@@ -99,8 +111,9 @@
viewP = waterValid ? waterScore : null;
if (waterValid && numeric(vars.water_sev) && vars.water_sev >= 60 && vars.water_sev <= 100 && waterScore >= 80) viewP = 100 - vars.water_sev;
valid = waterValid;
level = valid ? (viewP < 80 ? '改善' : viewP < 90 ? '良好' : '优秀') : null;
}
var viewS = valid ? scoreOfP(viewP) : null;
level = levelForScore(viewS);
var template = valid && level ? Copy.conclusion[d[0]][level] : '';
if (d[0] === 'water' && valid) {
vars.water_severity_note = numeric(vars.water_sev) && vars.water_sev >= 0 && vars.water_sev <= 100
@@ -131,8 +144,9 @@
var nodules = vars.acne_counts ? vars.acne_counts.acne_nodule : count(result, 'acne_nodule');
var heavy = [pustules > 0 ? '脓包' : '', nodules > 0 ? '结节' : ''].filter(Boolean);
vars.heavy_note = heavy.length ? ',含' + heavy.join('、') + '等中重度皮损,炎症反应明显' : '';
if (heavy.length) { level = '改善'; template = Copy.conclusion.acne[level]; }
else if (vars.acne_cnt > 0 && level === '优秀') { level = '良好'; template = Copy.conclusion.acne[level]; }
// Never downgrade the grade independently of the displayed score,
// but never claim absence when the API detected lesions either.
if (vars.acne_cnt > 0) template = '检测到 {acne_cnt} 处皮损({acne_detail}){heavy_note},局部皮损仍需关注。';
if (vars.acne_cnt == null) template = '本次未获取完整皮损计数,请结合检测图片查看。';
else if (vars.acne_cnt === 0) template = '本次未检出明显皮损,请结合检测图片查看。';
if (vars.acne_cnt != null && vars.acne_counts_complete === false) {
@@ -156,7 +170,7 @@
}
var details = d[0] === 'wrinkles' ? wrinkleDetails(result) : null;
var advice = level ? Copy.care[d[0]][level] : null;
return { key: d[0], name: d[1], dim: d[2], source: d[3], P: viewP, level: level || null,
return { key: d[0], name: d[1], dim: d[2], source: d[3], P: viewP, S: viewS, level: level || null,
text: valid ? fill(template, vars) : '本次未检出有效数据,请重新拍摄后查看。',
details: details, valid: valid, advice: valid ? advice : null };
});
@@ -170,7 +184,20 @@
var care = chosen.map(function(v) { return { key: v.key, name: v.name, dim: v.dim, P: v.P, level: v.level,
priority: priority(v) === 0, text: v.advice.care + v.advice.lifestyle }; });
var product = products.filter(function(p) { return p.match === vm.essence.name; })[0] || null;
return { version: Copy.version + '+feedback-2026-10-12+care-2026-10-15', careRule: 'seven-lowest-three-focus-first-v1', dims: dimNames, views: views, care: care, product: product, focus: focus };
return { version: Copy.version + '+feedback-2026-10-12+care-2026-10-15+grade-8-9+two-copy-fixes', gradeRule: gradeRule, copyRule: copyRule, careRule: 'seven-lowest-three-focus-first-v1', dims: dimNames, views: views, care: care, product: product, focus: focus };
}
return { dimensionScore: dimensionScore, build: build, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails, focusOf: focusOf, customerText: customerText };
// 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 || (saved.gradeRule === gradeRule && saved.copyRule === copyRule)) return saved || build(vm, result);
var current = build(vm, result);
var care = (saved.care || []).map(function(item) {
// Pre-seven-item snapshots contain name/text only, without a stable key.
// Match exact known names; never infer from array position or insert items.
var view = current.views.filter(function(v) { return item.key ? v.key === item.key : v.name === item.name; })[0];
return view && view.advice ? Object.assign({}, item, { key: view.key, level: view.level, text: view.advice.care + view.advice.lifestyle }) : item;
});
return Object.assign({}, saved, { gradeRule: gradeRule, copyRule: copyRule, views: current.views, care: care });
}
return { dimensionScore: dimensionScore, scoreOfP: scoreOfP, levelForScore: levelForScore, internalLevel: internalLevel, gradeRule: gradeRule, copyRule: copyRule, build: build, forDisplay: forDisplay, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails, focusOf: focusOf, customerText: customerText };
});
+25 -8
View File
@@ -281,10 +281,10 @@ function fillSuccess(rec, fpJson, persist = true) {
rec.skin_age_display = vm.skinAgeDisplay;
rec.recommendation = vm.recommendation;
rec.presentation = ReportUI.build(vm, fpJson.result);
rec.grade_rule = ReportUI.gradeRule;
rec.vita_score_rule = vm.vitaScoreRule;
rec.sub_scores = Object.fromEntries(rec.presentation.views.map(view => [view.key, {
P: view.P, S: view.P == null ? null : Math.round((Math.max(7, Math.min(10, 7 + 3 * view.P / 100)) + 1e-9) * 100) / 100,
level: view.level === '改善' ? '预警' : view.level === '良好' ? '改善' : view.level === '优秀' ? '良好' : null
P: view.P, S: view.S, level: ReportUI.internalLevel(view.S)
}]));
rec.total_score = vm.total;
rec.total_score_rule = vm.totalRule;
@@ -299,7 +299,7 @@ function fillSuccess(rec, fpJson, persist = true) {
rec.copy_6[k] = vm.cats[k].text;
}
rec.dims = {};
for (const k of Object.keys(vm.dims)) rec.dims[k] = { P: vm.dims[k].P, S: vm.dims[k].S, level: vm.dims[k].level };
for (const k of Object.keys(vm.dims)) rec.dims[k] = { P: vm.dims[k].P, S: vm.dims[k].S, level: ReportUI.internalLevel(vm.dims[k].S) };
rec.avg_s = vm.avgS;
rec.group = vm.group;
rec.copy_summary = vm.summary;
@@ -530,6 +530,10 @@ function displaySkinAge(rec) {
function displayTotalScore(rec) {
return SkinScore.weightedTotal(rec.dims);
}
function displayScoreLevels(scores) {
return scores ? Object.fromEntries(Object.entries(scores).map(([key, value]) => [key,
Object.assign({}, value, { level: ReportUI.internalLevel(value && value.S) })])) : scores;
}
function listItem(rec) {
return {
record_id: rec.record_id,
@@ -541,7 +545,7 @@ function listItem(rec) {
fail_code: rec.fail_code, fail_msg: rec.fail_msg,
total_score: displayTotalScore(rec), total_score_rule: 'dimensions-433-v1', skin_age: rec.skin_age,
skin_age_display: displaySkinAge(rec), recommendation: rec.recommendation || null,
dims: rec.dims, group: rec.group,
dims: displayScoreLevels(rec.dims), grade_rule: ReportUI.gradeRule, group: rec.group,
essence: rec.essence ? rec.essence.name : null,
cost_ms: rec.cost_ms,
retry_of: rec.retry_of, retry_count: rec.retry_count || 0,
@@ -566,7 +570,7 @@ function matchQuery(rec, q) {
if (q.age_max != null) { if (rec.real_age == null || rec.real_age > q.age_max) return false; }
if (q.dim && q.dim_level) {
const d = rec.dims && rec.dims[q.dim];
if (!d || d.level !== q.dim_level) return false;
if (!d || ReportUI.internalLevel(d.S) !== q.dim_level) return false;
}
if (q.only_fail === '1' && rec.detect_status !== '失败') return false;
return true;
@@ -626,10 +630,23 @@ function handleRecordDetail(req, res, id) {
const parent = rec.retry_of ? (all.find(r => r.record_id === rec.retry_of) || null) : null;
const children = all.filter(r => r.retry_of === rec.record_id)
.map(r => ({ record_id: r.record_id, detect_status: r.detect_status, created_at: r.created_at }));
let presentation = rec.presentation || accounts.reportPresentation(rec.owner_id, rec.record_id);
if (presentation && (presentation.gradeRule !== ReportUI.gradeRule || presentation.copyRule !== ReportUI.copyRule) && rawJson && rawJson.result) {
// Recover text variables from raw data, but retain the saved scores,
// recommendation and care order. No database/snapshot mutation.
const displayVm = SkinScore.buildViewModel(rawJson.result, {
left: rawJson.left_side_result || (rawJson.side_result && rawJson.side_result.left_side_result),
right: rawJson.right_side_result || (rawJson.side_result && rawJson.side_result.right_side_result)
}, scoreProfile(rec));
for (const key of Object.keys(rec.cats || {})) Object.assign(displayVm.cats[key], rec.cats[key]);
displayVm.dims = rec.dims || displayVm.dims;
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);
}
sendJson(res, 200, {
ok: true,
record: Object.assign({}, rec, { total_score: displayTotalScore(rec), total_score_rule: 'dimensions-433-v1', skin_age_display: displaySkinAge(rec),
presentation: rec.presentation || accounts.reportPresentation(rec.owner_id, rec.record_id) }),
dims: displayScoreLevels(rec.dims), sub_scores: displayScoreLevels(rec.sub_scores), grade_rule: ReportUI.gradeRule, presentation }),
raw_json: rawJson,
photos: photoUrls(rec),
versions: {
@@ -691,7 +708,7 @@ function handleStats(req, res) {
const group_dist = {};
for (const r of succeeded) {
for (const d of ['firm', 'vita', 'stab']) {
const lv = r.dims && r.dims[d] && r.dims[d].level;
const lv = r.dims && r.dims[d] && ReportUI.internalLevel(r.dims[d].S);
if (lv && dim_dist[d][lv] != null) dim_dist[d][lv] += 1;
}
const g = r.group || '未分组';
@@ -787,7 +804,7 @@ function handleExport(req, res, url) {
.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]) ? r.dims[d].S : null;
const dimLv = (r, d) => (r.dims && r.dims[d]) ? ReportUI.levelMap[r.dims[d].level] : null;
const dimLv = (r, d) => (r.dims && r.dims[d]) ? ReportUI.levelForScore(r.dims[d].S) : null;
const header = ['记录ID', '创建时间(北京时间)', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
'失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄(推荐依据)', '前端展示肌龄',
'紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档',
+1 -1
View File
@@ -18,7 +18,7 @@ assert.deepEqual(result.cat.vars.acne_scoring.types[1].sources,['left','right'])
assert.equal(result.vm.dims.stab.S,9.61);assert(result.view.text.includes('丘疹1处'));
assert.equal(run([2,0,0,0,3],[5,1,0,0,1],[3,1,0,0,4],80).cat.P,70,'front score mapping still limits score');
const heavy=run([0,0,0,0,0],[0,0,1,0,0],[0,0,0,1,0]);
assert.equal(heavy.cat.P,90);assert.equal(heavy.view.level,'改善');
assert.equal(heavy.cat.P,90);assert.equal(heavy.view.level,'优秀');
assert(heavy.view.text.includes('脓包1处')&&heavy.view.text.includes('结节1处'));
assert(heavy.view.text.includes('含脓包、结节'));
const empty=run([null,null,null,null,null],[null,null,null,null,null],[null,null,null,null,null]);
+3 -2
View File
@@ -24,9 +24,10 @@ check([79.999,79.998,79.997,99,99,99,99],'firm',['wrinkles','contour','color']);
check([null,null,50,null,60,null,null],'firm',['color','water']);
check([null,null,null,null,null,null,null],null,[]);
const x=check([99,98,79.99,99,90,99,99],'vita',['color','water','wrinkles']);
assert.equal(x.ui.views.find(v=>v.key==='color').level,'改善');assert.equal(x.ui.views.find(v=>v.key==='water').level,'优秀');
assert.equal(x.ui.views.find(v=>v.key==='color').level,'优秀');assert.equal(x.ui.views.find(v=>v.key==='water').level,'优秀');
assert.equal(x.ui.careRule,'seven-lowest-three-focus-first-v1');
const heavy=sample(Object.fromEntries(keys.map(k=>[k,95])),'stab');heavy.vm.cats.acne.vars.acne_counts.acne_nodule=1;heavy.vm.cats.acne.vars.acne_cnt=1;
assert.equal(UI.build(heavy.vm,{}).views.find(v=>v.key==='acne').level,'改善');
assert.equal(UI.build(heavy.vm,{}).views.find(v=>v.key==='acne').level,'优秀');
assert(UI.build(heavy.vm,{}).views.find(v=>v.key==='acne').text.includes('结节'));
assert(!Copy.care.wrinkles['改善'].lifestyle.startsWith('保证睡眠'));
console.log('PASS care: global lowest3, focus-first selected only, two/one/no focus members, tie/document order, raw precision, missing, independent color/water, acne guard, exact copy and immutable scores');
+1 -1
View File
@@ -8,7 +8,7 @@ const templates={
优秀:'检测到当前肌肤色素分 88,无明显色素沉积,{note};肤色均匀透亮。'
};
let checked=0;
for(const [P,level] of [[79.99,'改善'],[80,'良好'],[89.99,'良好'],[90,'优秀'],[100,'优秀']]){
for(const [P,level] of [[30,'改善'],[40,'良好'],[60,'良好'],[65,'优秀'],[79.99,'优秀'],[100,'优秀']]){
for(const severity of [0,1,2,3,null,undefined,-1,4,1.5,'2',NaN,Infinity]){
const result=structuredClone(SS.MOCK_RESULT);
result.score_info.melanin_score=88;
+88
View File
@@ -0,0 +1,88 @@
// Browser-only fixtures, no writes/paid detection. Full photo opens in the
// existing zoom overlay while thumbnail cropping is CSS-only.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {execSync} from 'node:child_process';
import {pathToFileURL} from 'node:url';
const {chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href);
const browser=await chromium.launch();
try {
const page=await browser.newPage({viewport:{width:375,height:1000},isMobile:true,hasTouch:true});
const errors=[];page.on('pageerror',e=>errors.push(e.message));
const source=fs.readFileSync(new URL('../js/app.js',import.meta.url),'utf8');
await page.route('**/api/**',r=>r.fulfill({json:{ok:true,user:{kind:'browser'}}}));
await page.route('**/js/app.js*',r=>r.fulfill({contentType:'text/javascript',body:source.replace(/\}\)\(\);\s*$/,`
window.__sideTest=function(ph){
var fx=window.MOCK_FIXTURE,vm=SkinScore.buildViewModel(fx.result,fx.side,{});
renderReport(vm,fx.photos[0],null,{result:fx.result},ph);showScreen('s-report');
};})();`)}));
await page.goto((process.env.H5_BASE_URL||'http://127.0.0.1:8080/')+'?mock=1&screen=report');
const photo=color=>'data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240"><rect width="320" height="240" fill="'+color+'"/></svg>');
const left=photo('red'),right=photo('blue');
for(const width of [320,375,414,480]) {
await page.setViewportSize({width,height:1000});
await page.evaluate(ph=>window.__sideTest(ph),[null,left,right]);
await page.waitForFunction(()=>[...document.querySelectorAll('#contour-sides figure')].every(f=>!f.hidden&&f.querySelector('img').naturalWidth===320));
const figures=page.locator('#contour-sides figure');
assert.equal(await figures.count(),2);
assert.equal((await page.locator('#contour-sides').textContent()).trim(),'','no visible left/right captions');
assert.equal(await page.locator('#contour-left').getAttribute('src'),left);
assert.equal(await page.locator('#contour-right').getAttribute('src'),right);
await page.waitForFunction(()=>document.querySelector('#ditem-img1').naturalWidth>0);
const crop=await page.locator('#ditem-img1').evaluate(img=>{
const i=img.getBoundingClientRect(),f=img.parentElement.getBoundingClientRect();
const other=document.querySelector('#ditem-img2').parentElement.getBoundingClientRect();
return{ratio:f.height/i.height,top:i.top-f.top,width:i.width-f.width,other:other.height/i.height,overflow:getComputedStyle(img.parentElement).overflow};
});
assert(Math.abs(crop.ratio-.9)<.002);assert(Math.abs(crop.top)<1);assert(Math.abs(crop.width)<1);
assert(Math.abs(crop.other-1)<.002);assert.equal(crop.overflow,'hidden');
const offset=await page.locator('#ditem-img1').getAttribute('data-content-offset');
assert(offset!=null&&Number(offset)>=0&&Number(offset)<=.090001);
const fullFront=await page.locator('#ditem-img1').getAttribute('src');
await page.locator('#ditem-img1').click();
assert.equal(await page.locator('#zoom-img').getAttribute('src'),fullFront,'front zoom still uses uncropped source');
await page.locator('#p-zoom').click();
const geo=await page.locator('.contour-side-photo').first().evaluate(e=>{const r=e.getBoundingClientRect();return{w:r.width,h:r.height,fit:getComputedStyle(e.querySelector('img')).objectFit};});
assert(Math.abs(geo.h/geo.w-92.25/104.35)<.002);assert.equal(geo.fit,'cover');
for(const img of await page.locator('.contour-side-photo img').all()) {
const fill=await img.evaluate(e=>{
const m=new DOMMatrix(getComputedStyle(e).transform),r=e.getBoundingClientRect(),b=e.parentElement.getBoundingClientRect();
return{sx:m.a,sy:m.d,shift:m.f/b.height,top:r.top-b.top,bottom:r.bottom-b.bottom};
});
assert(Math.abs(fill.sx-1.3)<.001);assert(Math.abs(fill.sy-1.3)<.001);assert(Math.abs(fill.shift-.15)<.001);
assert(Math.abs(fill.top)<1&&fill.bottom>0,'zoomed fill covers frame without blank top edge');
}
const spacing=await page.locator('#contour-sides').evaluate(e=>{
const frame=document.querySelector('.contour-front-frame'),f=frame.getBoundingClientRect(),s=e.getBoundingClientRect();
return{u:f.width/215,horizontal:parseFloat(getComputedStyle(e).columnGap),vertical:s.top-f.bottom};
});
assert(Math.abs(spacing.horizontal/spacing.u-6.3)<.1);
assert(Math.abs(spacing.vertical/spacing.u-7.56)<.1);
await page.getByRole('button',{name:'查看左脸完整大图'}).click();
assert(await page.locator('#p-zoom').evaluate(e=>e.classList.contains('show')));
assert.equal(await page.locator('#zoom-img').getAttribute('src'),left);
assert.equal(await page.locator('#zoom-img').evaluate(e=>e.naturalWidth/e.naturalHeight),4/3);
await page.locator('#p-zoom').click();
await page.getByRole('button',{name:'查看右脸完整大图'}).click();
assert.equal(await page.locator('#zoom-img').getAttribute('src'),right);
await page.keyboard.press('Escape');
assert(!await page.locator('#p-zoom').evaluate(e=>e.classList.contains('show')));
await page.locator('.card-detail').first().locator('[data-tab="1"]').click();
await page.waitForTimeout(400);
assert(await page.locator('#contour-sides').evaluate(e=>{
const r=e.getBoundingClientRect(),v=e.closest('.detail-viewport').getBoundingClientRect();
return e.closest('.dpanel').getAttribute('aria-hidden')==='true'&&r.right<=v.left+1;
}),'contour thumbnails remain clipped outside the wrinkle tab');
}
await page.evaluate(ph=>window.__sideTest(ph),[null,left,null]);
await page.waitForFunction(()=>!document.querySelector('#contour-left').closest('figure').hidden);
assert(await page.locator('#contour-right').evaluate(e=>e.closest('figure').hidden&&!e.hasAttribute('src')));
await page.evaluate(()=>window.__sideTest([null,null,null]));
assert(await page.locator('#contour-sides').evaluate(e=>e.hidden));
assert.equal(await page.locator('#contour-sides img[src]').count(),0,'new report clears previous side photos');
await page.evaluate(()=>window.__sideTest([null,'data:image/jpeg;base64,broken',null]));
await page.waitForFunction(()=>!document.querySelector('#contour-left').hasAttribute('src'));
assert(await page.locator('#contour-sides').evaluate(e=>e.hidden));
assert.deepEqual(errors,[]);
console.log('PASS contour side photos: 4 widths, preserved thumbnail heights and 30% tighter gaps, left/right sources, full-aspect zoom, close/Escape, tab isolation, one/missing/broken photos and stale-photo cleanup');
} finally {await browser.close();}
+10 -1
View File
@@ -36,11 +36,12 @@ assert(!text.includes('分区明细') && !text.includes('标准化长度'));
const approved = {
'预警': '全脸共检测到皱纹 12 条、细纹 34 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。',
'改善': '全脸共检测到皱纹 12 条、细纹 34 条,面部整体平整,仅眼周或口周有细微的干纹,无明显静态深纹,纹路面积占比相对较低。',
'良好': '面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。'
'良好': '全脸共检测到皱纹 12 条、细纹 34 条;整体纹路状态较好,建议维持日常淡纹与保湿护理。'
};
for (const level of Object.keys(approved)) {
const fixture = JSON.parse(modelBefore);
fixture.cats.wrinkles.level = level;
fixture.cats.wrinkles.P = { '预警': 20, '改善': 50, '良好': 90 }[level];
fixture.cats.wrinkles.vars.wr_cnt = 12;
fixture.cats.wrinkles.vars.fl_cnt = 34;
assert.equal(ReportUI.build(fixture, result).views.find(view => view.key === 'wrinkles').text, approved[level]);
@@ -49,6 +50,7 @@ assert.equal(JSON.stringify(model), modelBefore);
assert.equal(JSON.stringify(result), rawBefore);
const excellent = JSON.parse(JSON.stringify(model));
excellent.cats.smooth.level = '良好'; // Legacy level name maps to new "优秀".
excellent.cats.smooth.P = 90;
excellent.cats.smooth.vars.pore_cnt = 7;
excellent.cats.smooth.vars.blackhead_cnt = 2;
let smooth = ReportUI.build(excellent, result).views.find(view => view.key === 'smooth');
@@ -67,6 +69,13 @@ assert(crop.width < 1600 && crop.height < 1800);
assert(crop.left <= 1479 && crop.top <= 1133);
assert(crop.left + crop.width >= 2595 && crop.top + crop.height >= 2249);
assert(Math.abs(crop.width / crop.height - 215 / 278) < 1e-8);
const moved = sandbox.vizShiftContentDown(crop,{left:1479,top:1133,width:1116,height:1116},.09);
assert(Math.abs(moved.top-(crop.top-crop.height*.09))<1e-8);
assert.equal(moved.width,crop.width);assert.equal(moved.height,crop.height);assert.equal(moved.left,crop.left);
assert(moved.top>=0&&moved.top+moved.height>=2249);
assert.equal(sandbox.vizShiftContentDown(crop,null,.09),crop);
const edgeCrop=sandbox.vizFaceCrop(1000,1000,{left:100,top:0,width:300,height:300});
assert.equal(sandbox.vizShiftContentDown(edgeCrop,{left:100,top:0,width:300,height:300},.09).top,0);
for (const rect of [null, { left: NaN }, { left: 0, top: 0, width: -1, height: 20 },
{ left: 3900, top: 0, width: 1000, height: 1000 }, { left: 0, top: 0, width: '100', height: 100 }]) {
const full = sandbox.vizFaceCrop(4000, 3000, rect);
+42
View File
@@ -0,0 +1,42 @@
'use strict';
const assert = require('node:assert/strict');
const SS = require('../js/skin-score'), UI = require('../js/report-ui'), Copy = require('../js/report-copy');
for (const [S, level] of [[7,'改善'],[7.94,'改善'],[7.95,'良好'],[8,'良好'],[8.94,'良好'],[8.95,'优秀'],[9,'优秀'],[9.3,'优秀'],[10,'优秀'],[null,null],[NaN,null]]) {
assert.equal(UI.levelForScore(S), level);
}
const keys = ['contour','wrinkles','color','smooth','water','sens','acne'];
for (const [P, level] of [[0,'改善'],[20,'改善'],[31.49,'改善'],[31.5,'良好'],[50,'良好'],[64.83,'良好'],[65,'优秀'],[76,'优秀'],[79,'优秀'],[100,'优秀'],[null,null]]) {
const r = structuredClone(SS.MOCK_RESULT), vm = SS.buildViewModel(r, SS.MOCK_SIDE, {});
for (const key of ['contour','wrinkles','smooth','sens','acne']) vm.cats[key].P = P;
Object.assign(vm.cats.tone.vars, {melanin:P,color_score:P,water_score:P,water_sev:0});
vm.cats.tone.vars.tone_scoring.color_score = P;
const before = JSON.stringify({vm,r}), ui = UI.build(vm,r);
for (const view of ui.views) {
assert.equal(view.level,level,view.key+' P='+P);
assert.deepEqual(view.advice,level ? Copy.care[view.key][level] : null);
assert.equal(view.S,UI.scoreOfP(P));
}
assert.equal(JSON.stringify({vm,r}),before);
assert.equal(ui.gradeRule,UI.gradeRule);
for (const care of ui.care) assert.equal(care.level,level);
assert.deepEqual(ui.views.map(v=>v.key),keys);
}
const r = structuredClone(SS.MOCK_RESULT);
r.eye_pouch_severity = {value:2};
r.score_info.wrinkle_score = 76;
const vm = SS.buildViewModel(r,SS.MOCK_SIDE,{}), ui = UI.build(vm,r);
assert.equal(UI.dimensionScore(vm.dims.firm.S),'9.3');
assert.equal(ui.views.find(v=>v.key==='contour').level,'优秀');
assert(ui.views.find(v=>v.key==='contour').text.includes('眼周无明显眼袋问题'),'contour copy remains original by user request');
const old = structuredClone(ui);
delete old.gradeRule;
old.views[0].level='改善';old.views[0].text='旧轮廓文案';
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.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);
console.log('PASS: seven views/advice use displayed 8/9 grades; rounding, missing, 9.3 case, unchanged contour copy, immutable history and product/order');
+32
View File
@@ -0,0 +1,32 @@
'use strict';
const assert=require('node:assert/strict'), SS=require('../js/skin-score'), UI=require('../js/report-ui'), Copy=require('../js/report-copy');
const Original=require('./fixtures/report-copy-before-two-fixes.cjs');
// Strict scope guard: every care template and 20 other conclusion templates
// must be byte-for-byte identical to the released baseline.
assert.deepEqual(Copy.care,Original.care);
for(const key of Object.keys(Original.conclusion)) for(const level of Object.keys(Original.conclusion[key])) {
if(key==='wrinkles'&&level==='优秀') continue;
assert.equal(Copy.conclusion[key][level],Original.conclusion[key][level],key+' '+level+' must stay original');
}
const r=structuredClone(SS.MOCK_RESULT), vm=SS.buildViewModel(r,SS.MOCK_SIDE,{});
vm.cats.wrinkles.P=76;Object.assign(vm.cats.wrinkles.vars,{wr_cnt:10,fl_cnt:10});
vm.cats.acne.P=95;
Object.assign(vm.cats.acne.vars,{acne_cnt:2,acne_detail:'丘疹2处',acne_counts_complete:true,acne_counts:{closed_comedones:0,acne:2,acne_pustule:0,acne_nodule:0,acne_mark:0}});
const before=JSON.stringify({vm,r}),ui=UI.build(vm,r),view=k=>ui.views.find(v=>v.key===k);
assert.equal(view('wrinkles').text,'全脸共检测到皱纹 10 条、细纹 10 条;整体纹路状态较好,建议维持日常淡纹与保湿护理。');
assert.equal(view('acne').text,'检测到 2 处皮损(丘疹2处),局部皮损仍需关注。');
assert.equal(view('acne').level,'优秀');
assert.equal(JSON.stringify({vm,r}),before);
const saved={gradeRule:UI.gradeRule,copyRule:'retired',product:{tier:3,title:'旧推荐'},care:ui.views.slice().reverse().map(v=>({name:v.name,text:'旧护理'}))};
const bytes=JSON.stringify(saved),display=UI.forDisplay(vm,r,saved);
assert.equal(display.copyRule,UI.copyRule);assert.deepEqual(display.product,saved.product);
assert.deepEqual(display.care.map(v=>v.name),saved.care.map(v=>v.name));
assert.equal(JSON.stringify(saved),bytes);
for(const item of display.care){const v=view(item.key);assert.equal(item.level,v.level);assert.equal(item.text,Copy.care[item.key][v.level].care+Copy.care[item.key][v.level].lifestyle);}
assert.equal(UI.forDisplay(vm,r,{...saved,care:saved.care.slice(0,2)}).care.length,2);
const unknown={name:'未知旧项目',text:'保留原文'};
assert.deepEqual(UI.forDisplay(vm,r,{...saved,care:[unknown]}).care[0],unknown);
const missing=structuredClone(vm);Object.assign(missing.cats.wrinkles.vars,{wr_cnt:null,fl_cnt:null});
const text=UI.build(missing,{}).views.find(v=>v.key==='wrinkles').text;
assert(text.includes('计数未获取'));assert(!text.includes('皱纹 0 条'));assert(!/[{}]|undefined|NaN/.test(text));
console.log('PASS strict two-copy scope: all care and 20 other templates unchanged; excellent wrinkles, detected acne, legacy care mapping and immutable data');
+141
View File
@@ -0,0 +1,141 @@
// Generated from reference/AI测肤报告页话术修改.md; run tools/build_report_copy.py.
(function(root,factory){if(typeof module==="object"&&module.exports)module.exports=factory();else root.ReportCopy=factory();})(typeof window!=="undefined"?window:this,function(){return {
"version": "2026-10-09",
"conclusion": {
"contour": {
"改善": "面部轮廓紧致度有待提升,下颌线清晰度不足,左右下颌线角度分别为左 {la}°/ 右 {ra}°;眼袋明显、黑眼圈轻/中/重度(根据分数给出判断);中面部容积略欠饱满,肌肤支撑力偏弱。",
"良好": "面部轮廓清晰,下颌线较为清晰,左右下颌线角度左 {la}°/ 右 {ra}°;眼周眼袋初显、黑眼圈轻/中/重度(根据分数给出判断);中面部容积无明显流失;肌肤处于健康状态,支撑力表现良好。",
"优秀": "面部轮廓紧致立体,下颌线清晰;眼周无明显眼袋问题、黑眼圈轻/中/重度(根据分数给出判断);中面部容积饱满,肌肤支撑力充足。"
},
"wrinkles": {
"改善": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。",
"良好": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面部整体平整,仅眼周或口周有细微的干纹,无明显静态深纹,纹路面积占比相对较低。",
"优秀": "面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。"
},
"color": {
"改善": "检测到当前肌肤色素分 {melanin},存在色素沉着较显著,{dark_circle_note},整体透亮感不足。",
"良好": "检测到当前肌肤色素分 {melanin},局部有色素沉积;{dark_circle_note},整体状态稳定,局部透亮感稍弱。",
"优秀": "检测到当前肌肤色素分 {melanin},无明显色素沉积,{dark_circle_note};肤色均匀透亮。"
},
"smooth": {
"改善": "当前肌肤平滑度还有提升空间,检测到粗大毛孔 {pore_cnt} 颗、黑头 {blackhead_cnt} 颗,肤感整体细腻度有提升空间,纹理不均。",
"良好": "肌肤整体较为平滑,局部区域细腻度不足;检测到粗大毛孔 {pore_cnt} 颗、黑头 {blackhead_cnt} 颗,有轻微角质粗糙感。",
"优秀": "肌肤质感表现优异,表面细腻平滑,角质层排列整齐,无明显粗大毛孔与黑头。"
},
"water": {
"改善": "水分分 {water_score};缺水面积 {water_area}%,{water_severity_note}肌肤水润度有待提升,建议加强补水与保湿。",
"良好": "水分分 {water_score};缺水面积 {water_area}%、肌肤含水量处于健康水平,整体水润度良好。",
"优秀": "水分分 {water_score};角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。"
},
"sens": {
"改善": "泛红面积达 {sens_area}%,肌肤泛红明显,敏感区域可见泛红;对外界刺激耐受度不足,容易受外界因素影响产生状态波动。",
"良好": "泛红面积 {sens_area}%,肌肤存在局部轻微泛红;对外界环境有良好的耐受度,仅在接触强刺激物时偶有轻微反应。",
"优秀": "泛红面积 {sens_area}%,无明显泛红、敏感迹象;对外界刺激耐受度佳,肌肤处于稳定的健康状态。"
},
"acne": {
"改善": "检测到 {acne_cnt} 处皮损({acne_detail}){heavy_note},肌肤状态不稳定,毛孔易堵塞,皮脂分泌波动明显。",
"良好": "检测到 {acne_cnt} 处皮损({acne_detail}),以闭口粉刺、丘疹为主,无明显红肿痘痘;皮脂分泌基本正常,毛孔偶有轻微堵塞。",
"优秀": "未检出明显痘痘、粉刺或闭口,无明显红肿、脓包迹象。"
}
},
"care": {
"contour": {
"改善": {
"care": "重点进行轮廓紧致管理,关注面中、苹果肌与下颌线区域。日常增加紧致精华步骤,配合由下向上的轻柔提升按摩,帮助肌肤维持弹润和支撑感,让轮廓看起来更紧实。",
"lifestyle": "保证充足、规律的睡眠,让肌肤在夜间充分修护;坚持防晒,减少紫外线带来的老化影响;减少熬夜。"
},
"良好": {
"care": "关注面中、苹果肌、下颌线等容易显疲态区域的支撑感。日常可叠加紧致精华步骤,并配合向上提升按摩手法,预防疲态引起的松弛与垮塌累积,稳住面部支撑线。",
"lifestyle": "日常注意防晒,保持规律作息;坚持适度运动,帮助肌肤维持活力。"
},
"优秀": {
"care": "保持现有紧致抗衰护理节奏,日常叠加使用提亮精华,坚持防晒,加强光损伤防护与状态管理,持续关注面中疲态与轮廓支撑状态,让紧致状态更稳定、更持久。",
"lifestyle": "坚持防晒与规律作息,均衡饮食,保证优质蛋白、新鲜蔬果和健康脂肪摄入,适度运动。"
}
},
"wrinkles": {
"改善": {
"care": "重视纹路管理,日常可使用含有肽类、胶原类成分的淡纹精华,针对眼周、法令纹等动态纹高发区域配合点按手法,遏制静态纹转化,帮肌肤恢复平滑细腻的状态。",
"lifestyle": "注意睡姿,减少面部长期受压;减少夸张表情,避免揉眼、托腮等表情性挤压习惯,减少纹路加深。"
},
"良好": {
"care": "加强日常淡纹与肌肤弹润护理,叠加使用含肽类、胶原类成分的淡纹精华,提升肌肤水润度与饱满感,维持肤表平滑。",
"lifestyle": "减少夸张表情反复牵拉,睡前加强保湿修护。"
},
"优秀": {
"care": "以预防初纹为核心,搭配抗初老型精华,提前建立抗氧化/紧致护理习惯,延缓第一道静态纹出现。",
"lifestyle": "保持良好作息与运动习惯,坚持防晒,减轻光老化与糖化负担,维持肌肤稳定状态。"
}
},
"color": {
"改善": {
"care": "重点关照暗沉与色素易沉积区域,强化透亮与肤色均匀,严格防晒,配合温和焕亮或抗氧化精华帮助均匀肤色与透亮感。",
"lifestyle": "日常加强防晒与抗氧化护理,减少熬夜与压力引发的暗沉,适当增加富含维C的蔬果摄入。"
},
"良好": {
"care": "加强日常光泽管理与局部暗沉护理,做好防晒与抗氧化打底;叠加使用温和焕亮精华,稳住当前肤色状态并提升整体通透感。",
"lifestyle": "持续做好防晒,保持作息规律,避免波动性色沉,守护均匀透亮感。"
},
"优秀": {
"care": "维持现有防晒与抗氧化护肤习惯,叠加使用温和焕亮精华,巩固肤色均匀度与透亮感。",
"lifestyle": "坚持日常防晒,保持均衡饮食支持皮肤稳态。"
}
},
"smooth": {
"改善": {
"care": "关注毛孔可见度与肤表粗糙感改善,加强角质管理,提升肌肤平滑细腻度。",
"lifestyle": "减少高糖高脂饮食,降低皮脂分泌刺激,定期温和调理角质,避免用手挤压黑头。"
},
"良好": {
"care": "加强基础清洁与角质管理,关注鼻翼、下颌等易堵塞部位,提升毛孔通畅度与肌肤细腻度。",
"lifestyle": "温水洁面避免烫洗,保持床品清洁减少局部刺激。"
},
"优秀": {
"care": "维持当前温和护理节奏,维护角质层健康与肌肤屏障稳定状态。",
"lifestyle": "保持规律清洁与保湿习惯,避免频繁更换功效类产品,减少肌肤适应负担。"
}
},
"water": {
"改善": {
"care": "强化屏障锁水修复,注意水油平衡,每周2-3次深度补水,深度改善肌肤缺水状态。",
"lifestyle": "每日足量饮水,补充身体水分,改善干燥环境湿度,避免长时间空调直吹。"
},
"良好": {
"care": "日常需注重水油平衡调理,避免过度清洁损伤皮肤屏障,注意为肌肤持续补水。",
"lifestyle": "每日足量饮水,环境干燥时及时增加肌肤水分补充。"
},
"优秀": {
"care": "持续为皮肤补水与保湿,避免过度清洁与频繁去角质,巩固肌肤水润度与水油平衡。",
"lifestyle": "坚持多喝水与规律作息,季节交替时提前调整保湿方案。"
}
},
"sens": {
"改善": {
"care": "关注屏障修复与舒缓,精简护肤流程,选择温和洁面方式,修复受损角质层。若状态持续无明显好转,可寻求专业护肤建议。",
"lifestyle": "减少外界刺激,如酒精、香精、高浓度酸类精华等成分,避免冷热骤变刺激。"
},
"良好": {
"care": "保持温和护理节奏,注意防晒与环境防护,逐步提升皮肤耐受度。",
"lifestyle": "避免冷热骤变刺激肌肤屏障,运动后及时清洁降温。"
},
"优秀": {
"care": "持续规律护理并按季节阶段性调整,巩固皮肤耐受稳态。",
"lifestyle": "保持健康作息与均衡饮食,维持皮肤屏障稳态。"
}
},
"acne": {
"改善": {
"care": "优先针对性调理痘区肌肤状态,精简护肤流程,避免频繁更换护肤方案与叠加功效成分;严重时及时就医。",
"lifestyle": "减少高糖高脂饮食与熬夜,避免手挤痘痘;保持床品、毛巾清洁,运动后及时清洁,减少汗液与皮脂堆积。"
},
"良好": {
"care": "保持温和清洁与肌肤炎症调理,关注T区、下颌等易出油区域,提升毛孔通畅度,同时注意防晒与保湿。",
"lifestyle": "保持规律作息与清淡饮食,运动后及时清洁,减少汗液与皮脂堆积。"
},
"优秀": {
"care": "坚持定期清洁,维持水油平衡与毛孔通畅,巩固皮肤耐受稳态。",
"lifestyle": "保持健康作息与均衡饮食,维持皮肤屏障健康稳定。"
}
}
}
};});
+4
View File
@@ -41,6 +41,10 @@ try{
return{heading:rect('.prod-title'),care:rect('.card-care'),product:rect('.card-product'),info:rect('.prod-info'),title:rect('#product-title'),name:rect('#product-name'),image:rect('#product-image'),badge:rect('.prod-badge'),disclaimer:rect('.report-disclaimer'),align:getComputedStyle(document.querySelector('.prod-title')).textAlign,headingFont:parseFloat(getComputedStyle(document.querySelector('.prod-title')).fontSize)};
});
assert.equal(geo.align,'center');assert(geo.heading.bottom<=geo.care.y+1);
const divider=await p.locator('.product-section-heading').evaluate(e=>{const s=getComputedStyle(e,'::before');return{width:s.borderTopWidth,color:s.borderTopColor,left:s.left,right:s.right};});
assert.equal(divider.width,'1px');assert.equal(divider.color,'rgb(221, 221, 221)');assert.equal(divider.left,'0px');assert.equal(divider.right,'0px');
const careGap=await p.locator('.product-section-heading').evaluate(e=>({gap:parseFloat(getComputedStyle(e).marginTop),u:document.querySelector('.viz-frame').getBoundingClientRect().width/215}));
assert(Math.abs(careGap.gap-careGap.u*24.0625)<.1,'care/product whitespace reduced by 30%');
assert(Math.abs(geo.heading.w-595/1080*width)<.1,'Figma text group width scales with preview');
const stabilityFont=await p.locator('.dimension-stability-title').evaluate(e=>parseFloat(getComputedStyle(e).fontSize));
assert(Math.abs(geo.headingFont-stabilityFont)<.01,'bottom title matches stability heading font size at every width');
+37
View File
@@ -0,0 +1,37 @@
'use strict';
// Input is private, sanitized real-case data outside Git. No HTTP writes,
// upstream API calls, scoring recalculation or production database access.
const fs=require('node:fs'), assert=require('node:assert/strict'), UI=require('../js/report-ui');
const input=process.env.H5_COPY_CASE_FILE;
if (!input) throw Error('Set H5_COPY_CASE_FILE to private JSON outside the repository');
const cases=JSON.parse(fs.readFileSync(input,'utf8'));
let views=0, careItems=0;
for(const c of cases) {
const bytes=JSON.stringify(c), out=UI.forDisplay(c.vm,c.result,c.saved);
assert.equal(JSON.stringify(c),bytes,c.id+' immutable input');
assert.deepEqual(out.product,c.saved.product,c.id+' saved recommendation');
assert.deepEqual(out.care.map(v=>v.name),c.saved.care.map(v=>v.name),c.id+' care selection/order');
assert.equal(out.views.length,7);
for(const v of out.views) {
views++;
assert.equal(v.level,UI.levelForScore(v.S),c.id+' '+v.key);
assert(!/[{}]|undefined|NaN/.test(v.text),c.id+' unresolved copy');
if(v.key==='wrinkles'&&v.level==='优秀') {
const vars=c.vm.cats.wrinkles.vars;
if(vars.wr_cnt!=null) assert(v.text.includes('皱纹 '+vars.wr_cnt+' 条'));
if(vars.fl_cnt!=null) assert(v.text.includes('细纹 '+vars.fl_cnt+' 条'));
assert(!/无静态深纹|均无明显皱纹|固化趋势/.test(v.text));
}
if(v.key==='acne'&&c.vm.cats.acne.vars.acne_cnt>0) {
assert(v.text.includes('局部皮损仍需关注')); assert(!v.text.includes('未检出'));
}
}
for(const item of out.care) {
careItems++;
const v=out.views.find(v=>v.key===item.key);
assert(v,c.id+' legacy name unresolved: '+item.name);
assert.equal(item.level,v.level);
assert.equal(item.text,v.advice.care+v.advice.lifestyle);
}
}
console.log(`PASS ${cases.length} real cases, ${views} views, ${careItems} care items: score-based grades, factual copy, legacy care names, immutable scores/product/order`);
+22 -5
View File
@@ -68,6 +68,9 @@ try {
assert.equal(adminRecord.skin_age_display, result.vm.skinAgeDisplay);
assert.equal(adminRecord.total_score, expectedTotal);
assert.equal(adminRecord.total_score_rule, 'dimensions-433-v1');
assert.equal(adminRecord.grade_rule, ReportUI.gradeRule);
for (const dim of Object.values(adminRecord.dims)) assert.equal(dim.level,ReportUI.internalLevel(dim.S));
for (const item of Object.values(adminRecord.sub_scores)) assert.equal(item.level,ReportUI.internalLevel(item.S));
assert.deepEqual(adminRecord.acne_scoring,result.vm.cats.acne.vars.acne_scoring);
assert.deepEqual(adminRecord.tone_scoring,result.vm.cats.tone.vars.tone_scoring);
assert.equal(adminRecord.vita_score_rule,'independent-color-water-smooth-min-v1');
@@ -94,6 +97,8 @@ try {
assert.deepEqual(snapshot.report.presentation, result.presentation);
assert.deepEqual(snapshot.report.payload.result.face_maps, result.result.face_maps);
assert.equal(snapshot.report.photos[0], fixture.photos[0]);
assert.equal(snapshot.report.photos[1],fixture.photos[0]); // This HTTP test submits the same fixture for all three views.
assert.equal(snapshot.report.photos[2],fixture.photos[0]);
assert.equal(snapshot.report.vm.total, expectedTotal);
assert.equal(JSON.parse(fs.readFileSync(path.join(dataRoot, 'records.jsonl'), 'utf8').trim()).total_score, expectedTotal, 'new records persist weighted score');
const desktop = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
@@ -106,7 +111,7 @@ try {
assert((await row.textContent()).includes('展示 ' + expectedAge + '岁'));
assert((await row.textContent()).includes("1'me 主场精华 I(25+)"));
for (const key of ['firm', 'vita', 'stab']) {
assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + ReportUI.dimensionScore(result.vm.dims[key].S) + ' ' + ReportUI.levelMap[result.vm.dims[key].level]));
assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + ReportUI.dimensionScore(result.vm.dims[key].S) + ' ' + ReportUI.levelForScore(result.vm.dims[key].S)));
}
assert.deepEqual(await desktop.locator('#f-dimlevel option').allTextContents(), ['不限档位', '改善', '良好', '优秀']);
if (process.env.H5_ARTIFACT_DIR) {
@@ -114,10 +119,11 @@ try {
await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-list.png'), fullPage: true });
}
await desktop.selectOption('#f-dim', 'firm');
await desktop.selectOption('#f-dimlevel', adminRecord.dims.firm.level);
await desktop.selectOption('#f-dimlevel', { value: adminRecord.dims.firm.level });
const filtered = desktop.waitForResponse(r => r.url().includes('/api/admin/records?'));
await desktop.click('#btn-search');
assert.equal((await (await filtered).json()).total, 1);
const filteredResponse = await filtered;
assert.equal((await filteredResponse.json()).total, 1, filteredResponse.url() + ' expected grade=' + adminRecord.dims.firm.level);
await row.click();
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('推荐依据'));
const detailText = await desktop.locator('#d-body').textContent();
@@ -163,6 +169,9 @@ try {
await page.click('[data-report-id]');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#stat-score b').textContent(), String(expectedTotal));
await page.waitForFunction(()=>[...document.querySelectorAll('#contour-sides figure')].every(f=>!f.hidden));
assert.equal(await page.locator('#contour-left').getAttribute('src'),snapshot.report.photos[1]);
assert.equal(await page.locator('#contour-right').getAttribute('src'),snapshot.report.photos[2]);
if (process.env.H5_ARTIFACT_DIR) {
await page.waitForTimeout(400); // Capture after the report's entrance fade.
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'report-weighted-total.png') });
@@ -190,6 +199,9 @@ 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 = '历史报告原文,不应按新文案重新计算。';
delete legacy.presentation.gradeRule; // Old copy must regrade for display only.
delete legacy.presentation.copyRule;
for (const care of legacy.presentation.care) { delete care.key; delete care.level; care.text='旧护理待刷新'; }
legacy.vm.skinAgeDisplay = 57; // Old snapshot retains the retired display clamp.
legacy.vm.total = 12; // Old stored upstream total must not control the current display.
legacy.vm.essence = { name: '35+支撑精华', text: '旧版推荐', triggers: ['最差项约束'] };
@@ -214,15 +226,20 @@ try {
const legacyDetail = await (await api.get(base + 'api/admin/records/' + result.record_id)).json();
assert.equal(legacyDetail.record.skin_age_display, expectedAge);
assert.equal(legacyDetail.record.total_score, expectedTotal);
assert.equal(legacyDetail.record.presentation.gradeRule,ReportUI.gradeRule);
assert.equal(legacyDetail.record.presentation.copyRule,ReportUI.copyRule);
for (const item of legacyDetail.record.presentation.care) { assert(item.key); assert(item.level); assert(!item.text.includes('旧护理待刷新')); }
for (const view of legacyDetail.record.presentation.views) assert.equal(view.level,ReportUI.levelForScore(view.S));
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((await page.locator('.ditem-para').allTextContents()).includes(legacy.presentation.views[0].text));
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(() => document.querySelector('#d-body').textContent.includes('历史报告原文'));
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.equal(fs.readFileSync(snapshotFile, 'utf8'), storedBytes);
+5 -5
View File
@@ -11,12 +11,12 @@ const example=run(97);assert.equal(example.vm.cats.tone.P,77.9);assert.equal(exa
assert(!/含水量偏低|缺水比较严重|水润度不足/.test(example.water.text));assert(example.water.text.includes('97'));
assert.deepEqual(example.water.advice,Copy.care.water['优秀']);
assert(!example.presentation.care.some(v=>v.name==='水分'),'healthy water97 should not displace a weaker item in global bottom three');
for(const[p,level]of[[0,'改善'],[79.99,'改善'],[80,'良好'],[89.99,'良好'],[90,'优秀'],[100,'优秀']]){
for(const[p,level]of[[0,'改善'],[30,'改善'],[40,'良好'],[60,'良好'],[65,'优秀'],[79.99,'优秀'],[80,'优秀'],[90,'优秀'],[100,'优秀']]){
for(const pigment of[0,50,100]){const x=run(p,3,pigment,0);assert.equal(x.water.level,level);assert.equal(x.water.P,p);assert.deepEqual(x.water.advice,Copy.care.water[level]);}
}
const crossed=run(97,70);assert.equal(crossed.water.P,30);assert.equal(crossed.water.level,'改善');assert(crossed.water.text.includes('分数与缺水指标不一致'));assert.equal(crossed.vm.cats.tone.vars.tone_scoring.water_adjusted_score,30);
assert(run(50,70).water.text.includes('缺水程度较高'));
assert(!run(50,3).water.text.includes('程度较高'));
assert(!run(50,null).water.text.includes('严重度'));
assert(run(20,70).water.text.includes('缺水程度较高'));
assert(!run(20,3).water.text.includes('程度较高'));
assert(!run(20,null).water.text.includes('严重度'));
for(const score of[null,undefined,-1,101,NaN,Infinity]){const x=run(score);assert.equal(x.water.valid,false);assert.equal(x.water.advice,null);assert(!x.presentation.care.some(v=>v.name==='水分'));}
console.log('PASS water: 97 vs combined77.9, independent water copy/advice, 80/90 boundaries, true severity, existing cross-check, missing/invalid, scoring immutable');
console.log('PASS water: independent copy/advice, displayed 8/9 boundaries, true severity, existing cross-check, missing/invalid, scoring immutable');