跟新需求文档

This commit is contained in:
xsl
2026-09-26 19:50:39 +08:00
parent ab9a39a8f3
commit 71a090fd75
26 changed files with 41746 additions and 712 deletions
+977
View File
@@ -0,0 +1,977 @@
/* ═════ 巨子测肤 H5 — 三维度×6分类评分引擎(纯逻辑,浏览器/Node 双端) ═════
* 最高契约:docs/三维度评分与话术生成需求文档_V2.0.md(公式/锁值/话术/产品推荐以其为准)
* 浏览器:window.SkinScore;Node:module.exports。ES5、零依赖、不碰 DOM。
*
* 字段口径(已对 reference/facepp-skinanalyze/examples/ 真实 pro JSON 复核):
* - 分数类字段优先取 result.score_info.<key>,缺失退化 result.<key>(V2.0 §137 归一)
* - pores_type_score 分区键存在无下划线变体(pores_leftcheek_score 等),且无 severity 键
* - wrinkle_count / fine_line 为分区计数对象(forehead_count 等叶子),非 {value:n}
* - 敏感面积优先 red_spot.red_spot_area(V2.0 第二章字段来源),缺失回退 sensitivity.sensitivity_area
* - eye_pouch_severity.value 为 0–3 枚举(缺失退化 eye_pouch.value,非整数取整、越界按缺失)
* - sensitivity_area / red_spot_area / water_area ≤1 视为小数口径×100,否则按百分比直读
* - 水分交叉校验「按 severity 换算」实现为 水分项 = 100 − water_severity(待客户确认)
* - oily_intensity_score → 出油概览句为自设计措辞(文档未给全文)
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.SkinScore = factory();
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';
/* ─── 基础工具 ─── */
function isNum(x) { return typeof x === 'number' && isFinite(x); }
function val(x) { // 解包 {value:n} / 数字 / 数字字符串
if (x == null) return null;
if (isNum(x)) return x;
if (typeof x === 'object') { return isNum(x.value) ? x.value : null; }
var n = Number(x);
return isNaN(n) ? null : n;
}
function pick(r, key) { // score_info 优先,再顶层(§137 字段归一)
if (r == null) return null;
var si = r.score_info;
if (si && si[key] != null) { var v = val(si[key]); if (v != null) return v; }
return val(r[key]);
}
function cnt(node) { // 计数:.count 缺失退化 rectangle 数组长度
if (node == null) return null;
var c = val(node.count);
if (c != null) return c;
if (node.rectangle && typeof node.rectangle.length === 'number') return node.rectangle.length;
return null;
}
function sumLeaves(node) { // 分区计数对象(真实 wrinkle_count/fine_line 为 {forehead_count:22,...} 形状):对数值叶子求和
if (node == null || typeof node !== 'object') return val(node);
var sum = 0, any = false, k;
for (k in node) {
if (node[k] != null && typeof node[k] === 'object') continue; // 只看数值叶子,不递归嵌套对象
var v = val(node[k]);
if (v != null) { sum += v; any = true; }
}
return any ? sum : null;
}
function cntField(r, key) { // 计数字段归一:标量/{value} 直读(score_info 优先),分区对象对数值叶子求和
var v = pick(r, key);
if (v != null) return v;
var si = r.score_info;
var node = (si && si[key] != null) ? si[key] : r[key];
return sumLeaves(node);
}
function round2(x) { return Math.round((x + 1e-9) * 100) / 100; } // 1e-9 修正 9.445 类浮点
function sRaw(P) { return 7 + 3 * P / 100; }
function sShow(P) { // 展示层钳制:P 可为负(肤色扣分/大面积敏感),S 会 <7,与文档「S 7.0–10.0」口径矛盾;
if (P == null) return null; // 仅钳 viewModel 展示值,判档/最差项/精华三重判据仍用未舍入原值
var s = sRaw(P);
return round2(s < 7 ? 7 : (s > 10 ? 10 : s));
}
function levelOf(P) { // 判档一律用未四舍五入 P 原值
if (P == null) return null;
return P < 80 ? '预警' : (P < 90 ? '改善' : '良好');
}
function fmt(x) { // 展示用:两位内,去掉多余的 .0
if (x == null) return '';
var v = round2(x);
return Math.round(v) === v ? String(Math.round(v)) : String(v);
}
/* 模板填充:{var} 替换;变量缺失(null/undefined/'')时省略包含它的最小分句(,;。界),
* 严禁输出 None/undefined/— 占位(V2.0 第五章生成规则)。 */
function fill(tpl, vars) {
var parts = tpl.split(/([,;。])/);
var out = '';
for (var i = 0; i < parts.length; i += 2) {
var clause = parts[i], delim = parts[i + 1] || '';
var miss = false;
var c = clause.replace(/\{(\w+)\}/g, function (m, k) {
var v = vars[k];
if (v === null || v === undefined || v === '') { miss = true; return m; }
return v;
});
if (!miss) out += c + delim; // 缺失:整分句连同分隔符省略
}
return out;
}
function joinParts(parts, sep) {
var out = [];
for (var i = 0; i < parts.length; i++) if (parts[i]) out.push(parts[i]);
return out.join(sep);
}
/* ─── 字段归一:分区配置 ─── */
var WRINKLE_REGIONS = [ // 12 分区;left_nasolabial_wrinkle_info 与 _fold_info 并存(§137)
{ keys: ['forehead_wrinkle_info'], cn: '抬头纹' },
{ keys: ['glabella_wrinkle_info'], cn: '眉间纹' },
{ keys: ['left_eye_wrinkle_info'], cn: '左眼周' },
{ keys: ['right_eye_wrinkle_info'], cn: '右眼周' },
{ keys: ['left_crowsfeet_wrinkle_info'], cn: '左鱼尾纹' },
{ keys: ['right_crowsfeet_wrinkle_info'], cn: '右鱼尾纹' },
{ keys: ['left_nasolabial_wrinkle_info', 'left_nasolabial_fold_info'], cn: '左法令纹' },
{ keys: ['right_nasolabial_wrinkle_info', 'right_nasolabial_fold_info'], cn: '右法令纹' },
{ keys: ['left_mouth_wrinkle_info'], cn: '左嘴角纹' },
{ keys: ['right_mouth_wrinkle_info'], cn: '右嘴角纹' },
{ keys: ['left_cheek_wrinkle_info'], cn: '左脸颊' },
{ keys: ['right_cheek_wrinkle_info'], cn: '右脸颊' }
];
var PORE_REGION_CN = { // 真实返回同时存在下划线/无下划线变体(如 pores_left_cheek_score 与 pores_leftcheek_score),均需中文名兜底
forehead: '额头', left_cheek: '左脸颊', right_cheek: '右脸颊', jaw: '下巴', nose: '鼻部',
leftcheek: '左侧面颊', rightcheek: '右侧面颊', chin: '下巴'
};
/* ─── 分类计算(每项返回 { P, vars },P=null 表示缺项) ─── */
// 轮廓:P = min(眼袋severity映射, 下颌线角度分)
var EYE_POUCH_MAP = [100, 90, 79, 74]; // 0→100/1→90/2→79/3→74
function jawScore(angle) { // 100−|偏离116–120°|×2,下限60
var dev = angle < 116 ? 116 - angle : (angle > 120 ? angle - 120 : 0);
return Math.max(60, 100 - dev * 2);
}
function jawlineOf(side) { // 键名归一:*_jawline_info / *_jawline_angle / *_jawline_quality
if (!side) return null;
var info = null, k;
for (k in side) if (/_jawline_info$/.test(k)) { info = side[k]; break; }
if (!info) return null;
var angle = null, quality = null;
for (k in info) {
if (/_jawline_angle$/.test(k)) angle = val(info[k]);
else if (/_jawline_quality$/.test(k)) quality = val(info[k]);
}
return { angle: angle, quality: quality };
}
function calcContour(r, sides, notes) {
var pouchSev = pick(r, 'eye_pouch_severity');
if (pouchSev == null) pouchSev = pick(r, 'eye_pouch'); // 退化
var pouchP = null, pouchLvl = null;
if (pouchSev != null) {
var idx = Math.round(pouchSev); // severity 可能返回非整数:取整后再做映射数组索引
if (idx < 0 || idx > 3) {
notes.push('眼袋 severity 越界(' + pouchSev + '),按缺失处理,轮廓评分缺眼袋项');
} else {
pouchLvl = idx;
pouchP = EYE_POUCH_MAP[idx];
}
} else {
notes.push('眼袋 severity 字段缺失,轮廓评分缺眼袋项');
}
var la = null, ra = null, jawParts = [], jawScores = [];
var anySide = sides && (sides.left || sides.right);
var sidesMeta = [['left', '左', sides && sides.left], ['right', '右', sides && sides.right]];
for (var i = 0; i < 2; i++) {
var tag = sidesMeta[i][1], side = sidesMeta[i][2];
if (!side) continue;
var j = jawlineOf(side);
if (!j) { notes.push(tag + '侧脸未返回下颌线数据,下颌线未参与轮廓评分'); continue; }
if (j.quality !== 1) { // 仅 quality=1 角度有效
notes.push(tag + '侧脸角度无效(quality=' + (j.quality == null ? '缺失' : j.quality) + '),下颌线未参与轮廓评分,侧脸角度需20°–120°,建议重拍');
continue;
}
if (j.angle == null) { notes.push(tag + '侧脸下颌线角度缺失,未参与轮廓评分'); continue; }
if (tag === '左') la = j.angle; else ra = j.angle;
jawScores.push(jawScore(j.angle));
}
var jawP = jawScores.length ? Math.min.apply(null, jawScores) : null; // 左右取较差侧
if (!anySide) notes.push('未拍摄侧脸视图,轮廓评分仅由眼袋决定,建议补拍侧脸视图');
else if (jawP == null) notes.push('双侧下颌线均不可用,轮廓评分仅由眼袋决定');
var cand = [];
if (pouchP != null) cand.push(pouchP);
if (jawP != null) cand.push(jawP);
if (!cand.length) { notes.push('轮廓:眼袋与下颌线数据均缺失,轮廓分类不参与评分'); return { P: null, vars: {} }; }
return {
P: Math.min.apply(null, cand),
vars: {
la: la, ra: ra,
eye_pouch: pouchLvl, // 取整后的等级;越界/缺失时为 null(话术省略该分句)
dark_circle_sev: pick(r, 'dark_circle')
}
};
}
// 皱纹:P = 100 − max(12分区 wrinkle_score);分区 severity≥2 锁79、≥3 锁74
function calcWrinkles(r, notes) {
var regions = [], used = {}, i, k, node;
for (i = 0; i < WRINKLE_REGIONS.length; i++) {
var rg = WRINKLE_REGIONS[i]; node = null;
for (var j = 0; j < rg.keys.length; j++) {
if (r[rg.keys[j]] != null) { node = r[rg.keys[j]]; used[rg.keys[j]] = 1; break; }
}
if (!node) continue;
var sc = val(node.wrinkle_score);
if (sc == null) continue;
regions.push({ cn: rg.cn, score: sc, sev: val(node.wrinkle_severity_level) });
}
for (k in r) { // 兜底:未配置的 *_wrinkle_info / *_fold_info 变体也参与
if (used[k] || !/(_wrinkle_info|_fold_info)$/.test(k)) continue;
node = r[k];
if (!node || typeof node !== 'object') continue;
var s2 = val(node.wrinkle_score);
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;
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');
return {
P: P,
vars: {
wr_cnt: wrCnt,
fl_cnt: flCnt,
worst_wr_region: worst.cn,
wr_max: worst.score,
max_sev: maxSev
}
};
}
// 光滑:P = 0.4×min(4分区 pores_type_score) + 0.3×blackhead_score + 0.3×rough_score;分区 severity≥2 锁79
function calcSmooth(r, notes) {
var pts = (r.score_info && r.score_info.pores_type_score) || r.pores_type_score || null;
var poreMin = null, poreRegion = null, poreSevMax = null, k;
if (pts && typeof pts === 'object') {
for (k in pts) {
if (/_score$/.test(k)) {
var s = val(pts[k]);
if (s == null) continue;
var region = k.replace(/^pores_/, '').replace(/_score$/, '');
if (poreMin == null || s < poreMin) { poreMin = s; poreRegion = PORE_REGION_CN[region] || region; }
} else if (/_severity$/.test(k)) {
var sv = val(pts[k]);
if (sv != null && (poreSevMax == null || sv > poreSevMax)) poreSevMax = sv;
}
}
} else {
notes.push('pores_type_score 缺失(score_info 内亦无),光滑评分缺毛孔项');
}
// 真实 pores_type_score 内无 severity 键:锁所需数据缺失时留痕,避免锁静默失效
if (pts && typeof pts === 'object' && poreSevMax == null) {
notes.push('毛孔分区严重度未返回,光滑上限锁未执行');
}
var bh = pick(r, 'blackhead_score');
var rough = pick(r, 'rough_score');
if (bh == null) notes.push('blackhead_score 缺失,光滑评分缺黑头项');
if (rough == null) notes.push('rough_score 缺失,光滑评分缺纹理项');
var factors = [[0.4, poreMin], [0.3, bh], [0.3, rough]], wSum = 0, acc = 0, n = 0, i;
for (i = 0; i < factors.length; i++) {
if (factors[i][1] == null) continue; // 单项缺失:剔除并按剩余权重归一
wSum += factors[i][0]; acc += factors[i][0] * factors[i][1]; n++;
}
if (!n) { notes.push('光滑:毛孔/黑头/纹理三项全部缺失,光滑分类不参与评分'); return { P: null, vars: {} }; }
var P = acc / wSum;
if (poreSevMax != null && poreSevMax >= 2) P = Math.min(P, 79);
var poreCnt = null;
if (r.enlarged_pore_count != null) {
if (isNum(val(r.enlarged_pore_count))) poreCnt = val(r.enlarged_pore_count);
else if (typeof r.enlarged_pore_count === 'object') { // 4 分区求和
poreCnt = 0;
for (k in r.enlarged_pore_count) { var c = val(r.enlarged_pore_count[k]); if (c != null) poreCnt += c; }
}
}
var bhCnt = cnt(r.blackhead);
if (bhCnt == null) bhCnt = pick(r, 'blackhead_count');
return {
P: P,
vars: {
worst_pore_region: poreRegion,
pore4_min: poreMin,
pore_cnt: poreCnt,
blackhead_cnt: bhCnt,
tex: rough,
oily: pick(r, 'oily_intensity_score')
}
};
}
// 肤色水润:P = 0.5×(melanin_score−melasma×10−freckle×5) + 0.5×water_score
// 交叉校验:water_severity≥60 且 water_score≥80 → 水分项按 severity 换算(实现为 100−severity,待客户确认)
function calcTone(r, notes) {
var melanin = pick(r, 'melanin_score');
var melasma = pick(r, 'melasma') || 0; // 缺失按未检出(不扣分)
var freckle = pick(r, 'freckle') || 0;
var waterScore = pick(r, 'water_score');
var waterObj = r.water || {};
var waterSev = val(waterObj.water_severity);
if (waterSev == null) waterSev = pick(r, 'water_severity');
var waterArea = val(waterObj.water_area);
if (waterArea == null) waterArea = pick(r, 'water_area');
if (waterArea != null && waterArea <= 1) waterArea = waterArea * 100; // 小数口径×100 → 百分比
var melaninItem = null, waterItem = null;
if (melanin != null) melaninItem = melanin - melasma * 10 - freckle * 5;
else notes.push('melanin_score 缺失,肤色水润评分缺肤色项');
if (waterScore != null) {
waterItem = waterScore;
if (waterSev != null && waterSev >= 60 && waterScore >= 80) {
waterItem = 100 - waterSev;
notes.push('水分交叉校验触发(water_severity=' + waterSev + ' 且 water_score=' + waterScore + '),水润项按 severity 换算为 ' + waterItem);
}
} else {
notes.push('water_score 缺失,肤色水润评分缺水润项');
}
var items = [melaninItem, 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 权重归一
var itaObj = r.skintone_ita || {};
var haObj = r.skin_hue_ha || {};
var st = itaObj.skin_tone != null ? itaObj.skin_tone
: (itaObj.skintone != null ? itaObj.skintone // 真实键名为 skintone(另有 ITA 值),兼容 skin_tone/tone
: (itaObj.tone != null ? itaObj.tone : null));
return {
P: P,
vars: {
ita: val(itaObj.ITA != null ? itaObj.ITA : itaObj.value),
st: st,
ha: val(haObj.HA != null ? haObj.HA : haObj.value),
melanin: melanin,
spot_flag: (melasma || freckle) ? joinParts([melasma ? '黄褐斑' : '', freckle ? '雀斑' : ''], '、') : '未见明显色斑标记',
water_score: waterScore,
water_area: waterArea,
water_sev: waterSev
}
};
}
// 敏感:P = min(sensitivity_score, 100−面积%×1.5);面积≥15% 锁79、≥30% 锁74
function calcSens(r, notes) {
var sensObj = r.sensitivity || null;
var score = pick(r, 'sensitivity_score');
if (score == null && sensObj) score = val(sensObj.sensitivity_score);
// 面积口径来源(V2.0 第二章字段来源):优先 red_spot.red_spot_area(小数×100=%);
// 缺失再回退 sensitivity.sensitivity_area;两者均保留 ≤1→×100 启发式
var areaRaw = r.red_spot && typeof r.red_spot === 'object' ? val(r.red_spot.red_spot_area) : null;
if (areaRaw == null) {
areaRaw = sensObj ? val(sensObj.sensitivity_area) : null;
if (areaRaw == null) areaRaw = pick(r, 'sensitivity_area');
}
if (score == null && areaRaw == null) {
notes.push('sensitivity 对象缺失,敏感分类不参与评分与均分(禁止编造降级假分数)');
return { P: null, vars: { missing: true } };
}
// 面积口径:≤1 视为小数(如 0.001)×100 换算成百分比;否则按百分比直读(如 2.0 → 2%)
var areaPct = areaRaw == null ? null : (areaRaw <= 1 ? areaRaw * 100 : areaRaw);
var cand = [];
if (score != null) cand.push(score);
if (areaPct != null) cand.push(100 - areaPct * 1.5);
var P = Math.min.apply(null, cand);
if (areaPct != null) {
if (areaPct >= 30) P = Math.min(P, 74);
else if (areaPct >= 15) P = Math.min(P, 79);
}
return { P: P, vars: { sens_area: areaPct, sens_score: score } };
}
// 痘痘: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 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; }
if (score == null && allNull) {
notes.push('acne_score 与皮损计数全部缺失,痘痘分类不参与评分');
return { P: null, 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,
heavy: (pu || 0) > 0 || (no || 0) > 0 // 仅在真实检出脓包/结节时输出 heavy_note
}
};
}
/* ─── 话术库(V2.0 第五章逐字;变量缺失省略对应分句) ─── */
function oilDesc(oily) { // {oil_desc} 出油概览句(自设计措辞,文档未给全文)
if (oily == null) return '';
if (oily < 40) return '肌肤出油较少,整体清爽';
if (oily < 60) return 'T区存在轻度出油';
return '全脸出油偏多,需加强控油';
}
function jawSeg(v, goodTpl) { // 组合下颌线分句;返回 '' 表示省略
if (v.la == null && v.ra == null) return '';
var ang = v.la != null && v.ra != null ? '下颌线角度左' + fmt(v.la) + '°/右' + fmt(v.ra) + '°'
: (v.la != null ? '下颌线角度左' + fmt(v.la) + '°' : '下颌线角度右' + fmt(v.ra) + '°');
var outL = v.la != null && (v.la < 116 || v.la > 120);
var outR = v.ra != null && (v.ra < 116 || v.ra > 120);
var paren;
if (goodTpl && !outL && !outR) paren = '(处于标准区间)';
else if (outL && outR) paren = '(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降)';
else if (outL || outR) paren = '(标准区间116–120°,单侧偏离标准,提示下颌缘支撑力下降)';
else paren = '(标准区间116–120°)';
return ang + paren;
}
function eyeSeg(v) {
return joinParts([
v.eye_pouch != null ? '眼袋' + fmt(v.eye_pouch) + '级' : '',
v.dark_circle_sev != null ? '黑眼圈' + fmt(v.dark_circle_sev) + '级' : ''
], '、');
}
function textContour(P, v) {
var lv = levelOf(P), jaw = jawSeg(v, lv === '良好'), eye = eyeSeg(v);
if (lv === '预警') return fill('面部轮廓出现可见松弛{jaw};{eye};中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。',
{ jaw: jaw ? ',' + jaw : '', eye: eye });
// V2.0 §5.1 改善档原文只含眼袋,不含黑眼圈
if (lv === '改善') return fill('{jaw},{eye},眼下区域出现轻度松弛信号。建议增加紧致类精华步骤,配合由下向上的提拉按摩手法,重点关照眼周与中面部。',
{ jaw: jaw, eye: v.eye_pouch != null ? '眼袋' + fmt(v.eye_pouch) + '级' : '' });
return fill('面部轮廓紧致立体{jaw},{eye},中面部容积饱满,未见明显软组织下垂。轮廓支撑力良好,建议维持日常紧致护理与防晒,保持规律作息,守护现有轮廓。',
{ jaw: jaw ? ',' + jaw : '', eye: eye });
}
function wrSeg(v) {
return joinParts([
v.wr_cnt != null ? '全脸皱纹' + fmt(v.wr_cnt) + '条' : '',
v.fl_cnt != null ? '细纹' + fmt(v.fl_cnt) + '条' : ''
], '、');
}
function textWrinkles(P, v) {
var lv = levelOf(P), seg = wrSeg(v);
var paren = v.wr_max != null
? (lv === '预警'
? '(分区分' + fmt(v.wr_max) + (v.max_sev != null ? ',最高严重度' + fmt(v.max_sev) + '级' : '') + ')'
: '(分区分' + fmt(v.wr_max) + (v.max_sev != null ? ',严重度' + fmt(v.max_sev) + '级' : '') + ')')
: '';
if (lv === '预警') return fill('{seg},最差区域为{r}{p},部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。',
{ seg: seg, r: v.worst_wr_region, p: paren });
if (lv === '改善') return fill('{seg},最差区域为{r}{p}。建议加强日常淡纹与弹润护理,保持肌肤滋润度。',
{ seg: seg, r: v.worst_wr_region, p: paren });
return fill('全脸12分区纹路平整(最差区域{r}{p2}){fl},未见明显静态纹。建议维持日常保湿与防晒,保持现有淡纹护理节奏。',
{ r: v.worst_wr_region, p2: v.wr_max != null ? ',分区分' + fmt(v.wr_max) : '', fl: v.fl_cnt != null ? ',细纹合计' + fmt(v.fl_cnt) + '条' : '' });
}
function cntSeg(v) {
return joinParts([
v.pore_cnt != null ? '粗大毛孔' + fmt(v.pore_cnt) + '颗' : '',
v.blackhead_cnt != null ? '黑头' + fmt(v.blackhead_cnt) + '颗' : ''
], '、');
}
function textSmooth(P, v) {
var lv = levelOf(P), seg = cntSeg(v), oil = oilDesc(v.oily);
if (lv === '预警') return fill('皮肤表面平整度较低,最差毛孔区位于{r}{p},{c}{o}纹理分仅{t},整体细腻度不足。建议避免过度清洁,重点关注{r},配合低浓度酸类周期护理与柔润精华。',
{ r: v.worst_pore_region, p: v.pore4_min != null ? '(分区毛孔分' + fmt(v.pore4_min) + ')' : '', c: seg ? seg + ',' : '', o: oil ? oil + ',' : '', t: fmt(v.tex) });
if (lv === '改善') return fill('皮肤表面整体较为平滑,毛孔最差区为{r}{p},{c}{o}纹理分{t},局部存在轻微角质粗糙感。建议加强基础清洁与低浓度酸类周期护理。',
{ r: v.worst_pore_region, p: v.pore4_min != null ? '(分区毛孔分' + fmt(v.pore4_min) + ')' : '', c: seg ? seg + ',' : '', o: oil ? oil + ',' : '', t: fmt(v.tex) });
return fill('皮肤表面细腻平滑,最差毛孔区{r}控制良好(分区毛孔分{p}{c}),纹理分{t},角质层排列整齐。建议维持温和清洁与保湿节奏。',
{ r: v.worst_pore_region, p: fmt(v.pore4_min), c: seg ? ',' + seg : '', t: fmt(v.tex) });
}
function itaHdr(v) { // 「肤色ITA {ita}({st},HA {ha})」组合,缺项省略
var stStr = (v.st === null || v.st === undefined || v.st === '') ? '' : String(v.st); // st=0 是合法肤色分类,不能被 falsy 吞掉
var inner = joinParts([stStr, v.ha != null ? 'HA ' + fmt(v.ha) : ''], ',');
if (v.ita == null && !inner) return '';
if (v.ita == null) return '肤色' + (inner ? '(' + inner + ')' : '');
return '肤色ITA ' + fmt(v.ita) + (inner ? '(' + inner + ')' : '');
}
function textTone(P, v) {
var lv = levelOf(P), hdr = itaHdr(v);
var wseg = joinParts([
v.water_area != null ? '缺水面积' + fmt(v.water_area) + '%' : '',
v.water_sev != null ? '严重度' + fmt(v.water_sev) : ''
], '、');
if (wseg) wseg = ',' + wseg;
var vars = { h: hdr, m: fmt(v.melanin), f: v.spot_flag, w: fmt(v.water_score), ws: wseg };
if (lv === '预警') return fill('{h},色素分{m},整体偏暗沉({f});水分分{w}{ws},水润度不足。建议加强透亮与补水护理,做好防晒,减少色素刺激。', vars);
if (lv === '改善') return fill('{h},色素分{m},整体稳定但存在局部暗沉倾向({f});水分分{w}{ws}。建议加强抗氧化与光泽管理,做好基础防晒与补水。', vars);
return fill('{h},色素分{m},肤色均匀透亮({f});水分分{w}{ws}。色素与水润状态均衡,建议持续做好防晒与基础保湿。', vars);
}
function textSens(P, v) {
if (v.missing) return '本次检测未获取到敏感度数据(sensitivity 字段缺失),敏感分类暂不参与评分,建议重新拍摄检测以获取完整结果。';
var lv = levelOf(P), vars = { a: fmt(v.sens_area), s: fmt(v.sens_score) };
if (lv === '预警') return fill('泛红面积达{a}%,红区分{s},屏障受损信号明显、敏感区域较大。建议立即转为舒缓修护路线,停用酸类与强清洁,必要时寻求专业建议。', vars);
if (lv === '改善') return fill('泛红面积{a}%,红区分{s},存在局部轻微泛红与屏障波动。建议使用舒缓修护类产品,减少刺激源,注意温差与清洁力度。', vars);
return fill('泛红面积{a}%,红区分{s},屏障状态稳定,未见明显敏感信号。建议维持现有温和护理,避免过度去角质。', vars);
}
function textAcne(P, v) {
var lv = levelOf(P);
var heavy = v.heavy ? ',含脓包、结节等中重度皮损,炎症反应明显' : '';
// 「检出N处皮损(明细)」与 heavy 拼接成句:heavy 为空时不得拖垮整分句(fill 的缺失省略以分句为单位)
var found = (v.acne_cnt != null && v.acne_detail) ? '检出' + fmt(v.acne_cnt) + '处皮损(' + v.acne_detail + ')' : '';
if (lv === '预警') return (found ? found + heavy + '。' : '')
+ '建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。';
if (lv === '改善') return (found ? found + ',' : '')
+ '以闭口粉刺、丘疹为主,局部存在炎症倾向。建议加强温和清洁与控油,避免挤压,配合抗炎舒缓护理。';
// 良好档:所有计数字段缺失(仅靠 acne_score 分级得高 P)时不能声称「未检出」,输出缺数据提示句
if (v.acne_cnt == null) return '本次检测未获取皮损计数数据,仅按痤疮分级评估。建议维持清洁与控油节奏,注意作息与饮食。';
return '未检出痤疮与皮损。建议维持清洁与控油节奏,注意作息与饮食。';
}
/* ─── 常量表 ─── */
var CAT_DEFS = [ // 顺序即最差分类并列取舍优先级:轮廓>皱纹>光滑>肤色水润>敏感>痘痘
{ key: 'contour', name: '轮廓', dim: 'firm', calc: calcContour, text: textContour },
{ key: 'wrinkles', name: '皱纹', dim: 'firm', calc: calcWrinkles, text: textWrinkles },
{ key: 'smooth', name: '光滑', dim: 'vita', calc: calcSmooth, text: textSmooth },
{ key: 'tone', name: '肤色水润', dim: 'vita', calc: calcTone, text: textTone },
{ key: 'sens', name: '敏感', dim: 'stab', calc: calcSens, text: textSens },
{ key: 'acne', name: '痘痘', dim: 'stab', calc: calcAcne, text: textAcne }
];
var DIM_DEFS = [
{ key: 'firm', name: '紧塑力', tip: '轮廓支撑与紧致度:重点针对下颌线与眼周使用紧致类精华,配合由下向上的提拉按摩手法' },
{ key: 'vita', name: '活力感', tip: '细腻度与水润光泽:加强补水与温和清洁,做好防晒与抗氧化管理' },
{ key: 'stab', name: '稳定力', tip: '屏障稳定与痘痘管理:温和清洁、舒缓修护,避免刺激源与挤压' }
];
var LIFESTYLE = { // V2.0 §6.2 全文
contour: '戒除揉眼、托腮等表情性挤压习惯,保证睡眠减少纹路加深',
wrinkles: '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳',
smooth: '规律去角质但不过度(每1–2周一次),运动后及时清洁,减少油炸与高糖饮食',
tone: '每日防晒足量补涂,增加维C与抗氧化食物摄入,保证饮水节奏',
sens: '避免过热的水洗脸与桑拿,选择无香精配方,换季时精简护肤步骤',
acne: '不挤压痘痘,枕巾高频更换,减少乳制品与高升糖饮食摄入'
};
var ESSENCE = { // V2.0 §6.1 三档全文
1: { name: '25+束紧精华', text: '建议搭配使用25+束紧精华:针对年轻肌肤活力减弱问题,激活肌肤动力、改善暗沉,提升弹润紧致表现,守护年轻光泽并延缓初始衰老迹象。' },
2: { name: '35+支撑精华', text: '建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。' },
3: { name: '50+重塑精华', text: '建议搭配使用50+重塑精华:针对成熟肌肤恢复能力下降,提升紧致表现、改善松弛与轮廓模糊,修复强韧屏障,重塑年轻质感。' }
};
/* ─── 主入口 ─── */
function buildViewModel(apiResult, sideResults, profile) {
var r = apiResult || {};
profile = profile || {};
var notes = [];
var realAge = val(profile.realAge);
// 肌肤年龄:原值不改写,展示值钳制到 realAge±3 并留痕
var skinAge = val(r.skin_age);
var skinAgeDisplay = skinAge, ageClampNote = '';
if (realAge == null) {
notes.push('未提供实际年龄:产品组别、肌肤年龄钳制与精华「年龄组档」判据均不生效');
} else if (skinAge != null) {
skinAgeDisplay = Math.min(Math.max(skinAge, realAge - 3), realAge + 3);
if (skinAgeDisplay !== skinAge) ageClampNote = '触发(原' + skinAge + '→' + skinAgeDisplay + ')';
}
var group = null;
if (realAge != null) group = realAge < 32 ? '25+组' : (realAge <= 45 ? '35+组' : '50+组');
// 6 分类
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);
cats[def.key] = {
name: def.name, dim: def.dim,
P: res.P, S: sShow(res.P), level: levelOf(res.P),
text: res.P == null
? (res.vars.missing ? def.text(null, res.vars) : '本次检测' + def.name + '分类数据缺失,暂不参与评分。')
: def.text(res.P, res.vars)
};
if (res.P != null) sList.push(sRaw(res.P));
}
// 三维度(木桶 min,缺项剔除)
var dims = {};
for (i = 0; i < DIM_DEFS.length; i++) {
var d = DIM_DEFS[i], ps = [], j;
for (j = 0; j < CAT_DEFS.length; j++) {
if (CAT_DEFS[j].dim !== d.key) continue;
var P = cats[CAT_DEFS[j].key].P;
if (P != null) ps.push(P);
}
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) };
}
// 均分(判档用未四舍五入原值)与最差分类(并列按 CAT_DEFS 顺序取前者)
var avgRaw = null, avgS = null;
if (sList.length) {
var sum = 0;
for (i = 0; i < sList.length; i++) sum += sList[i];
avgRaw = sum / sList.length;
avgS = round2(avgRaw);
}
var worst = null;
for (i = 0; i < CAT_DEFS.length; i++) {
def = CAT_DEFS[i];
var cP = cats[def.key].P;
if (cP == null) continue;
if (!worst || cP < worst.P) worst = { catKey: def.key, name: def.name, P: cP, S: sShow(cP) };
}
// 精华推荐:三重判据取最强档,triggers 记录命中(档位=最终档)的判据
var crits = []; // {name, tier}
if (avgRaw != null) crits.push({ name: '均分档', tier: avgRaw >= 9 ? 1 : (avgRaw >= 8 ? 2 : 3) });
if (worst) {
var wS = sRaw(worst.P);
if (wS < 7) crits.push({ name: '最差项约束', tier: 3 });
else if (wS < 8) crits.push({ name: '最差项约束', tier: 2 });
}
if (group) crits.push({ name: '年龄组档', tier: group === '25+组' ? 1 : (group === '35+组' ? 2 : 3) });
var tier = 1;
if (crits.length) {
tier = crits[0].tier;
for (i = 0; i < crits.length; i++) tier = Math.max(tier, crits[i].tier); // 推荐强度只升不降
} else {
notes.push('三重判据均不可用,精华推荐缺省为 25+束紧精华');
}
var triggers = [];
for (i = 0; i < crits.length; i++) if (crits[i].tier === tier) triggers.push(crits[i].name);
var essence = { name: ESSENCE[tier].name, text: ESSENCE[tier].text, triggers: triggers };
// 近期优先关注:最差维度
var focus = { name: '', tip: '' }, fP = null;
for (i = 0; i < DIM_DEFS.length; i++) {
var dd = DIM_DEFS[i], dP2 = dims[dd.key].P;
if (dP2 == null) continue;
if (fP == null || dP2 < fP) { fP = dP2; focus = { name: dd.name, tip: dd.tip }; }
}
// 综合文案(§6.1)
var summary;
if (avgRaw == null) {
summary = '【综合】本次检测数据不足,暂无法生成完整评分,建议重新拍摄检测。';
} else {
var ageSeg = '';
if (realAge != null) {
ageSeg = '实际年龄' + realAge + '岁';
if (skinAge != null) ageSeg += '(接口测得肌肤年龄' + skinAge + '岁,差' + Math.abs(skinAge - realAge) + '岁)';
} else if (skinAge != null) {
ageSeg = '接口测得肌肤年龄' + skinAge + '岁';
}
var head = joinParts([ageSeg, group ? '产品组别' + group : ''], ',');
// 优先改善:P 最低两项
var sorted = [];
for (i = 0; i < CAT_DEFS.length; i++) {
def = CAT_DEFS[i];
if (cats[def.key].P != null) sorted.push({ name: def.name, P: cats[def.key].P });
}
sorted.sort(function (a, b) { return a.P - b.P; });
var improve = '';
if (sorted.length >= 2) improve = '优先改善' + sorted[0].name + '(P=' + fmt(sorted[0].P) + ')与' + sorted[1].name + '(P=' + fmt(sorted[1].P) + '),其余维度维持现有护理节奏。';
else if (sorted.length === 1) improve = '优先改善' + sorted[0].name + '(P=' + fmt(sorted[0].P) + '),其余维度维持现有护理节奏。';
var tail = joinParts([
profile.skinType ? '整体肤质' + profile.skinType : '',
oilDesc(pick(r, 'oily_intensity_score'))
], ',');
summary = '【综合】' + (head ? head + ';' : '')
+ '六分类S均分' + fmt(avgS) + (worst ? '、最差分类' + worst.name + ' S=' + fmt(worst.S) : '') + '。'
+ '按三重判据' + (triggers.length ? '(触发:' + triggers.join('、') + ')' : '') + '推荐' + essence.name
+ (improve ? ':' + improve : '。')
+ (tail ? tail + '。' : '');
}
return {
total: pick(r, 'total_score'),
skinAgeRaw: skinAge,
skinAgeDisplay: skinAgeDisplay,
ageClampNote: ageClampNote,
dims: dims,
cats: cats,
group: group,
essence: essence,
avgS: avgS,
worst: worst,
focus: focus,
summary: summary,
lifestyle: worst ? LIFESTYLE[worst.catKey] : '',
notes: notes
};
}
/* ─── Mock 数据(pro 形状;数值对齐测试文档「图片 13」案例) ─── */
var MOCK_RESULT = {
score_info: {
total_score: 79,
water_score: 83,
rough_score: 87,
sensitivity_score: 98,
melanin_score: 80,
blackhead_score: 88,
acne_score: 86,
oily_intensity_score: 42,
pores_type_score: { // §137:嵌套在 score_info 内
pores_forehead_score: 73, pores_left_cheek_score: 86,
pores_right_cheek_score: 85, pores_jaw_score: 90,
pores_forehead_severity: 1, pores_left_cheek_severity: 1,
pores_right_cheek_severity: 1, pores_jaw_severity: 1
}
},
skin_age: { value: 20 },
skin_type: { skin_type: 3 },
eye_pouch: { value: 0, confidence: 0.75 },
eye_pouch_severity: { value: 0 },
dark_circle: { value: 1, confidence: 0.63 },
wrinkle_count: { value: 11 },
forehead_wrinkle_info: { wrinkle_score: 20, wrinkle_severity_level: 1 },
glabella_wrinkle_info: { wrinkle_score: 15, wrinkle_severity_level: 1 },
left_eye_wrinkle_info: { wrinkle_score: 30, wrinkle_severity_level: 1 },
right_eye_wrinkle_info: { wrinkle_score: 25, wrinkle_severity_level: 1 },
left_crowsfeet_wrinkle_info: { wrinkle_score: 40, wrinkle_severity_level: 2 },
right_crowsfeet_wrinkle_info: { wrinkle_score: 35, wrinkle_severity_level: 1 },
left_nasolabial_wrinkle_info: { wrinkle_score: 60, wrinkle_severity_level: 2 },
right_nasolabial_fold_info: { wrinkle_score: 77, wrinkle_severity_level: 3 }, // _fold_info 变体
left_mouth_wrinkle_info: { wrinkle_score: 45, wrinkle_severity_level: 2 },
right_mouth_wrinkle_info: { wrinkle_score: 50, wrinkle_severity_level: 2 },
left_cheek_wrinkle_info: { wrinkle_score: 10, wrinkle_severity_level: 0 },
right_cheek_wrinkle_info: { wrinkle_score: 12, wrinkle_severity_level: 0 },
water: { water_score: 83, water_area: 15.0, water_severity: 12 },
sensitivity: { sensitivity_score: 98, sensitivity_area: 0.001, sensitivity_intensity: 30 }, // 0.001=小数口径→0.1%
melasma: { value: 0 },
freckle: { value: 0 },
skintone_ita: { ITA: 71.8 },
skin_hue_ha: { HA: 2 },
enlarged_pore_count: { forehead: 150, left_cheek: 66, right_cheek: 50, jaw: 30 }, // 合计 296
blackhead: { count: 39, rectangle: [] },
closed_comedones: { count: 3, rectangle: [{}, {}, {}] },
acne: { count: 3, rectangle: [{}, {}, {}] }, // 丘疹
acne_pustule: { count: 1, rectangle: [{}] },
acne_nodule: { count: 0, rectangle: [] },
acne_mark: { count: 1, rectangle: [{}] },
mole: { count: 2, rectangle: [{}, {}] }
};
var MOCK_SIDE = {
left: { left_jawline_info: { left_jawline_angle: 117.2, left_jawline_angle_level: 0, left_jawline_quality: 1 } },
right: { right_jawline_info: { right_jawline_angle: 113.4, right_jawline_angle_level: 1, right_jawline_quality: 1 } } // 偏离 2.6° → 94.8
};
return {
buildViewModel: buildViewModel,
MOCK_RESULT: MOCK_RESULT,
MOCK_SIDE: MOCK_SIDE
};
}));
/* ─── Node 自测:node h5/js/skin-score.js ─── */
if (typeof require === 'function' && typeof module !== 'undefined' && require.main === module) {
(function () {
var SS = module.exports;
var pass = 0, fail = 0;
function ok(cond, msg) {
if (cond) { pass++; console.log(' ✓ ' + msg); }
else { fail++; console.error(' ✗ FAIL: ' + msg); }
}
function approx(a, b) { return a != null && Math.abs(a - b) < 1e-9; }
function clone(o) { return JSON.parse(JSON.stringify(o)); }
function collectTexts(vm) { // 收集 viewModel 全部面向用户的字符串
var out = [], k;
out.push(vm.summary, vm.lifestyle, vm.ageClampNote, vm.focus.name, vm.focus.tip, vm.essence.name, vm.essence.text);
for (k in vm.cats) out.push(vm.cats[k].text);
for (k = 0; k < vm.notes.length; k++) out.push(vm.notes[k]);
return out;
}
var BAD = /None|undefined|NaN|null|—|\{[a-z_0-9]+\}/i;
function checkTexts(vm, tag) {
var ts = collectTexts(vm), bad = [];
for (var i = 0; i < ts.length; i++) if (BAD.test(ts[i])) bad.push(ts[i]);
ok(bad.length === 0, tag + ':话术无 None/undefined/NaN/null/—/未替换占位' + (bad.length ? ' → ' + bad[0] : ''));
}
console.log('== T0 MOCK 全量(数值对拍测试文档「图片 13」) ==');
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(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');
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 === 81.5 && vm.dims.stab.P === 85, '维度木桶 min:紧塑力23 / 活力感81.5 / 稳定力85');
ok(vm.skinAgeRaw === 20 && vm.skinAgeDisplay === 22 && vm.ageClampNote === '触发(原20→22)', '肌肤年龄钳制 20→22 留痕,原值不改写');
ok(vm.group === '25+组', 'realAge=25 → 25+组');
ok(vm.essence.name === '35+支撑精华' && vm.essence.triggers.length === 1 && vm.essence.triggers[0] === '最差项约束',
'精华三重判据:均分9.32→25+、最差S7.69→至少35+、年龄→25+,取最强档35+,triggers=["最差项约束"]');
ok(vm.avgS === 9.32, '六分类S均分 9.32');
ok(vm.worst.catKey === 'wrinkles' && vm.worst.P === 23, '最差分类 = 皱纹');
ok(vm.focus.name === '紧塑力', '近期优先关注 = 最差维度紧塑力');
ok(vm.total === 79, '综合评分 = score_info.total_score');
ok(vm.summary.indexOf('【综合】') === 0 && vm.summary.indexOf('35+支撑精华') > 0, '综合文案生成');
ok(vm.cats.smooth.text.indexOf('粗大毛孔296颗、黑头39颗') >= 0 && vm.cats.smooth.text.indexOf('纹理分87') >= 0,
'光滑话术含粗大毛孔/黑头/纹理分变量(分区计数 4 区求和 296)');
checkTexts(vm, 'T0');
console.log('== T1 上限锁 ==');
var r1 = clone(SS.MOCK_RESULT);
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.right_nasolabial_fold_info.wrinkle_severity_level = 2;
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 79, '皱纹分区 severity=2 → 锁 79');
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');
checkTexts(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }), 'T1');
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.P)
&& vm2.dims.stab.P === Math.min(vm2.cats.sens.P, vm2.cats.acne.P), '三维度 P = min(旗下两分类)');
ok(vm2.group === '35+组', 'realAge=40 → 35+组');
console.log('== T3 钳制边界 ==');
var r3 = clone(SS.MOCK_RESULT);
r3.skin_age = { value: 24 };
var vm3 = SS.buildViewModel(r3, SS.MOCK_SIDE, { realAge: 25 });
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 === 28 && vm3b.ageClampNote === '触发(原40→28)' && vm3b.skinAgeRaw === 40, '上边界钳制 40→28');
console.log('== T4 精华三重判据:最差项 S<7 → 50+重塑 ==');
var r4 = clone(SS.MOCK_RESULT);
r4.score_info.melanin_score = 0;
r4.score_info.water_score = 0;
r4.melasma = { value: 1 };
r4.freckle = { value: 1 };
r4.water = { water_score: 0, water_area: 40, water_severity: 70 }; // sev≥60 但 score<80,不触发换算
var vm4 = SS.buildViewModel(r4, SS.MOCK_SIDE, { realAge: 25 });
ok(approx(vm4.cats.tone.P, -7.5), '肤色水润P = 0.5×(0−10−5)+0.5×0 = −7.5(P 原值不动,不夹逼)');
ok(vm4.cats.tone.S === 7, '展示层 S 钳到 7(未钳原值 6.78 仍参与判档)');
ok(vm4.essence.name === '50+重塑精华' && vm4.essence.triggers.indexOf('最差项约束') >= 0, '最差项 S 原值<7 → 50+重塑精华,triggers 含最差项约束(三重判据用未舍入原值)');
checkTexts(vm4, 'T4');
console.log('== T5 缺数据降级(V2.0 第七章) ==');
var r5 = clone(SS.MOCK_RESULT);
delete r5.sensitivity;
delete r5.score_info.sensitivity_score;
var vm5 = SS.buildViewModel(r5, SS.MOCK_SIDE, { realAge: 25 });
ok(vm5.cats.sens.P === null && vm5.cats.sens.text.indexOf('缺失') >= 0, 'sensitivity 缺失 → 敏感 P=null + 数据缺失提示(不编假分数)');
ok(vm5.dims.stab.P === 85, '稳定力剔除敏感项 = 痘痘P 85');
var hasSensNote = false, k;
for (k = 0; k < vm5.notes.length; k++) if (vm5.notes[k].indexOf('sensitivity') >= 0) hasSensNote = true;
ok(hasSensNote, 'notes 记录 sensitivity 缺失降级');
ok(vm5.avgS === 9.2, '均分剔除敏感项按 5 分类计算(未舍入 9.196 → 展示 9.2)');
var vm5b = SS.buildViewModel(clone(SS.MOCK_RESULT), null, { realAge: 25 });
ok(vm5b.cats.contour.P === 100 && vm5b.notes.join('|').indexOf('侧脸') >= 0, '无侧脸 → 轮廓 P 由眼袋单独决定并记 note');
var vm5c = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, {});
ok(vm5c.group === null && vm5c.skinAgeDisplay === 20 && vm5c.ageClampNote === ''
&& vm5c.essence.triggers.indexOf('年龄组档') < 0 && vm5c.notes.join('|').indexOf('实际年龄') >= 0,
'realAge 缺失 → group=null、不钳制、判据③不生效并记 note');
checkTexts(vm5, 'T5');
console.log('== T6 下颌线 quality 降级 ==');
var vm6 = SS.buildViewModel(clone(SS.MOCK_RESULT), {
left: { left_jawline_info: { left_jawline_angle: 100, left_jawline_angle_level: 1, left_jawline_quality: 3 } },
right: { right_jawline_info: { right_jawline_angle: 118, right_jawline_angle_level: 0, right_jawline_quality: 1 } }
}, { realAge: 25 });
ok(vm6.cats.contour.P === 100, '左 quality=3 不参与,右 118° 标准 → 轮廓 P=min(100,100)=100');
ok(vm6.notes.join('|').indexOf('左侧脸角度无效(quality=3)') >= 0, 'notes 含「左侧脸角度无效(quality=3)」');
console.log('== T7 AR列数值对拍(第二条) ==');
var vm7 = SS.buildViewModel({
score_info: { sensitivity_score: 30 },
sensitivity: { sensitivity_score: 30, sensitivity_area: 0.103 } // 图片37:10.3%
}, null, { realAge: 29 });
ok(vm7.cats.sens.P === 30, '敏感P = min(30, 100−10.3×1.5=84.55) = 30(AR列对拍)');
console.log('== T8 count 缺失退化 rectangle 长度 ==');
var r8 = clone(SS.MOCK_RESULT);
r8.acne_pustule = { rectangle: [{}, {}] }; // 无 count → 2 个脓包
var vm8 = SS.buildViewModel(r8, SS.MOCK_SIDE, { realAge: 25 });
// 扣分 = 3×1+3×2+2×4+0+1×1.5 = 18.5 → 100−18.5=81.5 < 分级映射85
ok(approx(vm8.cats.acne.P, 81.5), '痘痘P = min(85, 100−18.5) = 81.5(rectangle 退化计数)');
ok(vm8.cats.acne.text.indexOf('脓包2处') >= 0, 'acne_detail 按计数列明细,为0的类型不列');
var vm8b = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }); // 含脓包1处
ok(vm8b.cats.acne.text.indexOf('含脓包、结节等中重度皮损') < 0, '改善档无 heavy_note(heavy_note 仅预警档且真实检出时输出)');
var r8c = clone(SS.MOCK_RESULT);
r8c.score_info.acne_score = 70; // 分级映射 → 55,预警档
ok(SS.buildViewModel(r8c, SS.MOCK_SIDE, { realAge: 25 }).cats.acne.text.indexOf('(闭口粉刺3处、丘疹3处、脓包1处、痘印1处),含脓包、结节等中重度皮损,炎症反应明显。') >= 0,
'预警档真实检出脓包 → heavy_note 逐字输出');
console.log('== T9 真实接口字段形状(对齐 reference/facepp-skinanalyze/examples 样本) ==');
// (5)(9) 毛孔分区无下划线变体键 + 无 severity 键
var r9 = clone(SS.MOCK_RESULT);
r9.score_info.pores_type_score = { pores_forehead_score: 60, pores_leftcheek_score: 50, pores_rightcheek_score: 72, pores_jaw_score: 84 };
var vm9 = SS.buildViewModel(r9, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9.cats.smooth.text.indexOf('左侧面颊') >= 0 && vm9.cats.smooth.text.indexOf('leftcheek') < 0,
'毛孔分区无下划线变体 pores_leftcheek_score → 中文「左侧面颊」,话术无英文键名残留');
ok(vm9.notes.join('|').indexOf('毛孔分区严重度未返回,光滑上限锁未执行') >= 0, 'pores_type_score 无 severity 键 → notes 留痕「光滑上限锁未执行」');
checkTexts(vm9, 'T9-毛孔');
// (6) 敏感面积优先 red_spot.red_spot_area(V2.0 第二章字段来源)
var r9b = clone(SS.MOCK_RESULT);
r9b.red_spot = { red_spot_area: 0.006, red_spot_intensity: 3 }; // 小数口径 → 0.6%
r9b.sensitivity.sensitivity_area = 0.5; // 若干扰字段被误读会变 50% → 锁 74
var vm9b = SS.buildViewModel(r9b, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9b.cats.sens.P === 98 && vm9b.cats.sens.text.indexOf('泛红面积0.6%') >= 0,
'敏感面积优先 red_spot.red_spot_area:0.006×100=0.6%,P=min(98, 100−0.9)=98(不回退 sensitivity_area=0.5)');
ok(SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }).cats.sens.P === 98,
'red_spot 缺失 → 回退 sensitivity.sensitivity_area(0.001 小数口径→0.1%,P=98)');
// (7) wrinkle_count/fine_line 为分区计数对象(真实样本形状,无 .value)
var r9d = clone(SS.MOCK_RESULT);
r9d.wrinkle_count = { forehead_count: 22, left_undereye_count: 58, right_undereye_count: 44, glabella_count: 14, left_crowsfeet_count: 10, right_crowsfeet_count: 7, left_mouth_count: 1, right_mouth_count: 1, left_nasolabial_count: 1, right_nasolabial_count: 1, left_cheek_count: 0, right_cheek_count: 0 };
r9d.fine_line = { forehead_count: 18, left_undereye_count: 57, right_undereye_count: 46, glabella_count: 9, left_crowsfeet_count: 11, right_crowsfeet_count: 9, left_cheek_count: 0, right_cheek_count: 0 };
var vm9d = SS.buildViewModel(r9d, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9d.cats.wrinkles.text.indexOf('全脸皱纹159条') >= 0 && vm9d.cats.wrinkles.text.indexOf('细纹150条') >= 0,
'wrinkle_count/fine_line 分区对象数值叶子求和 → 全脸皱纹159条、细纹150条');
checkTexts(vm9d, 'T9-皱纹计数');
// (8) 肤色分类真实键名 skintone_ita.skintone(值为 0 时不得被 falsy 吞掉)
var r9e = clone(SS.MOCK_RESULT);
r9e.skintone_ita = { ITA: 58.64615, skintone: 0 };
r9e.skin_hue_ha = { HA: 56.246513, skin_hue: 0 };
var vm9e = SS.buildViewModel(r9e, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9e.cats.tone.text.indexOf('肤色ITA 58.65(0,HA 56.25)') >= 0, 'skintone_ita.skintone=0 → 肤色分类 0 正常输出');
// (10)(12) 眼袋 severity 非整数取整 / 越界按缺失;轮廓改善档话术无「黑眼圈」(V2.0 §5.1 逐字)
var r9f = clone(SS.MOCK_RESULT);
r9f.eye_pouch_severity = { value: 1.6 };
var vm9f = SS.buildViewModel(r9f, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9f.cats.contour.P === 79 && vm9f.cats.contour.text.indexOf('眼袋2级') >= 0,
'眼袋 severity 非整数 1.6 → 取整 2 → 映射 79(min(79, 下颌线94.8),预警档)');
var vm9f2 = SS.buildViewModel(clone(SS.MOCK_RESULT), { // 下颌线 117°/110° → jawP=88 → P=88 改善档
left: { left_jawline_info: { left_jawline_angle: 117, left_jawline_quality: 1 } },
right: { right_jawline_info: { right_jawline_angle: 110, right_jawline_quality: 1 } }
}, { realAge: 25 });
ok(vm9f2.cats.contour.level === '改善' && vm9f2.cats.contour.text.indexOf('眼袋0级') >= 0 && vm9f2.cats.contour.text.indexOf('黑眼圈') < 0,
'轮廓改善档含「眼袋0级」且无「黑眼圈」(§5.1 改善档原文无黑眼圈)');
var r9g = clone(SS.MOCK_RESULT);
r9g.eye_pouch_severity = { value: 9 };
var vm9g = SS.buildViewModel(r9g, SS.MOCK_SIDE, { realAge: 25 });
ok(approx(vm9g.cats.contour.P, 94.8) && vm9g.notes.join('|').indexOf('眼袋 severity 越界(9)') >= 0,
'眼袋 severity 越界 9 → 按缺失处理记 note,轮廓 P 仅由下颌线 94.8 决定');
checkTexts(vm9f, 'T9-眼袋');
// (13) 痘痘良好档计数全缺失 → 缺数据提示句,不声称「未检出」
var r9h = clone(SS.MOCK_RESULT);
delete r9h.closed_comedones; delete r9h.acne; delete r9h.acne_pustule; delete r9h.acne_nodule; delete r9h.acne_mark;
r9h.score_info.acne_score = 96; // 分级映射 100 → 良好档
var vm9h = SS.buildViewModel(r9h, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9h.cats.acne.text.indexOf('未检出') < 0 && vm9h.cats.acne.text.indexOf('未获取皮损计数数据') >= 0,
'痘痘良好档且计数全缺失 → 缺数据提示句(不声称未检出)');
// 预警档无脓包/结节(heavy 为空)时「检出N处皮损(明细)」不得被 fill 分句省略拖垮(真实样本形状:丘疹3+闭口6)
var r9i = clone(SS.MOCK_RESULT);
r9i.acne_pustule = { count: 0, rectangle: [] };
r9i.acne_mark = { count: 0, rectangle: [] };
r9i.score_info.acne_score = 70; // 分级映射 55 → 预警档
var vm9i = SS.buildViewModel(r9i, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9i.cats.acne.text.indexOf('检出6处皮损(闭口粉刺3处、丘疹3处)。建议尽快') >= 0,
'痘痘预警档无中重度皮损 → 计数明细正常输出、无 heavy_note');
console.log('----------------------------------------');
console.log('自测结果:' + pass + ' 通过,' + fail + ' 失败');
if (fail > 0) process.exit(1);
}());
}