From 757b96dfadf70886791c3d04dd95181fb18ef97f Mon Sep 17 00:00:00 2001 From: xsl Date: Thu, 15 Oct 2026 19:45:21 +0800 Subject: [PATCH] feat(report): add annotated wrinkle views and restore capture countdown --- docs/皱纹三视图标注说明-2026-10-15.md | 16 ++++ h5/css/ui-20261012.css | 15 +++- h5/index.html | 8 +- h5/js/admin.js | 6 +- h5/js/app.js | 121 ++++++++++++++++++++++---- h5/js/report-copy.js | 6 +- h5/js/report-ui.js | 15 ++-- h5/js/skin-score.js | 13 +-- h5/lib/wrinkle-views.js | 62 +++++++++++++ h5/server.js | 15 ++++ h5/test/contour-sides.mjs | 6 +- h5/test/countdown-capture-browser.mjs | 21 ++++- h5/test/customer-feedback-fourth.js | 9 ++ h5/test/customer-feedback-second.js | 3 + h5/test/guest-privacy.mjs | 4 +- h5/test/regression.js | 3 +- h5/test/report-bottom-figma.mjs | 10 +++ h5/test/report-persistence.mjs | 18 +++- h5/test/vita-independent.js | 9 +- h5/test/water-copy.js | 16 ++-- h5/test/wrinkle-three-views.mjs | 53 +++++++++++ h5/test/wrinkle-views.js | 34 ++++++++ reference/AI测肤报告页话术修改.md | 8 +- 23 files changed, 396 insertions(+), 75 deletions(-) create mode 100644 docs/皱纹三视图标注说明-2026-10-15.md create mode 100644 h5/lib/wrinkle-views.js create mode 100644 h5/test/wrinkle-three-views.mjs create mode 100644 h5/test/wrinkle-views.js diff --git a/docs/皱纹三视图标注说明-2026-10-15.md b/docs/皱纹三视图标注说明-2026-10-15.md new file mode 100644 index 0000000..05629a8 --- /dev/null +++ b/docs/皱纹三视图标注说明-2026-10-15.md @@ -0,0 +1,16 @@ +# 皱纹三视图标注 + +- 报告皱纹页卡展示正面大图和两张侧脸缩略图,点击侧脸查看完整标注图。 +- 每张照片只使用对应照片的皱纹增强图或真实标注坐标,不将正面标注投射到侧脸。 +- 新检测优先复用三视图接口已提供的侧脸标注;缺失时分别以左右侧脸为主图补取,每侧最多调用一次。通常新增两次计费调用。 +- 补取串行执行,每次前间隔 4 秒,每侧最多等待 20 秒;整个检测按 120 秒预算限制补取,给现有 150 秒前端超时预留余量。因此生成报告会增加等待时间。 +- 补取失败不影响主报告、评分或产品推荐。该侧展示原图并明确说明缺少标注;后台记录每侧获取状态和错误代码。 +- 微信用户保存报告时同时保存侧脸标注,历史回看不重复调用检测接口。旧报告不自动补取、不产生额外费用。游客仍不保存报告或照片。 +- 补取结果仅用于图片展示,不参与七个小维度、三个大维度、综合评分或产品推荐计算。 + +## 验证 + +- 单元测试:请求照片与标注一一对应、顺序调用、复用已有标注、缺图、接口失败、超时、预算不足、不污染评分结果。 +- 浏览器回归:320/375/414/480 宽度,正面浮层保留、左右独立浮层与大图、真实坐标兜底、缺图/缺标注提示、清理旧照片。 +- 报告持久化回归:三次调用、响应与缓存标注一致、用户报告隔离及后台查看。 +- 已使用获授权的真实案例分别补取左右侧脸,确认均返回独立皱纹增强图和标注坐标。 diff --git a/h5/css/ui-20261012.css b/h5/css/ui-20261012.css index 1235981..ae0bdfa 100644 --- a/h5/css/ui-20261012.css +++ b/h5/css/ui-20261012.css @@ -2,6 +2,10 @@ Native 248px WeChat header is excluded. Fixed text sizes/line breaks are intentional; flexible photo backgrounds and safe areas accommodate phones. */ .account-logout { font-size: 11px; opacity: .8; white-space: nowrap; } +.cam-countdown { position:absolute; left:50%; top:45%; transform:translate(-50%,-50%); z-index:4; pointer-events:none; color:#fff; font:600 88px/1 Arial,sans-serif; text-shadow:0 2px 16px rgba(0,0,0,.5); } +.cam-countdown-tick { display:block; animation:camera-countdown-tick .6s ease-out both; } +@keyframes camera-countdown-tick { from { opacity:.3; transform:scale(1.25); } to { opacity:1; transform:scale(1); } } +@media (prefers-reduced-motion:reduce) { .cam-countdown-tick { animation:none; } } .splash-slogan { left: calc(50% - 1.041667px); right: auto; width: 249.305556px; height: 75.347222px; @@ -85,7 +89,7 @@ /* Figma 1:65, 1080px canvas. One algorithm-selected product, not a catalogue. */ /* User-confirmed: both lines centered; use the same font size/line height as the stability heading rather than scaling text on wide previews. */ -.prod-title { width:calc(206.597222 * var(--u)); max-width:100%; text-align:center; margin:calc(13 * var(--u)) auto calc(8.583333 * var(--u)); padding:0; transform:translateX(calc(.347222 * var(--u))); font-size:22.106597px; line-height:28.738542px; font-weight:700; white-space:nowrap; } +.prod-title { width:calc(206.597222 * var(--u)); max-width:100%; text-align:center; margin:calc(16.9 * var(--u)) auto calc(11.158333 * var(--u)); padding:0; transform:translateX(calc(.347222 * var(--u))); font-size:22.106597px; line-height:28.738542px; font-weight:700; white-space:nowrap; } .report-care-products { width:calc(340.625 * var(--u)); margin-left:auto; margin-right:auto; padding:calc(21.180556 * var(--u)) calc(24.652778 * var(--u)) calc(7.986111 * var(--u)); } .report-care-products .card-heading, .product-section-heading { font-size:13.888889px; line-height:19.444444px; font-weight:700; text-align:left; } @@ -102,6 +106,8 @@ .report-care-products .prod-name { left:8.677249%; top:41.095890%; font-size:12.013166px; line-height:15.070408px; font-weight:700; color:#989898; white-space:nowrap; } .report-care-products .prod-badge { left:8.783069%; top:56.751468%; width:10.687831%; } .report-care-products + .report-disclaimer { margin:calc(12.152778 * var(--u)) calc(5.208333 * var(--u)) calc(20 * var(--u)); font-size:7.233681px; line-height:10.127431px; } +/* Equal whitespace above/below all three headings; keep the original total gap. */ +.dimension-title { margin:calc(18 * var(--u)) 0; } .dimension-stability-title { font-size:22.106597px; line-height:28.738542px; } .stability-card .dtab-tx { font-size:11.055903px; } .stability-card .ditem-para { font-size:11.574074px; line-height:14.467593px; } @@ -205,9 +211,10 @@ .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; } +.contour-side-photo, .wrinkle-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, .wrinkle-side-photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; transform: translateY(-25%) scale(2.8); transform-origin: center; -webkit-user-drag: none; } +.contour-side-photo:focus-visible, .wrinkle-side-photo:focus-visible { outline: 2px solid #333; outline-offset: 3px; } +.wrinkle-layer-note { width:calc(215 * var(--u)); margin:0 auto calc(12 * var(--u)); color:#888; font-size:10px; line-height:1.4; } @media (prefers-reduced-motion: reduce) { .detail-panels, .detail-viewport { transition-duration: .01ms; } } diff --git a/h5/index.html b/h5/index.html index 4c1f5e2..82d82cf 100644 --- a/h5/index.html +++ b/h5/index.html @@ -144,6 +144,7 @@

检测引擎加载中…

+

识别不到?请正对镜头,并到光线充足处

@@ -218,7 +219,12 @@

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

-
本次未获取皱纹检测图
+
本次未获取皱纹检测图
+ +

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

diff --git a/h5/js/admin.js b/h5/js/admin.js index fc36c52..61a7116 100644 --- a/h5/js/admin.js +++ b/h5/js/admin.js @@ -419,7 +419,7 @@ function renderDetail(data) { if (r.wrinkle_score_rule === 'api-overall-v1') h += '

皱纹评分直接使用接口整体皱纹分数,不做100减分,也不再按分区严重度限分。

'; if (r.tone_scoring && r.tone_scoring.version === 'color-dark-circle-20-v1') { var tone = r.tone_scoring; - h += '

肤色项=色素扣斑分×80%+黑眼圈评分×20%;' + (r.vita_score_rule ? '透亮感取独立肤色、水分、平滑度三项最低分,不再使用肤色水润平均分。' : '历史版本与水分项各占50%。') + '色素扣斑分:' + esc(fmtToneScore(tone.pigment_adjusted_score)) + ',黑眼圈评分:' + esc(fmtToneScore(tone.dark_circle_score)) + ',独立肤色项:' + esc(fmtToneScore(tone.color_score)) + ',水分项:' + esc(fmtToneScore(tone.water_adjusted_score)) + '。肤色缺项按有效权重归一。

'; + h += '

肤色项=色素扣斑分×80%+黑眼圈评分×20%;' + (r.vita_score_rule === 'independent-color-water-smooth-min-v1' ? '该历史版本透亮感取独立肤色、水分、平滑度三项最低分。' : '肤色项与水分项各占50%合成肤色水润分,透亮感取肤色水润与平滑度的最低分。') + '色素扣斑分:' + esc(fmtToneScore(tone.pigment_adjusted_score)) + ',黑眼圈评分:' + esc(fmtToneScore(tone.dark_circle_score)) + ',独立肤色项:' + esc(fmtToneScore(tone.color_score)) + ',水分项:' + esc(fmtToneScore(tone.water_adjusted_score)) + '。肤色缺项按有效权重归一。

'; } h += '

前端展示肌龄限制在实际年龄±5岁;推荐仍使用原始肌龄。历史报告保留当时的推荐。

'; @@ -449,6 +449,10 @@ function renderDetail(data) { h += dSec('痘痘三视图计分明细', '

每种皮损分别取三视图最大数量,不相加,也不是全脸去重总数;“-”表示未获取。整体评分仍用正脸:' + esc(fmtScore(audit.front_score)) + ',映射分:' + esc(fmtScore(audit.mapped_score)) + '。计数扣分合计:' + esc(fmtScore(audit.raw_deduction)) + ',封顶50后:' + esc(fmtScore(audit.deduction)) + '。

' + acneRows + '
类型正脸左脸右脸计分数量单处扣分该项扣分
'); } + if (r.wrinkle_view_status) { + h += dSec('侧脸皱纹标注获取状态', '

左右侧脸各自独立调用,仅用于图层展示,不参与计分;查看报告不重新调用。

' + + ['left','right'].map(function(view) { var s=r.wrinkle_view_status[view]||{}; return '

'+ (view==='left'?'左脸':'右脸') +':'+esc(s.status==='ready'?'已获取':'未获取')+(s.error_code?'('+esc(s.error_code)+')':'')+'

'; }).join('')); + } /* 文案全文 */ var copyHtml = ''; var presentation = r.presentation; diff --git a/h5/js/app.js b/h5/js/app.js index b9eb836..fbc2985 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -516,6 +516,7 @@ startCamera(); } function stopCamera(keepFrozen) { + clearCaptureCountdown(); camSeqCam++; // 使进行中的 getUserMedia 回调失效 clearInterval(camReadyTimer); clearTimeout(camOpenTimer); camReadyTimer = camOpenTimer = null; @@ -701,14 +702,14 @@ cur = null; if (c.src) { c.src.onended = null; try { c.src.stop(); } catch (e) {} } } - function start(id, kind) { + function start(id, kind, onStart) { stopCur(); lastAt[id] = Date.now(); var token = {}; cur = { id: id, kind: kind, token: token, src: null, end: 0 }; load(id).then(function (buf) { if (!cur || cur.token !== token) return; // 加载期间已被打断 - if (!buf || !ctx) { cur = null; drain(); return; } + if (!buf || !ctx) { if (onStart) onStart(3000); cur = null; drain(); return; } var src = ctx.createBufferSource(); src.buffer = buf; src.connect(ctx.destination); @@ -716,11 +717,12 @@ cur.end = Date.now() + buf.duration * 1000; src.onended = function () { if (cur && cur.token === token) { cur = null; drain(); } }; src.start(0); + if (onStart) onStart(buf.duration * 1000); }); } function drain() { if (cur) return; - if (queue.length) { start(queue.shift(), 'flow'); return; } + if (queue.length) { var next = queue.shift(); start(next.id, 'flow', next.onStart); return; } if (pendingHint) { var h = pendingHint; pendingHint = null; start(h, 'hint'); return; } var w = waiters; waiters = []; @@ -731,16 +733,16 @@ // 流程音是离散事件,每次都播(连拍的「拍摄完成」、取消后重来的倒计时) function play(id, opts) { opts = opts || {}; - if (!ctx || !id || ctx.state !== 'running' || !files || !files[id]) return; + if (!ctx || !id || ctx.state !== 'running' || !files || !files[id]) { if (opts.onStart) opts.onStart(3000); return; } if (opts.hint) { if (lastAt[id] && Date.now() - lastAt[id] < 4000) return; if (busyWithFlow()) { pendingHint = id; return; } start(id, 'hint'); return; } - if (opts.cut) { queue = []; pendingHint = null; start(id, 'flow'); return; } - if (busyWithFlow()) { if (queue.indexOf(id) < 0) queue.push(id); return; } - start(id, 'flow'); // 在播的姿态提示让位给流程音 + if (opts.cut) { queue = []; pendingHint = null; start(id, 'flow', opts.onStart); return; } + if (busyWithFlow()) { if (!queue.some(function(item) { return item.id === id; })) queue.push({ id:id, onStart:opts.onStart }); return; } + start(id, 'flow', opts.onStart); // 在播的姿态提示让位给流程音 } function dropHint() { pendingHint = null; } // 提示已清空/换步:挂起的旧提示作废 // 流程切走:作废排队/挂起/等待;hard 连在播一并掐断(退出拍照、页面隐藏),否则让在播自然收尾(出报告) @@ -1082,7 +1084,29 @@ }, hasNext ? NEXT_PAUSE_MS : 300); } - // 白屏补光抓拍:全屏纯白瞬间点亮→约300ms 后抓全分辨率帧→保持300ms 再淡出(总 600–900ms) + var captureCountdownTimers = []; + function clearCaptureCountdown() { + captureCountdownTimers.forEach(clearTimeout); + captureCountdownTimers = []; + var el = $('#cam-countdown'); + el.hidden = true; el.textContent = ''; + } + function startCaptureCountdown(seq, duration, done) { + clearCaptureCountdown(); + function tick(number) { + if (seq !== camSeq || !camLive || currentScreen !== 's-cam') { clearCaptureCountdown(); return; } + var el = $('#cam-countdown'), digit = document.createElement('span'); + digit.className = 'cam-countdown-tick'; digit.textContent = String(number); + el.replaceChildren(digit); el.hidden = false; + } + tick(3); + [2,1].forEach(function(number,i) { captureCountdownTimers.push(setTimeout(function() { tick(number); }, duration * (i+1) / 3)); }); + captureCountdownTimers.push(setTimeout(function() { + clearCaptureCountdown(); + if (seq === camSeq && camLive && currentScreen === 's-cam') done(); + }, duration)); + } + // 白屏补光抓拍:数字倒计时和语音都结束后才点亮补光、抓帧。 function doCapture() { if (shutterLock) return; if (!camLive) return; @@ -1093,8 +1117,19 @@ hideNudge(); var seq = camSeq; voice.dropHint(); - voice.play(VOICE.countdown); + var audioDone = false, visualDone = false, started = false; + function finishCountdown() { if (audioDone && visualDone) capture(); } + voice.play(VOICE.countdown, { onStart:function(duration) { + if (seq !== camSeq || !camLive || currentScreen !== 's-cam') return; + started = true; + startCaptureCountdown(seq, duration, function() { visualDone = true; finishCountdown(); }); + } }); voice.afterCurrent(function () { + audioDone = true; + if (!started) { started = true; startCaptureCountdown(seq, 3000, function() { visualDone = true; finishCountdown(); }); } + finishCountdown(); + }, true); + function capture() { if (seq !== camSeq || !camLive || currentScreen !== 's-cam') return; var fill = $('#cam-fill'); fill.classList.add('on'); // .on 无过渡,瞬间点亮 @@ -1117,7 +1152,7 @@ }); }, 300); }, 300); - }, true); + } } // 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2]) @@ -1200,7 +1235,7 @@ if (fx) { // marks 坐标空间=fixture 照片空间:三连拍替换为 fixture 照片,保证可视化叠加对齐 ph[0] = fx.photos[0]; ph[1] = fx.photos[1]; ph[2] = fx.photos[2]; - done(null, { result: fx.result, side: fx.side, face_rectangle: fx.face_rectangle }); + done(null, { result: fx.result, side: fx.side, wrinkle_views: fx.wrinkle_views, face_rectangle: fx.face_rectangle }); } else { done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE }); } @@ -1270,8 +1305,8 @@ function setCat(key, cat) { var dom = CAT_DOM[key]; - // 设计稿分类卡只有话术正文:不展示 S 与档位徽章;缺数据分类以说明代正文 - $(dom.tx).textContent = cat.P == null ? '本次未检出有效数据' : cat.text; + // 分类卡只展示有效话术正文;缺数据不在报告中提示重拍。 + $(dom.tx).textContent = cat.P == null ? '' : cat.text; } /* ─── 报告页检测可视化(face_maps 图层 + marks 坐标叠加,数据驱动) ─── */ @@ -1350,6 +1385,17 @@ } return out; } + function vizWrinkleMarks(result) { + var outlines = result && result.wrinkle_mark; + if (!outlines || typeof outlines !== 'object') return []; + var normalized = {}, defs = []; + Object.keys(outlines).forEach(function(key) { + if (!/_wrinkle_outline$/.test(key) || !Array.isArray(outlines[key])) return; + normalized[key] = { polygon: [outlines[key]] }; + defs.push({ key: key, color: '#6E9900' }); + }); + return vizCollectMarks(defs, normalized); + } // marks 坐标空间 = 上传图片像素空间:按「画布自然宽 / 坐标空间宽」换算(画布=照片自然尺寸时比值为 1) function vizScaleMarks(marks, scale) { @@ -1395,6 +1441,7 @@ if (!photo) return; // 照片不可用:保留占位 var finish = function (overlays) { if (epoch !== vizEpoch) return; + if (!overlays.some(Boolean) && opts && opts.fallbackMarks) marks = opts.fallbackMarks; var crop = vizFaceCrop(photo.naturalWidth, photo.naturalHeight, opts && opts.faceRectangle); var originalTop = crop.top; crop = vizShiftContentDown(crop, opts && opts.faceRectangle, opts && opts.contentDown); @@ -1450,6 +1497,7 @@ 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.hasAnnotation = String(overlays.some(Boolean) || marks.length > 0); imgEl.dataset.overlayAlpha = String(opts && typeof opts.alpha === 'number' ? opts.alpha : 1); } catch (e) { /* 合成失败保留占位 */ } }; @@ -1503,9 +1551,10 @@ } if (cfg.overlay) { // 透明增强图层(皱纹):有照片则合成(可按 cfg.dim 压暗),无照片单挂图层;图层缺失 → 保留占位 var ov = vizMapURL(maps[cfg.overlay]); - if (!ov) continue; + var wrinkleMarks = key === 'wrinkles' && !ov ? vizWrinkleMarks(result) : []; + if (!ov && !frontPhoto) continue; if (!frontPhoto) { imgEl.src = ov; imgEl.hidden = false; continue; } - vizCompose(frontPhoto, ov, [], imgEl, { dim: cfg.dim, faceRectangle: rectangle }); + vizCompose(frontPhoto, ov, wrinkleMarks, imgEl, { dim: ov || wrinkleMarks.length ? cfg.dim : 0, faceRectangle: rectangle, contentDown: key === 'wrinkles' ? .09 : 0, fallbackMarks: key === 'wrinkles' ? vizWrinkleMarks(result) : null }); continue; } // 照片类(轮廓/光滑/痘痘):无照片 → 保留占位;有坐标则 canvas 叠加,无坐标纯照片 @@ -1542,6 +1591,37 @@ }); refreshHeight(); } + var wrinklePhotoEpoch = 0; + function renderWrinkleSides(reportPhotos, payload) { + var epoch = ++wrinklePhotoEpoch, wrap = $('#wrinkle-sides'), note = $('#wrinkle-layer-note'); + function refresh() { + var visible = Array.from(wrap.querySelectorAll('figure:not([hidden])')); + wrap.hidden = !visible.length; + note.hidden = !visible.some(function(f) { return f.querySelector('img').dataset.hasAnnotation !== 'true'; }); + var card = wrap.closest('.card-detail'), state = detailSwipes.get(card); + if (state) state.height(Number(card.querySelector('.dtab.active').dataset.tab)); + } + wrap.hidden = note.hidden = true; + ['left', 'right'].forEach(function(view, i) { + var img = $('#wrinkle-' + view), figure = img.closest('figure'); + img.onload = img.onerror = null; img.removeAttribute('src'); + img.dataset.hasAnnotation = 'false'; img.draggable = false; figure.hidden = true; + var src = reportPhotos && reportPhotos[i + 1]; + if (!src) return; + // Only this photograph's own API data may be used. The front-face map + // is NEVER a fallback for a side photo. + var side = payload && payload.wrinkle_views && payload.wrinkle_views[view]; + if (!side || !side.result) side = payload && payload.side && payload.side[view]; + var result = side && (side.result || side); + var overlay = vizMapURL(result && result.face_maps && result.face_maps.texture_enhanced_lines); + var marks = overlay ? [] : vizWrinkleMarks(result); + img.onload = function() { if (epoch === wrinklePhotoEpoch) { figure.hidden = false; refresh(); } }; + img.onerror = function() { if (epoch === wrinklePhotoEpoch) { figure.hidden = true; img.removeAttribute('src'); refresh(); } }; + if (overlay || marks.length) vizCompose(src, overlay, marks, img, { dim: .55, fallbackMarks: vizWrinkleMarks(result) }); + else img.src = src; + }); + refresh(); + } function renderReport(vm, frontPhoto, sidePhoto, payload, reportPhotos) { renderContourSides(reportPhotos || [frontPhoto, sidePhoto, null]); reportOwner = AccountClient.user() && AccountClient.user().id; @@ -1578,10 +1658,15 @@ focusEl.append(first, second); } var textIds = { contour: '#ditem-para1', wrinkles: '#ditem-para2', color: '#tx-tone', smooth: '#tx-smooth', water: '#tx-water', sens: '#tx-sens', acne: '#tx-acne' }; - presentation.views.forEach(function (v) { $(textIds[v.key]).textContent = ReportUI.customerText(v); }); + presentation.views.forEach(function (v) { + var text = ReportUI.customerText(v), el = $(textIds[v.key]); + el.textContent = text; + el.hidden = !text; + }); // 检测可视化:轮廓=正脸实拍,其余按 VIZ_CAT 取 face_maps 图层或叠加 marks(payload 缺失时各卡保留占位) renderViz(payload, frontPhoto); + renderWrinkleSides(reportPhotos || [frontPhoto, sidePhoto, null], payload); // Fourth feedback: capture quality belongs in the protected admin record, // not in the customer report. Preserve vm.notes/snapshots unchanged. @@ -1692,7 +1777,7 @@ card.addEventListener('click', function (e) { var btn = e.target.closest('.dtab'); if (btn) { setDetailTab(card, btn.dataset.tab); return; } - var sideButton = e.target.closest('.contour-side-photo'); + var sideButton = e.target.closest('.contour-side-photo, .wrinkle-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; @@ -1944,7 +2029,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); + }), fx ? fx.photos[0] : null, null, fx ? { result: mockRes, side: mockSide, wrinkle_views: fx.wrinkle_views, 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 7636b17..d9c82d0 100644 --- a/h5/js/report-copy.js +++ b/h5/js/report-copy.js @@ -23,9 +23,9 @@ "优秀": "肌肤质感表现优异,表面细腻平滑,角质层排列整齐,无明显粗大毛孔与黑头。" }, "water": { - "改善": "水分分 {water_score};缺水面积 {water_area}%,{water_severity_note}肌肤水润度有待提升,建议加强补水与保湿。", - "良好": "水分分 {water_score};缺水面积 {water_area}%、肌肤含水量处于健康水平,整体水润度良好。", - "优秀": "水分分 {water_score};角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。" + "改善": "角质层含水量较低,缺水面积 {water_area}%、肌肤缺水比较严重 {water_sev},肌肤含水量偏低,水润度不足,表面偏干燥,容易出现干纹。", + "良好": "角质层含水量适中,缺水面积 {water_area}%、肌肤含水量处于健康水平,整体水润度良好。", + "优秀": "角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。" }, "sens": { "改善": "泛红面积达 {sens_area}%,肌肤泛红明显,敏感区域可见泛红;对外界刺激耐受度不足,容易受外界因素影响产生状态波动。", diff --git a/h5/js/report-ui.js b/h5/js/report-ui.js index 64fd3c2..bee6873 100644 --- a/h5/js/report-ui.js +++ b/h5/js/report-ui.js @@ -22,7 +22,7 @@ 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'; + var copyRule = 'wrinkles-excellent-acne-detected-water-v2'; function scoreOfP(P) { return numeric(P) ? Math.round((Math.max(7, Math.min(10, 7 + 3 * P / 100)) + 1e-9) * 100) / 100 : null; } @@ -45,11 +45,13 @@ return selected; } function customerText(view) { + // Keep missing-data diagnostics in the saved/admin record, not on the + // customer report. Also suppress equivalent text in older snapshots. + if (view.valid === false || /本次未检出有效数据|本次未获取轮廓有效数据/.test(view.text || '')) return ''; if (view.key === 'wrinkles') return view.text.split('\n分区明细')[0]; if (view.key !== 'contour') return view.text; // Also cover immutable old presentations, without modifying stored copy. - return view.text.replace(/侧脸角度数据未完整获取[;;]?/g, '') - .replace('本次未检出有效数据,请重新拍摄后查看。', '本次未获取轮廓有效数据。'); + return view.text.replace(/侧脸角度数据未完整获取[;;]?/g, ''); } function scalar(value) { return numeric(value) ? value : value && numeric(value.value) ? value.value : null; } var wrinkleDefs = [ @@ -115,13 +117,6 @@ 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 - ? (vars.water_sev >= 60 ? '缺水程度较高(严重度 {water_sev}),' : '缺水严重度 {water_sev},') : ''; - if (viewP !== vars.water_score) template = '水分分 {water_score};缺水面积 {water_area}%,缺水严重度 {water_sev}。水分分数与缺水指标不一致,本次按缺水指标评估,建议加强补水与保湿。'; - // Resolve the optional phrase before normal scalar substitution. - template = template.replace(/\{water_severity_note\}/g, vars.water_severity_note); - } if (d[0] === 'contour') { var severity = vars.dark_circle_sev; var description = numeric(severity) ? ['无明显黑眼圈', '黑眼圈轻度', '黑眼圈中度', '黑眼圈重度'][severity] : null; diff --git a/h5/js/skin-score.js b/h5/js/skin-score.js index fdda022..c7f0526 100644 --- a/h5/js/skin-score.js +++ b/h5/js/skin-score.js @@ -752,16 +752,9 @@ var d = DIM_DEFS[i], ps = [], j; for (j = 0; j < CAT_DEFS.length; j++) { if (CAT_DEFS[j].dim !== d.key) continue; - if (d.key === 'vita' && CAT_DEFS[j].key === 'tone') continue; // retired combined score var P = cats[CAT_DEFS[j].key].P; if (P != null) ps.push(P); } - if (d.key === 'vita') { - var toneVars = cats.tone.vars; - [toneVars.color_score, toneVars.tone_scoring && toneVars.tone_scoring.water_adjusted_score].forEach(function(score) { - if (isNum(score)) ps.push(score); - }); - } var dP = ps.length ? Math.min.apply(null, ps) : null; if (!ps.length) notes.push(d.name + ':旗下分类数据全部缺失,维度不参与评分'); dims[d.key] = { name: d.name, P: dP, S: sShow(dP), level: levelOf(dP) }; @@ -855,7 +848,7 @@ return { total: weightedTotal(dims), totalRule: 'dimensions-433-v1', - vitaScoreRule: 'independent-color-water-smooth-min-v1', + vitaScoreRule: 'combined-tone-smooth-min-v2', wrinkleScoreRule: 'api-overall-v1', acneScoreRule: 'three-view-type-max-v1', skinAgeRaw: skinAge, @@ -992,7 +985,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma ok(vm.cats.sens.P === 98, '敏感P = min(98, 100−0.1×1.5=99.85) = 98(面积 0.001 小数口径×100)'); ok(vm.cats.acne.P === 85, '痘痘P = min(分级映射85, 100−14.5=85.5) = 85'); ok(vm.cats.wrinkles.S === 7.69 && vm.cats.smooth.S === 9.45 && vm.cats.tone.S === 9.45, 'S=7+3×P/100 两位小数(含 9.445→9.45 浮点修正)'); - ok(vm.dims.firm.P === 23 && vm.dims.vita.P === 80 && vm.dims.stab.P === 85, '维度木桶 min:紧塑力23 / 独立肤色短板80 / 稳定力85'); + ok(vm.dims.firm.P === 23 && vm.dims.vita.P === Math.min(vm.cats.smooth.P,vm.cats.tone.P) && vm.dims.stab.P === 85, '维度木桶 min:紧塑力23 / 平滑度与肤色水润最低分 / 稳定力85'); ok(vm.skinAgeRaw === 20 && vm.skinAgeDisplay === 20 && vm.ageClampNote === '', '肌肤年龄直接展示原值20,不按实际年龄钳制'); ok(vm.group === '25+组', '原始肌龄20 → 25+组'); ok(vm.essence.name === '25+束紧精华' && vm.recommendation.tier === 1, @@ -1023,7 +1016,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma console.log('== T2 木桶 min(显式构造) =='); var vm2 = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 40 }); ok(vm2.dims.firm.P === Math.min(vm2.cats.contour.P, vm2.cats.wrinkles.P) - && vm2.dims.vita.P === Math.min(vm2.cats.smooth.P, vm2.cats.tone.vars.color_score, vm2.cats.tone.vars.tone_scoring.water_adjusted_score) + && vm2.dims.vita.P === Math.min(vm2.cats.smooth.P, vm2.cats.tone.P) && vm2.dims.stab.P === Math.min(vm2.cats.sens.P, vm2.cats.acne.P), '三维度 P = min(旗下两分类)'); ok(vm2.group === '25+组', '实际年龄40不影响推荐,原始肌龄20仍为25+组'); diff --git a/h5/lib/wrinkle-views.js b/h5/lib/wrinkle-views.js new file mode 100644 index 0000000..5e07782 --- /dev/null +++ b/h5/lib/wrinkle-views.js @@ -0,0 +1,62 @@ +'use strict'; +// Display-only side analyses. Never merge these scores into the primary +// three-view result or re-run detection when reading a historical report. +const VERSION = 'independent-side-wrinkles-v1'; +function hasAnnotation(result) { + if (typeof result?.face_maps?.texture_enhanced_lines === 'string' && result.face_maps.texture_enhanced_lines.trim()) return true; + return Object.values(result?.wrinkle_mark || {}).some(points => Array.isArray(points) && points.filter(p => Number.isFinite(p?.x) && Number.isFinite(p?.y)).length >= 3); +} +async function collect(primary, photos, options) { + const {key, secret, url} = options; + const fetchImpl = options.fetch || fetch; + const sleep = options.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms))); + const now = options.now || Date.now; + const deadline = options.deadline || now() + 90000; + const gap = options.gapMs ?? 4000; + const views = {version: VERSION}; + for (const view of ['left','right']) { + const existing = primary[view + '_side_result'] || primary.side_result?.[view + '_side_result']; + const existingResult = existing?.result || existing; + if (hasAnnotation(existingResult)) { + views[view] = {status:'ready',source:'primary-side-result',result:existingResult}; + continue; + } + const photo = photos[view]; + if (!photo) { views[view] = {status:'unavailable',error_code:'MISSING_SIDE_PHOTO'}; continue; } + if (deadline - now() <= gap + 1000) { views[view] = {status:'unavailable',error_code:'WRINKLE_TIME_BUDGET'}; continue; } + await sleep(gap); + const timeout = Math.min(options.timeoutMs || 20000, deadline - now()); + if (timeout <= 0) { views[view] = {status:'unavailable',error_code:'WRINKLE_TIME_BUDGET'}; continue; } + try { + const form = new FormData(); + form.append('api_key',key); form.append('api_secret',secret); + // A side photo must be the main image of ITS OWN request to obtain + // wrinkle maps in the corresponding coordinate space. + form.append('image_file',new Blob([Buffer.from(photo,'base64')]),view+'.jpg'); + form.append('return_maps','texture_enhanced_lines'); + form.append('return_marks','wrinkle_mark'); + const response = await fetchImpl(url,{method:'POST',body:form,signal:AbortSignal.timeout(timeout)}); + const raw = await response.json(); + const code = raw.error_message ? String(raw.error_message).split(':')[0] : !response.ok ? 'WRINKLE_HTTP_'+response.status : !hasAnnotation(raw.result) ? 'WRINKLE_ANNOTATION_NOT_RETURNED' : null; + views[view] = Object.assign({},raw,{status:code?'unavailable':'ready',source:'independent-side-request',error_code:code}); + } catch (e) { + views[view] = {status:'unavailable',source:'independent-side-request',error_code:e.name==='TimeoutError'||e.name==='AbortError'?'WRINKLE_TIMEOUT':'WRINKLE_REQUEST_FAILED'}; + } + } + return views; +} +function forReport(views) { + if (!views) return null; + const out = {version:views.version}; + for (const view of ['left','right']) { + const item=views[view]; + if (!item) continue; + out[view]={status:item.status,error_code:item.error_code||null,face_rectangle:item.face_rectangle||null}; + if (item.status==='ready'&&item.result) out[view].result={ + face_maps:{texture_enhanced_lines:item.result.face_maps?.texture_enhanced_lines||null}, + wrinkle_mark:item.result.wrinkle_mark||null + }; + } + return out; +} +module.exports={VERSION,hasAnnotation,collect,forReport}; diff --git a/h5/server.js b/h5/server.js index 613dfb4..de9bac8 100644 --- a/h5/server.js +++ b/h5/server.js @@ -28,6 +28,7 @@ const RECORDS_FILE = path.join(DATA_ROOT, 'records.jsonl'); // 检测记录(一 const ReportUI = require('./js/report-ui.js'); const BeijingTime = require('./js/beijing-time.js'); const CaptureDiagnostics = require('./lib/capture-diagnostics.js'); +const WrinkleViews = require('./lib/wrinkle-views.js'); const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts')); /* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */ @@ -291,6 +292,9 @@ function fillSuccess(rec, fpJson, persist = true) { rec.wrinkle_score_rule = vm.wrinkleScoreRule; rec.acne_scoring = vm.cats.acne.vars.acne_scoring; rec.tone_scoring = vm.cats.tone.vars.tone_scoring; + rec.wrinkle_view_status = fpJson.wrinkle_views ? Object.fromEntries(['left','right'].map(view => [view, { + status:fpJson.wrinkle_views[view]?.status||'unavailable', error_code:fpJson.wrinkle_views[view]?.error_code||null + }])) : null; rec.jawline_angle_l = jawlineAngle(fpJson.left_side_result, 'left'); rec.jawline_angle_r = jawlineAngle(fpJson.right_side_result, 'right'); rec.cats = {}; rec.copy_6 = {}; @@ -326,6 +330,15 @@ async function runDetect(rec, front, left, right, persist = true) { rec.updated_at = nowIso(); const t0 = Date.now(); const fpJson = await callFacepp(front, left, right); + if (fpJson.result && !fpJson.error_message) { + fpJson.wrinkle_views = await WrinkleViews.collect(fpJson,{left,right},{ + key:CRED.key,secret:CRED.secret,url:FACEPP_URL, + gapMs:process.env.NODE_ENV==='test'?0:4000, + // Keep within the existing 150-second browser timeout, including + // upload/serialization headroom. Missing side maps never fail a report. + deadline:t0+120000 + }); + } rec.cost_ms = Date.now() - t0; if (fpJson.error_message) { const code = fpJson.error_message.split(':')[0]; @@ -466,6 +479,7 @@ async function handleAnalyze(req, res) { const nested = fpJson.side_result || {}; const payload = { result, face_rectangle: fpJson.face_rectangle || null, + wrinkle_views: WrinkleViews.forReport(fpJson.wrinkle_views), side: { left: fpJson.left_side_result || nested.left_side_result || null, right: fpJson.right_side_result || nested.right_side_result || null } }; @@ -485,6 +499,7 @@ async function handleAnalyze(req, res) { ok: true, record_id: recordId, report_saved: reportSaved, presentation, vm: snapshotVm, result, + wrinkle_views: payload.wrinkle_views, face_rectangle: fpJson.face_rectangle || null, side: { left: fpJson.left_side_result || nested.left_side_result || null, diff --git a/h5/test/contour-sides.mjs b/h5/test/contour-sides.mjs index 88a6811..fb237ba 100644 --- a/h5/test/contour-sides.mjs +++ b/h5/test/contour-sides.mjs @@ -35,7 +35,7 @@ try { 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'); + assert(Math.abs(crop.other-.9)<.002,'wrinkle front frame matches contour crop');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'); @@ -49,8 +49,8 @@ try { 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'); + assert(Math.abs(fill.sx-2.8)<.001);assert(Math.abs(fill.sy-2.8)<.001);assert(Math.abs(fill.shift-(-.25))<.001); + assert(fill.top<=.01&&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(); diff --git a/h5/test/countdown-capture-browser.mjs b/h5/test/countdown-capture-browser.mjs index 9bae29e..015808d 100644 --- a/h5/test/countdown-capture-browser.mjs +++ b/h5/test/countdown-capture-browser.mjs @@ -17,15 +17,16 @@ try { captureFromVideo=function(){window.__shots.push(step);return 'fixture';}; checkStill=function(){return Promise.resolve(null);}; onPhotoCaptured=function(){window.__completed.push(step);}; doCapture(); }, - again:doCapture, cancel:function(){camSeq++; voice.stop(true);}, + again:doCapture, cancel:leaveCamera, busy:function(){return shutterLock;} }; })();`)})); await page.addInitScript(()=>{ window.__audio=[];window.__shots=[];window.__completed=[]; window.AudioContext=class { - constructor(){this.state='running';this.destination={};} - decodeAudioData(ab,resolve){const b={id:new TextDecoder().decode(ab),duration:3};resolve(b);return Promise.resolve(b);} + constructor(){this.destination={};} + get state(){return window.__mute?'suspended':'running';} + decodeAudioData(ab,resolve){const b={id:new TextDecoder().decode(ab),duration:.9};resolve(b);return Promise.resolve(b);} createBufferSource(){return {connect(){},stop(){},start(){window.__audio.push(this);}};} }; }); @@ -36,18 +37,30 @@ try { await page.evaluate(n=>window.__countdown.start(n),step); await page.waitForFunction(n=>window.__audio.length===n,step+1); assert.equal(await page.evaluate(()=>window.__audio.at(-1).buffer.id),'countdown-321.mp3'); + assert.equal(await page.locator('#cam-countdown').textContent(),'3'); + assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),false); await page.evaluate(()=>window.__countdown.again()); await page.waitForTimeout(350); assert.equal(await page.evaluate(()=>window.__shots.length),step,'no capture before audio ended'); assert(await page.evaluate(()=>window.__countdown.busy())); + assert.equal(await page.locator('#cam-countdown').textContent(),'2'); + await page.waitForFunction(()=>document.querySelector('#cam-countdown').textContent==='1'); await page.evaluate(()=>window.__audio.at(-1).onended()); await page.waitForFunction(n=>window.__completed.length===n,step+1); + assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),true); } await page.evaluate(()=>window.__countdown.start(0)); await page.waitForFunction(()=>window.__audio.length===4); await page.evaluate(()=>window.__countdown.cancel()); + assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),true,'cancel immediately clears countdown'); await page.waitForTimeout(700); assert.equal(await page.evaluate(()=>window.__shots.length),3,'cancelled countdown never captures'); + await page.evaluate(()=>{window.__mute=true;window.__countdown.start(0);}); + assert.equal(await page.locator('#cam-countdown').textContent(),'3'); + await page.waitForTimeout(350); + assert.equal(await page.evaluate(()=>window.__shots.length),3,'silent devices still wait for visual countdown'); + await page.waitForFunction(()=>window.__completed.length===4); + assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),true); assert.deepEqual(errors,[]); - console.log('PASS: each of 3 shots waits for countdown ended; duplicate shutter ignored; cancellation safe'); + console.log('PASS: all 3 shots show animated 3/2/1, wait for voice and visual completion; duplicate shutter ignored; cancellation clears digits; silent-device countdown'); }finally{await browser.close();} diff --git a/h5/test/customer-feedback-fourth.js b/h5/test/customer-feedback-fourth.js index 4c0e624..5e696e2 100644 --- a/h5/test/customer-feedback-fourth.js +++ b/h5/test/customer-feedback-fourth.js @@ -92,6 +92,15 @@ const wrinkle = { key: 'wrinkles', text: '原始皱纹结论。\n分区明细( assert.equal(ReportUI.customerText(wrinkle), '原始皱纹结论。'); assert(wrinkle.text.includes('分区明细'), 'historical snapshot not overwritten'); assert.equal(ReportUI.customerText({ key: 'contour', text: '轮廓清晰;侧脸角度数据未完整获取;其他结论。' }), '轮廓清晰;其他结论。'); +for (const key of ['contour','wrinkles','color','smooth','water','sens','acne']) { + const missing={key,valid:false,text:'本次未检出有效数据,请重新拍摄后查看。'}; + const before=JSON.stringify(missing); + assert.equal(ReportUI.customerText(missing),''); + assert.equal(JSON.stringify(missing),before,'retain backend diagnostic'); + assert.equal(ReportUI.customerText({key,text:'本次未检出有效数据'}),'','legacy missing-data text hidden'); + assert.equal(ReportUI.customerText({key,text:'正常结论。',valid:true}),'正常结论。'); +} +assert.equal(ReportUI.customerText({key:'contour',text:'本次未获取轮廓有效数据。'}),''); const raw = { left_side_result: { left_jawline_info: { left_jawline_quality: 3, left_jawline_angle: 110 } }, side_result: { right_side_result: { right_jawline_info: { right_jawline_quality: 1, right_jawline_angle: 115 } } } }; assert.equal(Diagnostics.assess(raw).status, 'invalid'); assert.equal(Diagnostics.assess({}).status, 'unavailable'); diff --git a/h5/test/customer-feedback-second.js b/h5/test/customer-feedback-second.js index 9875508..ff2f034 100644 --- a/h5/test/customer-feedback-second.js +++ b/h5/test/customer-feedback-second.js @@ -70,6 +70,9 @@ now += 150; state.onDetect(face); assert.equal(captures, 2, 'vibration failure m for (const manual of [true, false]) { let manualBuzzes = 0, scheduled = []; const shutter = { shutterLock: false, camLive: true, capturing: false, camSeq: 1, + currentScreen:'s-cam', VOICE:{countdown:'countdown-321'}, + voice:{dropHint:()=>{},play:(_,opts)=>opts.onStart(3000),afterCurrent:cb=>cb()}, + startCaptureCountdown:(_,duration,done)=>done(), isManualMode: () => manual, navigator: { vibrate: () => { manualBuzzes++; throw Error('blocked'); } }, clearTimeout: () => {}, nudgeTimer: null, hideNudge: () => {}, $: () => ({ classList: { add: () => {} } }), setTimeout: (_, ms) => scheduled.push(ms) }; diff --git a/h5/test/guest-privacy.mjs b/h5/test/guest-privacy.mjs index ceae092..7a0b59a 100644 --- a/h5/test/guest-privacy.mjs +++ b/h5/test/guest-privacy.mjs @@ -19,10 +19,10 @@ const child = spawn(process.execPath, ['-e', ` const fs=require('fs'),vm=require('vm'),w={}; vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w}); const f=w.MOCK_FIXTURE; let calls=0; - global.fetch=async()=>({ok:true,status:200,json:async()=>++calls===2?{error_message:'INVALID_IMAGE_FACE'}: + global.fetch=async(url,options)=>({ok:true,status:200,json:async()=>options.body.has('left_side_image_file')&&++calls===2?{error_message:'INVALID_IMAGE_FACE'}: {request_id:'fixture',result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right}}); require('./server.js'); -`], { cwd: h5, env: { ...process.env, PORT: '18115', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot, +`], { cwd: h5, env: { ...process.env, NODE_ENV: 'test', PORT: '18115', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot, PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture' }, stdio: ['ignore', 'pipe', 'pipe'] }); let browser; function diskSnapshot(dir = dataRoot, prefix = '') { diff --git a/h5/test/regression.js b/h5/test/regression.js index cba57d4..23c1bc6 100644 --- a/h5/test/regression.js +++ b/h5/test/regression.js @@ -114,8 +114,7 @@ function main() { tr.cats.acne = { P: 99, S: 9.97, tier: '良好档', copy: '检出1处皮损(闭口粉刺1处)。建议维持清洁与控油节奏,注意作息与饮食。' }; tr.avgS = 9.53; tr.summary = tr.summary.replace('六分类S均分9.54', '六分类S均分9.53'); - // Authorized independent minimum: water90 no longer averaged up to91.5. - tr.dims.vita.P = 90; tr.dims.vita.S = 9.7; + // Restored original minimum: compare combined tone with smoothness. } var vm = SkinScore.buildViewModel(fixture.result, fixture.side, tr.profile); diff --git a/h5/test/report-bottom-figma.mjs b/h5/test/report-bottom-figma.mjs index 4b1f949..9b915e9 100644 --- a/h5/test/report-bottom-figma.mjs +++ b/h5/test/report-bottom-figma.mjs @@ -41,6 +41,16 @@ 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 spacing=await p.evaluate(()=>{ + const u=document.querySelector('.viz-frame').getBoundingClientRect().width/215; + const title=getComputedStyle(document.querySelector('.prod-title')); + return{u,top:parseFloat(title.marginTop),bottom:parseFloat(title.marginBottom),dimensions:[...document.querySelectorAll('.dimension-title')].map(e=>{ + const r=e.getBoundingClientRect();return{above:r.top-e.previousElementSibling.getBoundingClientRect().bottom,below:e.nextElementSibling.getBoundingClientRect().top-r.bottom}; + })}; + }); + assert(Math.abs(spacing.top-spacing.u*13*1.3)<.1,'bottom heading top whitespace increased 30%'); + assert(Math.abs(spacing.bottom-spacing.u*8.583333*1.3)<.1,'bottom heading bottom whitespace increased 30%'); + for(const d of spacing.dimensions){assert(Math.abs(d.above-d.below)<.1,'dimension heading whitespace symmetric');assert(Math.abs(d.above-spacing.u*18)<.1,'all dimension headings use same gap');} 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})); diff --git a/h5/test/report-persistence.mjs b/h5/test/report-persistence.mjs index b385c25..6a3d3f9 100644 --- a/h5/test/report-persistence.mjs +++ b/h5/test/report-persistence.mjs @@ -29,10 +29,17 @@ const child = spawn(process.execPath, ['-e', ` f.side.left.left_acne_info={score:0};set(f.side.left.left_acne_info,[5,1,0,0,1]); f.side.right.right_acne_info={score:0};set(f.side.right.right_acne_info,[3,1,0,0,4]); } - global.fetch=async()=>({ok:true,status:200,json:async()=>({result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right})}); + let callCount=0; + global.fetch=async(url,options)=>{ + callCount++; + const sideOnly=options.body.get('return_maps')==='texture_enhanced_lines'; + if(sideOnly){if(options.body.has('left_side_image_file')||options.body.has('right_side_image_file'))throw Error('side wrinkle photo must be its own main image');} + if(callCount>3)throw Error('unexpected paid request count'); + return{ok:true,status:200,json:async()=>({result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right})}; + }; require('./server.js'); `], { cwd: h5, env: { ...process.env, PORT: '18089', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot, - SKIN_API_KEY: 'fixture-key', SKIN_API_SECRET: 'fixture-secret', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] }); + SKIN_API_KEY: 'fixture-key', SKIN_API_SECRET: 'fixture-secret', NODE_ENV:'test', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] }); let browser; try { await new Promise((resolve, reject) => { @@ -53,6 +60,8 @@ try { assert.equal(result.ok, true); assert.equal(result.report_saved, true); assert(result.vm && result.presentation); + assert.equal(result.wrinkle_views.version,'independent-side-wrinkles-v1'); + assert.equal(result.wrinkle_views.left.status,'ready');assert.equal(result.wrinkle_views.right.status,'ready'); const expectedTotal = Math.round(result.vm.dims.firm.S * 4 + result.vm.dims.vita.S * 3 + result.vm.dims.stab.S * 3); assert.equal(result.vm.total, expectedTotal); assert.notEqual(expectedTotal, result.result.score_info.total_score, 'fixture distinguishes weighted score from API total'); @@ -73,9 +82,9 @@ try { 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'); + assert.equal(adminRecord.vita_score_rule,'combined-tone-smooth-min-v2'); assert.equal(Object.keys(adminRecord.sub_scores).length,7); - assert.equal(adminRecord.dims.vita.P,Math.min(...['color','water','smooth'].map(k=>adminRecord.sub_scores[k].P).filter(v=>v!=null))); + assert.equal(adminRecord.dims.vita.P,Math.min(...['tone','smooth'].map(k=>adminRecord.cats[k].P).filter(v=>v!=null))); if(process.env.H5_ACNE_MAX_TEST==='1') { assert.deepEqual(result.vm.cats.acne.vars.acne_counts,{closed_comedones:5,acne:1,acne_pustule:0,acne_nodule:0,acne_mark:4}); assert.equal(adminRecord.acne_scoring.deduction,13);assert.equal(adminRecord.cats.acne.P,87); @@ -95,6 +104,7 @@ try { const snapshot = await (await api.get(base + 'api/reports/' + result.record_id)).json(); assert.equal(snapshot.report.photos.length, 3); assert.deepEqual(snapshot.report.presentation, result.presentation); + assert.deepEqual(snapshot.report.payload.wrinkle_views,result.wrinkle_views); 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. diff --git a/h5/test/vita-independent.js b/h5/test/vita-independent.js index 39b06f8..5d3573a 100644 --- a/h5/test/vita-independent.js +++ b/h5/test/vita-independent.js @@ -1,8 +1,9 @@ const assert=require('node:assert/strict'),S=require('../js/skin-score'); function run(melanin,water,dark){const r=structuredClone(S.MOCK_RESULT);Object.assign(r.score_info,{melanin_score:melanin,water_score:water,dark_circle_score:dark});r.melasma={value:0};r.freckle={value:0};r.water={water_severity:3};return S.buildViewModel(r,S.MOCK_SIDE,{});} for(const [m,w,d]of[[66,97,30],[99,20,99],[99,99,99],[null,97,null],[null,null,null]]){ - const v=run(m,w,d),vars=v.cats.tone.vars,values=[v.cats.smooth.P,vars.color_score,vars.tone_scoring&&vars.tone_scoring.water_adjusted_score].filter(x=>typeof x==='number'&&Number.isFinite(x)); - assert.equal(v.dims.vita.P,Math.min(...values));assert.equal(v.vitaScoreRule,'independent-color-water-smooth-min-v1');assert.equal(v.total,S.weightedTotal(v.dims)); + const v=run(m,w,d),values=[v.cats.smooth.P,v.cats.tone.P].filter(x=>typeof x==='number'&&Number.isFinite(x)); + assert.equal(v.dims.vita.P,Math.min(...values));assert.equal(v.vitaScoreRule,'combined-tone-smooth-min-v2');assert.equal(v.total,S.weightedTotal(v.dims)); + assert.equal(v.dims.firm.P,Math.min(v.cats.contour.P,v.cats.wrinkles.P));assert.equal(v.dims.stab.P,Math.min(v.cats.sens.P,v.cats.acne.P)); } -assert(Math.abs(run(66,97,30).dims.vita.P-58.8)<1e-9); -console.log('PASS independent vita min: color/water/smooth bottleneck, missing inputs, version and weighted total'); +assert(Math.abs(run(66,97,30).cats.tone.P-77.9)<1e-9); +console.log('PASS restored dimension minima: combined tone/smooth, firm/stability unchanged, missing inputs, version and weighted total'); diff --git a/h5/test/water-copy.js b/h5/test/water-copy.js index b9415a7..6d0324c 100644 --- a/h5/test/water-copy.js +++ b/h5/test/water-copy.js @@ -8,15 +8,21 @@ function run(score,severity=3,pigment=66,dc=30){ return{r,vm,presentation,water:presentation.views.find(v=>v.key==='water')}; } const example=run(97);assert.equal(example.vm.cats.tone.P,77.9);assert.equal(example.water.P,97);assert.equal(example.water.level,'优秀'); -assert(!/含水量偏低|缺水比较严重|水润度不足/.test(example.water.text));assert(example.water.text.includes('97')); +assert.equal(example.water.text,'角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。'); 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,'改善'],[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(20,70).water.text.includes('缺水程度较高')); -assert(!run(20,3).water.text.includes('程度较高')); -assert(!run(20,null).water.text.includes('严重度')); +const crossed=run(97,70);assert.equal(crossed.water.P,30);assert.equal(crossed.water.level,'改善');assert.equal(crossed.vm.cats.tone.vars.tone_scoring.water_adjusted_score,30); +assert.equal(crossed.water.text,'角质层含水量较低,缺水面积 3%、肌肤缺水比较严重 70,肌肤含水量偏低,水润度不足,表面偏干燥,容易出现干纹。'); +assert.equal(run(40).water.text,'角质层含水量适中,缺水面积 3%、肌肤含水量处于健康水平,整体水润度良好。'); +assert(run(20,3).water.text.includes('肌肤缺水比较严重 3')); +assert(!run(20,null).water.text.includes('肌肤缺水比较严重')); +const old=structuredClone(example.presentation);old.copyRule='wrinkles-excellent-acne-detected-v1';old.views.find(v=>v.key==='water').text='水分分 97;旧水分文案'; +const savedBefore=JSON.stringify(old),updated=UI.forDisplay(example.vm,example.r,old); +assert.equal(updated.views.find(v=>v.key==='water').text,example.water.text); +assert.equal(JSON.stringify(old),savedBefore,'history snapshot not rewritten');assert.deepEqual(updated.product,old.product); +for(const v of updated.views.filter(v=>v.key!=='water'))assert.equal(v.text,old.views.find(x=>x.key===v.key).text,'other conclusions unchanged'); 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: independent copy/advice, displayed 8/9 boundaries, true severity, existing cross-check, missing/invalid, scoring immutable'); diff --git a/h5/test/wrinkle-three-views.mjs b/h5/test/wrinkle-three-views.mjs new file mode 100644 index 0000000..fa0fcd2 --- /dev/null +++ b/h5/test/wrinkle-three-views.mjs @@ -0,0 +1,53 @@ +// Deterministic API payloads; no network writes or upstream detection. +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 b=await chromium.launch(); +try{ +const p=await b.newPage({viewport:{width:390,height:1100},isMobile:true,hasTouch:true});const errors=[];p.on('pageerror',e=>errors.push(e.message)); +await p.route('**/api/**',r=>{assert.equal(r.request().method(),'GET');return r.fulfill({json:{ok:true,user:{kind:'browser'}}});}); +const source=fs.readFileSync(new URL('../js/app.js',import.meta.url),'utf8'); +await p.route('**/js/app.js*',r=>r.fulfill({contentType:'text/javascript',body:source.replace(/\}\)\(\);\s*$/,` +window.__wrinkleTest=function(x){var fx=MOCK_FIXTURE,raw=JSON.parse(JSON.stringify(fx.result)); +if(x.noFrontMap)delete raw.face_maps.texture_enhanced_lines; +var vm=SkinScore.buildViewModel(raw,fx.side,{}); +renderReport(vm,fx.photos[0],null,{result:raw,side:fx.side,wrinkle_views:x.views,face_rectangle:fx.face_rectangle},[fx.photos[0],x.left,x.right]); +showScreen('s-report');setDetailTab(document.querySelector('.card-detail'),'1',true); +};})();`)})); +await p.goto((process.env.H5_BASE_URL||'http://127.0.0.1:8080/')+'?mock=1&screen=report'); +const svg=body=>'data:image/svg+xml,'+encodeURIComponent(''+body+''); +const left=svg(''),right=svg(''); +const map=color=>svg(''); +const entry=color=>({status:'ready',result:{face_maps:{texture_enhanced_lines:map(color)}}}); +for(const width of[320,375,414,480]){ +await p.setViewportSize({width,height:1100}); +await p.evaluate(x=>window.__wrinkleTest(x),{left,right,views:{left:entry('red'),right:entry('blue')}}); +await p.waitForFunction(()=>['wrinkle-left','wrinkle-right'].every(id=>{const i=document.getElementById(id);return i.naturalWidth>0&&!i.closest('figure').hidden&&i.dataset.hasAnnotation==='true';})); +assert(await p.locator('#wrinkle-layer-note').evaluate(e=>e.hidden)); +assert(await p.locator('#ditem-img2').evaluate(e=>e.dataset.hasAnnotation==='true')); +const layout=await p.evaluate(()=>{ + const styles=e=>{const s=getComputedStyle(e);return{width:s.width,height:s.height,marginBottom:s.marginBottom,transform:s.transform,objectFit:s.objectFit,objectPosition:s.objectPosition};}; + return{contour:styles(document.querySelector('#ditem-img1').parentElement),wrinkle:styles(document.querySelector('#ditem-img2').parentElement),frontA:styles(document.querySelector('#ditem-img1')),frontB:styles(document.querySelector('#ditem-img2')),sideA:styles(document.querySelector('#contour-left')),sideB:styles(document.querySelector('#wrinkle-left')),offsetA:document.querySelector('#ditem-img1').dataset.contentOffset,offsetB:document.querySelector('#ditem-img2').dataset.contentOffset}; +}); +assert.deepEqual(layout.contour,layout.wrinkle,'same front frame and spacing'); +assert.deepEqual(layout.frontA,layout.frontB,'same front image size'); +assert.deepEqual(layout.sideA,layout.sideB,'same side image scaling and positioning'); +assert.equal(layout.offsetA,layout.offsetB,'same face crop offset; overlay moves with photo'); +const leftSrc=await p.locator('#wrinkle-left').getAttribute('src'),rightSrc=await p.locator('#wrinkle-right').getAttribute('src'); +assert.notEqual(leftSrc,left);assert.notEqual(rightSrc,right);assert.notEqual(leftSrc,rightSrc); +await p.getByRole('button',{name:'查看左脸皱纹检测大图'}).click();assert.equal(await p.locator('#zoom-img').getAttribute('src'),leftSrc);await p.locator('#p-zoom').click(); +await p.getByRole('button',{name:'查看右脸皱纹检测大图'}).click();assert.equal(await p.locator('#zoom-img').getAttribute('src'),rightSrc);await p.locator('#p-zoom').click(); +} +await p.evaluate(x=>window.__wrinkleTest(x),{left,right,views:{}}); +await p.waitForFunction(()=>!document.querySelector('#wrinkle-left').closest('figure').hidden&&!document.querySelector('#wrinkle-right').closest('figure').hidden); +assert.equal(await p.locator('#wrinkle-left').getAttribute('src'),left,'no front-face map projected onto unannotated side'); +assert(await p.locator('#wrinkle-layer-note').isVisible()); +const marks={left_cheek_wrinkle_outline:[{x:10,y:10},{x:200,y:10},{x:200,y:80}]}; +await p.evaluate(x=>window.__wrinkleTest(x),{left,right:null,noFrontMap:true,views:{left:{status:'ready',result:{wrinkle_mark:marks}}}}); +await p.waitForFunction(()=>document.querySelector('#wrinkle-left').dataset.hasAnnotation==='true'&&document.querySelector('#ditem-img2').dataset.hasAnnotation==='true'); +assert(await p.locator('#wrinkle-right').evaluate(e=>e.closest('figure').hidden&&!e.hasAttribute('src'))); +await p.evaluate(x=>window.__wrinkleTest(x),{left:null,right:null,views:{}}); +assert(await p.locator('#wrinkle-sides').evaluate(e=>e.hidden));assert(await p.locator('#wrinkle-layer-note').evaluate(e=>e.hidden)); +assert.deepEqual(errors,[]); +console.log('PASS wrinkle three views: 4 widths, own overlays, annotated zoom, front overlay preserved, missing layer/photo, real-coordinate fallback, no false projection and stale clearing'); +}finally{await b.close();} diff --git a/h5/test/wrinkle-views.js b/h5/test/wrinkle-views.js new file mode 100644 index 0000000..8caae77 --- /dev/null +++ b/h5/test/wrinkle-views.js @@ -0,0 +1,34 @@ +'use strict'; +const assert=require('node:assert/strict'), Views=require('../lib/wrinkle-views'); +const side=label=>({request_id:label,result:{score_info:{wrinkle_score:1},face_maps:{texture_enhanced_lines:label+'-map',red_area:'do-not-forward'},wrinkle_mark:{left_cheek_wrinkle_outline:[{x:1,y:1},{x:2,y:1},{x:2,y:2}]}}}); +const photos={left:Buffer.from('left photo').toString('base64'),right:Buffer.from('right photo').toString('base64')}; +(async()=>{ + const original={result:{score_info:{wrinkle_score:76}},left_side_result:{left_jawline_info:{left_jawline_angle:112}}}; + const before=JSON.stringify(original),calls=[]; + const options={key:'fixture',secret:'fixture',url:'https://fixture.invalid',sleep:async()=>{},now:()=>0,fetch:async(url,o)=>{ + assert.equal(o.method,'POST');const form=o.body; + assert.equal(form.get('return_maps'),'texture_enhanced_lines');assert.equal(form.get('return_marks'),'wrinkle_mark'); + assert(!form.has('left_side_image_file')&&!form.has('right_side_image_file')); + const file=form.get('image_file'),label=file.name.split('.')[0];calls.push(label); + assert.equal(await file.text(),label+' photo'); + return{ok:true,status:200,json:async()=>side(label)}; + }}; + const views=await Views.collect(original,photos,options); + assert.deepEqual(calls,['left','right']);assert.equal(JSON.stringify(original),before); + assert.equal(views.left.status,'ready');assert.equal(views.right.status,'ready'); + const report=Views.forReport(views); + assert.equal(report.left.result.face_maps.texture_enhanced_lines,'left-map'); + assert.equal(report.right.result.face_maps.texture_enhanced_lines,'right-map'); + assert(!report.left.result.score_info&&!report.left.result.face_maps.red_area); + const mixed=await Views.collect(original,photos,{...options,fetch:async(url,o)=>({ok:true,status:200,json:async()=>o.body.get('image_file').name==='left.jpg'?{error_message:'INVALID_IMAGE_FACE: test'}:side('right')})}); + assert.equal(mixed.left.status,'unavailable');assert.equal(mixed.left.error_code,'INVALID_IMAGE_FACE');assert.equal(mixed.right.status,'ready'); + assert(!Views.forReport(mixed).left.result); + const deadline=await Views.collect(original,photos,{...options,deadline:1,gapMs:4,fetch:()=>{throw Error('must not call');}}); + assert.equal(deadline.left.error_code,'WRINKLE_TIME_BUDGET');assert.equal(deadline.right.error_code,'WRINKLE_TIME_BUDGET'); + const missing=await Views.collect(original,{},options);assert.equal(missing.left.error_code,'MISSING_SIDE_PHOTO'); + const reused=await Views.collect({left_side_result:side('cached').result},photos,{...options,fetch:async()=>({ok:true,json:async()=>side('right')})}); + assert.equal(reused.left.source,'primary-side-result');assert.equal(reused.right.source,'independent-side-request'); + const failed=await Views.collect(original,photos,{...options,fetch:async()=>{const e=new Error();e.name='TimeoutError';throw e;}}); + assert.equal(failed.left.error_code,'WRINKLE_TIMEOUT');assert.equal(failed.right.error_code,'WRINKLE_TIMEOUT'); + console.log('PASS wrinkle views: own photo requests, sequential order, separate maps, no score mutation/forwarding, native reuse, missing/API failure/timeout/budget and report-safe payload'); +})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/reference/AI测肤报告页话术修改.md b/reference/AI测肤报告页话术修改.md index 0cdc9db..d97ab8f 100644 --- a/reference/AI测肤报告页话术修改.md +++ b/reference/AI测肤报告页话术修改.md @@ -50,13 +50,13 @@ #### 5\.5 水分(透亮感) -**【改善】**水分分 \{water\_score\};缺水面积 \{water\_area\}%,\{water\_severity\_note\}肌肤水润度有待提升,建议加强补水与保湿。 +**【改善】**角质层含水量较低,缺水面积 \{water\_area\}%、肌肤缺水比较严重 \{water\_sev\},肌肤含水量偏低,水润度不足,表面偏干燥,容易出现干纹。 -**【良好】**水分分 \{water\_score\};缺水面积 \{water\_area\}%、肌肤含水量处于健康水平,整体水润度良好。 +**【良好】**角质层含水量适中,缺水面积 \{water\_area\}%、肌肤含水量处于健康水平,整体水润度良好。 -**【优秀】** 水分分 \{water\_score\};角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。 +**【优秀】** 角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。 -水分分档补充(2026-10-15):文案及水分护理建议独立使用水分项分档,低于80为改善、80至低于90为良好、90及以上为优秀,不再跟随肤色水润合成分。沿用现有水分交叉校验:水分分≥80且缺水严重度≥60时,水分项按100减严重度评估,并在文案中明确提示分数与缺水指标不一致。\{water\_severity\_note\}仅在严重度为0~100的有效数值时展示:≥60描述缺水程度较高,其他值仅报告严重度数值;缺失时省略。维度评分、综合评分、推荐及护理类别选取不变。 +水分文案更新(2026-10-15):三个档位采用以上原文,不额外添加水分分数或分数与缺水指标不一致的提示。缺失的面积注明未获取,缺失的严重度省略对应短句,不填造数值。计分、交叉校验和分档规则保持现有代码不变;水分页卡及护理建议仍按独立水分项的展示分数分档(低于8为改善,8至低于9为良好,9及以上为优秀)。 #### 5\.6 泛红(稳韧力)