1199 lines
75 KiB
JavaScript
1199 lines
75 KiB
JavaScript
/* ═════ 巨子测肤 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.<key>,缺失退化 result.<key>(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);
|
||
}());
|
||
}
|