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

This commit is contained in:
xsl
2026-10-15 00:20:48 +08:00
parent d8c67762da
commit 60ae6c97ef
37 changed files with 5110 additions and 1632 deletions
+6 -3
View File
@@ -64,7 +64,7 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
- **取景蒙版**:内联 SVG(`#cam-mask`)按步骤切换轮廓——正脸步为正脸剪影(含耳部),侧脸步为对应方向的侧脸剪影;**轮廓路径由 Figma 拍照示意图(`design/assets/images/*-拍照示意.png` 的 alpha 镂空区)逐像素描摹生成**,坐标为 Figma 375×812 整帧,viewBox 只取 y 86–812;框外 85% 黑,轮廓线 2.5px 半透明白。检测运行时轮廓内有自上而下的扫描带(`#cam-clip` 由 app.js 复制轮廓生成);未检测到人脸时轮廓呼吸动画;条件合格时轮廓变绿。
- **预览铺满**:`#cam-video` 保持现有 `object-fit:cover` 铺满与镜像策略,不改取景视觉。
- **动态提示**:根据真机难拍反馈适度放宽(参数版本 `2026-10-12-fourth-v2`),单人、检测置信度≥0.4、人脸框高占原始画面 30%~68%、水平中心偏离≤16% 屏宽、完整入镜且距原始帧边缘≥1%。正脸 |yaw|≤15°/|pitch|≤18°,侧脸转头幅度 30°~70°/|pitch|≤20°,两者 |roll|≤15°;脸部平均亮度 65~235。参数集中在 `js/capture-quality.js` 与 `js/head-pose.js`,均为经验值,不等价于服务端质量分。
- **合格即拍**:连续稳定至少 450ms 且至少 2 个合格检测帧;容许 yaw/pitch 漂移≤8°、中心移动≤原帧 5.5%、框高占比变化≤6.5 个百分点。漏检、超限漂移或帧间隔超过 2 秒均重置稳定累计;白屏抓拍后在本地复验实际 JPEG,合格才推进。**无倒计时、无手动快门**(已有降级模式除外)。
- **合格后倒计时拍摄**:连续稳定至少 450ms 且至少 2 个合格检测帧;容许 yaw/pitch 漂移≤8°、中心移动≤原帧 5.5%、框高占比变化≤6.5 个百分点。漏检、超限漂移或帧间隔超过 2 秒均重置稳定累计;每张照片播放原有 `countdown-321.mp3`,等音频完整结束后再白屏抓拍,本地复验实际 JPEG,合格才推进。倒计时期间锁定快门及翻转,退出或隐藏页面取消未完成拍摄。网页内自动/降级手动抓拍均适用;系统相机降级的原生快门由操作系统控制。音频不支持、未解锁或加载失败时沿用无声抓拍,避免阻塞流程。
- **两张之间停顿**:正脸、右脸拍完后中央弹出停顿卡约 2s(「正脸拍摄完成 / 下一张:右脸出镜,请向左转头」),停顿期间不检测,结束后再切到下一步并播下一步引导,让用户对下一张有心理预期。
- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`),进入拍照页开始加载(状态提示「检测引擎加载中…」)。检测循环 ~150ms 一帧,特征点取自原始帧(预览 CSS 镜像不影响),个别前置帧已镜像的机型由侧脸自校准翻转 `sideYawFlip`;连续漏检 ≥3 帧才报「未检测到人脸」。
- **镜像策略**:前置镜头预览镜像(照镜子习惯);后置预览不镜像,侧脸轮廓随之翻转(`.cam-mask.flip`)。**抓帧一律不镜像**,三张照片都是摄像头原始视角。
@@ -89,9 +89,11 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
1. 三视图送检:POST `/api/skin-analyze`,请求 `{ front_image, left_image, right_image, profile? }`(base64 可带 dataURL 前缀;正脸=photos[0],左=photos[2],右=photos[1];`profile` 为可选档案 `{real_age,gender,region,daily_skin_type}`,后台记录用)。
2. 服务端转发 Face++ **专业版 `skinanalyze_pro`**(三视图必须走 `image_file` 文件形式),请求 `return_side_results=jawline_info` 取左右下颌线角度,`return_maps`/`return_marks` 取叠加图与标注;响应 `{ ok:true, result, face_rectangle, side:{ left, right } }`,失败 `{ ok:false, code, message }`(message 已是用户可读文案)。`result.face_maps` 为对象,含报告页所需图层的完整 data URL(`red_area`/`brown_area`/`texture_enhanced_lines`/`texture_enhanced_pores`/`texture_enhanced_blackheads`,由 server.js 的 `REPORT_MAPS` 常量控制);`marks` 坐标(`acne`/`brown_spot`/`mole`/`closed_comedones`/`acne_pustule`/`acne_nodule`/`wrinkle_mark`/`melanin_mark`/`dark_circle_mark`)随 `result` 原样透传,与上传图片同一像素空间。
3. 评分由 `js/skin-score.js` 引擎完成,公式/锁值/话术/产品推荐以 `docs/三维度评分与话术生成需求文档_V2.3.docx` 为最高契约(V2.3 口径,含 §8.5 护理建议五段):
- 6 分类:轮廓 = min(眼袋映射分, 下颌线角度分);皱纹(12 分区);光滑(毛孔/黑头/纹理);肤色水润(色素×水润);敏感(泛红面积取 `red_spot.red_spot_area`/红区分);痘痘(分级映射×丘疹 `acne.count` 计数扣分);分组按实际年龄;
- 6 分类:轮廓 = min(眼袋映射分, 下颌线角度分);皱纹直接采用 `score_info.wrinkle_score` 整体分(不反向、不按分区严重度限分;分区仅作说明/可视化,缺失整体分则不回退旧规则);光滑(毛孔/黑头/纹理);肤色水润(色素×水润);敏感(泛红面积取 `red_spot.red_spot_area`/红区分);痘痘(分级映射×丘疹 `acne.count` 计数扣分);
- 三维度木桶 min:紧塑力(轮廓+皱纹)、活力感(光滑+肤色水润)、稳定力(敏感+痘痘);
- S = 7 + 3×P/100;档位:良好(P≥90)/改善(80–90)/预警(<80);生物可视年龄直接展示算法原始肌龄,不再按实际年龄 ±3 修正;三视图版 18 条话术;
- 痘痘计数:闭口、丘疹、脓包、结节、痘印分别取正/左/右三视图最大数量(不相加),权重1/2/4/6/1.5、合计扣分封顶50;整体 `acne_score` 仍取正脸。报告文案使用相同计分数量;缺失不当0,后台详情显示三视图及最终取值、扣分。
- 肤色项:色素扣斑分80%+`dark_circle_score`黑眼圈评分20%,与水分项各占50%得到肤色水润P;缺项按有效权重归一。肤色页在原照片上以30%强度叠加色素图(缺图时用真实色素坐标),黄褐斑/雀斑显示检出状态,没有对应坐标不画伪造定位框。
- S = 7 + 3×P/100;档位:良好(P≥90)/改善(80–90)/预警(<80);生物可视年龄限制在用户实际年龄±5岁:`max(实际年龄−5, min(实际年龄+5, 原始肌龄))`,如实际25、接口40展示30;原始肌龄和推荐规则不变。缺少实际年龄展示原始肌龄,缺少肌龄显示`--`;三视图版 18 条话术;
- 精华推荐三重判据(均分档/最差项约束/年龄组档)取最强档,triggers 记录命中判据。
4. 前端 `SkinScore.buildViewModel(data.result, data.side, { realAge, skinType })` → viewModel 直渲报告页:
- 头部:综合得分通过 `SkinScore.weightedTotal(vm.dims)` 计算,紧塑力×4+透亮感×3+稳韧力×3,使用维度S分数(两位小数)加权、四舍五入取整数,不使用接口 `total_score`;任一维度缺失显示 `--`。后台列表、详情、CSV采用相同公式,历史报告按已保存维度计算展示,不改写原文件;肤龄读取 `SkinScore.reportSkinAge(vm, result)`,原值缺失显示 `--`;三维度 S(1 位小数)+ 档位配色(良好绿/改善琥珀/预警红);
@@ -110,6 +112,7 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
- **数据落库**:每次有效调用(正脸照片校验通过起)生成 `record_id`,照片写 `data/photos/<record_id>/`,记录(档案/照片地址/拍摄与上传状态/检测状态与失败原因/评分/文案/精华/耗时/raw 文件名/重试关系)追加到 `data/records.jsonl`。C 端响应结构不变。
- **记录列表**:多条件筛选(时间区间/检测状态/失败原因/年龄/产品组别/维度+档位/记录ID),行点击开详情抽屉(照片大图与下载、状态、六分类与三维度评分表、文案全文、护理建议、精华与触发判据、计算备注、接口原始 JSON 折叠查看、手动重试)。
- **时间口径**:后台列表/异常/详情显示、纯日期筛选、今日及每日统计、CSV时间统一为北京时间(UTC+8),由 `js/beijing-time.js` 共享处理,不依赖服务器或浏览器时区。原始记录及JSON接口时间仍为UTC ISO;完整ISO筛选保留显式时区对应的时间点。
- **失败与异常**:失败原因分布条 + 失败列表(每条可一键重试:用原照片重新检测,生成新记录并挂版本链)。
- **统计概览**:今日/累计/成功率/平均耗时卡片,三维度档位分布,产品组别分布(纯 CSS 饼图),近 14 天每日检测量柱状图,按日期区间导出 CSV(UTF-8 BOM,不含 raw_json)。
- **演示数据**:`node h5/data/seed.js` 生成约 10 条演示记录(覆盖重写 records.jsonl,照片复制自 `reference/facepp-skinanalyze/examples/images/` 真实三视图)。
+4 -3
View File
@@ -20,7 +20,7 @@
<!-- ─── 记录列表(文档 4.1/4.2) ─── -->
<section id="tab-records" class="panel on">
<div class="filters">
<label>创建时间 <input type="date" id="f-from"> ~ <input type="date" id="f-to"></label>
<label>创建时间(北京时间) <input type="date" id="f-from"> ~ <input type="date" id="f-to"></label>
<label>检测状态
<select id="f-status">
<option value="">全部</option>
@@ -114,7 +114,7 @@
</div>
</div>
<div class="block">
<h3>近 14 天每日检测量</h3>
<h3>近 14 天每日检测量(北京时间)</h3>
<div id="daily-chart" class="daily-chart"></div>
</div>
<div class="block">
@@ -123,7 +123,7 @@
<label>起始日期 <input type="date" id="exp-from"></label>
<label>结束日期 <input type="date" id="exp-to"></label>
<button type="button" class="btn primary" id="btn-export">导出 CSV</button>
<span class="hint">UTF-8 BOM,Excel 直接打开;不含接口原始 JSON</span>
<span class="hint">日期及导出时间均为北京时间;UTF-8 BOM,Excel 直接打开;不含接口原始 JSON</span>
</div>
</div>
</section>
@@ -151,6 +151,7 @@
<script src="js/report-copy.js"></script>
<script src="js/report-ui.js"></script>
<script src="js/beijing-time.js"></script>
<script src="js/admin.js"></script>
</body>
</html>
+17 -3
View File
@@ -18,11 +18,12 @@ function esc(s) { // 拼 HTML 前转义(接口文案/失败信息不可信)
}
function fmtTime(iso) {
if (!iso) return '-';
return iso.replace('T', ' ').slice(0, 19);
return BeijingTime.format(iso);
}
function fmtMs(ms) { return ms == null ? '-' : ms + 'ms'; }
function fmtScore(v) { return v == null ? '-' : v; }
function fmtTotal(v) { return v == null ? '--' : v; }
function fmtToneScore(v) { return v == null ? '-' : Math.round((v + 1e-9) * 100) / 100; }
var LV_CLASS = { '预警': 'lv-bad', '改善': 'lv-warn', '良好': 'lv-good' };
var DIM_NAMES = ReportUI.dimNames;
@@ -69,7 +70,7 @@ function dimsHtml(dims) {
for (var k in DIM_NAMES) {
var d = dims[k];
h += '<div><span class="lv ' + (d && LV_CLASS[d.level] || '') + '" title="' + DIM_NAMES[k] + '">' +
DIM_NAMES[k] + ' ' + (d && typeof d.S === 'number' ? d.S.toFixed(1) : '-') + ' ' +
DIM_NAMES[k] + ' ' + ReportUI.dimensionScore(d && d.S) + ' ' +
(d ? esc(ReportUI.levelMap[d.level] || '-') : '-') + '</span></div>';
}
return h;
@@ -414,7 +415,12 @@ function renderDetail(data) {
'<span>右下颌线角 <b>' + (r.jawline_angle_r != null ? r.jawline_angle_r + '°' : '-') + '</b></span>' +
'</div>');
h += '<p class="sub">综合得分=紧塑力×4+透亮感×3+稳韧力×3,按已计算的维度S分数(两位小数)加权,四舍五入取整数;任一维度缺失不生成综合得分,不使用接口总分。</p>';
h += '<p class="sub">前端展示肌龄沿用实际年龄±3岁的修正;新推荐只使用原始肌龄。历史报告保留当时的推荐。</p>';
if (r.wrinkle_score_rule === 'api-overall-v1') h += '<p class="sub">皱纹评分直接使用接口整体皱纹分数,不做100减分,也不再按分区严重度限分。</p>';
if (r.tone_scoring && r.tone_scoring.version === 'color-dark-circle-20-v1') {
var tone = r.tone_scoring;
h += '<p class="sub">肤色项=色素扣斑分×80%+黑眼圈评分×20%;再与水分项各占50%。色素扣斑分:' + esc(fmtToneScore(tone.pigment_adjusted_score)) + ',黑眼圈评分:' + esc(fmtToneScore(tone.dark_circle_score)) + ',合成肤色项:' + esc(fmtToneScore(tone.color_score)) + ',水分项:' + esc(fmtToneScore(tone.water_adjusted_score)) + '。缺项按有效权重归一。</p>';
}
h += '<p class="sub">前端展示肌龄限制在实际年龄±5岁;推荐仍使用原始肌龄。历史报告保留当时的推荐。</p>';
/* 6 分类表格 */
var catRows = '';
@@ -432,6 +438,14 @@ function renderDetail(data) {
dimRows += '<tr><td>' + DIM_NAMES[dk] + '</td><td>' + fmtScore(dim && dim.P) + '</td><td>' + fmtScore(dim && dim.S) + '</td><td>' + lvHtml(dim && dim.level) + '</td></tr>';
}
h += dSec('三维度评分', '<table class="d-table"><thead><tr><th>维度</th><th>P</th><th>S</th><th>档位</th></tr></thead><tbody>' + dimRows + '</tbody></table>');
if (r.acne_scoring && r.acne_scoring.version === 'three-view-type-max-v1') {
var audit = r.acne_scoring, acneRows = '';
(audit.types || []).forEach(function(t) {
var v = t.views || {};
acneRows += '<tr><td>' + esc(t.name) + '</td><td>' + esc(fmtScore(v.front)) + '</td><td>' + esc(fmtScore(v.left)) + '</td><td>' + esc(fmtScore(v.right)) + '</td><td>' + esc(fmtScore(t.count)) + '</td><td>' + esc(fmtScore(t.weight)) + '</td><td>' + esc(fmtScore(t.deduction)) + '</td></tr>';
});
h += dSec('痘痘三视图计分明细', '<p class="sub">每种皮损分别取三视图最大数量,不相加,也不是全脸去重总数;“-”表示未获取。整体评分仍用正脸:' + esc(fmtScore(audit.front_score)) + ',映射分:' + esc(fmtScore(audit.mapped_score)) + '。计数扣分合计:' + esc(fmtScore(audit.raw_deduction)) + ',封顶50后:' + esc(fmtScore(audit.deduction)) + '。</p><table class="d-table"><thead><tr><th>类型</th><th>正脸</th><th>左脸</th><th>右脸</th><th>计分数量</th><th>单处扣分</th><th>该项扣分</th></tr></thead><tbody>' + acneRows + '</tbody></table>');
}
/* 文案全文 */
var copyHtml = '';
+27 -7
View File
@@ -751,8 +751,8 @@
if (hard) stopCur();
}
// 等全部播报(含排队与挂起提示)自然收尾后执行 cb(倒计时接续「保持不动」,不拦腰切断);空闲则立即执行
function afterCurrent(cb) {
var tail = cur && cur.src && Date.now() >= cur.end - 60; // 只剩尾音视为已收尾
function afterCurrent(cb, waitForEnded) {
var tail = !waitForEnded && cur && cur.src && Date.now() >= cur.end - 60; // 倒计时必须等 ended,不能提前抓拍
if ((!cur || tail) && !queue.length && !pendingHint) { cb(); return null; }
var fn = function () { cb(); };
waiters.push(fn);
@@ -762,7 +762,7 @@
})();
document.addEventListener('click', voice.unlock); // 任意点击即完成解锁
/* ─── 姿态检测(face-api + HeadPose):动态捕捉——距离/居中/角度/光线全部合格并稳住片刻即自动抓拍,不倒计时、无手动快门 ─── */
/* ─── 姿态检测(face-api + HeadPose):全部合格并稳住片刻后播321,播完自动抓拍 ─── */
var MODEL_URL = 'vendor/faceapi/models';
var DETECT_INTERVAL = 150; // 检测帧间隔(ms)
var DETECT_INPUT_SIZE = 416; // tinyFaceDetector 输入尺寸:320 下正脸置信度仅 0.67 且框偏松,416 置信度 ~0.99 框更准(CPU 端略慢,移动端 GPU 无感)
@@ -1092,9 +1092,14 @@
clearTimeout(nudgeTimer);
hideNudge();
var seq = camSeq;
voice.dropHint();
voice.play(VOICE.countdown);
voice.afterCurrent(function () {
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') return;
var fill = $('#cam-fill');
fill.classList.add('on'); // .on 无过渡,瞬间点亮
setTimeout(function () {
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') { fill.classList.remove('on'); return; }
var shot = captureFromVideo();
setTimeout(function () {
fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出
@@ -1112,6 +1117,7 @@
});
}, 300);
}, 300);
}, true);
}
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
@@ -1254,7 +1260,7 @@
function setDim(key, dim, rank) {
var box = document.querySelector('.dim[data-dim="' + key + '"]');
var el = $('#dim-' + key), has = dim.S != null;
var parts = has ? dim.S.toFixed(1).split('.') : ['-', '-'];
var parts = has ? ReportUI.dimensionScore(dim.S).split('.') : ['-', '-'];
el.innerHTML = '<b>' + parts[0] + '</b>.' + parts[1];
el.classList.toggle('dim-bad', has && rank === 'worst');
box.classList.toggle('worst', has && rank === 'worst');
@@ -1276,7 +1282,10 @@
wrinkles: { img: '#ditem-img2', overlay: 'texture_enhanced_lines', dim: 0.55 }, // 皱纹:正脸压暗+皱纹增强线(图层几乎全透明,直挂在白卡上不可读,故合成)
smooth: { img: '#viz-smooth', overlays: ['texture_enhanced_pores', 'texture_enhanced_blackheads'] },
water: { img: '#viz-water', blendMap: 'water_area' },
tone: { img: '#viz-tone', blendMap: 'brown_area' }, // 肤色水润:正脸×水分图正片叠底(water_area,接口返回)
tone: { img: '#viz-tone', blendMap: 'brown_area', alpha: 0.3, marks: [
{ key: 'melanin_mark', color: '#9F641F' },
{ key: 'melasma', color: '#E68A2E' }, { key: 'freckle', color: '#8750C8' }
] }, // 仅使用接口真实坐标;黄褐斑/雀斑只有分类值时不画伪造框。
sens: { img: '#viz-sens', blendMap: 'red_area' }, // 敏感:正脸×红区图正片叠底(直挂白底过淡不可读)
acne: { img: '#viz-acne', photo: true, marks: [ // 痘痘:正脸+闭口粉刺/痤疮标记
{ key: 'closed_comedones', color: '#E8A13D' }, // 闭口粉刺
@@ -1390,15 +1399,19 @@
overlays.forEach(function (overlay) {
if (!overlay) return;
if (opts && opts.blend) ctx.globalCompositeOperation = opts.blend; // 正片叠底:白底图层(红区/色沉)叠到脸上
ctx.globalAlpha = opts && typeof opts.alpha === 'number' ? opts.alpha : 1;
var ox = overlay.naturalWidth / photo.naturalWidth, oy = overlay.naturalHeight / photo.naturalHeight;
ctx.drawImage(overlay, crop.left * ox, crop.top * oy, crop.width * ox, crop.height * oy, 0, 0, w, h);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
});
ctx.save();
ctx.translate(-crop.left * scale, -crop.top * scale);
var scaled = vizScaleMarks(marks, scale);
var lw = Math.max(3, Math.round(w / 200)); // 线宽随图走,卡片缩略后仍可见
var minBox = Math.round(w / 36); // 过小标记放大到最小框,保证缩略可见
var markAlpha = opts && typeof opts.alpha === 'number' ? opts.alpha : 1;
ctx.globalAlpha = markAlpha;
for (var i = 0; i < scaled.length; i++) {
var m = scaled[i];
ctx.strokeStyle = m.color;
@@ -1411,9 +1424,9 @@
ctx.moveTo(pts[0][0], pts[0][1]);
for (q = 1; q < pts.length; q++) ctx.lineTo(pts[q][0], pts[q][1]);
ctx.closePath();
ctx.globalAlpha = 0.22;
ctx.globalAlpha = markAlpha * 0.22;
ctx.fill();
ctx.globalAlpha = 1;
ctx.globalAlpha = markAlpha;
ctx.stroke();
}
for (p = 0; p < m.rects.length; p++) {
@@ -1427,6 +1440,7 @@
try {
imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false;
imgEl.dataset.crop = crop.focused ? 'face' : 'full';
imgEl.dataset.overlayAlpha = String(opts && typeof opts.alpha === 'number' ? opts.alpha : 1);
} catch (e) { /* 合成失败保留占位 */ }
};
var urls = Array.isArray(overlayURL) ? overlayURL : overlayURL ? [overlayURL] : [];
@@ -1466,6 +1480,12 @@
}
if (cfg.blendMap) { // 图层×正脸正片叠底(色沉/红区):无照片时直挂图层;图层缺失 → 保留占位
var bu = vizMapURL(maps[cfg.blendMap]);
if (key === 'tone') {
var toneDefs = bu ? cfg.marks.filter(function(def) { return def.key !== 'melanin_mark'; }) : cfg.marks;
var toneMarks = vizCollectMarks(toneDefs, result);
if (frontPhoto) vizCompose(frontPhoto, bu, toneMarks, imgEl, { blend: 'multiply', alpha: cfg.alpha, faceRectangle: rectangle });
continue;
}
if (!bu) continue;
if (!frontPhoto) { imgEl.src = bu; imgEl.hidden = false; continue; }
vizCompose(frontPhoto, bu, [], imgEl, { blend: 'multiply', faceRectangle: rectangle });
+37
View File
@@ -0,0 +1,37 @@
/* Admin presentation/calendar rules only. Stored timestamps remain UTC ISO. */
(function(root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.BeijingTime = factory();
})(typeof window !== 'undefined' ? window : this, function() {
'use strict';
var OFFSET = 8 * 60 * 60 * 1000, DAY = 86400000;
function milliseconds(value) {
if (typeof value === 'number') return isFinite(value) ? value : NaN;
if (typeof value !== 'string' || !value) return NaN;
// Zone-less full timestamps, if supplied, mean Beijing time, not host time.
if (/^\d{4}-\d\d-\d\d[T ]\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/.test(value)) value = value.replace(' ', 'T') + '+08:00';
return Date.parse(value);
}
function shifted(value) {
var ms = milliseconds(value);
return isFinite(ms) ? new Date(ms + OFFSET).toISOString() : null;
}
function day(value) { var iso = shifted(value); return iso ? iso.slice(0, 10) : null; }
function format(value) { var iso = shifted(value); return iso ? iso.replace('T', ' ').slice(0, 19) : '-'; }
function boundary(value, end) {
if (/^\d{4}-\d\d-\d\d$/.test(value)) {
var date = Date.parse(value + 'T00:00:00.000Z');
if (!isFinite(date) || new Date(date).toISOString().slice(0, 10) !== value) return NaN;
return date - OFFSET + (end ? DAY : 0);
}
var ms = milliseconds(value);
return isFinite(ms) ? ms + (end ? 1 : 0) : NaN;
}
function range(from, to) {
var start = from ? boundary(from, false) : -Infinity;
var end = to ? boundary(to, true) : Infinity;
if (isNaN(start) || isNaN(end) || start >= end) throw new Error('日期无效或起始时间晚于结束时间');
return { start: start, end: end };
}
return { day: day, format: format, range: range, milliseconds: milliseconds };
});
+3 -3
View File
@@ -13,9 +13,9 @@
"优秀": "面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。"
},
"color": {
"改善": "检测到当前肌肤色素分 {melanin},存在色素沉着较显著,整体透亮感不足。",
"良好": "检测到当前肌肤色素分 {melanin},局部有色素沉积;整体状态稳定,局部透亮感稍弱。",
"优秀": "检测到当前肌肤色素分 {melanin},无明显色素沉积;肤色均匀透亮。"
"改善": "检测到当前肌肤色素分 {melanin},存在色素沉着较显著,{dark_circle_note},整体透亮感不足。",
"良好": "检测到当前肌肤色素分 {melanin},局部有色素沉积;{dark_circle_note},整体状态稳定,局部透亮感稍弱。",
"优秀": "检测到当前肌肤色素分 {melanin},无明显色素沉积,{dark_circle_note};肤色均匀透亮。"
},
"smooth": {
"改善": "当前肌肤平滑度还有提升空间,检测到粗大毛孔 {pore_cnt} 颗、黑头 {blackhead_cnt} 颗,肤感整体细腻度有提升空间,纹理不均。",
+22 -2
View File
@@ -18,6 +18,9 @@
var dimNames = { firm: '紧塑力', vita: '透亮感', stab: '稳韧力' };
var focusTips = { firm: '强化紧塑力,提拉轮廓抚平细纹。', vita: '提升透亮感,补水提亮加强防晒。', stab: '增强稳韧力,舒缓泛红强韧屏障。' };
function numeric(value) { return typeof value === 'number' && isFinite(value); }
function dimensionScore(value) {
return numeric(value) ? (Math.round((value + 1e-9) * 10) / 10).toFixed(1) : '-';
}
// Read saved raw dimension values, never re-score historical reports or use
// the legacy focus.name (which contains the old dimension terminology).
function focusOf(vm) {
@@ -91,14 +94,31 @@
template = template.replace(/黑眼圈轻\/中\/重度(根据分数给出判断)/g, description || '黑眼圈数据未获取');
if (vars.la == null || vars.ra == null) template = template.replace(/左右下颌线角度(?:分别为左|左) \{la\}°\/ 右 \{ra\}°[;;]/g, '');
}
if (d[0] === 'color') {
// Severity is an independent detection, not inferred from tone level
// or dark_circle_score. Missing/invalid severity must not imply mild.
var colorSeverity = scalar(result.dark_circle_severity);
var colorDescriptions = ['无明显黑眼圈', '仅存在轻度黑眼圈', '存在中度黑眼圈', '可见明显黑眼圈'];
if (numeric(colorSeverity) && colorSeverity % 1 === 0 && colorSeverity >= 0 && colorSeverity <= 3) {
vars.dark_circle_note = colorDescriptions[colorSeverity];
} else {
template = template.replace(/\{dark_circle_note\}[,;]/g, '');
}
}
if (d[0] === 'acne') {
var pustules = count(result, 'acne_pustule'), nodules = count(result, 'acne_nodule');
var pustules = vars.acne_counts ? vars.acne_counts.acne_pustule : count(result, 'acne_pustule');
var nodules = vars.acne_counts ? vars.acne_counts.acne_nodule : count(result, 'acne_nodule');
var heavy = [pustules > 0 ? '脓包' : '', nodules > 0 ? '结节' : ''].filter(Boolean);
vars.heavy_note = heavy.length ? ',含' + heavy.join('、') + '等中重度皮损,炎症反应明显' : '';
if (heavy.length) { level = '改善'; template = Copy.conclusion.acne[level]; }
else if (vars.acne_cnt > 0 && level === '优秀') { level = '良好'; template = Copy.conclusion.acne[level]; }
if (vars.acne_cnt == null) template = '本次未获取完整皮损计数,请结合检测图片查看。';
else if (vars.acne_cnt === 0) template = '本次未检出明显皮损,请结合检测图片查看。';
if (vars.acne_cnt != null && vars.acne_counts_complete === false) {
template = vars.acne_cnt > 0
? '本次已获取数据中检测到 {acne_cnt} 处皮损({acne_detail}){heavy_note},部分类型计数未获取,请结合检测图片查看。'
: '本次已获取的皮损计数为0,部分类型数据缺失,请结合检测图片查看。';
}
}
// Missing counts are unknown, not zero: remove only the unavailable metric.
if (vars.fl_cnt == null) template = template.replace(/、细纹 \{fl_cnt\} 条/g, '(细纹计数未获取)');
@@ -128,5 +148,5 @@
var product = products.filter(function(p) { return p.match === vm.essence.name; })[0] || null;
return { version: Copy.version + '+feedback-2026-10-12', dims: dimNames, views: views, care: care, product: product, focus: focusOf(vm) };
}
return { build: build, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails, focusOf: focusOf, customerText: customerText };
return { dimensionScore: dimensionScore, build: build, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails, focusOf: focusOf, customerText: customerText };
});
+82 -51
View File
@@ -216,8 +216,13 @@
};
}
// 皱纹:P = 100 − max(12分区 wrinkle_score);分区 severity≥2 锁79、≥3 锁74
// 皱纹:直接使用接口整体评分,不反向、不按最差分区或严重度限分。
function calcWrinkles(r, notes) {
var P = pick(r, 'wrinkle_score');
if (!isNum(P) || P < 0 || P > 100) {
P = null;
notes.push('皱纹:整体皱纹评分缺失或无效,不回退最差分区计算');
}
var regions = [], used = {}, i, k, node;
for (i = 0; i < WRINKLE_REGIONS.length; i++) {
var rg = WRINKLE_REGIONS[i]; node = null;
@@ -237,19 +242,11 @@
if (s2 == null) continue;
regions.push({ cn: k.replace(/(_wrinkle_info|_fold_info)$/, ''), score: s2, sev: val(node.wrinkle_severity_level) });
}
if (!regions.length) { notes.push('皱纹:12 分区 wrinkle_info 全部缺失,皱纹分类不参与评分'); return { P: null, vars: {} }; }
var worst = regions[0], maxSev = null;
var worst = regions.length ? regions[0] : null, maxSev = null;
for (i = 0; i < regions.length; i++) {
if (regions[i].score > worst.score) worst = regions[i];
if (regions[i].sev != null && (maxSev == null || regions[i].sev > maxSev)) maxSev = regions[i].sev;
}
var P = 100 - worst.score;
if (maxSev != null) {
if (maxSev >= 3) P = Math.min(P, 74);
else if (maxSev >= 2) P = Math.min(P, 79);
} else {
notes.push('皱纹:分区 severity 不可得,上限锁未执行');
}
var wrCnt = cntField(r, 'wrinkle_count');
var flCnt = cntField(r, 'fine_line'); // 真实键名为 fine_line(分区计数对象),兼容 fine_line_count
if (flCnt == null) flCnt = cntField(r, 'fine_line_count');
@@ -258,8 +255,9 @@
vars: {
wr_cnt: wrCnt,
fl_cnt: flCnt,
worst_wr_region: worst.cn,
wr_max: worst.score,
overall_wrinkle_score: P,
worst_wr_region: worst ? worst.cn : null,
wr_max: worst ? worst.score : null,
max_sev: maxSev
}
};
@@ -325,10 +323,12 @@
};
}
// 肤色水润:P = 0.5×(melanin_score−melasma×10−freckle×5) + 0.5×water_score
// 肤色项 = 80%×(色素分−黄褐斑×10−雀斑×5) + 20%×黑眼圈分;肤色/水分仍各50%。
// 交叉校验:water_severity≥60 且 water_score≥80 → 水分项按 severity 换算(实现为 100−severity,待客户确认)
function calcTone(r, notes) {
var melanin = pick(r, 'melanin_score');
var darkCircle = pick(r, 'dark_circle_score');
if (!isNum(darkCircle) || darkCircle < 0 || darkCircle > 100) darkCircle = null;
var melasma = pick(r, 'melasma') || 0; // 缺失按未检出(不扣分)
var freckle = pick(r, 'freckle') || 0;
var waterScore = pick(r, 'water_score');
@@ -342,6 +342,11 @@
var melaninItem = null, waterItem = null;
if (melanin != null) melaninItem = melanin - melasma * 10 - freckle * 5;
else notes.push('melanin_score 缺失,肤色水润评分缺肤色项');
var colorItem = null, colorWeight = 0, colorSum = 0;
if (melaninItem != null) { colorSum += melaninItem * 0.8; colorWeight += 0.8; }
if (darkCircle != null) { colorSum += darkCircle * 0.2; colorWeight += 0.2; }
else notes.push('dark_circle_score 缺失或无效,肤色项剔除黑眼圈并按有效权重归一');
if (colorWeight) colorItem = colorSum / colorWeight;
if (waterScore != null) {
waterItem = waterScore;
if (waterSev != null && waterSev >= 60 && waterScore >= 80) {
@@ -351,7 +356,7 @@
} else {
notes.push('water_score 缺失,肤色水润评分缺水润项');
}
var items = [melaninItem, waterItem], acc = 0, n = 0, i;
var items = [colorItem, waterItem], acc = 0, n = 0, i;
for (i = 0; i < 2; i++) if (items[i] != null) { acc += 0.5 * items[i]; n++; }
if (!n) { notes.push('肤色水润:肤色项与水润项均缺失,肤色水润分类不参与评分'); return { P: null, vars: {} }; }
var P = n === 2 ? acc : acc * 2 / n; // 单项时按 0.5 权重归一
@@ -376,6 +381,12 @@
st: st,
ha: haV == null ? null : round1(haV),
melanin: melanin,
dark_circle_score: darkCircle,
color_score: colorItem,
tone_scoring: { version: 'color-dark-circle-20-v1', melanin_score: melanin,
pigment_adjusted_score: melaninItem, dark_circle_score: darkCircle, color_score: colorItem,
pigment_weight: 0.8, dark_circle_weight: 0.2, color_effective_weight: colorWeight,
water_score: waterScore, water_adjusted_score: waterItem },
spot_flag: spotFlag,
water_score: waterScore,
water_area: waterArea,
@@ -415,40 +426,45 @@
// 痘痘:P = min(acne_score 分级映射, 100−计数扣分);扣分封顶50
function acneGrade(score) { return score >= 95 ? 100 : (score >= 85 ? 85 : (score >= 75 ? 70 : 55)); }
function calcAcne(r, notes) {
var ACNE_TYPES = [
['closed_comedones', '闭口粉刺', 1], ['acne', '丘疹', 2],
['acne_pustule', '脓包', 4], ['acne_nodule', '结节', 6], ['acne_mark', '痘印', 1.5]
];
function calcAcne(r, sides, notes) {
var score = pick(r, 'acne_score');
var cc = cnt(r.closed_comedones); // 闭口 ×1
var pa = cnt(r.acne); // 丘疹 ×2
var pu = cnt(r.acne_pustule); // 脓包 ×4
var no = cnt(r.acne_nodule); // 结节 ×6
var mk = cnt(r.acne_mark); // 痘印 ×1.5
var counts = [cc, pa, pu, no, mk], allNull = true, i;
for (i = 0; i < 5; i++) if (counts[i] != null) { allNull = false; break; }
var left = sides && sides.left, right = sides && sides.right;
var sources = { front: r, left: left && (left.left_acne_info || left.acne_info), right: right && (right.right_acne_info || right.acne_info) };
var merged = {}, rows = [], total = 0, rawDeduct = 0, allNull = true, complete = true;
for (var i = 0; i < ACNE_TYPES.length; i++) {
var def = ACNE_TYPES[i], views = {}, max = null;
['front', 'left', 'right'].forEach(function(view) {
var n = cnt(sources[view] && sources[view][def[0]]);
if (!isNum(n) || n < 0 || Math.floor(n) !== n) n = null;
views[view] = n;
if (n != null && (max == null || n > max)) max = n;
});
merged[def[0]] = max;
if (max == null) { complete = false; notes.push('痘痘:' + def[1] + '三视图计数均缺失,不为该项编造扣分'); }
else { allNull = false; total += max; rawDeduct += max * def[2]; }
rows.push({ key: def[0], name: def[1], weight: def[2], views: views, count: max,
sources: Object.keys(views).filter(function(view) { return max != null && views[view] === max; }),
deduction: max == null ? null : max * def[2] });
}
var deduct = Math.min(50, rawDeduct);
var audit = { version: 'three-view-type-max-v1', front_score: score,
mapped_score: score == null ? null : acneGrade(score), types: rows,
raw_deduction: allNull ? null : rawDeduct, deduction: allNull ? null : deduct, complete: complete };
var vars = { acne_cnt: allNull ? null : total, acne_counts: merged, acne_counts_complete: complete,
acne_detail: rows.filter(function(row) { return row.count > 0; }).map(function(row) { return row.name + row.count + '处'; }).join('、'), acne_scoring: audit };
if (score == null && allNull) {
notes.push('acne_score 与皮损计数全部缺失,痘痘分类不参与评分');
return { P: null, vars: {} };
return { P: null, vars: vars };
}
if (score == null) notes.push('acne_score 缺失,痘痘评分仅由计数扣分决定');
var deduct = Math.min(50, (cc || 0) * 1 + (pa || 0) * 2 + (pu || 0) * 4 + (no || 0) * 6 + (mk || 0) * 1.5);
var cand = [];
if (score != null) cand.push(acneGrade(score));
if (!allNull) cand.push(100 - deduct);
var detail = joinParts([
cc ? '闭口粉刺' + cc + '处' : '',
pa ? '丘疹' + pa + '处' : '',
pu ? '脓包' + pu + '处' : '',
no ? '结节' + no + '处' : '',
mk ? '痘印' + mk + '处' : ''
], '、');
var total = 0;
for (i = 0; i < 5; i++) total += counts[i] || 0;
return {
P: Math.min.apply(null, cand),
vars: {
acne_cnt: allNull ? null : total,
acne_detail: detail
}
};
return { P: Math.min.apply(null, cand), vars: vars };
}
/* ─── 话术库(V2.2 第七章逐字;变量缺失省略对应分句) ─── */
@@ -571,6 +587,8 @@
+ '以闭口粉刺、丘疹为主,局部存在炎症倾向。建议加强温和清洁与控油,避免挤压,配合抗炎舒缓护理。';
// 良好档:所有计数字段缺失(仅靠 acne_score 分级得高 P)时不能声称「未检出」,输出缺数据提示句
if (v.acne_cnt == null) return '本次检测未获取皮损计数数据,仅按痤疮分级评估。建议维持清洁与控油节奏,注意作息与饮食。';
if (v.acne_counts_complete === false) return (found ? found + '。' : '') + '部分类型皮损计数未获取,请结合检测图片查看。';
if (found) return found + '。建议维持清洁与控油节奏,注意作息与饮食。';
return '未检出痤疮与皮损。建议维持清洁与控油节奏,注意作息与饮食。';
}
@@ -701,9 +719,11 @@
var notes = [];
var realAge = val(profile.realAge);
// 生物可视年龄直接使用算法原始肌龄,不再按实际年龄钳制。
// 仅展示年龄限制在实际年龄±5岁,原始肌龄继续用于产品推荐。
var skinAge = val(r.skin_age);
var skinAgeDisplay = skinAge, ageClampNote = '';
var skinAgeDisplay = reportSkinAge({ skinAgeRaw: skinAge, realAge: realAge });
var ageClampNote = skinAgeDisplay != null && skinAgeDisplay !== skinAge
? '生物可视年龄已按实际年龄±5岁限制展示;接口原始肌龄保留用于推荐。' : '';
if (realAge == null) {
notes.push('未提供实际年龄:肌龄展示与产品推荐均使用接口原始肌龄');
}
@@ -714,7 +734,7 @@
var cats = {}, sList = [], i, def, res;
for (i = 0; i < CAT_DEFS.length; i++) {
def = CAT_DEFS[i];
res = def.key === 'contour' ? def.calc(r, sideResults, notes) : def.calc(r, notes);
res = (def.key === 'contour' || def.key === 'acne') ? def.calc(r, sideResults, notes) : def.calc(r, notes);
cats[def.key] = {
name: def.name, dim: def.dim,
vars: res.vars, // 原始模板变量供新版展示层使用,不参与评分规则变更
@@ -828,6 +848,8 @@
return {
total: weightedTotal(dims),
totalRule: 'dimensions-433-v1',
wrinkleScoreRule: 'api-overall-v1',
acneScoreRule: 'three-view-type-max-v1',
skinAgeRaw: skinAge,
skinAgeDisplay: skinAgeDisplay,
ageClampNote: ageClampNote,
@@ -851,6 +873,7 @@
var MOCK_RESULT = {
score_info: {
total_score: 79,
wrinkle_score: 23, // Keep this synthetic fixture's other baseline results stable.
water_score: 83,
rough_score: 87,
sensitivity_score: 98,
@@ -908,11 +931,14 @@
right: { right_jawline_info: { right_jawline_angle: 113.4, right_jawline_angle_level: 1, right_jawline_quality: 1 } } // 偏离 2.6° → 94.8
};
// Historical snapshots may retain an old clamped display value. Read their
// original age without modifying the stored snapshot or recalculating copy.
// Apply the current display rule to new and historical reports without
// modifying their raw age, stored snapshot, copy or recommendation.
function reportSkinAge(vm, result) {
if (vm && Object.prototype.hasOwnProperty.call(vm, 'skinAgeRaw')) return val(vm.skinAgeRaw);
return val(result && result.skin_age);
var raw = vm && Object.prototype.hasOwnProperty.call(vm, 'skinAgeRaw') ? val(vm.skinAgeRaw) : val(result && result.skin_age);
if (!isNum(raw) || raw < 0) return null;
var actual = val(vm && vm.realAge);
if (!isNum(actual) || actual < 0) return raw;
return Math.max(actual - 5, Math.min(actual + 5, raw));
}
return {
weightedTotal: weightedTotal,
@@ -953,7 +979,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
var vm = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25, skinType: '混合偏干肌肤' });
ok(approx(vm.cats.smooth.P, 81.7), '光滑P = 0.4×73+0.3×88+0.3×87 = 81.7(AR列对拍)');
ok(approx(vm.cats.tone.P, 81.5), '肤色水润P = 0.5×80+0.5×83 = 81.5(AR列对拍)');
ok(vm.cats.wrinkles.P === 23 && vm.cats.wrinkles.level === '预警', '皱纹P = 100−77 = 23(severity=3 锁74 不再抬升)');
ok(vm.cats.wrinkles.P === 23 && vm.cats.wrinkles.level === '预警', '皱纹P直接使用整体评分23,与最差分区77无关');
ok(approx(vm.cats.contour.P, 94.8), '轮廓P = min(眼袋100, 下颌线较差侧94.8) = 94.8');
ok(vm.cats.sens.P === 98, '敏感P = min(98, 100−0.1×1.5=99.85) = 98(面积 0.001 小数口径×100)');
ok(vm.cats.acne.P === 85, '痘痘P = min(分级映射85, 100−14.5=85.5) = 85');
@@ -977,9 +1003,10 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
var rk;
for (rk in r1) if (/_wrinkle_info$|_fold_info$/.test(rk)) r1[rk].wrinkle_score = 5; // 全部压低 → 自然 P=95
r1.right_nasolabial_fold_info.wrinkle_severity_level = 3;
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 74, '皱纹分区 severity=3 → 锁 74');
r1.score_info.wrinkle_score = 95;
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 95, '整体评分95不受分区 severity=3 限分');
r1.right_nasolabial_fold_info.wrinkle_severity_level = 2;
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 79, '皱纹分区 severity=2 → 锁 79');
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 95, '整体评分95不受分区 severity=2 限分');
var r1b = clone(SS.MOCK_RESULT);
r1b.score_info.pores_type_score.pores_forehead_severity = 2; // 自然 81.7
ok(SS.buildViewModel(r1b, SS.MOCK_SIDE, { realAge: 25 }).cats.smooth.P === 79, '毛孔分区 severity=2 → 光滑锁 79');
@@ -999,7 +1026,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
ok(vm3.skinAgeDisplay === 24 && vm3.ageClampNote === '', '±3 以内不钳制、不留痕');
r3.skin_age = { value: 40 };
var vm3b = SS.buildViewModel(r3, SS.MOCK_SIDE, { realAge: 25 });
ok(vm3b.skinAgeDisplay === 40 && vm3b.ageClampNote === '' && vm3b.skinAgeRaw === 40, '原始肌龄40不因实际年龄25而改变');
ok(vm3b.skinAgeDisplay === 30 && vm3b.ageClampNote !== '' && vm3b.skinAgeRaw === 40, '实际年龄25、原始肌龄40:展示30,原值不改变');
console.log('== T4 最差项不再覆盖肤龄推荐 ==');
var r4 = clone(SS.MOCK_RESULT);
@@ -1180,6 +1207,10 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
} catch (e) {}
if (!raw) { console.log(' - 跳过(未找到 reference ref_3view_raw.json)'); return; }
var d = JSON.parse(raw);
// Legacy reference assertions isolate unrelated calculations/copy. The
// new scalar source/rule is covered by test/wrinkle-overall.js.
d.result.score_info.wrinkle_score = 23;
d.result.score_info.dark_circle_score = d.result.score_info.melanin_score - ((d.result.melasma || {}).value || 0) * 10 - ((d.result.freckle || {}).value || 0) * 5;
var vm = SS.buildViewModel(d.result, { left: d.left_side_result, right: d.right_side_result }, { realAge: 30 });
ok(approx(vm.cats.contour.P, 75.5) && vm.cats.contour.S === 9.27 && vm.cats.contour.level === '预警',
'轮廓 P=75.5 / S=9.27 / 预警(min(眼袋1级→90, 下颌线较差侧103.75°→75.5))');
+19 -13
View File
@@ -26,6 +26,7 @@ const DATA_DIR = path.join(DATA_ROOT, 'results'); // 每次检测的原始返
const PHOTOS_DIR = path.join(DATA_ROOT, 'photos'); // 检测照片:data/photos/<record_id>/{front,left,right}.jpg
const RECORDS_FILE = path.join(DATA_ROOT, 'records.jsonl'); // 检测记录(一行一条 JSON)
const ReportUI = require('./js/report-ui.js');
const BeijingTime = require('./js/beijing-time.js');
const CaptureDiagnostics = require('./lib/capture-diagnostics.js');
const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts'));
@@ -282,6 +283,9 @@ function fillSuccess(rec, fpJson, persist = true) {
rec.presentation = ReportUI.build(vm, fpJson.result);
rec.total_score = vm.total;
rec.total_score_rule = vm.totalRule;
rec.wrinkle_score_rule = vm.wrinkleScoreRule;
rec.acne_scoring = vm.cats.acne.vars.acne_scoring;
rec.tone_scoring = vm.cats.tone.vars.tone_scoring;
rec.jawline_angle_l = jawlineAngle(fpJson.left_side_result, 'left');
rec.jawline_angle_r = jawlineAngle(fpJson.right_side_result, 'right');
rec.cats = {}; rec.copy_6 = {};
@@ -516,7 +520,7 @@ function photoUrls(rec) {
/* 列表用的字段子集(不含 raw_json 与文案全文) */
function displaySkinAge(rec) {
// Match H5 even for old records; do not rewrite historical files.
return SkinScore.reportSkinAge({ skinAgeRaw: rec.skin_age });
return SkinScore.reportSkinAge({ skinAgeRaw: rec.skin_age, realAge: rec.real_age });
}
function displayTotalScore(rec) {
return SkinScore.weightedTotal(rec.dims);
@@ -540,12 +544,11 @@ function listItem(rec) {
};
}
/* 查询参数过滤。from/to 支持 YYYY-MM-DD 或完整 ISO;纯日期 to 含当天全天 */
/* Dates mean Beijing calendar days; fully zoned ISO timestamps retain their instant. */
function matchQuery(rec, q) {
if (q.from && rec.created_at < q.from) return false;
if (q.to) {
const to = q.to.length === 10 ? q.to + 'T23:59:59.999Z' : q.to;
if (rec.created_at > to) return false;
if (q.timeRange && (q.from || q.to)) {
const timestamp = BeijingTime.milliseconds(rec.created_at);
if (!Number.isFinite(timestamp) || timestamp < q.timeRange.start || timestamp >= q.timeRange.end) return false;
}
if (q.detect_status && rec.detect_status !== q.detect_status) return false;
if (q.fail_code && rec.fail_code !== q.fail_code) return false;
@@ -582,6 +585,8 @@ function handleRecordsQuery(req, res, url) {
if ((q.age_min != null && !Number.isFinite(q.age_min)) || (q.age_max != null && !Number.isFinite(q.age_max))) {
return sendJson(res, 400, { ok: false, message: 'age_min/age_max 必须是数字' });
}
try { q.timeRange = BeijingTime.range(q.from, q.to); }
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
const page = Math.max(1, parseInt(sp.get('page') || '1', 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(sp.get('page_size') || '20', 10) || 20));
const filtered = readAllRecords().filter(r => matchQuery(r, q))
@@ -660,7 +665,7 @@ function handlePhoto(req, res, id, which) {
function handleStats(req, res) {
const all = readAllRecords();
const today = nowIso().slice(0, 10);
const now = Date.now(), today = BeijingTime.day(now);
const finished = all.filter(r => r.detect_status === '成功' || r.detect_status === '失败');
const succeeded = finished.filter(r => r.detect_status === '成功');
const failed = finished.filter(r => r.detect_status === '失败');
@@ -691,9 +696,8 @@ function handleStats(req, res) {
// 近 14 天每日检测量/成功数(含今天,今天在后)
const daily = [];
for (let i = 13; i >= 0; i--) {
const d = new Date(Date.now() - i * 86400000);
const day = d.toISOString().slice(0, 10);
const recs = all.filter(r => (r.created_at || '').slice(0, 10) === day);
const day = BeijingTime.day(now - i * 86400000);
const recs = all.filter(r => BeijingTime.day(r.created_at) === day);
daily.push({
date: day,
total: recs.length,
@@ -703,7 +707,7 @@ function handleStats(req, res) {
sendJson(res, 200, {
ok: true,
today_total: all.filter(r => (r.created_at || '').slice(0, 10) === today).length,
today_total: all.filter(r => BeijingTime.day(r.created_at) === today).length,
total: all.length,
finished_total: finished.length,
success_total: succeeded.length,
@@ -772,19 +776,21 @@ function handleExport(req, res, url) {
from: url.searchParams.get('from') || null,
to: url.searchParams.get('to') || null
};
try { q.timeRange = BeijingTime.range(q.from, q.to); }
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
const filtered = readAllRecords().filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
const dimP = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].P : null;
const dimS = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].S : null;
const dimLv = (r, d) => (r.dims && r.dims[d]) ? ReportUI.levelMap[r.dims[d].level] : null;
const header = ['记录ID', '创建时间', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
const header = ['记录ID', '创建时间(北京时间)', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
'失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄(推荐依据)', '前端展示肌龄',
'紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档',
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本'];
const lines = [header.join(',')];
for (const r of filtered) {
lines.push([
r.record_id, r.created_at, r.real_age, r.gender, r.region, r.daily_skin_type,
r.record_id, BeijingTime.format(r.created_at), r.real_age, r.gender, r.region, r.daily_skin_type,
r.shoot_status, r.upload_status, r.detect_status,
r.fail_stage, r.fail_code, r.fail_msg, displayTotalScore(r), r.skin_age, displaySkinAge(r),
dimP(r, 'firm'), dimS(r, 'firm'), dimLv(r, 'firm'),
+37
View File
@@ -0,0 +1,37 @@
'use strict';
const assert = require('node:assert/strict');
const SS = require('../js/skin-score'), UI = require('../js/report-ui');
const keys = ['closed_comedones','acne','acne_pustule','acne_nodule','acne_mark'];
const node = counts => Object.fromEntries(keys.map((key,i)=>[key,counts[i]===null?{}:{count:counts[i]}]));
function run(front,left,right,score=100){
const r=structuredClone(SS.MOCK_RESULT);Object.assign(r,node(front));r.score_info.acne_score=score;
const sides={left:{left_acne_info:{...node(left),score:0}},right:{right_acne_info:{...node(right),score:0}}};
const before=JSON.stringify({r,sides});const vm=SS.buildViewModel(r,sides,{});
assert.equal(JSON.stringify({r,sides}),before,'raw inputs unchanged');
return {vm,cat:vm.cats.acne,view:UI.build(vm,r).views.find(v=>v.key==='acne')};
}
const result=run([2,0,0,0,3],[5,1,0,0,1],[3,1,0,0,4]);
assert.deepEqual(Object.values(result.cat.vars.acne_counts),[5,1,0,0,4]);
assert.equal(result.cat.vars.acne_scoring.deduction,13);assert.equal(result.cat.P,87);
assert.equal(result.cat.vars.acne_cnt,10);assert.equal(result.cat.vars.acne_detail,'闭口粉刺5处、丘疹1处、痘印4处');
assert.deepEqual(result.cat.vars.acne_scoring.types[1].sources,['left','right']);
assert.equal(result.vm.dims.stab.S,9.61);assert(result.view.text.includes('丘疹1处'));
assert.equal(run([2,0,0,0,3],[5,1,0,0,1],[3,1,0,0,4],80).cat.P,70,'front score mapping still limits score');
const heavy=run([0,0,0,0,0],[0,0,1,0,0],[0,0,0,1,0]);
assert.equal(heavy.cat.P,90);assert.equal(heavy.view.level,'改善');
assert(heavy.view.text.includes('脓包1处')&&heavy.view.text.includes('结节1处'));
assert(heavy.view.text.includes('含脓包、结节'));
const empty=run([null,null,null,null,null],[null,null,null,null,null],[null,null,null,null,null]);
assert.equal(empty.cat.P,100);assert.equal(empty.cat.vars.acne_cnt,null);assert(empty.view.text.includes('未获取完整'));
assert.equal(empty.cat.vars.acne_scoring.deduction,null);
const partial=run([null,0,null,null,null],[null,null,null,null,null],[null,null,null,null,null]);
assert.equal(partial.cat.vars.acne_counts_complete,false);assert(partial.view.text.includes('部分类型数据缺失'));
assert.equal(run([null,null,null,null,null],[null,null,null,null,null],[null,null,null,null,null],null).cat.P,null);
const capped=run([100,100,100,100,100],[0,0,0,0,0],[0,0,0,0,0]);
assert.equal(capped.cat.P,50);assert.equal(capped.cat.vars.acne_scoring.raw_deduction,1450);assert.equal(capped.cat.vars.acne_scoring.deduction,50);
const r=structuredClone(SS.MOCK_RESULT);r.acne={rectangle:[{},{}]};
let vm=SS.buildViewModel(r,null,{});assert.equal(vm.cats.acne.vars.acne_counts.acne,2,'rectangle fallback');
for(const n of [-1,1.5,NaN,Infinity]){r.acne={count:n};vm=SS.buildViewModel(r,null,{});assert.equal(vm.cats.acne.vars.acne_counts.acne,null);}
const zero=run([0,0,0,0,0],[null,null,null,null,null],[null,null,null,null,null]);
assert.equal(zero.cat.P,100);assert.equal(zero.cat.vars.acne_counts_complete,true);assert(zero.view.text.includes('未检出明显皮损'));
console.log('PASS acne maxima per type, no sum/view selection, front-score mapping, heavy side lesions/copy, missing vs zero, cap50, source audit, invalid counts and unchanged inputs');
+64
View File
@@ -0,0 +1,64 @@
// Seeded temporary records only; no real account/report reads, writes or paid calls.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn, execSync } from 'node:child_process';
import { once } from 'node:events';
import { fileURLToPath, pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding:'utf8' }).trim() + '/playwright/index.mjs').href);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-beijing-test-'));
const times = ['2026-10-13T15:59:59.999Z', '2026-10-13T16:00:00.000Z', '2026-10-14T06:34:42.839Z', '2026-10-14T15:59:59.999Z', '2026-10-14T16:00:00.000Z', '2026-10-12T16:00:00.000Z'];
const records = times.map((created_at, i) => ({ record_id:'R20261014-TIME' + i, created_at, updated_at:created_at, detect_status:'成功',
dims:{ firm:{ S:8 }, vita:{ S:9 }, stab:{ S:8.5 } }, cats:{}, total_score:85, photos:{}, notes:[] }));
const file = path.join(root, 'records.jsonl');
const stored = records.map(r => JSON.stringify(r)).join('\n') + '\n'; fs.writeFileSync(file, stored);
const child = spawn(process.execPath, ['-e', "Date.now=()=>Date.parse('2026-10-13T16:30:00.000Z');require('./server.js')"], {
cwd:fileURLToPath(new URL('../', import.meta.url)),
env:{ ...process.env, TZ:'America/New_York', PORT:'18117', HOST:'127.0.0.1', JUZI_DATA_ROOT:root,
SKIN_API_KEY:'fixture', SKIN_API_SECRET:'fixture', PUBLIC_BASE_URL:'', WECHAT_APP_ID:'', WECHAT_APP_SECRET:'' },
stdio:['ignore','pipe','pipe']
});
let browser;
try {
await new Promise((resolve,reject) => {
const timer=setTimeout(()=>reject(Error('startup timeout')),10000);
child.stdout.on('data', b=>{ if(b.toString().includes('已启动')){clearTimeout(timer);resolve();} });
child.once('exit',code=>{clearTimeout(timer);reject(Error('server exited '+code));});
});
const base='http://127.0.0.1:18117/';
const read=async route=>{const r=await fetch(base+route);assert.equal(r.status,200);return r.json();};
const filtered=await read('api/admin/records?from=2026-10-14&to=2026-10-14');
assert.deepEqual(filtered.items.map(r=>r.record_id), [3,2,1].map(i=>records[i].record_id));
const early=await read('api/admin/records?to=2026-10-13');assert.equal(early.total,2);
const late=await read('api/admin/records?from=2026-10-15');assert.equal(late.total,1);
const exact=new URLSearchParams({from:times[2],to:'2026-10-14T14:34:42.839+08:00'});
assert.equal((await read('api/admin/records?'+exact)).total,1);
for(const route of ['api/admin/records?from=2026-02-30','api/admin/export?to=bad']) assert.equal((await fetch(base+route)).status,400);
const stats=await read('api/admin/stats');assert.equal(stats.today_total,3,'today is October14 in Beijing, not UTC October13');
assert.equal(stats.daily.at(-1).date,'2026-10-14');assert.equal(stats.daily.at(-1).total,3);assert.equal(stats.daily.at(-2).total,2);
const csv=await(await fetch(base+'api/admin/export?from=2026-10-14&to=2026-10-14')).text();
assert(csv.includes('创建时间(北京时间)'));assert(csv.includes('2026-10-14 00:00:00'));assert(csv.includes('2026-10-14 14:34:42'));assert(csv.includes('2026-10-14 23:59:59'));
assert(!csv.includes(records[0].record_id));assert(!csv.includes(records[4].record_id));
browser=await chromium.launch();const errors=[];
for(const timezoneId of ['America/Los_Angeles','UTC','Asia/Shanghai']){
const context=await browser.newContext({viewport:{width:1600,height:1000},timezoneId});
const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
await page.route('**/*',route=>{assert.equal(route.request().method(),'GET');return route.continue();});
await page.goto(base+'admin.html');await page.waitForSelector('#tbl-records tbody tr');
const row=page.locator('#tbl-records tbody tr').filter({hasText:records[2].record_id});
assert((await row.textContent()).includes('2026-10-14 14:34:42'));
await page.fill('#f-from','2026-10-14');await page.fill('#f-to','2026-10-14');
const response=page.waitForResponse(r=>r.url().includes('/api/admin/records?'));
await page.click('#btn-search');assert.equal((await(await response).json()).total,3);
await row.click();await page.waitForFunction(()=>document.querySelector('#d-body').textContent.includes('创建时间'));
const body=await page.locator('#d-body').textContent();assert(body.includes('创建时间 2026-10-14 14:34:42'));assert(body.includes('更新时间 2026-10-14 14:34:42'));
if(timezoneId==='Asia/Shanghai')await page.screenshot({path:'/tmp/juzi-admin-beijing-20261014.png'});
await context.close();
}
assert.deepEqual(errors,[]);assert.equal(fs.readFileSync(file,'utf8'),stored,'original UTC records remain byte-identical');
console.log('PASS Beijing admin: list/detail across browser timezones; date/ISO filters, CSV, midnight today/daily stats, invalid dates; UTC records unchanged');
}finally{
if(browser)await browser.close();child.kill('SIGTERM');if(child.exitCode===null)await once(child,'exit');
fs.rmSync(root,{recursive:true,force:true});
}
+18
View File
@@ -0,0 +1,18 @@
'use strict';
const assert = require('node:assert/strict');
const Time = require('../js/beijing-time');
assert.equal(Time.format('2026-10-14T06:34:42.839Z'), '2026-10-14 14:34:42');
assert.equal(Time.format('2026-10-14T14:34:42.839+08:00'), '2026-10-14 14:34:42');
assert.equal(Time.format('2026-10-14 14:34:42'), '2026-10-14 14:34:42');
assert.equal(Time.day('2026-10-13T15:59:59.999Z'), '2026-10-13');
assert.equal(Time.day('2026-10-13T16:00:00.000Z'), '2026-10-14');
assert.equal(Time.format(null), '-'); assert.equal(Time.format('bad'), '-');
const range = Time.range('2026-10-14', '2026-10-14');
assert.equal(new Date(range.start).toISOString(), '2026-10-13T16:00:00.000Z');
assert.equal(new Date(range.end).toISOString(), '2026-10-14T16:00:00.000Z');
assert.equal(Time.range('2024-02-29', '2024-02-29').end - Time.range('2024-02-29', '2024-02-29').start, 86400000);
for (const args of [['2026-02-29', null], ['2026-02-30', null], ['bad', null], ['2026-10-15', '2026-10-14']]) assert.throws(() => Time.range(...args));
const instant = Time.range('2026-10-14T06:34:42.839Z', '2026-10-14T14:34:42.839+08:00');
assert.equal(instant.end - instant.start, 1, 'full ISO end remains inclusive to the millisecond');
assert.deepEqual(Time.range(null, null), { start: -Infinity, end: Infinity });
console.log('PASS Beijing time: UTC/+08/zone-less formatting, midnight boundaries, leap day, invalid dates, ISO instants');
+35
View File
@@ -0,0 +1,35 @@
'use strict';
const assert=require('node:assert/strict');
const SS=require('../js/skin-score'),UI=require('../js/report-ui');
const descriptions=['无明显黑眼圈','仅存在轻度黑眼圈','存在中度黑眼圈','可见明显黑眼圈'];
const templates={
改善:'检测到当前肌肤色素分 88,存在色素沉着较显著,{note},整体透亮感不足。',
良好:'检测到当前肌肤色素分 88,局部有色素沉积;{note},整体状态稳定,局部透亮感稍弱。',
优秀:'检测到当前肌肤色素分 88,无明显色素沉积,{note};肤色均匀透亮。'
};
let checked=0;
for(const [P,level] of [[79.99,'改善'],[80,'良好'],[89.99,'良好'],[90,'优秀'],[100,'优秀']]){
for(const severity of [0,1,2,3,null,undefined,-1,4,1.5,'2',NaN,Infinity]){
const result=structuredClone(SS.MOCK_RESULT);
result.score_info.melanin_score=88;
// Opposite score/type must never dictate the severity phrase.
result.score_info.dark_circle_score=severity===3?100:0;
result.dark_circle={value:3};
if(severity===undefined)delete result.dark_circle_severity;
else result.dark_circle_severity={value:severity};
const vm=SS.buildViewModel(result,SS.MOCK_SIDE,{});
vm.cats.tone.P=P;vm.cats.tone.level=P<80?'预警':P<90?'改善':'良好';
const before=JSON.stringify({vm,result});
const presentation=UI.build(vm,result),view=presentation.views.find(v=>v.key==='color');
const valid=Number.isInteger(severity)&&severity>=0&&severity<=3;
const expected=valid?templates[level].replace('{note}',descriptions[severity]):templates[level].replace(/\{note\}[,;]/,'');
assert.equal(view.level,level);assert.equal(view.text,expected);
assert(!view.text.includes('评分'));assert(!view.text.includes('20%'));assert(!/[{}]/.test(view.text));
assert.equal(JSON.stringify({vm,result}),before,'presentation never mutates scores or input');
checked++;
}
}
const missing=structuredClone(SS.MOCK_RESULT);delete missing.score_info.melanin_score;
const v=UI.build(SS.buildViewModel(missing,SS.MOCK_SIDE,{}),missing).views.find(v=>v.key==='color');
assert.equal(v.valid,false);assert(!v.text.includes('色素分'));
console.log('PASS color copy: '+checked+' level/severity combinations, score-independent severity, missing/invalid omission, boundaries and immutable scoring');
+53
View File
@@ -0,0 +1,53 @@
// Controlled audio ended events; real capture function, no camera/API writes.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {execSync} from 'node:child_process';
import {fileURLToPath,pathToFileURL} from 'node:url';
const {chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href);
const browser=await chromium.launch();
try {
const page=await browser.newPage();const errors=[];
page.on('pageerror',e=>errors.push(e.message));
const source=fs.readFileSync(fileURLToPath(new URL('../js/app.js',import.meta.url)),'utf8');
await page.route('**/api/**',r=>{assert.equal(r.request().method(),'GET');return r.fulfill({json:{ok:true,user:{kind:'browser'}}});});
await page.route('**/assets/voice/*.mp3',r=>r.fulfill({body:r.request().url().split('/').pop()}));
await page.route('**/js/app.js*',r=>r.fulfill({contentType:'text/javascript',body:source.replace(/\}\)\(\);\s*$/,`
window.__countdown = {
start: function(step) { camStep=step; currentScreen='s-cam'; camLive=true; shutterLock=false;
captureFromVideo=function(){window.__shots.push(step);return 'fixture';};
checkStill=function(){return Promise.resolve(null);};
onPhotoCaptured=function(){window.__completed.push(step);}; doCapture(); },
again:doCapture, cancel:function(){camSeq++; voice.stop(true);},
busy:function(){return shutterLock;}
};
})();`)}));
await page.addInitScript(()=>{
window.__audio=[];window.__shots=[];window.__completed=[];
window.AudioContext=class {
constructor(){this.state='running';this.destination={};}
decodeAudioData(ab,resolve){const b={id:new TextDecoder().decode(ab),duration:3};resolve(b);return Promise.resolve(b);}
createBufferSource(){return {connect(){},stop(){},start(){window.__audio.push(this);}};}
};
});
await page.goto(process.env.H5_BASE_URL||'http://127.0.0.1:8080/');
await page.locator('body').click({position:{x:2,y:2}});
await page.waitForTimeout(300);
for(let step=0;step<3;step++){
await page.evaluate(n=>window.__countdown.start(n),step);
await page.waitForFunction(n=>window.__audio.length===n,step+1);
assert.equal(await page.evaluate(()=>window.__audio.at(-1).buffer.id),'countdown-321.mp3');
await page.evaluate(()=>window.__countdown.again());
await page.waitForTimeout(350);
assert.equal(await page.evaluate(()=>window.__shots.length),step,'no capture before audio ended');
assert(await page.evaluate(()=>window.__countdown.busy()));
await page.evaluate(()=>window.__audio.at(-1).onended());
await page.waitForFunction(n=>window.__completed.length===n,step+1);
}
await page.evaluate(()=>window.__countdown.start(0));
await page.waitForFunction(()=>window.__audio.length===4);
await page.evaluate(()=>window.__countdown.cancel());
await page.waitForTimeout(700);
assert.equal(await page.evaluate(()=>window.__shots.length),3,'cancelled countdown never captures');
assert.deepEqual(errors,[]);
console.log('PASS: each of 3 shots waits for countdown ended; duplicate shutter ignored; cancellation safe');
}finally{await browser.close();}
+44
View File
@@ -0,0 +1,44 @@
// Explicit private case; render only, no backend mutations or paid API calls.
import assert from 'node:assert/strict';
import fs from 'node:fs';import path from 'node:path';
import {execSync} from 'node:child_process';
import {fileURLToPath,pathToFileURL} from 'node:url';
import {createRequire} from 'node:module';
const require=createRequire(import.meta.url),SS=require('../js/skin-score');
const dir=process.env.H5_PIGMENTATION_CASE_DIR;assert(dir,'set private case directory');
const raw=JSON.parse(fs.readFileSync(path.join(dir,'raw.json')));
const photo='data:image/jpeg;base64,'+fs.readFileSync(path.join(dir,'front.jpg')).toString('base64');
const vm=SS.buildViewModel(raw.result,{left:raw.left_side_result,right:raw.right_side_result},{realAge:24,skinType:'混合偏油肌肤'});
assert.equal(raw.result.brown_spot.count,81);assert.equal(raw.result.freckle.value,0);
const audit=vm.cats.tone.vars.tone_scoring;
assert(Math.abs(audit.color_score-(audit.pigment_adjusted_score*.8+audit.dark_circle_score*.2))<1e-9);
const {chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href);
const browser=await chromium.launch();
try {
const page=await browser.newPage({viewport:{width:375,height:812},isMobile:true,hasTouch:true});const errors=[];
page.on('pageerror',e=>errors.push(e.message));
await page.route('**/api/**',r=>{assert.equal(r.request().method(),'GET');return r.fulfill({json:{ok:true,user:{kind:'browser'}}});});
const source=fs.readFileSync(fileURLToPath(new URL('../js/app.js',import.meta.url)),'utf8');
await page.route('**/js/app.js*',r=>r.fulfill({contentType:'text/javascript',body:source.replace(/\}\)\(\);\s*$/,`
window.__renderCase=function(x){renderReport(x.vm,x.photo,null,x.raw);showScreen('s-report');};
})();`)}));
await page.goto(process.env.H5_BASE_URL||'http://127.0.0.1:8080/');
await page.evaluate(x=>window.__renderCase(x),{vm,photo,raw});
await page.locator('#viz-tone').scrollIntoViewIfNeeded();
await page.waitForFunction(()=>{const i=document.querySelector('#viz-tone');return i.naturalWidth&&!i.hidden&&i.dataset.overlayAlpha==='0.3';});
assert.equal(await page.locator('#tone-detections').count(),0,'detection status overlay removed');
assert.equal(await page.locator('#tone-map-note').count(),0,'technical caption removed');
assert(vm.cats.tone.vars.spot_flag.includes('褐色斑81处'));
const dcDescriptions=['无明显黑眼圈','仅存在轻度黑眼圈','存在中度黑眼圈','可见明显黑眼圈'];
assert((await page.locator('#tx-tone').textContent()).includes(dcDescriptions[raw.result.dark_circle_severity.value]));
assert(!(await page.locator('#tx-tone').textContent()).includes('占肤色项20%'));
await page.locator('#viz-tone').locator('xpath=ancestor::div[contains(@class,"card-detail")][1]').screenshot({path:path.join(dir,'pigmentation-81-color.png')});
// Display-only example, explicitly synthetic; real downloaded case is unchanged.
const example=structuredClone(vm);example.skinAgeRaw=40;example.realAge=25;example.skinAgeDisplay=40;
await page.evaluate(x=>window.__renderCase(x),{vm:example,photo,raw});
assert.equal(await page.locator('#stat-age b').textContent(),'30');
assert.equal(await page.locator('#stat-age-note').textContent(),'比实际年龄大5岁');
assert.equal(example.skinAgeRaw,40);
assert.deepEqual(errors,[]);
console.log(JSON.stringify({case:'R20261014-155810-LFB8',brownSpots:81,freckle:0,audit,toneP:vm.cats.tone.P,toneS:vm.cats.tone.S,vita:vm.dims.vita.S,total:vm.total,tests:'PASS; render only; original case unchanged'}));
}finally{await browser.close();}
+12 -4
View File
@@ -7,16 +7,24 @@ for (const raw of [20, 24, 28, 40, 50, 24.6, null]) {
if (raw == null) delete result.skin_age; else result.skin_age = { value: raw };
const before = JSON.stringify(result);
const vm = SS.buildViewModel(result, SS.MOCK_SIDE, { realAge });
assert.equal(vm.skinAgeRaw, raw); assert.equal(vm.skinAgeDisplay, raw);
assert.equal(vm.ageClampNote, ''); assert.equal(SS.reportSkinAge(vm, result), raw);
const expected = raw == null || realAge == null ? raw : Math.max(realAge - 5, Math.min(realAge + 5, raw));
assert.equal(vm.skinAgeRaw, raw); assert.equal(vm.skinAgeDisplay, expected);
assert.equal(Boolean(vm.ageClampNote), expected != null && expected !== raw);
assert.equal(SS.reportSkinAge(vm, result), expected);
assert.equal(JSON.stringify(result), before);
}
}
const legacy = { skinAgeRaw: 24, skinAgeDisplay: 57, realAge: 60 };
assert.equal(SS.reportSkinAge(legacy), 24);
assert.equal(SS.reportSkinAge(legacy), 55);
assert.equal(legacy.skinAgeDisplay, 57, 'historical VM is not mutated');
assert.equal(SS.reportSkinAge({ skinAgeDisplay: 57 }, { skin_age: { value: 24 } }), 24);
assert.equal(SS.reportSkinAge({ skinAgeDisplay: 57 }), null, 'clamped display is never a raw-age fallback');
assert.equal(SS.reportSkinAge({ skinAgeRaw: null, skinAgeDisplay: 57 }), null);
assert.equal(SS.reportSkinAge({}, { skin_age: '24.6' }), 24.6);
console.log('PASS: raw age across actual-age/decimal/missing cases; old display ignored; legacy payload fallback; no mutation or invented age');
for (const [raw, expected] of [[24,25],[25,25],[30,30],[35,35],[36,35]]) {
assert.equal(SS.reportSkinAge({skinAgeRaw:raw,realAge:30}),expected);
}
assert.equal(SS.reportSkinAge({skinAgeRaw:40,realAge:25}),30);
assert.equal(SS.reportSkinAge({skinAgeRaw:10,realAge:25}),20);
for(const raw of [NaN,Infinity,-1]) assert.equal(SS.reportSkinAge({skinAgeRaw:raw,realAge:25}),null);
console.log('PASS: age display clamps ±5, boundaries/decimal/missing; original age and historical snapshots unchanged');
+1 -1
View File
@@ -54,7 +54,7 @@ for (const realAge of [24, 40, 60, undefined]) {
assert.equal(vm.recommendation.skinAge, 24);
assert(Math.abs(vm.recommendation.scores.firm - 7.69) < 1e-12);
assert.equal(UI.build(vm, raw).product.tier, 1);
assert.equal(vm.skinAgeDisplay, 24); // Display and recommendation both use raw skin age.
assert.equal(vm.skinAgeDisplay, realAge == null ? 24 : Math.max(realAge - 5, Math.min(realAge + 5, 24))); // Display clamp does not change raw-age recommendation.
count++;
}
delete raw.skin_age;
+13
View File
@@ -102,6 +102,19 @@ function main() {
for (var ci = 0; ci < cases.length; ci++) {
var tag = cases[ci].tag, fixture = cases[ci].fixture, tr = cases[ci].truth;
console.log('== ' + tag + '(realAge=' + tr.profile.realAge + ',skinType=' + tr.profile.skinType + ') ==');
// The old Excel assumes inverse worst-region wrinkles. Pin the new scalar
// input to its old P to isolate regressions elsewhere; do not edit fixtures.
// Actual overall-score behavior is independently tested in wrinkle-overall.js.
fixture.result.score_info.wrinkle_score = tr.cats.wrinkles.P;
// Pin dark-circle score to the existing pigment item to isolate old Excel
// assertions; independent tone-dark-circle tests exercise its new 20% weight.
fixture.result.score_info.dark_circle_score = fixture.result.score_info.melanin_score - (fixture.result.melasma?.value || 0) * 10 - (fixture.result.freckle?.value || 0) * 5;
if (tag === 'case2') {
// Authorized three-view change: left cheek has one closed comedone.
tr.cats.acne = { P: 99, S: 9.97, tier: '良好档', copy: '检出1处皮损(闭口粉刺1处)。建议维持清洁与控油节奏,注意作息与饮食。' };
tr.avgS = 9.53;
tr.summary = tr.summary.replace('六分类S均分9.54', '六分类S均分9.53');
}
var vm = SkinScore.buildViewModel(fixture.result, fixture.side, tr.profile);
// 下颌线角度 AP/AQ:fixture 保真校验(引擎消费角度后由 L 列逐字断言兜底)
+34 -8
View File
@@ -22,6 +22,13 @@ const child = spawn(process.execPath, ['-e', `
const fs=require('fs'), vm=require('vm'), w={};
vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w});
const f=w.MOCK_FIXTURE;
if(process.env.H5_ACNE_MAX_TEST==='1') {
const keys=['closed_comedones','acne','acne_pustule','acne_nodule','acne_mark'];
const set=(target,counts)=>keys.forEach((k,i)=>target[k]={count:counts[i]});
set(f.result,[2,0,0,0,3]);f.result.score_info.acne_score=100;
f.side.left.left_acne_info={score:0};set(f.side.left.left_acne_info,[5,1,0,0,1]);
f.side.right.right_acne_info={score:0};set(f.side.right.right_acne_info,[3,1,0,0,4]);
}
global.fetch=async()=>({ok:true,status:200,json:async()=>({result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right})});
require('./server.js');
`], { cwd: h5, env: { ...process.env, PORT: '18089', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
@@ -50,7 +57,8 @@ try {
assert.equal(result.vm.total, expectedTotal);
assert.notEqual(expectedTotal, result.result.score_info.total_score, 'fixture distinguishes weighted score from API total');
assert(result.vm.skinAgeRaw < 32);
assert.equal(result.vm.skinAgeDisplay, result.vm.skinAgeRaw);
const expectedAge = Math.max(55, Math.min(65, result.vm.skinAgeRaw));
assert.equal(result.vm.skinAgeDisplay, expectedAge);
assert.equal(result.vm.essence.tier, 1); // Raw age, never actual60.
assert.equal(result.presentation.product.tier, 1);
const adminRecord = (await (await api.get(base + 'api/admin/records/' + result.record_id)).json()).record;
@@ -60,16 +68,23 @@ try {
assert.equal(adminRecord.skin_age_display, result.vm.skinAgeDisplay);
assert.equal(adminRecord.total_score, expectedTotal);
assert.equal(adminRecord.total_score_rule, 'dimensions-433-v1');
assert.deepEqual(adminRecord.acne_scoring,result.vm.cats.acne.vars.acne_scoring);
assert.deepEqual(adminRecord.tone_scoring,result.vm.cats.tone.vars.tone_scoring);
if(process.env.H5_ACNE_MAX_TEST==='1') {
assert.deepEqual(result.vm.cats.acne.vars.acne_counts,{closed_comedones:5,acne:1,acne_pustule:0,acne_nodule:0,acne_mark:4});
assert.equal(adminRecord.acne_scoring.deduction,13);assert.equal(adminRecord.cats.acne.P,87);
}
for (const key of ['firm', 'vita', 'stab']) assert.equal(adminRecord.dims[key].S, result.vm.dims[key].S);
const list = await (await api.get(base + 'api/admin/records')).json();
assert.equal(list.items[0].skin_age, result.vm.skinAgeRaw);
assert.equal(list.items[0].skin_age_display, result.vm.skinAgeRaw);
assert.equal(list.items[0].skin_age_display, expectedAge);
assert.deepEqual(list.items[0].recommendation, result.vm.recommendation);
assert.equal(list.items[0].total_score, expectedTotal);
const csv = await (await api.get(base + 'api/admin/export')).text();
assert(csv.includes('原始肌龄(推荐依据),前端展示肌龄'));
assert(csv.includes(result.vm.recommendation.version));
assert(csv.includes('综合得分(4:3:3)'));
assert.equal(csv.trim().split('\n')[1].split(',')[14],String(expectedAge));
assert.equal(csv.trim().split('\n')[1].split(',')[12], String(expectedTotal));
const snapshot = await (await api.get(base + 'api/reports/' + result.record_id)).json();
assert.equal(snapshot.report.photos.length, 3);
@@ -85,10 +100,10 @@ try {
await row.waitFor();
assert.equal(await row.locator('td').nth(6).textContent(), String(expectedTotal));
assert((await row.textContent()).includes('原始 ' + result.vm.skinAgeRaw + '岁'));
assert((await row.textContent()).includes('展示 ' + result.vm.skinAgeRaw + '岁'));
assert((await row.textContent()).includes('展示 ' + expectedAge + '岁'));
assert((await row.textContent()).includes("1'me 主场精华 I(25+)"));
for (const key of ['firm', 'vita', 'stab']) {
assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + result.vm.dims[key].S.toFixed(1) + ' ' + ReportUI.levelMap[result.vm.dims[key].level]));
assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + ReportUI.dimensionScore(result.vm.dims[key].S) + ' ' + ReportUI.levelMap[result.vm.dims[key].level]));
}
assert.deepEqual(await desktop.locator('#f-dimlevel option').allTextContents(), ['不限档位', '改善', '良好', '优秀']);
if (process.env.H5_ARTIFACT_DIR) {
@@ -105,9 +120,16 @@ try {
const detailText = await desktop.locator('#d-body').textContent();
assert(detailText.includes('综合得分 ' + expectedTotal));
assert(detailText.includes('紧塑力×4+透亮感×3+稳韧力×3'));
assert(detailText.includes('痘痘三视图计分明细'));
assert(detailText.includes('黑眼圈评分×20%'));
const acneAuditRows=desktop.locator('.d-sec').filter({hasText:'痘痘三视图计分明细'}).locator('tbody tr');
assert.equal(await acneAuditRows.count(),5);
for(const [i,t] of adminRecord.acne_scoring.types.entries()) {
assert.deepEqual(await acneAuditRows.nth(i).locator('td').allTextContents(),[t.name,...['front','left','right'].map(k=>t.views[k]==null?'-':String(t.views[k])),t.count==null?'-':String(t.count),String(t.weight),t.deduction==null?'-':String(t.deduction)]);
}
if (process.env.H5_ARTIFACT_DIR) await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-weighted-detail.png') });
assert(detailText.includes(result.vm.recommendation.reason));
assert(detailText.includes('前端展示肌龄 ' + result.vm.skinAgeRaw + '岁'));
assert(detailText.includes('前端展示肌龄 ' + expectedAge + '岁'));
for (const view of result.presentation.views) assert(detailText.includes(view.text));
for (const care of result.presentation.care) assert(detailText.includes(care.text));
if (process.env.H5_ARTIFACT_DIR) {
@@ -181,13 +203,17 @@ try {
await page.click('[data-action="history"]');
await page.click('[data-report-id]');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#stat-age b').textContent(), String(result.vm.skinAgeRaw), 'old saved display clamp must not override original skin age');
assert.equal(await page.locator('#stat-age b').textContent(), String(expectedAge), 'historical raw age uses current ±5 display rule, not saved display');
assert.equal(await page.locator('#stat-score b').textContent(), String(expectedTotal), 'old H5 report uses saved dimensions, not saved upstream total');
const legacyList = await (await api.get(base + 'api/admin/records')).json();
assert.equal(legacyList.items[0].skin_age_display, expectedAge);
assert.equal(legacyList.items[0].total_score, expectedTotal);
const legacyDetail = await (await api.get(base + 'api/admin/records/' + result.record_id)).json();
assert.equal(legacyDetail.record.skin_age_display, expectedAge);
assert.equal(legacyDetail.record.total_score, expectedTotal);
assert.equal((await (await api.get(base + 'api/admin/export')).text()).trim().split('\n')[1].split(',')[12], String(expectedTotal));
const legacyCsv=(await (await api.get(base + 'api/admin/export')).text()).trim().split('\n')[1].split(',');
assert.equal(legacyCsv[12], String(expectedTotal));
assert.equal(legacyCsv[14], String(expectedAge));
assert.equal(await page.locator('#product-title').textContent(), ReportUI.products[1].title);
assert.equal(await page.locator('#product-image').getAttribute('src'), ReportUI.products[1].image, 'refresh artwork crop, never the historical recommendation/copy');
assert((await page.locator('.ditem-para').allTextContents()).includes(legacy.presentation.views[0].text));
@@ -195,7 +221,7 @@ try {
await desktop.locator('#tbl-records tbody tr').first().click();
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('历史报告原文'));
assert((await desktop.locator('#d-body').textContent()).includes('旧版规则(历史记录)'));
assert((await desktop.locator('#d-body').textContent()).includes('前端展示肌龄 ' + result.vm.skinAgeRaw + '岁'));
assert((await desktop.locator('#d-body').textContent()).includes('前端展示肌龄 ' + expectedAge + '岁'));
assert.equal(fs.readFileSync(snapshotFile, 'utf8'), storedBytes);
assert.equal(fs.readFileSync(recordFile, 'utf8'), recordBytes);
// An incomplete set must show no total everywhere, not fall back to the API score.
+20
View File
@@ -0,0 +1,20 @@
'use strict';
const assert=require('node:assert/strict');const SS=require('../js/skin-score'),UI=require('../js/report-ui');
const near=(a,b)=>assert(Math.abs(a-b)<1e-9, a+' !== '+b);
function run(dc,options={}){
const r=structuredClone(SS.MOCK_RESULT);Object.assign(r.score_info,{melanin_score:90,water_score:80,dark_circle_score:dc});
r.melasma={value:1};r.freckle={value:1};r.water={water_severity:20};
if(options.missingPigment)delete r.score_info.melanin_score;
if(options.missingWater)delete r.score_info.water_score;
const bytes=JSON.stringify(r),vm=SS.buildViewModel(r,SS.MOCK_SIDE,{});assert.equal(JSON.stringify(r),bytes);
return{vm,r,v:vm.cats.tone.vars};
}
const a=run(50);near(a.v.color_score,75*.8+50*.2);near(a.vm.cats.tone.P,75);
assert.equal(a.vm.cats.tone.S,9.25);assert.equal(a.vm.dims.vita.S,9.25);
assert(!UI.build(a.vm,a.r).views.find(v=>v.key==='color').text.includes('占肤色项20%'));
for(const dc of [0,100]){const x=run(dc);near(x.v.color_score,75*.8+dc*.2);near(x.vm.cats.tone.P,(x.v.color_score+80)/2);}
for(const dc of [null,undefined,-1,101,NaN,Infinity]){const x=run(dc);assert.equal(x.v.dark_circle_score,null);near(x.v.color_score,75);near(x.vm.cats.tone.P,77.5);}
near(run(50,{missingPigment:true}).vm.cats.tone.P,65);
near(run(50,{missingWater:true}).vm.cats.tone.P,70);
assert.equal(a.v.tone_scoring.dark_circle_weight,.2);assert.equal(a.v.tone_scoring.pigment_weight,.8);
console.log('PASS tone: pigment80/dark-circle20, color/water50:50, spot deductions before weighting, zero/missing/invalid scores, audit, copy and immutable source');
+26
View File
@@ -0,0 +1,26 @@
// Solid-color canvas fixtures verify actual blended pixels, not only CSS flags.
import assert from 'node:assert/strict';import {execSync}from'node:child_process';import{pathToFileURL}from'node:url';
const{chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href);
const browser=await chromium.launch();
try{
const page=await browser.newPage({viewport:{width:375,height:726}});const errors=[];let mode='map';
page.on('pageerror',e=>errors.push(e.message));
await page.route('**/*',r=>{assert.equal(r.request().method(),'GET');return r.continue();});
await page.route('**/assets/mock/report-fixture.js',r=>r.fulfill({contentType:'text/javascript',body:`
function solid(color){var c=document.createElement('canvas');c.width=100;c.height=100;var x=c.getContext('2d');x.fillStyle=color;x.fillRect(0,0,100,100);return c.toDataURL('image/png');}
window.MOCK_FIXTURE={result:{score_info:{melanin_score:90,water_score:80,dark_circle_score:50,wrinkle_score:65},melasma:{value:1},freckle:{value:0},face_maps:${mode==='map'?"{brown_area:solid('#000')}" : '{}'}},side:{},photos:[solid('rgb(200,100,50)')]};
`}));
const base=process.env.H5_BASE_URL||'http://127.0.0.1:8080/';
const pixel=async()=>page.locator('#viz-tone').evaluate(async i=>{await i.decode();const c=document.createElement('canvas');c.width=i.naturalWidth;c.height=i.naturalHeight;const x=c.getContext('2d');x.drawImage(i,0,0);return [...x.getImageData(c.width/2,c.height/2,1,1).data].slice(0,3);});
await page.goto(base+'?mock=1&screen=report');
await page.waitForFunction(()=>{const i=document.querySelector('#viz-tone');return !i.hidden&&i.naturalWidth&&i.dataset.overlayAlpha==='0.3'});
const actual=await pixel();[140,70,35].forEach((v,i)=>assert(Math.abs(actual[i]-v)<=4,actual+' != 30% overlay'));
assert.equal(await page.locator('#viz-tone').evaluate(i=>getComputedStyle(i).opacity),'1','face image not globally faded');
assert.equal(await page.locator('#tone-detections').count(),0,'no detection status overlay');
assert.equal(await page.locator('#tone-map-note').count(),0,'no technical caption below color copy');
assert(!(await page.locator('#tx-tone').textContent()).includes('占肤色项20%'));
mode='missing';await page.reload();await page.waitForFunction(()=>{const i=document.querySelector('#viz-tone');return !i.hidden&&i.naturalWidth});
const plain=await pixel();[200,100,50].forEach((v,i)=>assert(Math.abs(plain[i]-v)<=4));
assert.equal(await page.locator('#tone-map-note').count(),0,'missing map does not add technical caption');
assert.deepEqual(errors,[]);console.log('PASS tone overlay: actual 30% pixel blending, original photo opaque, true flags/no invented boxes, missing map photo fallback, new dark-circle copy, no writes');
}finally{await browser.close()}
+53
View File
@@ -0,0 +1,53 @@
// Explicitly supplied private case directory; never check real photos/raw into Git.
import assert from 'node:assert/strict';
import fs from 'node:fs';import os from 'node:os';import path from 'node:path';
import {spawn,execSync} from 'node:child_process';import{once}from'node:events';
import{fileURLToPath,pathToFileURL}from'node:url';import{createRequire}from'node:module';
const require=createRequire(import.meta.url),SS=require('../js/skin-score'),UI=require('../js/report-ui');
const input=process.env.H5_WRINKLE_CASE_DIR;
assert(input,'set H5_WRINKLE_CASE_DIR to a private, explicitly downloaded case directory');
const raw=JSON.parse(fs.readFileSync(path.join(input,'raw.json')));
const profile={real_age:26,daily_skin_type:'干性肌肤'};
const projected=SS.buildViewModel(raw.result,{left:raw.left_side_result,right:raw.right_side_result},{realAge:26,skinType:profile.daily_skin_type});
assert.equal(raw.result.score_info.wrinkle_score,65);
assert.equal(projected.cats.wrinkles.P,65);assert.equal(projected.cats.wrinkles.S,8.95);
assert.equal(projected.dims.firm.S,8.95);assert.equal(projected.total,94);
assert.equal(projected.dims.vita.S,9.63);assert.equal(projected.dims.stab.S,9.91);
assert.equal(projected.cats.acne.vars.acne_counts.acne,1);
assert.equal(projected.cats.acne.vars.acne_counts.closed_comedones,1);
assert.equal(projected.cats.acne.vars.acne_scoring.deduction,3);
const root=fs.mkdtempSync(path.join(os.tmpdir(),'juzi-wrinkle-replay-'));
const user=require('./wechat-fixture.cjs')(path.join(root,'accounts'))[0];
const{chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href);
const child=spawn(process.execPath,['-e',"const fs=require('fs');const raw=JSON.parse(fs.readFileSync(process.env.H5_WRINKLE_CASE_DIR+'/raw.json'));global.fetch=async()=>({ok:true,status:200,json:async()=>raw});require('./server.js')"],{
cwd:fileURLToPath(new URL('../',import.meta.url)),env:{...process.env,PORT:'18118',HOST:'127.0.0.1',JUZI_DATA_ROOT:root,SKIN_API_KEY:'fixture',SKIN_API_SECRET:'fixture',PUBLIC_BASE_URL:'',WECHAT_APP_ID:'',WECHAT_APP_SECRET:''},stdio:['ignore','pipe','pipe']});
let browser;
try{
await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(Error('startup timeout')),10000);child.stdout.on('data',b=>{if(b.toString().includes('已启动')){clearTimeout(timer);resolve()}});child.once('exit',c=>{clearTimeout(timer);reject(Error('exited '+c))})});
const base='http://127.0.0.1:18118/';browser=await chromium.launch();const context=await browser.newContext({viewport:{width:375,height:726}});
await context.addCookies([{name:'juzi_session',value:user.token,url:base}]);
const images=Object.fromEntries(['front','left','right'].map(k=>[k+'_image','data:image/jpeg;base64,'+fs.readFileSync(path.join(input,k+'.jpg')).toString('base64')]));
const response=await context.request.post(base+'api/skin-analyze',{data:{...images,profile}});assert.equal(response.status(),200);const result=await response.json();
assert.equal(result.ok,true);assert.equal(result.vm.total,94);assert.equal(result.vm.dims.firm.S,8.95);
const admin=await(await context.request.get(base+'api/admin/records/'+result.record_id)).json();
assert.equal(admin.record.total_score,94);assert.equal(admin.record.cats.wrinkles.P,65);assert.equal(admin.record.dims.firm.S,8.95);assert.equal(admin.record.wrinkle_score_rule,'api-overall-v1');
assert.equal(admin.record.acne_scoring.types.find(t=>t.key==='acne').count,1);
const errors=[];const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
await page.goto(base);await page.click('[data-action="history"]');await page.click('[data-report-id]');await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#stat-score b').textContent(),'94');assert.equal(await page.locator('#dim-firm').textContent(),'9.0');
await page.waitForTimeout(500);await page.screenshot({path:path.join(input,'report-overall-wrinkles.png')});
await page.locator('#viz-tone').scrollIntoViewIfNeeded();
await page.waitForFunction(()=>{const i=document.querySelector('#viz-tone');return !i.hidden&&i.naturalWidth&&i.dataset.overlayAlpha==='0.3'});
assert(!(await page.locator('#tx-tone').textContent()).includes('占肤色项20%'));
assert.equal(await page.locator('#tone-detections').count(),0,'detection status overlay removed');
await page.locator('#viz-tone').locator('xpath=ancestor::div[contains(@class,"card-detail")][1]').screenshot({path:path.join(input,'tone-color-30.png')});
const desktop=await context.newPage({viewport:{width:1600,height:1000}});await desktop.setViewportSize({width:1600,height:1000});desktop.on('pageerror',e=>errors.push(e.message));
await desktop.goto(base+'admin.html');const row=desktop.locator('#tbl-records tbody tr').first();assert.equal(await row.locator('td').nth(6).textContent(),'94');
await row.click();await desktop.waitForFunction(()=>document.querySelector('#d-body').textContent.includes('综合得分'));
assert((await desktop.locator('#d-body').textContent()).includes('痘痘三视图计分明细'));
await desktop.screenshot({path:path.join(input,'admin-overall-wrinkles.png')});assert.deepEqual(errors,[]);
const auditSection=desktop.locator('.d-sec').filter({hasText:'痘痘三视图计分明细'});
await auditSection.scrollIntoViewIfNeeded();
await auditSection.screenshot({path:path.join(input,'admin-acne-three-view.png')});
console.log(JSON.stringify({case:'R20261014-171215-CYLF',interfaceWrinkles:65,wrinkles:8.95,firm:8.95,display:UI.dimensionScore(8.95),vita:9.63,stab:9.91,papules:1,closedComedones:1,countDeduction:3,total:94,tests:'PASS local replay only; no paid call/production writes'}));
}finally{if(browser)await browser.close();child.kill('SIGTERM');if(child.exitCode===null)await once(child,'exit');fs.rmSync(root,{recursive:true,force:true})}
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const assert = require('node:assert/strict');
const SS = require('../js/skin-score'), UI = require('../js/report-ui');
function sample(score) {
const r = structuredClone(SS.MOCK_RESULT);
r.score_info.wrinkle_score = score;
r.left_mouth_wrinkle_info = { wrinkle_score:100, wrinkle_severity_level:3 };
return r;
}
for (const p of [0, 23, 65, 80, 95, 100]) {
const r=sample(p), bytes=JSON.stringify(r), vm=SS.buildViewModel(r,SS.MOCK_SIDE,{});
assert.equal(vm.cats.wrinkles.P,p,'overall score used directly');
assert.equal(vm.cats.wrinkles.S,Math.round((7+3*p/100+1e-9)*100)/100);
assert.equal(vm.dims.firm.P,Math.min(vm.cats.contour.P,p));
assert.equal(vm.wrinkleScoreRule,'api-overall-v1');
assert.equal(vm.cats.wrinkles.vars.overall_wrinkle_score,p);
assert.equal(JSON.stringify(r),bytes,'raw data not mutated');
}
for (const score of [null, undefined, NaN, Infinity, -1, 101]) {
const r=sample(score);const vm=SS.buildViewModel(r,SS.MOCK_SIDE,{});
assert.equal(vm.cats.wrinkles.P,null,'no silent worst-region fallback');
}
const onlyOverall={ score_info:{ wrinkle_score:65 } };
assert.equal(SS.buildViewModel(onlyOverall,null,{}).cats.wrinkles.S,8.95,'regions not required');
assert.equal(SS.buildViewModel({wrinkle_score:{value:65}},null,{}).cats.wrinkles.P,65,'legacy scalar field alias');
const base=SS.buildViewModel(sample(65),SS.MOCK_SIDE,{});
const changed=sample(65);changed.left_mouth_wrinkle_info={wrinkle_score:0,wrinkle_severity_level:0};
assert.equal(SS.buildViewModel(changed,SS.MOCK_SIDE,{}).cats.wrinkles.P,base.cats.wrinkles.P,'region severity has no scoring effect');
assert.equal(UI.dimensionScore(8.95),'9.0');assert.equal(UI.dimensionScore(9.45),'9.5');
assert.equal(UI.dimensionScore(9.97),'10.0');assert.equal(UI.dimensionScore(null),'-');
console.log('PASS overall wrinkles: direct 0..100, no inversion/region caps, missing/invalid data, firm min, source immutability, half-up dimension display');