feat(report): unify display grades and add contour side photos
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# 最终确认:只改两处文案
|
||||
|
||||
用户要求:仅修改“皱纹·优秀档”“痘痘·有皮损时”,其余文案不改。此前七项整体改写已撤回,护理建议原文也全部恢复。
|
||||
|
||||
|项目|原文|最终修改|
|
||||
|---|---|---|
|
||||
|皱纹优秀档|面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。|全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条;整体纹路状态较好,建议维持日常淡纹与保湿护理。|
|
||||
|痘痘有皮损时|随档位使用旧评价文案,优秀模板为未检出,旧逻辑通过降档避免套用。|检测到 {acne_cnt} 处皮损({acne_detail}){heavy_note},局部皮损仍需关注。|
|
||||
|
||||
`heavy_note` 沿用原有脓包/结节提示。不修改零计数、计数缺失、部分计数缺失时的原有保护。
|
||||
|
||||
## 保留的逻辑修复
|
||||
|
||||
- 七个小项文案与护理建议按十分制显示分数分档:低于8改善、8至低于9良好、9及以上优秀。不改变评分公式。
|
||||
- 痘痘不再单独降档,实际皮损数量与种类保留。
|
||||
- 旧护理条目按已知名称兼容,更新对应档位的原版护理全文;项目、数量、顺序和产品推荐不重选,原始快照不覆盖。
|
||||
- 肤色、水分继续独立分档;水分交叉校验、原有毛孔/黑头实测数量保护、黑眼圈实测程度替换保持原实现,不增加其他文案分支。
|
||||
|
||||
## 验证
|
||||
|
||||
- `node h5/test/factual-copy.js`:以发布版本60f0965的文案快照逐字比较,全部护理模板及其他20个结论模板保持不变,验证两处文案、缺失值、历史护理名称与不可变数据。
|
||||
- `H5_COPY_CASE_FILE=/private/ten-copy-cases.json node h5/test/report-copy-cases.js`:同批10例、70个页卡、25条护理建议,分档、两处文案、旧护理匹配、历史推荐及顺序检查通过。私有样例不入Git。
|
||||
- 浏览器及持久化测试继续覆盖前后台展示、历史回看、筛选、CSV与快照不改写。
|
||||
|
||||
本文件记录最终确认范围。实际发布版本及部署状态以 Git 提交和服务器发布检查为准。
|
||||
+13
-1
@@ -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
@@ -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
@@ -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
@@ -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) { /* 调试入口异常不阻塞正式流程 */ }
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"wrinkles": {
|
||||
"改善": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。",
|
||||
"良好": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面部整体平整,仅眼周或口周有细微的干纹,无明显静态深纹,纹路面积占比相对较低。",
|
||||
"优秀": "面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。"
|
||||
"优秀": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条;整体纹路状态较好,建议维持日常淡纹与保湿护理。"
|
||||
},
|
||||
"color": {
|
||||
"改善": "检测到当前肌肤色素分 {melanin},存在色素沉着较显著,{dark_circle_note},整体透亮感不足。",
|
||||
|
||||
+35
-8
@@ -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
@@ -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', '稳韧力档',
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
@@ -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
@@ -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": "保持健康作息与均衡饮食,维持皮肤屏障健康稳定。"
|
||||
}
|
||||
}
|
||||
}
|
||||
};});
|
||||
@@ -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');
|
||||
|
||||
@@ -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`);
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
**【良好】** 全脸共检测到皱纹 \{wr\_cnt\} 条、细纹 \{fl\_cnt\} 条,面部整体平整,仅眼周或口周有细微的干纹,无明显静态深纹,纹路面积占比相对较低。
|
||||
|
||||
**【优秀】** 面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。
|
||||
**【优秀】** 全脸共检测到皱纹 \{wr\_cnt\} 条、细纹 \{fl\_cnt\} 条;整体纹路状态较好,建议维持日常淡纹与保湿护理。
|
||||
|
||||
#### 5\.3 肤色(透亮感)
|
||||
|
||||
@@ -218,3 +218,15 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### 2026-10-15 本次确认范围
|
||||
|
||||
仅调整皱纹优秀档及痘痘有皮损时的展示文案。痘痘检出皮损时展示实际数量、种类及原有脓包/结节提示,结尾为“局部皮损仍需关注”,不因优秀档写成未检出。其他结论模板、护理建议原文及原有缺失值/实测数量保护保持不变。
|
||||
|
||||
分档与兼容修正继续保留:七项文案及护理按十分制显示分数分档(低于8改善、8至低于9良好、9及以上优秀),覆盖上文旧80/90分档及痘痘单独降档约束。肤色、水分仍独立分档,水分原有交叉校验不变。旧护理条目按已知名称兼容,保留历史项目与顺序,不改评分、产品推荐或原始快照。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user