From 2d80f6dfd0f6b012f5824b4fc573605f8cde2936 Mon Sep 17 00:00:00 2001 From: xsl Date: Thu, 15 Oct 2026 17:14:05 +0800 Subject: [PATCH] feat(report): unify display grades and add contour side photos --- ...终范围-仅皱纹优秀与痘痘检出-2026-10-15.md | 25 ++++ h5/css/ui-20261012.css | 14 +- h5/index.html | 6 +- h5/js/admin.js | 15 +- h5/js/app.js | 53 ++++++- h5/js/report-copy.js | 2 +- h5/js/report-ui.js | 43 +++++- h5/server.js | 33 +++- h5/test/acne-three-view.js | 2 +- h5/test/care-selection.js | 5 +- h5/test/color-copy.js | 2 +- h5/test/contour-sides.mjs | 88 +++++++++++ h5/test/customer-feedback.js | 11 +- h5/test/display-grade.js | 42 ++++++ h5/test/factual-copy.js | 32 ++++ .../fixtures/report-copy-before-two-fixes.cjs | 141 ++++++++++++++++++ h5/test/report-bottom-figma.mjs | 4 + h5/test/report-copy-cases.js | 37 +++++ h5/test/report-persistence.mjs | 27 +++- h5/test/water-copy.js | 10 +- reference/AI测肤报告页话术修改.md | 14 +- 21 files changed, 557 insertions(+), 49 deletions(-) create mode 100644 docs/文案最终范围-仅皱纹优秀与痘痘检出-2026-10-15.md create mode 100644 h5/test/contour-sides.mjs create mode 100644 h5/test/display-grade.js create mode 100644 h5/test/factual-copy.js create mode 100644 h5/test/fixtures/report-copy-before-two-fixes.cjs create mode 100644 h5/test/report-copy-cases.js diff --git a/docs/文案最终范围-仅皱纹优秀与痘痘检出-2026-10-15.md b/docs/文案最终范围-仅皱纹优秀与痘痘检出-2026-10-15.md new file mode 100644 index 0000000..0abbd91 --- /dev/null +++ b/docs/文案最终范围-仅皱纹优秀与痘痘检出-2026-10-15.md @@ -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 提交和服务器发布检查为准。 diff --git a/h5/css/ui-20261012.css b/h5/css/ui-20261012.css index 18a3b01..1235981 100644 --- a/h5/css/ui-20261012.css +++ b/h5/css/ui-20261012.css @@ -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; } } diff --git a/h5/index.html b/h5/index.html index 9a1a41a..4c1f5e2 100644 --- a/h5/index.html +++ b/h5/index.html @@ -210,7 +210,11 @@
-
本次未获取轮廓检测图
+
本次未获取轮廓检测图
+

检测完成后显示分析结果。

diff --git a/h5/js/admin.js b/h5/js/admin.js index cae0d11..fc36c52 100644 --- a/h5/js/admin.js +++ b/h5/js/admin.js @@ -69,9 +69,10 @@ function dimsHtml(dims) { var h = ''; for (var k in DIM_NAMES) { var d = dims[k]; - h += '
' + + var grade = ReportUI.internalLevel(d && d.S); + h += '
' + DIM_NAMES[k] + ' ' + ReportUI.dimensionScore(d && d.S) + ' ' + - (d ? esc(ReportUI.levelMap[d.level] || '-') : '-') + '
'; + esc(ReportUI.levelMap[grade] || '-') + '
'; } 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 += '' + esc(catName) + '' + fmtScore(cat && cat.P) + '' + fmtScore(cat && cat.S) + '' + lvHtml(cat && cat.level) + ''; + catRows += '' + esc(catName) + '' + fmtScore(cat && cat.P) + '' + fmtScore(cat && cat.S) + '' + lvHtml(ReportUI.internalLevel(cat && cat.S)) + ''; } h += dSec(r.sub_scores ? '七项独立评分' : '历史六分类评分(肤色 / 水分共用一项)', '' + catRows + '
分类PS档位
'); @@ -436,7 +437,7 @@ function renderDetail(data) { var dimRows = ''; for (var dk in DIM_NAMES) { var dim = r.dims && r.dims[dk]; - dimRows += '' + DIM_NAMES[dk] + '' + fmtScore(dim && dim.P) + '' + fmtScore(dim && dim.S) + '' + lvHtml(dim && dim.level) + ''; + dimRows += '' + DIM_NAMES[dk] + '' + fmtScore(dim && dim.P) + '' + fmtScore(dim && dim.S) + '' + lvHtml(ReportUI.internalLevel(dim && dim.S)) + ''; } h += dSec('三维度评分', '' + dimRows + '
维度PS档位
'); if (r.acne_scoring && r.acne_scoring.version === 'three-view-type-max-v1') { @@ -462,9 +463,9 @@ function renderDetail(data) { if (txt) copyHtml += '
' + esc((r.cats[CAT_ORDER[c2]] || {}).name || '') + ':' + esc(txt) + '
'; } } - if (r.copy_summary) copyHtml += '
综合文案:' + esc(r.copy_summary) + '
'; - if (r.lifestyle) copyHtml += '
生活方式:' + esc(r.lifestyle) + '
'; - if (copyHtml) h += dSec(presentation ? '前端报告结论(保存快照)与计算摘要' : '旧版生成文案(无前端快照)', copyHtml); + if (r.copy_summary) copyHtml += '
旧版算法摘要(原始留档,不用于当前文案分档):' + esc(r.copy_summary) + '
'; + if (r.lifestyle) copyHtml += '
旧版生活方式(原始留档):' + esc(r.lifestyle) + '
'; + 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]; diff --git a/h5/js/app.js b/h5/js/app.js index 73e8ff7..b9eb836 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -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) { /* 调试入口异常不阻塞正式流程 */ } } })(); diff --git a/h5/js/report-copy.js b/h5/js/report-copy.js index 000b1c3..7636b17 100644 --- a/h5/js/report-copy.js +++ b/h5/js/report-copy.js @@ -10,7 +10,7 @@ "wrinkles": { "改善": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。", "良好": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面部整体平整,仅眼周或口周有细微的干纹,无明显静态深纹,纹路面积占比相对较低。", - "优秀": "面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。" + "优秀": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条;整体纹路状态较好,建议维持日常淡纹与保湿护理。" }, "color": { "改善": "检测到当前肌肤色素分 {melanin},存在色素沉着较显著,{dark_circle_note},整体透亮感不足。", diff --git a/h5/js/report-ui.js b/h5/js/report-ui.js index 5223f1d..64fd3c2 100644 --- a/h5/js/report-ui.js +++ b/h5/js/report-ui.js @@ -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 }; }); diff --git a/h5/server.js b/h5/server.js index b0f6670..613dfb4 100644 --- a/h5/server.js +++ b/h5/server.js @@ -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', '稳韧力档', diff --git a/h5/test/acne-three-view.js b/h5/test/acne-three-view.js index 7b6fa6e..b113466 100644 --- a/h5/test/acne-three-view.js +++ b/h5/test/acne-three-view.js @@ -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]); diff --git a/h5/test/care-selection.js b/h5/test/care-selection.js index 0839436..b897bfa 100644 --- a/h5/test/care-selection.js +++ b/h5/test/care-selection.js @@ -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'); diff --git a/h5/test/color-copy.js b/h5/test/color-copy.js index f50bda0..31baa47 100644 --- a/h5/test/color-copy.js +++ b/h5/test/color-copy.js @@ -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; diff --git a/h5/test/contour-sides.mjs b/h5/test/contour-sides.mjs new file mode 100644 index 0000000..88a6811 --- /dev/null +++ b/h5/test/contour-sides.mjs @@ -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(''); + 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();} diff --git a/h5/test/customer-feedback.js b/h5/test/customer-feedback.js index 93080ac..ec3772d 100644 --- a/h5/test/customer-feedback.js +++ b/h5/test/customer-feedback.js @@ -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); diff --git a/h5/test/display-grade.js b/h5/test/display-grade.js new file mode 100644 index 0000000..a5d4670 --- /dev/null +++ b/h5/test/display-grade.js @@ -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'); diff --git a/h5/test/factual-copy.js b/h5/test/factual-copy.js new file mode 100644 index 0000000..ebc2f8d --- /dev/null +++ b/h5/test/factual-copy.js @@ -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'); diff --git a/h5/test/fixtures/report-copy-before-two-fixes.cjs b/h5/test/fixtures/report-copy-before-two-fixes.cjs new file mode 100644 index 0000000..000b1c3 --- /dev/null +++ b/h5/test/fixtures/report-copy-before-two-fixes.cjs @@ -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": "保持健康作息与均衡饮食,维持皮肤屏障健康稳定。" + } + } + } +};}); diff --git a/h5/test/report-bottom-figma.mjs b/h5/test/report-bottom-figma.mjs index 71b5096..4b1f949 100644 --- a/h5/test/report-bottom-figma.mjs +++ b/h5/test/report-bottom-figma.mjs @@ -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'); diff --git a/h5/test/report-copy-cases.js b/h5/test/report-copy-cases.js new file mode 100644 index 0000000..938cf3d --- /dev/null +++ b/h5/test/report-copy-cases.js @@ -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`); diff --git a/h5/test/report-persistence.mjs b/h5/test/report-persistence.mjs index 106ae99..b385c25 100644 --- a/h5/test/report-persistence.mjs +++ b/h5/test/report-persistence.mjs @@ -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); diff --git a/h5/test/water-copy.js b/h5/test/water-copy.js index 6d16bed..b9415a7 100644 --- a/h5/test/water-copy.js +++ b/h5/test/water-copy.js @@ -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'); diff --git a/reference/AI测肤报告页话术修改.md b/reference/AI测肤报告页话术修改.md index 5eaa895..0cdc9db 100644 --- a/reference/AI测肤报告页话术修改.md +++ b/reference/AI测肤报告页话术修改.md @@ -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分档及痘痘单独降档约束。肤色、水分仍独立分档,水分原有交叉校验不变。旧护理条目按已知名称兼容,保留历史项目与顺序,不改评分、产品推荐或原始快照。 + + + + +