feat(h5): update skin scoring, age display and capture countdown

This commit is contained in:
xsl
2026-10-15 00:20:48 +08:00
parent d8c67762da
commit 60ae6c97ef
37 changed files with 5110 additions and 1632 deletions
+17 -3
View File
@@ -18,11 +18,12 @@ function esc(s) { // 拼 HTML 前转义(接口文案/失败信息不可信)
}
function fmtTime(iso) {
if (!iso) return '-';
return iso.replace('T', ' ').slice(0, 19);
return BeijingTime.format(iso);
}
function fmtMs(ms) { return ms == null ? '-' : ms + 'ms'; }
function fmtScore(v) { return v == null ? '-' : v; }
function fmtTotal(v) { return v == null ? '--' : v; }
function fmtToneScore(v) { return v == null ? '-' : Math.round((v + 1e-9) * 100) / 100; }
var LV_CLASS = { '预警': 'lv-bad', '改善': 'lv-warn', '良好': 'lv-good' };
var DIM_NAMES = ReportUI.dimNames;
@@ -69,7 +70,7 @@ function dimsHtml(dims) {
for (var k in DIM_NAMES) {
var d = dims[k];
h += '<div><span class="lv ' + (d && LV_CLASS[d.level] || '') + '" title="' + DIM_NAMES[k] + '">' +
DIM_NAMES[k] + ' ' + (d && typeof d.S === 'number' ? d.S.toFixed(1) : '-') + ' ' +
DIM_NAMES[k] + ' ' + ReportUI.dimensionScore(d && d.S) + ' ' +
(d ? esc(ReportUI.levelMap[d.level] || '-') : '-') + '</span></div>';
}
return h;
@@ -414,7 +415,12 @@ function renderDetail(data) {
'<span>右下颌线角 <b>' + (r.jawline_angle_r != null ? r.jawline_angle_r + '°' : '-') + '</b></span>' +
'</div>');
h += '<p class="sub">综合得分=紧塑力×4+透亮感×3+稳韧力×3,按已计算的维度S分数(两位小数)加权,四舍五入取整数;任一维度缺失不生成综合得分,不使用接口总分。</p>';
h += '<p class="sub">前端展示肌龄沿用实际年龄±3岁的修正;新推荐只使用原始肌龄。历史报告保留当时的推荐。</p>';
if (r.wrinkle_score_rule === 'api-overall-v1') h += '<p class="sub">皱纹评分直接使用接口整体皱纹分数,不做100减分,也不再按分区严重度限分。</p>';
if (r.tone_scoring && r.tone_scoring.version === 'color-dark-circle-20-v1') {
var tone = r.tone_scoring;
h += '<p class="sub">肤色项=色素扣斑分×80%+黑眼圈评分×20%;再与水分项各占50%。色素扣斑分:' + esc(fmtToneScore(tone.pigment_adjusted_score)) + ',黑眼圈评分:' + esc(fmtToneScore(tone.dark_circle_score)) + ',合成肤色项:' + esc(fmtToneScore(tone.color_score)) + ',水分项:' + esc(fmtToneScore(tone.water_adjusted_score)) + '。缺项按有效权重归一。</p>';
}
h += '<p class="sub">前端展示肌龄限制在实际年龄±5岁;推荐仍使用原始肌龄。历史报告保留当时的推荐。</p>';
/* 6 分类表格 */
var catRows = '';
@@ -432,6 +438,14 @@ function renderDetail(data) {
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>';
}
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') {
var audit = r.acne_scoring, acneRows = '';
(audit.types || []).forEach(function(t) {
var v = t.views || {};
acneRows += '<tr><td>' + esc(t.name) + '</td><td>' + esc(fmtScore(v.front)) + '</td><td>' + esc(fmtScore(v.left)) + '</td><td>' + esc(fmtScore(v.right)) + '</td><td>' + esc(fmtScore(t.count)) + '</td><td>' + esc(fmtScore(t.weight)) + '</td><td>' + esc(fmtScore(t.deduction)) + '</td></tr>';
});
h += dSec('痘痘三视图计分明细', '<p class="sub">每种皮损分别取三视图最大数量,不相加,也不是全脸去重总数;“-”表示未获取。整体评分仍用正脸:' + esc(fmtScore(audit.front_score)) + ',映射分:' + esc(fmtScore(audit.mapped_score)) + '。计数扣分合计:' + esc(fmtScore(audit.raw_deduction)) + ',封顶50后:' + esc(fmtScore(audit.deduction)) + '。</p><table class="d-table"><thead><tr><th>类型</th><th>正脸</th><th>左脸</th><th>右脸</th><th>计分数量</th><th>单处扣分</th><th>该项扣分</th></tr></thead><tbody>' + acneRows + '</tbody></table>');
}
/* 文案全文 */
var copyHtml = '';
+27 -7
View File
@@ -751,8 +751,8 @@
if (hard) stopCur();
}
// 等全部播报(含排队与挂起提示)自然收尾后执行 cb(倒计时接续「保持不动」,不拦腰切断);空闲则立即执行
function afterCurrent(cb) {
var tail = cur && cur.src && Date.now() >= cur.end - 60; // 只剩尾音视为已收尾
function afterCurrent(cb, waitForEnded) {
var tail = !waitForEnded && cur && cur.src && Date.now() >= cur.end - 60; // 倒计时必须等 ended,不能提前抓拍
if ((!cur || tail) && !queue.length && !pendingHint) { cb(); return null; }
var fn = function () { cb(); };
waiters.push(fn);
@@ -762,7 +762,7 @@
})();
document.addEventListener('click', voice.unlock); // 任意点击即完成解锁
/* ─── 姿态检测(face-api + HeadPose):动态捕捉——距离/居中/角度/光线全部合格并稳住片刻即自动抓拍,不倒计时、无手动快门 ─── */
/* ─── 姿态检测(face-api + HeadPose):全部合格并稳住片刻后播321,播完自动抓拍 ─── */
var MODEL_URL = 'vendor/faceapi/models';
var DETECT_INTERVAL = 150; // 检测帧间隔(ms)
var DETECT_INPUT_SIZE = 416; // tinyFaceDetector 输入尺寸:320 下正脸置信度仅 0.67 且框偏松,416 置信度 ~0.99 框更准(CPU 端略慢,移动端 GPU 无感)
@@ -1092,9 +1092,14 @@
clearTimeout(nudgeTimer);
hideNudge();
var seq = camSeq;
voice.dropHint();
voice.play(VOICE.countdown);
voice.afterCurrent(function () {
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') return;
var fill = $('#cam-fill');
fill.classList.add('on'); // .on 无过渡,瞬间点亮
setTimeout(function () {
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') { fill.classList.remove('on'); return; }
var shot = captureFromVideo();
setTimeout(function () {
fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出
@@ -1112,6 +1117,7 @@
});
}, 300);
}, 300);
}, true);
}
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
@@ -1254,7 +1260,7 @@
function setDim(key, dim, rank) {
var box = document.querySelector('.dim[data-dim="' + key + '"]');
var el = $('#dim-' + key), has = dim.S != null;
var parts = has ? dim.S.toFixed(1).split('.') : ['-', '-'];
var parts = has ? ReportUI.dimensionScore(dim.S).split('.') : ['-', '-'];
el.innerHTML = '<b>' + parts[0] + '</b>.' + parts[1];
el.classList.toggle('dim-bad', has && rank === 'worst');
box.classList.toggle('worst', has && rank === 'worst');
@@ -1276,7 +1282,10 @@
wrinkles: { img: '#ditem-img2', overlay: 'texture_enhanced_lines', dim: 0.55 }, // 皱纹:正脸压暗+皱纹增强线(图层几乎全透明,直挂在白卡上不可读,故合成)
smooth: { img: '#viz-smooth', overlays: ['texture_enhanced_pores', 'texture_enhanced_blackheads'] },
water: { img: '#viz-water', blendMap: 'water_area' },
tone: { img: '#viz-tone', blendMap: 'brown_area' }, // 肤色水润:正脸×水分图正片叠底(water_area,接口返回)
tone: { img: '#viz-tone', blendMap: 'brown_area', alpha: 0.3, marks: [
{ key: 'melanin_mark', color: '#9F641F' },
{ key: 'melasma', color: '#E68A2E' }, { key: 'freckle', color: '#8750C8' }
] }, // 仅使用接口真实坐标;黄褐斑/雀斑只有分类值时不画伪造框。
sens: { img: '#viz-sens', blendMap: 'red_area' }, // 敏感:正脸×红区图正片叠底(直挂白底过淡不可读)
acne: { img: '#viz-acne', photo: true, marks: [ // 痘痘:正脸+闭口粉刺/痤疮标记
{ key: 'closed_comedones', color: '#E8A13D' }, // 闭口粉刺
@@ -1390,15 +1399,19 @@
overlays.forEach(function (overlay) {
if (!overlay) return;
if (opts && opts.blend) ctx.globalCompositeOperation = opts.blend; // 正片叠底:白底图层(红区/色沉)叠到脸上
ctx.globalAlpha = opts && typeof opts.alpha === 'number' ? opts.alpha : 1;
var ox = overlay.naturalWidth / photo.naturalWidth, oy = overlay.naturalHeight / photo.naturalHeight;
ctx.drawImage(overlay, crop.left * ox, crop.top * oy, crop.width * ox, crop.height * oy, 0, 0, w, h);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
});
ctx.save();
ctx.translate(-crop.left * scale, -crop.top * scale);
var scaled = vizScaleMarks(marks, scale);
var lw = Math.max(3, Math.round(w / 200)); // 线宽随图走,卡片缩略后仍可见
var minBox = Math.round(w / 36); // 过小标记放大到最小框,保证缩略可见
var markAlpha = opts && typeof opts.alpha === 'number' ? opts.alpha : 1;
ctx.globalAlpha = markAlpha;
for (var i = 0; i < scaled.length; i++) {
var m = scaled[i];
ctx.strokeStyle = m.color;
@@ -1411,9 +1424,9 @@
ctx.moveTo(pts[0][0], pts[0][1]);
for (q = 1; q < pts.length; q++) ctx.lineTo(pts[q][0], pts[q][1]);
ctx.closePath();
ctx.globalAlpha = 0.22;
ctx.globalAlpha = markAlpha * 0.22;
ctx.fill();
ctx.globalAlpha = 1;
ctx.globalAlpha = markAlpha;
ctx.stroke();
}
for (p = 0; p < m.rects.length; p++) {
@@ -1427,6 +1440,7 @@
try {
imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false;
imgEl.dataset.crop = crop.focused ? 'face' : 'full';
imgEl.dataset.overlayAlpha = String(opts && typeof opts.alpha === 'number' ? opts.alpha : 1);
} catch (e) { /* 合成失败保留占位 */ }
};
var urls = Array.isArray(overlayURL) ? overlayURL : overlayURL ? [overlayURL] : [];
@@ -1466,6 +1480,12 @@
}
if (cfg.blendMap) { // 图层×正脸正片叠底(色沉/红区):无照片时直挂图层;图层缺失 → 保留占位
var bu = vizMapURL(maps[cfg.blendMap]);
if (key === 'tone') {
var toneDefs = bu ? cfg.marks.filter(function(def) { return def.key !== 'melanin_mark'; }) : cfg.marks;
var toneMarks = vizCollectMarks(toneDefs, result);
if (frontPhoto) vizCompose(frontPhoto, bu, toneMarks, imgEl, { blend: 'multiply', alpha: cfg.alpha, faceRectangle: rectangle });
continue;
}
if (!bu) continue;
if (!frontPhoto) { imgEl.src = bu; imgEl.hidden = false; continue; }
vizCompose(frontPhoto, bu, [], imgEl, { blend: 'multiply', faceRectangle: rectangle });
+37
View File
@@ -0,0 +1,37 @@
/* Admin presentation/calendar rules only. Stored timestamps remain UTC ISO. */
(function(root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.BeijingTime = factory();
})(typeof window !== 'undefined' ? window : this, function() {
'use strict';
var OFFSET = 8 * 60 * 60 * 1000, DAY = 86400000;
function milliseconds(value) {
if (typeof value === 'number') return isFinite(value) ? value : NaN;
if (typeof value !== 'string' || !value) return NaN;
// Zone-less full timestamps, if supplied, mean Beijing time, not host time.
if (/^\d{4}-\d\d-\d\d[T ]\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/.test(value)) value = value.replace(' ', 'T') + '+08:00';
return Date.parse(value);
}
function shifted(value) {
var ms = milliseconds(value);
return isFinite(ms) ? new Date(ms + OFFSET).toISOString() : null;
}
function day(value) { var iso = shifted(value); return iso ? iso.slice(0, 10) : null; }
function format(value) { var iso = shifted(value); return iso ? iso.replace('T', ' ').slice(0, 19) : '-'; }
function boundary(value, end) {
if (/^\d{4}-\d\d-\d\d$/.test(value)) {
var date = Date.parse(value + 'T00:00:00.000Z');
if (!isFinite(date) || new Date(date).toISOString().slice(0, 10) !== value) return NaN;
return date - OFFSET + (end ? DAY : 0);
}
var ms = milliseconds(value);
return isFinite(ms) ? ms + (end ? 1 : 0) : NaN;
}
function range(from, to) {
var start = from ? boundary(from, false) : -Infinity;
var end = to ? boundary(to, true) : Infinity;
if (isNaN(start) || isNaN(end) || start >= end) throw new Error('日期无效或起始时间晚于结束时间');
return { start: start, end: end };
}
return { day: day, format: format, range: range, milliseconds: milliseconds };
});
+3 -3
View File
@@ -13,9 +13,9 @@
"优秀": "面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。"
},
"color": {
"改善": "检测到当前肌肤色素分 {melanin},存在色素沉着较显著,整体透亮感不足。",
"良好": "检测到当前肌肤色素分 {melanin},局部有色素沉积;整体状态稳定,局部透亮感稍弱。",
"优秀": "检测到当前肌肤色素分 {melanin},无明显色素沉积;肤色均匀透亮。"
"改善": "检测到当前肌肤色素分 {melanin},存在色素沉着较显著,{dark_circle_note},整体透亮感不足。",
"良好": "检测到当前肌肤色素分 {melanin},局部有色素沉积;{dark_circle_note},整体状态稳定,局部透亮感稍弱。",
"优秀": "检测到当前肌肤色素分 {melanin},无明显色素沉积,{dark_circle_note};肤色均匀透亮。"
},
"smooth": {
"改善": "当前肌肤平滑度还有提升空间,检测到粗大毛孔 {pore_cnt} 颗、黑头 {blackhead_cnt} 颗,肤感整体细腻度有提升空间,纹理不均。",
+22 -2
View File
@@ -18,6 +18,9 @@
var dimNames = { firm: '紧塑力', vita: '透亮感', stab: '稳韧力' };
var focusTips = { firm: '强化紧塑力,提拉轮廓抚平细纹。', vita: '提升透亮感,补水提亮加强防晒。', stab: '增强稳韧力,舒缓泛红强韧屏障。' };
function numeric(value) { return typeof value === 'number' && isFinite(value); }
function dimensionScore(value) {
return numeric(value) ? (Math.round((value + 1e-9) * 10) / 10).toFixed(1) : '-';
}
// 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) {
@@ -91,14 +94,31 @@
template = template.replace(/黑眼圈轻\/中\/重度(根据分数给出判断)/g, description || '黑眼圈数据未获取');
if (vars.la == null || vars.ra == null) template = template.replace(/左右下颌线角度(?:分别为左|左) \{la\}°\/ 右 \{ra\}°[;;]/g, '');
}
if (d[0] === 'color') {
// Severity is an independent detection, not inferred from tone level
// or dark_circle_score. Missing/invalid severity must not imply mild.
var colorSeverity = scalar(result.dark_circle_severity);
var colorDescriptions = ['无明显黑眼圈', '仅存在轻度黑眼圈', '存在中度黑眼圈', '可见明显黑眼圈'];
if (numeric(colorSeverity) && colorSeverity % 1 === 0 && colorSeverity >= 0 && colorSeverity <= 3) {
vars.dark_circle_note = colorDescriptions[colorSeverity];
} else {
template = template.replace(/\{dark_circle_note\}[,;]/g, '');
}
}
if (d[0] === 'acne') {
var pustules = count(result, 'acne_pustule'), nodules = count(result, 'acne_nodule');
var pustules = vars.acne_counts ? vars.acne_counts.acne_pustule : count(result, 'acne_pustule');
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]; }
if (vars.acne_cnt == null) template = '本次未获取完整皮损计数,请结合检测图片查看。';
else if (vars.acne_cnt === 0) template = '本次未检出明显皮损,请结合检测图片查看。';
if (vars.acne_cnt != null && vars.acne_counts_complete === false) {
template = vars.acne_cnt > 0
? '本次已获取数据中检测到 {acne_cnt} 处皮损({acne_detail}){heavy_note},部分类型计数未获取,请结合检测图片查看。'
: '本次已获取的皮损计数为0,部分类型数据缺失,请结合检测图片查看。';
}
}
// Missing counts are unknown, not zero: remove only the unavailable metric.
if (vars.fl_cnt == null) template = template.replace(/、细纹 \{fl_cnt\} 条/g, '(细纹计数未获取)');
@@ -128,5 +148,5 @@
var product = products.filter(function(p) { return p.match === vm.essence.name; })[0] || null;
return { version: Copy.version + '+feedback-2026-10-12', dims: dimNames, views: views, care: care, product: product, focus: focusOf(vm) };
}
return { build: build, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails, focusOf: focusOf, customerText: customerText };
return { dimensionScore: dimensionScore, build: build, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails, focusOf: focusOf, customerText: customerText };
});
+82 -51
View File
@@ -216,8 +216,13 @@
};
}
// 皱纹:P = 100 − max(12分区 wrinkle_score);分区 severity≥2 锁79、≥3 锁74
// 皱纹:直接使用接口整体评分,不反向、不按最差分区或严重度限分。
function calcWrinkles(r, notes) {
var P = pick(r, 'wrinkle_score');
if (!isNum(P) || P < 0 || P > 100) {
P = null;
notes.push('皱纹:整体皱纹评分缺失或无效,不回退最差分区计算');
}
var regions = [], used = {}, i, k, node;
for (i = 0; i < WRINKLE_REGIONS.length; i++) {
var rg = WRINKLE_REGIONS[i]; node = null;
@@ -237,19 +242,11 @@
if (s2 == null) continue;
regions.push({ cn: k.replace(/(_wrinkle_info|_fold_info)$/, ''), score: s2, sev: val(node.wrinkle_severity_level) });
}
if (!regions.length) { notes.push('皱纹:12 分区 wrinkle_info 全部缺失,皱纹分类不参与评分'); return { P: null, vars: {} }; }
var worst = regions[0], maxSev = null;
var worst = regions.length ? regions[0] : null, maxSev = null;
for (i = 0; i < regions.length; i++) {
if (regions[i].score > worst.score) worst = regions[i];
if (regions[i].sev != null && (maxSev == null || regions[i].sev > maxSev)) maxSev = regions[i].sev;
}
var P = 100 - worst.score;
if (maxSev != null) {
if (maxSev >= 3) P = Math.min(P, 74);
else if (maxSev >= 2) P = Math.min(P, 79);
} else {
notes.push('皱纹:分区 severity 不可得,上限锁未执行');
}
var wrCnt = cntField(r, 'wrinkle_count');
var flCnt = cntField(r, 'fine_line'); // 真实键名为 fine_line(分区计数对象),兼容 fine_line_count
if (flCnt == null) flCnt = cntField(r, 'fine_line_count');
@@ -258,8 +255,9 @@
vars: {
wr_cnt: wrCnt,
fl_cnt: flCnt,
worst_wr_region: worst.cn,
wr_max: worst.score,
overall_wrinkle_score: P,
worst_wr_region: worst ? worst.cn : null,
wr_max: worst ? worst.score : null,
max_sev: maxSev
}
};
@@ -325,10 +323,12 @@
};
}
// 肤色水润:P = 0.5×(melanin_score−melasma×10−freckle×5) + 0.5×water_score
// 肤色项 = 80%×(色素分−黄褐斑×10−雀斑×5) + 20%×黑眼圈分;肤色/水分仍各50%。
// 交叉校验:water_severity≥60 且 water_score≥80 → 水分项按 severity 换算(实现为 100−severity,待客户确认)
function calcTone(r, notes) {
var melanin = pick(r, 'melanin_score');
var darkCircle = pick(r, 'dark_circle_score');
if (!isNum(darkCircle) || darkCircle < 0 || darkCircle > 100) darkCircle = null;
var melasma = pick(r, 'melasma') || 0; // 缺失按未检出(不扣分)
var freckle = pick(r, 'freckle') || 0;
var waterScore = pick(r, 'water_score');
@@ -342,6 +342,11 @@
var melaninItem = null, waterItem = null;
if (melanin != null) melaninItem = melanin - melasma * 10 - freckle * 5;
else notes.push('melanin_score 缺失,肤色水润评分缺肤色项');
var colorItem = null, colorWeight = 0, colorSum = 0;
if (melaninItem != null) { colorSum += melaninItem * 0.8; colorWeight += 0.8; }
if (darkCircle != null) { colorSum += darkCircle * 0.2; colorWeight += 0.2; }
else notes.push('dark_circle_score 缺失或无效,肤色项剔除黑眼圈并按有效权重归一');
if (colorWeight) colorItem = colorSum / colorWeight;
if (waterScore != null) {
waterItem = waterScore;
if (waterSev != null && waterSev >= 60 && waterScore >= 80) {
@@ -351,7 +356,7 @@
} else {
notes.push('water_score 缺失,肤色水润评分缺水润项');
}
var items = [melaninItem, waterItem], acc = 0, n = 0, i;
var items = [colorItem, waterItem], acc = 0, n = 0, i;
for (i = 0; i < 2; i++) if (items[i] != null) { acc += 0.5 * items[i]; n++; }
if (!n) { notes.push('肤色水润:肤色项与水润项均缺失,肤色水润分类不参与评分'); return { P: null, vars: {} }; }
var P = n === 2 ? acc : acc * 2 / n; // 单项时按 0.5 权重归一
@@ -376,6 +381,12 @@
st: st,
ha: haV == null ? null : round1(haV),
melanin: melanin,
dark_circle_score: darkCircle,
color_score: colorItem,
tone_scoring: { version: 'color-dark-circle-20-v1', melanin_score: melanin,
pigment_adjusted_score: melaninItem, dark_circle_score: darkCircle, color_score: colorItem,
pigment_weight: 0.8, dark_circle_weight: 0.2, color_effective_weight: colorWeight,
water_score: waterScore, water_adjusted_score: waterItem },
spot_flag: spotFlag,
water_score: waterScore,
water_area: waterArea,
@@ -415,40 +426,45 @@
// 痘痘:P = min(acne_score 分级映射, 100−计数扣分);扣分封顶50
function acneGrade(score) { return score >= 95 ? 100 : (score >= 85 ? 85 : (score >= 75 ? 70 : 55)); }
function calcAcne(r, notes) {
var ACNE_TYPES = [
['closed_comedones', '闭口粉刺', 1], ['acne', '丘疹', 2],
['acne_pustule', '脓包', 4], ['acne_nodule', '结节', 6], ['acne_mark', '痘印', 1.5]
];
function calcAcne(r, sides, notes) {
var score = pick(r, 'acne_score');
var cc = cnt(r.closed_comedones); // 闭口 ×1
var pa = cnt(r.acne); // 丘疹 ×2
var pu = cnt(r.acne_pustule); // 脓包 ×4
var no = cnt(r.acne_nodule); // 结节 ×6
var mk = cnt(r.acne_mark); // 痘印 ×1.5
var counts = [cc, pa, pu, no, mk], allNull = true, i;
for (i = 0; i < 5; i++) if (counts[i] != null) { allNull = false; break; }
var left = sides && sides.left, right = sides && sides.right;
var sources = { front: r, left: left && (left.left_acne_info || left.acne_info), right: right && (right.right_acne_info || right.acne_info) };
var merged = {}, rows = [], total = 0, rawDeduct = 0, allNull = true, complete = true;
for (var i = 0; i < ACNE_TYPES.length; i++) {
var def = ACNE_TYPES[i], views = {}, max = null;
['front', 'left', 'right'].forEach(function(view) {
var n = cnt(sources[view] && sources[view][def[0]]);
if (!isNum(n) || n < 0 || Math.floor(n) !== n) n = null;
views[view] = n;
if (n != null && (max == null || n > max)) max = n;
});
merged[def[0]] = max;
if (max == null) { complete = false; notes.push('痘痘:' + def[1] + '三视图计数均缺失,不为该项编造扣分'); }
else { allNull = false; total += max; rawDeduct += max * def[2]; }
rows.push({ key: def[0], name: def[1], weight: def[2], views: views, count: max,
sources: Object.keys(views).filter(function(view) { return max != null && views[view] === max; }),
deduction: max == null ? null : max * def[2] });
}
var deduct = Math.min(50, rawDeduct);
var audit = { version: 'three-view-type-max-v1', front_score: score,
mapped_score: score == null ? null : acneGrade(score), types: rows,
raw_deduction: allNull ? null : rawDeduct, deduction: allNull ? null : deduct, complete: complete };
var vars = { acne_cnt: allNull ? null : total, acne_counts: merged, acne_counts_complete: complete,
acne_detail: rows.filter(function(row) { return row.count > 0; }).map(function(row) { return row.name + row.count + '处'; }).join('、'), acne_scoring: audit };
if (score == null && allNull) {
notes.push('acne_score 与皮损计数全部缺失,痘痘分类不参与评分');
return { P: null, vars: {} };
return { P: null, vars: vars };
}
if (score == null) notes.push('acne_score 缺失,痘痘评分仅由计数扣分决定');
var deduct = Math.min(50, (cc || 0) * 1 + (pa || 0) * 2 + (pu || 0) * 4 + (no || 0) * 6 + (mk || 0) * 1.5);
var cand = [];
if (score != null) cand.push(acneGrade(score));
if (!allNull) cand.push(100 - deduct);
var detail = joinParts([
cc ? '闭口粉刺' + cc + '处' : '',
pa ? '丘疹' + pa + '处' : '',
pu ? '脓包' + pu + '处' : '',
no ? '结节' + no + '处' : '',
mk ? '痘印' + mk + '处' : ''
], '、');
var total = 0;
for (i = 0; i < 5; i++) total += counts[i] || 0;
return {
P: Math.min.apply(null, cand),
vars: {
acne_cnt: allNull ? null : total,
acne_detail: detail
}
};
return { P: Math.min.apply(null, cand), vars: vars };
}
/* ─── 话术库(V2.2 第七章逐字;变量缺失省略对应分句) ─── */
@@ -571,6 +587,8 @@
+ '以闭口粉刺、丘疹为主,局部存在炎症倾向。建议加强温和清洁与控油,避免挤压,配合抗炎舒缓护理。';
// 良好档:所有计数字段缺失(仅靠 acne_score 分级得高 P)时不能声称「未检出」,输出缺数据提示句
if (v.acne_cnt == null) return '本次检测未获取皮损计数数据,仅按痤疮分级评估。建议维持清洁与控油节奏,注意作息与饮食。';
if (v.acne_counts_complete === false) return (found ? found + '。' : '') + '部分类型皮损计数未获取,请结合检测图片查看。';
if (found) return found + '。建议维持清洁与控油节奏,注意作息与饮食。';
return '未检出痤疮与皮损。建议维持清洁与控油节奏,注意作息与饮食。';
}
@@ -701,9 +719,11 @@
var notes = [];
var realAge = val(profile.realAge);
// 生物可视年龄直接使用算法原始肌龄,不再按实际年龄钳制。
// 仅展示年龄限制在实际年龄±5岁,原始肌龄继续用于产品推荐。
var skinAge = val(r.skin_age);
var skinAgeDisplay = skinAge, ageClampNote = '';
var skinAgeDisplay = reportSkinAge({ skinAgeRaw: skinAge, realAge: realAge });
var ageClampNote = skinAgeDisplay != null && skinAgeDisplay !== skinAge
? '生物可视年龄已按实际年龄±5岁限制展示;接口原始肌龄保留用于推荐。' : '';
if (realAge == null) {
notes.push('未提供实际年龄:肌龄展示与产品推荐均使用接口原始肌龄');
}
@@ -714,7 +734,7 @@
var cats = {}, sList = [], i, def, res;
for (i = 0; i < CAT_DEFS.length; i++) {
def = CAT_DEFS[i];
res = def.key === 'contour' ? def.calc(r, sideResults, notes) : def.calc(r, notes);
res = (def.key === 'contour' || def.key === 'acne') ? def.calc(r, sideResults, notes) : def.calc(r, notes);
cats[def.key] = {
name: def.name, dim: def.dim,
vars: res.vars, // 原始模板变量供新版展示层使用,不参与评分规则变更
@@ -828,6 +848,8 @@
return {
total: weightedTotal(dims),
totalRule: 'dimensions-433-v1',
wrinkleScoreRule: 'api-overall-v1',
acneScoreRule: 'three-view-type-max-v1',
skinAgeRaw: skinAge,
skinAgeDisplay: skinAgeDisplay,
ageClampNote: ageClampNote,
@@ -851,6 +873,7 @@
var MOCK_RESULT = {
score_info: {
total_score: 79,
wrinkle_score: 23, // Keep this synthetic fixture's other baseline results stable.
water_score: 83,
rough_score: 87,
sensitivity_score: 98,
@@ -908,11 +931,14 @@
right: { right_jawline_info: { right_jawline_angle: 113.4, right_jawline_angle_level: 1, right_jawline_quality: 1 } } // 偏离 2.6° → 94.8
};
// Historical snapshots may retain an old clamped display value. Read their
// original age without modifying the stored snapshot or recalculating copy.
// Apply the current display rule to new and historical reports without
// modifying their raw age, stored snapshot, copy or recommendation.
function reportSkinAge(vm, result) {
if (vm && Object.prototype.hasOwnProperty.call(vm, 'skinAgeRaw')) return val(vm.skinAgeRaw);
return val(result && result.skin_age);
var raw = vm && Object.prototype.hasOwnProperty.call(vm, 'skinAgeRaw') ? val(vm.skinAgeRaw) : val(result && result.skin_age);
if (!isNum(raw) || raw < 0) return null;
var actual = val(vm && vm.realAge);
if (!isNum(actual) || actual < 0) return raw;
return Math.max(actual - 5, Math.min(actual + 5, raw));
}
return {
weightedTotal: weightedTotal,
@@ -953,7 +979,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
var vm = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25, skinType: '混合偏干肌肤' });
ok(approx(vm.cats.smooth.P, 81.7), '光滑P = 0.4×73+0.3×88+0.3×87 = 81.7(AR列对拍)');
ok(approx(vm.cats.tone.P, 81.5), '肤色水润P = 0.5×80+0.5×83 = 81.5(AR列对拍)');
ok(vm.cats.wrinkles.P === 23 && vm.cats.wrinkles.level === '预警', '皱纹P = 100−77 = 23(severity=3 锁74 不再抬升)');
ok(vm.cats.wrinkles.P === 23 && vm.cats.wrinkles.level === '预警', '皱纹P直接使用整体评分23,与最差分区77无关');
ok(approx(vm.cats.contour.P, 94.8), '轮廓P = min(眼袋100, 下颌线较差侧94.8) = 94.8');
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');
@@ -977,9 +1003,10 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
var rk;
for (rk in r1) if (/_wrinkle_info$|_fold_info$/.test(rk)) r1[rk].wrinkle_score = 5; // 全部压低 → 自然 P=95
r1.right_nasolabial_fold_info.wrinkle_severity_level = 3;
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 74, '皱纹分区 severity=3 → 锁 74');
r1.score_info.wrinkle_score = 95;
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 95, '整体评分95不受分区 severity=3 限分');
r1.right_nasolabial_fold_info.wrinkle_severity_level = 2;
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 79, '皱纹分区 severity=2 → 锁 79');
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 95, '整体评分95不受分区 severity=2 限分');
var r1b = clone(SS.MOCK_RESULT);
r1b.score_info.pores_type_score.pores_forehead_severity = 2; // 自然 81.7
ok(SS.buildViewModel(r1b, SS.MOCK_SIDE, { realAge: 25 }).cats.smooth.P === 79, '毛孔分区 severity=2 → 光滑锁 79');
@@ -999,7 +1026,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
ok(vm3.skinAgeDisplay === 24 && vm3.ageClampNote === '', '±3 以内不钳制、不留痕');
r3.skin_age = { value: 40 };
var vm3b = SS.buildViewModel(r3, SS.MOCK_SIDE, { realAge: 25 });
ok(vm3b.skinAgeDisplay === 40 && vm3b.ageClampNote === '' && vm3b.skinAgeRaw === 40, '原始肌龄40不因实际年龄25而改变');
ok(vm3b.skinAgeDisplay === 30 && vm3b.ageClampNote !== '' && vm3b.skinAgeRaw === 40, '实际年龄25、原始肌龄40:展示30,原值不改变');
console.log('== T4 最差项不再覆盖肤龄推荐 ==');
var r4 = clone(SS.MOCK_RESULT);
@@ -1180,6 +1207,10 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
} catch (e) {}
if (!raw) { console.log(' - 跳过(未找到 reference ref_3view_raw.json)'); return; }
var d = JSON.parse(raw);
// Legacy reference assertions isolate unrelated calculations/copy. The
// new scalar source/rule is covered by test/wrinkle-overall.js.
d.result.score_info.wrinkle_score = 23;
d.result.score_info.dark_circle_score = d.result.score_info.melanin_score - ((d.result.melasma || {}).value || 0) * 10 - ((d.result.freckle || {}).value || 0) * 5;
var vm = SS.buildViewModel(d.result, { left: d.left_side_result, right: d.right_side_result }, { realAge: 30 });
ok(approx(vm.cats.contour.P, 75.5) && vm.cats.contour.S === 9.27 && vm.cats.contour.level === '预警',
'轮廓 P=75.5 / S=9.27 / 预警(min(眼袋1级→90, 下颌线较差侧103.75°→75.5))');