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

This commit is contained in:
xsl
2026-10-15 17:14:05 +08:00
parent 60f0965c5a
commit 2d80f6dfd0
21 changed files with 557 additions and 49 deletions
+8 -7
View File
@@ -69,9 +69,10 @@ function dimsHtml(dims) {
var h = '';
for (var k in DIM_NAMES) {
var d = dims[k];
h += '<div><span class="lv ' + (d && LV_CLASS[d.level] || '') + '" title="' + DIM_NAMES[k] + '">' +
var grade = ReportUI.internalLevel(d && d.S);
h += '<div><span class="lv ' + (LV_CLASS[grade] || '') + '" title="' + DIM_NAMES[k] + '">' +
DIM_NAMES[k] + ' ' + ReportUI.dimensionScore(d && d.S) + ' ' +
(d ? esc(ReportUI.levelMap[d.level] || '-') : '-') + '</span></div>';
esc(ReportUI.levelMap[grade] || '-') + '</span></div>';
}
return h;
}
@@ -428,7 +429,7 @@ function renderDetail(data) {
for (var i = 0; i < scoreOrder.length; i++) {
var ck = scoreOrder[i], cat = (r.sub_scores || r.cats || {})[ck];
var catName = ({ contour: '轮廓', wrinkles: '皱纹', smooth: '平滑度', color:'肤色', water:'水分', tone: '肤色 / 水分(历史)', sens: '泛红', acne: '痘痘' })[ck];
catRows += '<tr><td>' + esc(catName) + '</td><td>' + fmtScore(cat && cat.P) + '</td><td>' + fmtScore(cat && cat.S) + '</td><td>' + lvHtml(cat && cat.level) + '</td></tr>';
catRows += '<tr><td>' + esc(catName) + '</td><td>' + fmtScore(cat && cat.P) + '</td><td>' + fmtScore(cat && cat.S) + '</td><td>' + lvHtml(ReportUI.internalLevel(cat && cat.S)) + '</td></tr>';
}
h += dSec(r.sub_scores ? '七项独立评分' : '历史六分类评分(肤色 / 水分共用一项)', '<table class="d-table"><thead><tr><th>分类</th><th>P</th><th>S</th><th>档位</th></tr></thead><tbody>' + catRows + '</tbody></table>');
@@ -436,7 +437,7 @@ function renderDetail(data) {
var dimRows = '';
for (var dk in DIM_NAMES) {
var dim = r.dims && r.dims[dk];
dimRows += '<tr><td>' + DIM_NAMES[dk] + '</td><td>' + fmtScore(dim && dim.P) + '</td><td>' + fmtScore(dim && dim.S) + '</td><td>' + lvHtml(dim && dim.level) + '</td></tr>';
dimRows += '<tr><td>' + DIM_NAMES[dk] + '</td><td>' + fmtScore(dim && dim.P) + '</td><td>' + fmtScore(dim && dim.S) + '</td><td>' + lvHtml(ReportUI.internalLevel(dim && dim.S)) + '</td></tr>';
}
h += dSec('三维度评分', '<table class="d-table"><thead><tr><th>维度</th><th>P</th><th>S</th><th>档位</th></tr></thead><tbody>' + dimRows + '</tbody></table>');
if (r.acne_scoring && r.acne_scoring.version === 'three-view-type-max-v1') {
@@ -462,9 +463,9 @@ function renderDetail(data) {
if (txt) copyHtml += '<div class="d-copy"><b>' + esc((r.cats[CAT_ORDER[c2]] || {}).name || '') + ':</b>' + esc(txt) + '</div>';
}
}
if (r.copy_summary) copyHtml += '<div class="d-copy"><b>综合文案:</b>' + esc(r.copy_summary) + '</div>';
if (r.lifestyle) copyHtml += '<div class="d-copy"><b>生活方式:</b>' + esc(r.lifestyle) + '</div>';
if (copyHtml) h += dSec(presentation ? '前端报告结论(保存快照)与计算摘要' : '旧版生成文案(无前端快照)', copyHtml);
if (r.copy_summary) copyHtml += '<div class="d-copy"><b>旧版算法摘要(原始留档,不用于当前文案分档):</b>' + esc(r.copy_summary) + '</div>';
if (r.lifestyle) copyHtml += '<div class="d-copy"><b>旧版生活方式(原始留档):</b>' + esc(r.lifestyle) + '</div>';
if (copyHtml) h += dSec(presentation ? (presentation.copyRule === ReportUI.copyRule ? '前端报告结论(当前文案展示,原始快照保留)与计算摘要' : '前端报告结论(保存快照,未刷新文案)与计算摘要') : '旧版生成文案(无前端快照)', copyHtml);
// Technical region metrics stay in the protected admin, not customer prose.
var wrinkleView = presentation && presentation.views && presentation.views.filter(function(view) { return view.key === 'wrinkles'; })[0];
+46 -7
View File
@@ -1164,7 +1164,7 @@
popHistorySilently();
return;
}
renderReport(vm, photos[0], photos[1], payload); // img1=正脸实拍,payload 供检测可视化取 face_maps/marks
renderReport(vm, photos[0], photos[1], payload, photos); // photos 已统一为正/左/右
if (IS_MOCK) toast('演示报告,不保存至我的报告');
else if (AccountClient.user() && AccountClient.user().kind !== 'wechat') toast('游客报告仅供本次查看,不保存历史记录');
else if (!payload.report_saved) toast('报告生成成功,但保存失败,请勿关闭页面并联系管理员');
@@ -1315,6 +1315,13 @@
if (top + ch < rectangle.top + rectangle.height) top = rectangle.top + rectangle.height - ch;
return { left: left, top: top, width: cw, height: ch, focused: true };
}
function vizShiftContentDown(crop, rectangle, fraction) {
if (!crop.focused || !rectangle || !fraction) return crop;
// Move the source window upward, so its subject moves DOWN inside the
// fixed frame. Keep the full face in bounds and never create a blank edge.
var minimumTop = Math.max(0, rectangle.top + rectangle.height - crop.height);
return Object.assign({}, crop, { top: Math.max(minimumTop, crop.top - crop.height * fraction) });
}
// 从 result 提取某分类的标记集合:polygon 与 rectangle 都保留,缺该标记类型则跳过
function vizCollectMarks(markDefs, result) {
var out = [];
@@ -1389,6 +1396,8 @@
var finish = function (overlays) {
if (epoch !== vizEpoch) return;
var crop = vizFaceCrop(photo.naturalWidth, photo.naturalHeight, opts && opts.faceRectangle);
var originalTop = crop.top;
crop = vizShiftContentDown(crop, opts && opts.faceRectangle, opts && opts.contentDown);
var scale = Math.min(1, 960 / crop.width);
var w = Math.round(crop.width * scale), h = Math.round(crop.height * scale);
var cv = document.createElement('canvas');
@@ -1440,6 +1449,7 @@
try {
imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false;
imgEl.dataset.crop = crop.focused ? 'face' : 'full';
imgEl.dataset.contentOffset = String((originalTop - crop.top) / crop.height);
imgEl.dataset.overlayAlpha = String(opts && typeof opts.alpha === 'number' ? opts.alpha : 1);
} catch (e) { /* 合成失败保留占位 */ }
};
@@ -1501,11 +1511,39 @@
// 照片类(轮廓/光滑/痘痘):无照片 → 保留占位;有坐标则 canvas 叠加,无坐标纯照片
if (!frontPhoto) continue;
var marks = cfg.marks ? vizCollectMarks(cfg.marks, result) : [];
vizCompose(frontPhoto, null, marks, imgEl, { faceRectangle: rectangle });
// The visible contour frame is 90% of the source height: 9% of the
// source equals a 10% downward shift relative to that visible frame.
vizCompose(frontPhoto, null, marks, imgEl, { faceRectangle: rectangle, contentDown: key === 'contour' ? .09 : 0 });
}
}
function renderReport(vm, frontPhoto, sidePhoto, payload) {
var contourPhotoEpoch = 0;
function renderContourSides(reportPhotos) {
var epoch = ++contourPhotoEpoch, wrap = $('#contour-sides');
function refreshHeight() {
var card = wrap.closest('.card-detail'), state = detailSwipes.get(card);
if (state) state.height(Number(card.querySelector('.dtab.active').dataset.tab));
}
wrap.hidden = true;
['left', 'right'].forEach(function(view, i) {
var img = $('#contour-' + view), figure = img.closest('figure');
img.onload = img.onerror = null; img.removeAttribute('src'); figure.hidden = true;
var src = reportPhotos && reportPhotos[i + 1];
if (!src) return;
img.draggable = false;
img.onload = function() { if (epoch === contourPhotoEpoch) { figure.hidden = false; wrap.hidden = false; refreshHeight(); } };
img.onerror = function() {
if (epoch !== contourPhotoEpoch) return;
figure.hidden = true; img.removeAttribute('src');
wrap.hidden = !wrap.querySelector('figure:not([hidden])');
refreshHeight();
};
img.src = src;
});
refreshHeight();
}
function renderReport(vm, frontPhoto, sidePhoto, payload, reportPhotos) {
renderContourSides(reportPhotos || [frontPhoto, sidePhoto, null]);
reportOwner = AccountClient.user() && AccountClient.user().id;
// Historical snapshots also use their saved dimensions, never the old API total.
var total = SkinScore.weightedTotal(vm.dims);
@@ -1529,7 +1567,7 @@
['firm', 'vita', 'stab'].forEach(function (k) {
setDim(k, vm.dims[k], k === worstDimKey ? 'worst' : k === bestDimKey ? 'best' : '');
});
var presentation = payload && payload.presentation || ReportUI.build(vm, payload && payload.result);
var presentation = ReportUI.forDisplay(vm, payload && payload.result, payload && payload.presentation);
var focus = ReportUI.focusOf(vm), focusEl = $('#dims-note');
focusEl.hidden = !focus;
focusEl.replaceChildren();
@@ -1654,7 +1692,8 @@
card.addEventListener('click', function (e) {
var btn = e.target.closest('.dtab');
if (btn) { setDetailTab(card, btn.dataset.tab); return; }
var img = e.target.closest('.ditem-img');
var sideButton = e.target.closest('.contour-side-photo');
var img = sideButton ? sideButton.querySelector('img') : e.target.closest('.ditem-img');
if (img && img.src && !img.hidden && img.closest('.dpanel').classList.contains('active')) {
$('#zoom-img').src = img.src;
openOverlay('p-zoom');
@@ -1779,7 +1818,7 @@
AccountClient.request('api/reports/' + encodeURIComponent(btn.dataset.reportId)).then(function (data) {
if (currentScreen !== 's-history' || seq !== historySeq || owner !== (AccountClient.user() && AccountClient.user().id)) return;
var entry = data.report;
renderReport(entry.vm, entry.photos[0], entry.photos[1], Object.assign({}, entry.payload, { presentation: entry.presentation }));
renderReport(entry.vm, entry.photos[0], entry.photos[1], Object.assign({}, entry.payload, { presentation: entry.presentation }), entry.photos);
showScreen('s-report', 'push');
}).catch(function (e) { toast(e.message); }).finally(function () { btn.disabled = false; });
});
@@ -1905,7 +1944,7 @@
renderReport(SkinScore.buildViewModel(mockRes, mockSide, {
realAge: +($('#f-age').value || 0) || 28, // 调试档案缺省 28 岁
skinType: SKINS[skinIndex].name
}), fx ? fx.photos[0] : null, null, fx ? { result: mockRes, side: mockSide, face_rectangle: fx.face_rectangle } : null);
}), fx ? fx.photos[0] : null, null, fx ? { result: mockRes, side: mockSide, face_rectangle: fx.face_rectangle } : null, fx && fx.photos);
} catch (e) { /* 调试入口异常不阻塞正式流程 */ }
}
})();
+1 -1
View File
@@ -10,7 +10,7 @@
"wrinkles": {
"改善": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。",
"良好": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面部整体平整,仅眼周或口周有细微的干纹,无明显静态深纹,纹路面积占比相对较低。",
"优秀": "面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。"
"优秀": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条;整体纹路状态较好,建议维持日常淡纹与保湿护理。"
},
"color": {
"改善": "检测到当前肌肤色素分 {melanin},存在色素沉着较显著,{dark_circle_note},整体透亮感不足。",
+35 -8
View File
@@ -21,6 +21,19 @@
function dimensionScore(value) {
return numeric(value) ? (Math.round((value + 1e-9) * 10) / 10).toFixed(1) : '-';
}
var gradeRule = 'display-score-8-9-v1';
var copyRule = 'wrinkles-excellent-acne-detected-v1';
function scoreOfP(P) {
return numeric(P) ? Math.round((Math.max(7, Math.min(10, 7 + 3 * P / 100)) + 1e-9) * 100) / 100 : null;
}
function levelForScore(S) {
if (!numeric(S)) return null;
var displayed = Number(dimensionScore(S));
return displayed < 8 ? '改善' : displayed < 9 ? '良好' : '优秀';
}
function internalLevel(S) {
return { '改善': '预警', '良好': '改善', '优秀': '良好' }[levelForScore(S)] || null;
}
// Read saved raw dimension values, never re-score historical reports or use
// the legacy focus.name (which contains the old dimension terminology).
function focusOf(vm) {
@@ -84,12 +97,11 @@
result = result || {};
var views = defs.map(function(d) {
var cat = vm.cats[d[3]], vars = Object.assign({}, cat.vars || {});
var level = levelMap[cat.level], valid = cat.P != null;
var level, valid = numeric(cat.P);
var viewP = cat.P;
if (d[0] === 'color') {
viewP = numeric(vars.color_score) ? vars.color_score : vars.tone_scoring && vars.tone_scoring.color_score;
valid = vars.melanin != null && numeric(viewP);
level = valid ? (viewP < 80 ? '改善' : viewP < 90 ? '良好' : '优秀') : null;
}
if (d[0] === 'water') {
// Water copy/advice is independent of pigment/dark circles. Reuse
@@ -99,8 +111,9 @@
viewP = waterValid ? waterScore : null;
if (waterValid && numeric(vars.water_sev) && vars.water_sev >= 60 && vars.water_sev <= 100 && waterScore >= 80) viewP = 100 - vars.water_sev;
valid = waterValid;
level = valid ? (viewP < 80 ? '改善' : viewP < 90 ? '良好' : '优秀') : null;
}
var viewS = valid ? scoreOfP(viewP) : null;
level = levelForScore(viewS);
var template = valid && level ? Copy.conclusion[d[0]][level] : '';
if (d[0] === 'water' && valid) {
vars.water_severity_note = numeric(vars.water_sev) && vars.water_sev >= 0 && vars.water_sev <= 100
@@ -131,8 +144,9 @@
var nodules = vars.acne_counts ? vars.acne_counts.acne_nodule : count(result, 'acne_nodule');
var heavy = [pustules > 0 ? '脓包' : '', nodules > 0 ? '结节' : ''].filter(Boolean);
vars.heavy_note = heavy.length ? ',含' + heavy.join('、') + '等中重度皮损,炎症反应明显' : '';
if (heavy.length) { level = '改善'; template = Copy.conclusion.acne[level]; }
else if (vars.acne_cnt > 0 && level === '优秀') { level = '良好'; template = Copy.conclusion.acne[level]; }
// Never downgrade the grade independently of the displayed score,
// but never claim absence when the API detected lesions either.
if (vars.acne_cnt > 0) template = '检测到 {acne_cnt} 处皮损({acne_detail}){heavy_note},局部皮损仍需关注。';
if (vars.acne_cnt == null) template = '本次未获取完整皮损计数,请结合检测图片查看。';
else if (vars.acne_cnt === 0) template = '本次未检出明显皮损,请结合检测图片查看。';
if (vars.acne_cnt != null && vars.acne_counts_complete === false) {
@@ -156,7 +170,7 @@
}
var details = d[0] === 'wrinkles' ? wrinkleDetails(result) : null;
var advice = level ? Copy.care[d[0]][level] : null;
return { key: d[0], name: d[1], dim: d[2], source: d[3], P: viewP, level: level || null,
return { key: d[0], name: d[1], dim: d[2], source: d[3], P: viewP, S: viewS, level: level || null,
text: valid ? fill(template, vars) : '本次未检出有效数据,请重新拍摄后查看。',
details: details, valid: valid, advice: valid ? advice : null };
});
@@ -170,7 +184,20 @@
var care = chosen.map(function(v) { return { key: v.key, name: v.name, dim: v.dim, P: v.P, level: v.level,
priority: priority(v) === 0, text: v.advice.care + v.advice.lifestyle }; });
var product = products.filter(function(p) { return p.match === vm.essence.name; })[0] || null;
return { version: Copy.version + '+feedback-2026-10-12+care-2026-10-15', careRule: 'seven-lowest-three-focus-first-v1', dims: dimNames, views: views, care: care, product: product, focus: focus };
return { version: Copy.version + '+feedback-2026-10-12+care-2026-10-15+grade-8-9+two-copy-fixes', gradeRule: gradeRule, copyRule: copyRule, careRule: 'seven-lowest-three-focus-first-v1', dims: dimNames, views: views, care: care, product: product, focus: focus };
}
return { dimensionScore: dimensionScore, build: build, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails, focusOf: focusOf, customerText: customerText };
// Refresh only presentation for old snapshots: scores, selected care order,
// product and original stored objects are never rewritten/recomputed.
function forDisplay(vm, result, saved) {
if (!saved || (saved.gradeRule === gradeRule && saved.copyRule === copyRule)) return saved || build(vm, result);
var current = build(vm, result);
var care = (saved.care || []).map(function(item) {
// Pre-seven-item snapshots contain name/text only, without a stable key.
// Match exact known names; never infer from array position or insert items.
var view = current.views.filter(function(v) { return item.key ? v.key === item.key : v.name === item.name; })[0];
return view && view.advice ? Object.assign({}, item, { key: view.key, level: view.level, text: view.advice.care + view.advice.lifestyle }) : item;
});
return Object.assign({}, saved, { gradeRule: gradeRule, copyRule: copyRule, views: current.views, care: care });
}
return { dimensionScore: dimensionScore, scoreOfP: scoreOfP, levelForScore: levelForScore, internalLevel: internalLevel, gradeRule: gradeRule, copyRule: copyRule, build: build, forDisplay: forDisplay, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails, focusOf: focusOf, customerText: customerText };
});