/* ═════ 巨子测肤 H5 — 三维度×6分类评分引擎(纯逻辑,浏览器/Node 双端) ═════ * 评分契约:docs/三维度评分与话术生成需求文档_V2.2.docx(公式/锁值/原始话术) * 推荐覆盖契约:docs/产品推荐规则-肤龄版.md(2026-10-12 用户确认) * 浏览器:window.SkinScore;Node:module.exports。ES5、零依赖、不碰 DOM。 * * 字段口径(已对 reference/facepp-skinanalyze/examples/ 真实 pro JSON 复核): * - 分数类字段优先取 result.score_info.,缺失退化 result.(V2.2 §3.1 映射归一) * - pores_type_score 分区键存在无下划线变体(pores_leftcheek_score 等),且无 severity 键 * - wrinkle_count / fine_line 为分区计数对象(forehead_count 等叶子),非 {value:n} * - 敏感面积优先 red_spot.red_spot_area(V2.2 §3.1:小数×100=%),缺失回退 sensitivity.sensitivity_area * - eye_pouch_severity.value 为 0–3 枚举(缺失退化 eye_pouch.value,非整数取整、越界按缺失) * - 黑眼圈级取 dark_circle_severity.value,缺失退化 dark_circle.value(真值表口径:案例1=1 级,非 dark_circle 的 3) * - sensitivity_area / red_spot_area / water_area / oily 分区 area ≤1 视为小数口径×100,否则按百分比直读 * - 水分交叉校验「按 severity 换算」实现为 水分项 = 100 − water_severity(文档未给公式,待客户确认) * - {oil_desc} 按 V2.2 §7.2 由 oily_intensity 分区面积生成:T区出油面积X%、左右脸颊Y%/Z%,全脸 intensity=0 省略 * - {st} 肤色分类、{skin_type_cn} 肤质类型按接口枚举映射中文(skintone 0–4 / skin_type 0–3,真值已校) * - {spot_flag} 按 V2.2 §7.2:色斑基句 +「,检出褐色斑N处」「、痣N处」续接,为 0 整体省略 */ (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 优先,再顶层(§3.1 字段归一) 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; // 仅钳展示值,推荐边界比较仍用未舍入维度 S 原值 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); } function round1(x) { return Math.round((x + 1e-9) * 10) / 10; } function fmtS(x) { return x == null ? '' : round2(x).toFixed(2); } // 综合文案 S:固定两位小数(真值「S=8.50」) function fmtP(x) { return x == null ? '' : round1(x).toFixed(1); } // 综合文案 P:固定一位小数(真值「P=23.0」) /* 模板填充:{var} 替换;变量缺失(null/undefined/'')时省略包含它的最小分句(,;。界), * 严禁输出 None/undefined/— 占位(V2.2 第七章生成规则与 7.3 缺值规则)。 */ 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 并存(V2.2 §3.3④ 变体归一) { 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: '下巴' }; var SKINTONE_CN = { 0: '极白', 1: '白皙', 2: '自然', 3: '小麦色', 4: '黝黑' }; // skintone_ita.skintone 枚举(真值:0→极白、3→小麦色) var SKIN_TYPE_CN = { 0: '油性', 1: '干性', 2: '中性', 3: '混合性' }; // skin_type.skin_type 枚举(真值:3→混合性) function skinTypeCnOf(r, profile) { // {skin_type_cn}:接口 skin_type 优先,缺失回退用户自选肤质(V2.2 §8.5 肤质不参与分数) var st = r ? r.skin_type : null; var v = st != null && typeof st === 'object' ? val(st.skin_type != null ? st.skin_type : st.value) : val(st); if (v != null && SKIN_TYPE_CN[v] != null) return SKIN_TYPE_CN[v]; return (profile && profile.skinType) || null; } /* ─── 分类计算(每项返回 { 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: {} }; } var dcSev = pick(r, 'dark_circle_severity'); // 黑眼圈级:dark_circle_severity 优先(真值口径),缺失退化 dark_circle if (dcSev == null) dcSev = pick(r, 'dark_circle'); return { P: Math.min.apply(null, cand), vars: { la: la, ra: ra, eye_pouch: pouchLvl, // 取整后的等级;越界/缺失时为 null(话术省略该分句) dark_circle_sev: dcSev } }; } // 皱纹: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: r.oily_intensity // {oil_desc} 数据源(V2.2 §7.2 分区面积) } }; } // 肤色水润: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 stRaw = itaObj.skin_tone != null ? itaObj.skin_tone : (itaObj.skintone != null ? itaObj.skintone // 真实键名为 skintone(另有 ITA 值),兼容 skin_tone/tone : (itaObj.tone != null ? itaObj.tone : null)); // 肤色分类枚举→中文(真值:0→极白、3→小麦色);未收录枚举值原样透出 var st = stRaw != null && SKINTONE_CN[stRaw] != null ? SKINTONE_CN[stRaw] : stRaw; // {spot_flag}(V2.2 §7.2):检出黄褐斑和/或雀斑写该种,均未检出写「未见明显色斑标记」;褐色斑/痣数>0 续接,为 0 整体省略 var spotFlag = (melasma || freckle) ? joinParts([melasma ? '黄褐斑' : '', freckle ? '雀斑' : ''], '、') : '未见明显色斑标记'; var bsCnt = cnt(r.brown_spot), moleCnt = cnt(r.mole); if (bsCnt != null && bsCnt > 0) spotFlag += ',检出褐色斑' + fmt(bsCnt) + '处'; if (moleCnt != null && moleCnt > 0) spotFlag += '、痣' + fmt(moleCnt) + '处'; var itaV = val(itaObj.ITA != null ? itaObj.ITA : itaObj.value); var haV = val(haObj.HA != null ? haObj.HA : haObj.value); return { P: P, vars: { ita: itaV == null ? null : round1(itaV), // 真值表为 1 位小数(58.64615→58.6、56.246513→56.2) st: st, ha: haV == null ? null : round1(haV), melanin: melanin, spot_flag: spotFlag, 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.2 §3.1):优先 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 } }; } /* ─── 话术库(V2.2 第七章逐字;变量缺失省略对应分句) ─── */ function oilPct(node) { // oily_intensity 分区面积:≤1 小数×100,否则直读 if (!node) return null; var a = val(node.area); return a == null ? null : (a <= 1 ? a * 100 : a); } function oilDesc(oily) { // {oil_desc}(V2.2 §7.2):T区出油面积X%、左右脸颊Y%/Z%;全脸 intensity=0 或数据缺失时整体省略 if (!oily || typeof oily !== 'object') return ''; var ff = oily.full_face; if (ff && val(ff.intensity) === 0) return ''; var t = oilPct(oily.t_zone), l = oilPct(oily.left_cheek), rr = oilPct(oily.right_cheek); if (t == null && l == null && rr == null) return ''; var parts = []; if (t != null) parts.push('T区出油面积' + fmt(t) + '%'); if (l != null && rr != null) parts.push('左右脸颊' + fmt(l) + '%/' + fmt(rr) + '%'); else if (l != null) parts.push('左脸颊' + fmt(l) + '%'); else if (rr != null) parts.push('右脸颊' + fmt(rr) + '%'); return parts.join('、'); } 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); // V2.2 第七章预警档原文自「下颌线角度…」起,无额外前缀句 if (lv === '预警') return fill('{jaw};{eye};中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。', { jaw: jaw, eye: eye }); // V2.2 第七章改善档原文只含眼袋,不含黑眼圈 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); // 「检出N处皮损(明细)」拼接成句(V2.2 第七章预警档逐字:检出{acne_cnt}处皮损({acne_detail})。建议…) var found = (v.acne_cnt != null && v.acne_detail) ? '检出' + fmt(v.acne_cnt) + '处皮损(' + v.acne_detail + ')' : ''; if (lv === '预警') return (found ? found + '。' : '') + '建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。'; 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.2 §8.4 全文 contour: '戒除揉眼、托腮等表情性挤压习惯,保证睡眠减少纹路加深', wrinkles: '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳', smooth: '规律去角质但不过度(每1–2周一次),运动后及时清洁,减少油炸与高糖饮食', tone: '每日防晒足量补涂,增加维C与抗氧化食物摄入,保证饮水节奏', sens: '避免过热的水洗脸与桑拿,选择无香精配方,换季时精简护肤步骤', acne: '不挤压痘痘,枕巾高频更换,减少乳制品与高升糖饮食摄入' }; var ESSENCE = { // V2.2 §8.3 三档全文(前缀「建议搭配使用X:」为报告页拼接胶) 1: { name: '25+束紧精华', text: '建议搭配使用25+束紧精华:针对年轻肌肤活力减弱问题,激活肌肤动力、改善暗沉,提升弹润紧致表现,守护年轻光泽并延缓初始衰老迹象。' }, 2: { name: '35+支撑精华', text: '建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。' }, 3: { name: '50+重塑精华', text: '建议搭配使用50+重塑精华:针对熟龄肌松弛与轮廓流失问题,深层重塑支撑结构,改善明显松弛与纹路,重建面部紧实与弹性。' } }; // 2026-10-12 已确认的业务规则。只接收原始肤龄和三维度未舍入 S, // 不使用实际年龄、展示肤龄、六分类均分或最差分类;历史快照不重算。 function recommendProduct(skinAge, scores) { scores = scores || {}; var firm = isNum(scores.firm) ? scores.firm : null; var vita = isNum(scores.vita) ? scores.vita : null; var stab = isNum(scores.stab) ? scores.stab : null; var rec = { version: '2026-10-12-skin-age-v1', skinAge: skinAge, baseTier: null, tier: null, boundary: null, adjustment: 'none', scores: { firm: firm, vita: vita, stab: stab }, reason: '', triggers: [] }; if (!isNum(skinAge) || skinAge <= 0) { rec.adjustment = 'unavailable'; rec.reason = '未获取有效原始肌龄,暂不推荐具体产品;不以实际年龄代替。'; return rec; } rec.baseTier = skinAge < 35 ? 1 : (skinAge < 50 ? 2 : 3); rec.tier = rec.baseTier; rec.triggers.push('肤龄基础分组'); if (skinAge >= 32 && skinAge <= 38) rec.boundary = 35; else if (skinAge >= 47 && skinAge <= 53) rec.boundary = 50; if (rec.boundary != null && skinAge < rec.boundary && firm != null && firm < 8) { rec.tier++; rec.adjustment = 'up'; rec.reason = '处于' + rec.boundary + '岁下侧边界,紧塑力低于8分,升一档。'; rec.triggers.push(rec.boundary + '岁边界低分升档'); } else if (rec.boundary != null && skinAge >= rec.boundary && firm != null && firm >= 9 && ((vita != null && vita >= 9) || (stab != null && stab >= 9))) { rec.tier--; rec.adjustment = 'down'; rec.reason = '处于' + rec.boundary + '岁上侧边界,紧塑力≥9且透亮感或稳韧力≥9,降一档。'; rec.triggers.push(rec.boundary + '岁边界高分降档'); } else if (skinAge < 32) { rec.reason = '原始肌龄小于32岁,固定推荐25+,轮廓或皱纹低分不升档。'; } else if (rec.boundary != null) { rec.reason = '处于' + rec.boundary + '岁边界,未满足升降档条件,保持肤龄基础档。'; } else { rec.reason = '不在35±3或50±3岁边界内,仅按原始肌龄推荐。'; } return rec; } /* ─── V2.3 §8 护理建议 ─── */ var SKIN_TYPE_TEXT = { // §8.6 五类映射【肤质定制】段全文 + 联动句 '干性肌肤': { base: '你的日常肤质为干性肌肤:全脸皮脂分泌偏少,角质层保水能力弱,易紧绷起屑。护理以高效保湿与封闭锁水为核心,洁面选用温和低泡产品,避免频繁去角质,面霜可选择含神经酰胺、角鲨烷等封闭性成分的配方。', link: function (c) { return c.tone && c.tone.level === '预警' ? '当前已处于明显缺水状态,建议暂缓一切控油类产品' : ''; } }, '油性肌肤': { base: '你的日常肤质为油性肌肤:皮脂分泌旺盛,毛孔易被氧化油脂堵塞。护理以温和控油与毛孔疏通为核心,可选择含烟酰胺、水杨酸的清爽配方,但清洁仍需适度,避免过度去脂反而刺激代偿性出油。', link: function (c) { return c.tone && c.tone.level === '预警' ? '需警惕外油内干:控油同时必须叠加保湿,避免屏障受损' : ''; } }, '中性肌肤': { base: '你的日常肤质为中性肌肤:水油分泌平衡,角质状态稳定,是理想的肌肤基础态。护理以维持现状为核心,精简护肤流程,做好基础清洁、保湿与防晒即可,无需叠加过多功效产品。', link: function () { return ''; } }, '混合偏干肌肤': { base: '你的日常肤质为混合偏干肌肤:T区状态尚可,但两颊及面部外侧偏干,易出现紧绷与细纹。建议分区护理:两颊侧重保湿滋养,选用滋润型乳液/面霜,T区轻量保湿即可,避免全脸统一用厚重面霜。', link: function (c) { return c.smooth && c.smooth.level === '预警' ? '干燥会放大纹理粗糙感,建议先将两颊水合度提上来再做角质调理' : ''; } }, '混合偏油肌肤': { base: '你的日常肤质为混合偏油肌肤:T区出油旺盛而两颊偏干,呈典型外油内干倾向。建议分区护理:T区使用控油清爽产品,两颊加强保湿;全脸避免高滋润面霜,可选用质地轻薄的保湿乳分区涂抹。', link: function (c) { return c.tone && c.tone.level === '预警' ? '外油内干状态下切勿加大清洁力度,补水优先于控油' : ''; } } }; function skinTypeKey(name) { // 归一:用户自选五类名;未知/缺失按中性 var keys = ['混合偏干肌肤', '混合偏油肌肤', '干性肌肤', '油性肌肤', '中性肌肤']; for (var i = 0; i < keys.length; i++) if (name && name.indexOf(keys[i].replace('肌肤', '')) >= 0) return keys[i]; return '中性肌肤'; } var CARE_POINTS = { // §8.7 护理要点库(6分类×3档,逐字;肤色水润=色沉+水分并列,先色沉后水分) contour: { '预警': '强化轮廓紧致管理,重点针对面中、苹果肌与下颌线支持带区域,配合向上提升按摩手法,促进真皮层胶原再生与微循环。', '改善': '加强弹韧护理补充胶原支架营养,关注易显疲态区域的支撑力,日常可增加紧致精华步骤并配合向上按摩。', '良好': '维持现有抗老护理节奏,做好光损伤防护与状态管理,巩固真皮胶原网络密度。' }, wrinkles: { '预警': '加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。', '改善': '加强日常淡纹与弹润护理,维持真皮基质含水量,保持肌肤饱满平滑。', '良好': '以抗初老预防为核心,提前建立抗氧化/紧致护理习惯,延缓第一道静态纹出现。' }, smooth: { '预警': '加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。', '改善': '加强基础清洁与角质调理,关注鼻翼、下颌等易粗糙部位,提升毛孔通畅度与柔滑度。', '良好': '维持当前温和护理节奏,保护角质层排列与微循环稳态。' }, tone: { // 每档 = [色沉段, 水分段](并列输出,先色沉后水分) '预警': ['强化透亮与色素代谢管理,严格广谱防晒,配合温和抗氧化成分(如维C衍生物)抑制黑色素转运。', '强化屏障锁水修复,停用控油类产品,改用含神经酰胺的封闭性面霜,每周2-3次补水面膜快速纠正缺水状态。'], '改善': ['加强日常光泽管理与局部色素防控,做好基础防晒与抗氧化打底。', '注重水油平衡调理,选用清爽保湿乳液,避免过度清洁带走皮脂膜。'], '良好': ['维持现有防护与护理习惯,巩固肤色均匀度与透亮感。', '维持现有补水习惯,巩固水合状态与水油平衡。'] }, sens: { '预警': '优先屏障修复与舒缓管理,精简护肤流程,避免频繁更换方案与叠加功效成分,修复受损角质层。', '改善': '保持温和护理节奏,注意防晒与环境防护,逐步提升皮肤耐受度。', '良好': '维持规律护理并按季节阶段性调整,巩固皮肤耐受稳态。' }, acne: { // 痘痘:V1.2 无原文,V2.3 本版新撰 '预警': '优先抗炎与痘肌修护,避免挤压与厚重型封闭产品,选用含水杨酸或壬二酸的清爽配方,局部点涂抗痘精华,必要时寻求皮肤科处理。', '改善': '维持温和清洁与控油节奏,注意枕巾与手部卫生,避免叠加过多功效产品刺激皮损。', '良好': '维持现有清洁控油习惯,保持作息与饮食规律,防止毛孔堵塞复发。' } }; /* ─── 主入口 ─── */ 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 skinTypeCn = skinTypeCnOf(r, profile); // {skin_type_cn}:接口枚举优先,回退用户自选肤质 // 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, vars: res.vars, // 原始模板变量供新版展示层使用,不参与评分规则变更 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) }; } var recommendation = recommendProduct(skinAge, { firm: dims.firm.P == null ? null : sRaw(dims.firm.P), vita: dims.vita.P == null ? null : sRaw(dims.vita.P), stab: dims.stab.P == null ? null : sRaw(dims.stab.P) }); var tier = recommendation.tier, triggers = recommendation.triggers; var group = tier == null ? null : ['25+组', '35+组', '50+组'][tier - 1]; var essence = { name: tier == null ? '暂无法推荐' : ESSENCE[tier].name, text: tier == null ? recommendation.reason : ESSENCE[tier].text, tier: tier, triggers: triggers }; if (tier == null) notes.push(recommendation.reason); // 近期优先关注:最差维度 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 }; } } // 综合文案(V2.2 §8.2 模板) 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=' + fmtP(sorted[0].P) + ')与' + sorted[1].name + '(P=' + fmtP(sorted[1].P) + '),其余维度维持现有护理节奏。'; else if (sorted.length === 1) improve = '优先改善' + sorted[0].name + '(P=' + fmtP(sorted[0].P) + '),其余维度维持现有护理节奏。'; var tail = joinParts([ skinTypeCn ? '整体肤质' + skinTypeCn : '', oilDesc(r.oily_intensity) ], ','); summary = '【综合】' + (head ? head + ';' : '') + '六分类S均分' + fmtS(avgRaw) + (worst ? '、最差分类' + worst.name + ' S=' + fmtS(sRaw(worst.P)) : '') + '。' + (tier == null ? recommendation.reason : '按肤龄及边界规则(触发:' + triggers.join('、') + ')推荐' + essence.name) + (improve ? ':' + improve : '。') + (tail ? tail + '。' : ''); } /* 护理建议(V2.3 §8.5 五段固定顺序:【肤质】【最差分类1】【最差分类2】【精华】【生活方式】; §8.5.1 输出纪律:段内不得出现 P/S 数值、档位名、判据、字段名等内部标注) */ var careAdvice = []; var stDef = SKIN_TYPE_TEXT[skinTypeKey(profile.skinType)]; var linkSeg = stDef.link(cats); careAdvice.push({ tag: '肤质', text: stDef.base + (linkSeg ? linkSeg + '。' : '') }); if (sorted && sorted.length >= 2) { for (i = 0; i < 2; i++) { var ck = CAT_DEFS.filter(function (d) { return d.name === sorted[i].name; })[0].key; var lv2 = cats[ck].level || '良好', pts = CARE_POINTS[ck][lv2]; careAdvice.push({ tag: sorted[i].name, text: Array.isArray(pts) ? pts.join('') : pts }); } } careAdvice.push({ tag: '精华', text: essence.text }); careAdvice.push({ tag: '生活方式', text: worst ? LIFESTYLE[worst.catKey] : '' }); return { total: pick(r, 'total_score'), skinAgeRaw: skinAge, skinAgeDisplay: skinAgeDisplay, ageClampNote: ageClampNote, realAge: realAge, dims: dims, cats: cats, group: group, essence: essence, recommendation: recommendation, avgS: avgS, worst: worst, focus: focus, summary: summary, lifestyle: worst ? LIFESTYLE[worst.catKey] : '', careAdvice: careAdvice, 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: { // §3.1:嵌套在 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 }, oily_intensity: { // 真实形状:分区 {area(小数),intensity},{oil_desc} 数据源(数值对齐案例1) t_zone: { area: 0.57, intensity: 2 }, left_cheek: { area: 0.2, intensity: 1 }, right_cheek: { area: 0.02, intensity: 0 }, chin_area: { area: 0, intensity: 0 }, full_face: { area: 0.4, intensity: 2 } }, 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, recommendProduct: recommendProduct, 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+组', '原始肌龄20 → 25+组'); ok(vm.essence.name === '25+束紧精华' && vm.recommendation.tier === 1, '肌龄小于32,即使皱纹低分也固定25+,不再使用最差项升档'); 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('25+束紧精华') > 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 === '25+组', '实际年龄40不影响推荐,原始肌龄20仍为25+组'); 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 最差项不再覆盖肤龄推荐 =='); 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,评分公式不变'); ok(vm4.essence.name === '25+束紧精华', '原始肌龄20,即使最差项S<7也不升档'); checkTexts(vm4, 'T4'); console.log('== T5 缺数据降级(V2.2 第十章异常与边界) =='); 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 === '25+组' && vm5c.skinAgeDisplay === 20 && vm5c.ageClampNote === '' && vm5c.essence.triggers.indexOf('年龄组档') < 0 && vm5c.notes.join('|').indexOf('实际年龄') >= 0, '实际年龄缺失不钳制,但原始肌龄仍可推荐25+'); 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('(闭口粉刺3处、丘疹3处、脓包1处、痘印1处),以闭口粉刺、丘疹为主') >= 0 && vm8b.cats.acne.text.indexOf('含脓包、结节') < 0, '改善档逐字(V2.2 第七章无 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('检出8处皮损(闭口粉刺3处、丘疹3处、脓包1处、痘印1处)。建议尽快进行针对性抗痘护理') >= 0, '预警档逐字:检出{acne_cnt}处皮损({acne_detail})。建议尽快…(V2.2 第七章)'); 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.2 §3.1 字段映射) 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→极白),ITA/HA 真值表口径 1 位小数 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.6(极白,HA 56.2)') >= 0, 'skintone_ita.skintone=0 → 中文「极白」,ITA/HA 各 1 位小数(58.64615→58.6、56.246513→56.2)'); // (14) {spot_flag} 褐色斑/痣续接(V2.2 §7.2):基句 + 「,检出褐色斑N处」「、痣N处」,为 0 省略 var vm9e2 = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }); // MOCK:色斑均未检出、mole=2 ok(vm9e2.cats.tone.text.indexOf('(未见明显色斑标记、痣2处)') >= 0, '{spot_flag} 均未检出 + 痣2处 → 「未见明显色斑标记、痣2处」'); var r9e3 = clone(SS.MOCK_RESULT); r9e3.melasma = { value: 1 }; r9e3.brown_spot = { count: 23 }; r9e3.mole = { count: 1 }; ok(SS.buildViewModel(r9e3, SS.MOCK_SIDE, { realAge: 25 }).cats.tone.text.indexOf('(黄褐斑,检出褐色斑23处、痣1处)') >= 0, '{spot_flag} 黄褐斑 + 褐色斑23处 + 痣1处 → 案例2 真值句式'); // (10)(12) 眼袋 severity 非整数取整 / 越界按缺失;轮廓改善档话术无「黑眼圈」(V2.2 第七章逐字) 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级」且无「黑眼圈」(V2.2 第七章改善档原文无黑眼圈)'); 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, '痘痘良好档且计数全缺失 → 缺数据提示句(不声称未检出)'); // 预警档「检出N处皮损(明细)」为固定首句,明细为 0 的类型不列(真实样本形状:丘疹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, '痘痘预警档 → 计数明细正常输出(为0类型不列)'); console.log('== T11 水润 severity 换算触发(V2.2 §3 交叉校验) =='); var r11 = clone(SS.MOCK_RESULT); r11.score_info.water_score = 85; r11.water = { water_area: 10, water_severity: 70 }; // sev≥60 且 score≥80 → 水润项 = 100−70 = 30 var vm11 = SS.buildViewModel(r11, SS.MOCK_SIDE, { realAge: 25 }); ok(approx(vm11.cats.tone.P, 55), '肤色水润P = 0.5×80+0.5×30 = 55(water_severity=70 且 water_score=85 → 水润项按 severity 换算)'); ok(vm11.notes.join('|').indexOf('水润项按 severity 换算为 30') >= 0, 'notes 记录水分交叉校验触发'); ok(vm4.cats.tone.P === -7.5, '对照:T4 sev=70 但 score=0<80 → 不触发换算(P=−7.5 不变)'); console.log('== T12 {oil_desc} 与 {skin_type_cn}(V2.2 §7.2/§8.2) =='); var vm12 = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }); ok(vm12.cats.smooth.text.indexOf('T区出油面积57%、左右脸颊20%/2%,纹理分87') >= 0, '{oil_desc} = T区出油面积57%、左右脸颊20%/2%(oily_intensity 0.57/0.2/0.02 小数×100)'); ok(vm12.summary.indexOf('整体肤质混合性,T区出油面积57%、左右脸颊20%/2%。') >= 0, '综合文案尾句 = 案例1 真值句式(skin_type=3→混合性 + oil_desc 无尾逗号)'); var r12b = clone(SS.MOCK_RESULT); r12b.oily_intensity.full_face.intensity = 0; var vm12b = SS.buildViewModel(r12b, SS.MOCK_SIDE, { realAge: 25 }); ok(vm12b.cats.smooth.text.indexOf('出油') < 0 && vm12b.summary.indexOf('出油') < 0, '全脸 intensity=0 → {oil_desc} 整体省略'); var r12c = clone(SS.MOCK_RESULT); delete r12c.skin_type; ok(SS.buildViewModel(r12c, SS.MOCK_SIDE, { realAge: 25, skinType: '油性肌肤' }).summary.indexOf('整体肤质油性肌肤') >= 0, 'skin_type 缺失 → {skin_type_cn} 回退用户自选肤质'); checkTexts(vm12, 'T12'); console.log('== T10 真值对拍:案例1 完整原始返回 vs 真值表(xlsx 第3行/V2.2 实测) =='); (function () { var raw = null, fs; try { fs = require('fs'); var paths = [__dirname + '/../../reference/facepp-skinanalyze/examples/ref_3view_raw.json', 'reference/facepp-skinanalyze/examples/ref_3view_raw.json']; for (var i = 0; i < paths.length; i++) { try { raw = fs.readFileSync(paths[i], 'utf8'); break; } catch (e) {} } } catch (e) {} if (!raw) { console.log(' - 跳过(未找到 reference ref_3view_raw.json)'); return; } var d = JSON.parse(raw); 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))'); ok(vm.cats.wrinkles.P === 23 && vm.dims.firm.P === 23 && vm.dims.firm.S === 7.69, '皱纹 P=23,紧塑力木桶 23 / S=7.69'); ok(approx(vm.cats.smooth.P, 57.2) && vm.dims.vita.P === 57.2 && vm.dims.vita.S === 8.72, '光滑 P=57.2,活力感 57.2 / S=8.72'); ok(vm.cats.tone.P === 85, '肤色水润 P = 0.5×92+0.5×78 = 85'); ok(vm.cats.sens.P === 99 && vm.cats.acne.P === 70 && vm.dims.stab.P === 70 && vm.dims.stab.S === 9.1, '敏感 99 / 痘痘 70(min(分级70, 100−12)),稳定力 70 / S=9.1'); ok(vm.avgS === 9.05 && vm.worst.catKey === 'wrinkles' && vm.group === '25+组' && vm.essence.name === '25+束紧精华' && vm.essence.triggers.join('、') === '肤龄基础分组', '评分不变,原始肌龄27推荐25+,不再因最差皱纹升档'); ok(vm.skinAgeRaw === 27 && vm.skinAgeDisplay === 27 && vm.ageClampNote === '', '肤龄 27(实际30)未触发钳制、原值不改写'); ok(vm.cats.contour.text === '下颌线角度左104.54°/右103.75°(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降);眼袋1级、黑眼圈1级;中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。', '轮廓文案逐字 = 真值表(黑眼圈取 dark_circle_severity=1,非 dark_circle=3)'); ok(vm.cats.wrinkles.text === '全脸皱纹159条、细纹150条,最差区域为左嘴角纹(分区分77,最高严重度3级),部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。', '皱纹文案逐字 = 真值表'); ok(vm.cats.smooth.text === '皮肤表面平整度较低,最差毛孔区位于额头(分区毛孔分32),粗大毛孔1041颗、黑头113颗,T区出油面积57%、左右脸颊20%/2%,纹理分仅82,整体细腻度不足。建议避免过度清洁,重点关注额头,配合低浓度酸类周期护理与柔润精华。', '光滑文案逐字 = 真值表'); ok(vm.cats.tone.text === '肤色ITA 58.6(极白,HA 56.2),色素分92,整体稳定但存在局部暗沉倾向(未见明显色斑标记,检出褐色斑37处);水分分78,缺水面积19.8%、严重度22。建议加强抗氧化与光泽管理,做好基础防晒与补水。', '肤色水润文案逐字 = 真值表'); ok(vm.cats.sens.text === '泛红面积0.6%,红区分99,屏障状态稳定,未见明显敏感信号。建议维持现有温和护理,避免过度去角质。', '敏感文案逐字 = 真值表'); ok(vm.cats.acne.text === '检出9处皮损(闭口粉刺6处、丘疹3处)。建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。', '痘痘文案逐字 = 真值表(无 heavy_note)'); ok(vm.summary === '【综合】实际年龄30岁(接口测得肌肤年龄27岁,差3岁),产品组别25+组;六分类S均分9.05、最差分类皱纹 S=7.69。按肤龄及边界规则(触发:肤龄基础分组)推荐25+束紧精华:优先改善皱纹(P=23.0)与光滑(P=57.2),其余维度维持现有护理节奏。整体肤质混合性,T区出油面积57%、左右脸颊20%/2%。', '综合文案仅产品判据按2026-10-12规则替换,其余逐字不变'); ok(vm.lifestyle === '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳', '生活方式 = 最差分类皱纹条'); }()); console.log('----------------------------------------'); console.log('自测结果:' + pass + ' 通过,' + fail + ' 失败'); if (fail > 0) process.exit(1); }()); }