feat(h5): apply October 12 customer report feedback

This commit is contained in:
xsl
2026-10-12 13:15:09 +08:00
parent 3193a4b683
commit d7ae748e08
22 changed files with 2282 additions and 1027 deletions
+111 -23
View File
@@ -92,7 +92,12 @@
/* ─── 报告页状态重置(各维度卡回到第一个子 Tab、产品横滑归零) ─── */
function setDetailTab(card, idx) {
card.querySelectorAll('.dtab').forEach(function (t) { t.classList.toggle('active', t.dataset.tab === idx); });
card.querySelectorAll('.dtab').forEach(function (t) {
var selected = t.dataset.tab === idx;
t.classList.toggle('active', selected);
t.setAttribute('aria-selected', String(selected));
t.setAttribute('tabindex', selected ? '0' : '-1');
});
card.querySelectorAll('.dpanel').forEach(function (p) { p.classList.toggle('active', p.dataset.panel === idx); });
}
function resetReport() {
@@ -1026,11 +1031,7 @@
var VIZ_CAT = {
contour: { img: '#ditem-img1', photo: true }, // 轮廓:用户正脸实拍
wrinkles: { img: '#ditem-img2', overlay: 'texture_enhanced_lines', dim: 0.55 }, // 皱纹:正脸压暗+皱纹增强线(图层几乎全透明,直挂在白卡上不可读,故合成)
smooth: { img: '#viz-smooth', photo: true, marks: [ // 光滑:正脸+色斑/痣/痤疮标记
{ key: 'brown_spot', color: '#B26A00' }, // 色斑
{ key: 'mole', color: '#282828' }, // 痣
{ key: 'acne', color: '#D30C0C' } // 痤疮
] },
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,接口返回)
sens: { img: '#viz-sens', blendMap: 'red_area' }, // 敏感:正脸×红区图正片叠底(直挂白底过淡不可读)
@@ -1043,6 +1044,25 @@
};
/* viz-pure-begin(纯函数段:与 DOM 无关,node 冒烟按锚点抽离断言,勿改锚点注释) */
// Rectangle coordinates come from the original upload; crop only the rendered
// composite, never the photo sent to the algorithm or saved in the report.
function vizFaceCrop(width, height, rectangle) {
var full = { left: 0, top: 0, width: width, height: height, focused: false };
if (!rectangle) return full;
var keys = ['left', 'top', 'width', 'height'];
if (!keys.every(function (key) { return typeof rectangle[key] === 'number' && isFinite(rectangle[key]); })) return full;
if (rectangle.width <= 0 || rectangle.height <= 0 || rectangle.left < 0 || rectangle.top < 0 ||
rectangle.left + rectangle.width > width || rectangle.top + rectangle.height > height) return full;
var ratio = 215 / 278;
var ch = Math.min(Math.max(rectangle.height * 1.55, rectangle.width * 1.18 / ratio), height, width / ratio);
var cw = ch * ratio;
if (cw < rectangle.width || ch < rectangle.height) return full;
var left = Math.max(0, Math.min(width - cw, rectangle.left + rectangle.width / 2 - cw / 2));
var top = Math.max(0, Math.min(height - ch, rectangle.top - rectangle.height * .3));
// Never omit a part of the detected face in an edge-of-frame photograph.
if (top + ch < rectangle.top + rectangle.height) top = rectangle.top + rectangle.height - ch;
return { left: left, top: top, width: cw, height: ch, focused: true };
}
// 从 result 提取某分类的标记集合:polygon 与 rectangle 都保留,缺该标记类型则跳过
function vizCollectMarks(markDefs, result) {
var out = [];
@@ -1108,26 +1128,32 @@
img.src = url;
}
// canvas 合成:照片绘制到自然尺寸 → (可选压暗)→ (可选盖透明增强图层)→ 画坐标标记 → dataURL 挂到卡片 <img>
// canvas 合成:照片和图层使用同一人脸裁切区 → (可选压暗/增强)→ 坐标标记 → 展示图
var vizEpoch = 0;
function vizCompose(photoURL, overlayURL, marks, imgEl, opts) {
var epoch = vizEpoch;
vizLoadImage(photoURL, function (photo) {
if (!photo) return; // 照片不可用:保留占位
var finish = function (overlay) {
var finish = function (overlays) {
if (epoch !== vizEpoch) return;
var w = photo.naturalWidth, h = photo.naturalHeight;
var crop = vizFaceCrop(photo.naturalWidth, photo.naturalHeight, opts && opts.faceRectangle);
var scale = Math.min(1, 960 / crop.width);
var w = Math.round(crop.width * scale), h = Math.round(crop.height * scale);
var cv = document.createElement('canvas');
cv.width = w; cv.height = h;
var ctx = cv.getContext('2d');
ctx.drawImage(photo, 0, 0, w, h);
ctx.drawImage(photo, crop.left, crop.top, crop.width, crop.height, 0, 0, w, h);
if (opts && opts.dim) { ctx.fillStyle = 'rgba(0,0,0,' + opts.dim + ')'; ctx.fillRect(0, 0, w, h); } // 压暗底色让增强线可读
if (overlay) {
overlays.forEach(function (overlay) {
if (!overlay) return;
if (opts && opts.blend) ctx.globalCompositeOperation = opts.blend; // 正片叠底:白底图层(红区/色沉)叠到脸上
ctx.drawImage(overlay, 0, 0, w, h);
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';
}
var scaled = vizScaleMarks(marks, w / photo.naturalWidth);
});
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); // 过小标记放大到最小框,保证缩略可见
for (var i = 0; i < scaled.length; i++) {
@@ -1154,23 +1180,42 @@
ctx.strokeRect(cx - rw / 2, cy - rh / 2, rw, rh);
}
}
try { imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false; } catch (e) { /* 合成失败保留占位 */ }
ctx.restore();
try {
imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false;
imgEl.dataset.crop = crop.focused ? 'face' : 'full';
} catch (e) { /* 合成失败保留占位 */ }
};
if (overlayURL) vizLoadImage(overlayURL, finish);
else finish(null);
var urls = Array.isArray(overlayURL) ? overlayURL : overlayURL ? [overlayURL] : [];
Promise.all(urls.map(function (url) { return new Promise(function (resolve) { vizLoadImage(url, resolve); }); })).then(finish);
});
}
// 报告页 6 张检测图统一入口:任一输入缺失 → 该卡保留既有占位(不报错)
// 报告页 7 张检测图统一入口:缺失输入按分类降级(不报错)
function renderViz(payload, frontPhoto) {
vizEpoch++;
document.querySelectorAll('.ditem-img').forEach(function (img) { img.hidden = true; img.removeAttribute('src'); });
var result = payload && payload.result;
var maps = (result && result.face_maps) || {};
var rectangle = payload && payload.face_rectangle;
for (var key in VIZ_CAT) {
var cfg = VIZ_CAT[key];
var imgEl = $(cfg.img);
if (!imgEl) continue;
if (cfg.overlays) {
var mapNotes = [], overlays = [];
cfg.overlays.forEach(function (name) {
var url = vizMapURL(maps[name]);
if (!url) return;
overlays.push(url);
mapNotes.push(name === 'texture_enhanced_pores' ? '蓝色为毛孔增强标记' : '红色为黑头增强标记');
});
$('#smooth-map-note').textContent = overlays.length
? mapNotes.join(',') + ';标记不代表严重程度,具体以检测计数和结果为准。'
: '本次未获取毛孔、黑头增强图;此处仅展示拍摄照片,不绘制色斑、痣或痤疮框。';
if (frontPhoto) vizCompose(frontPhoto, overlays, [], imgEl, { faceRectangle: rectangle });
continue;
}
if (cfg.map) { // 整幅图层:图层缺失 → 保留占位
var u = vizMapURL(maps[cfg.map]);
if (u) { imgEl.src = u; imgEl.hidden = false; }
@@ -1180,21 +1225,20 @@
var bu = vizMapURL(maps[cfg.blendMap]);
if (!bu) continue;
if (!frontPhoto) { imgEl.src = bu; imgEl.hidden = false; continue; }
vizCompose(frontPhoto, bu, [], imgEl, { blend: 'multiply' });
vizCompose(frontPhoto, bu, [], imgEl, { blend: 'multiply', faceRectangle: rectangle });
continue;
}
if (cfg.overlay) { // 透明增强图层(皱纹):有照片则合成(可按 cfg.dim 压暗),无照片单挂图层;图层缺失 → 保留占位
var ov = vizMapURL(maps[cfg.overlay]);
if (!ov) continue;
if (!frontPhoto) { imgEl.src = ov; imgEl.hidden = false; continue; }
vizCompose(frontPhoto, ov, [], imgEl, { dim: cfg.dim });
vizCompose(frontPhoto, ov, [], imgEl, { dim: cfg.dim, faceRectangle: rectangle });
continue;
}
// 照片类(轮廓/光滑/痘痘):无照片 → 保留占位;有坐标则 canvas 叠加,无坐标纯照片
if (!frontPhoto) continue;
var marks = cfg.marks ? vizCollectMarks(cfg.marks, result) : [];
if (!marks.length) { imgEl.src = frontPhoto; imgEl.hidden = false; continue; }
vizCompose(frontPhoto, null, marks, imgEl);
vizCompose(frontPhoto, null, marks, imgEl, { faceRectangle: rectangle });
}
}
@@ -1255,7 +1299,11 @@
$('#product-unavailable').hidden = !!product;
if (product) {
$('#product-title').textContent = product.title;
$('#product-name').textContent = product.name + '双仓精华液';
$('#product-name').replaceChildren();
[product.name, '双仓精华液'].forEach(function (line) {
var span = document.createElement('span'); span.className = 'product-line'; span.textContent = line;
$('#product-name').appendChild(span);
});
$('#product-image').src = product.image;
$('#product-image').alt = product.name + '双仓精华液';
}
@@ -1296,7 +1344,47 @@
/* ─── 报告页:每张维度卡各自的子 Tab;点检测图放大,再点返回 ─── */
document.querySelectorAll('.card-detail').forEach(function (card) {
card.querySelector('.detail-tabs').setAttribute('role', 'tablist');
card.querySelectorAll('.dtab').forEach(function (tab) { tab.setAttribute('role', 'tab'); });
setDetailTab(card, '0');
var gesture = null, suppressImageClick = false;
card.addEventListener('pointerdown', function (e) {
if (e.isPrimary === false || (e.pointerType === 'mouse' && e.button !== 0)) return;
gesture = { id: e.pointerId, x: e.clientX, y: e.clientY, horizontal: false };
suppressImageClick = false;
});
card.addEventListener('pointermove', function (e) {
if (!gesture || gesture.id !== e.pointerId) return;
var dx = e.clientX - gesture.x, dy = e.clientY - gesture.y;
if (Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(dy) * 1.4) {
gesture.horizontal = true;
if (e.cancelable) e.preventDefault();
}
});
card.addEventListener('pointerup', function (e) {
if (!gesture || gesture.id !== e.pointerId) return;
var dx = e.clientX - gesture.x, dy = e.clientY - gesture.y;
if (gesture.horizontal && Math.abs(dx) >= 45 && Math.abs(dx) > Math.abs(dy) * 1.4) {
var tabs = Array.from(card.querySelectorAll('.dtab'));
var active = tabs.findIndex(function (tab) { return tab.classList.contains('active'); });
var next = Math.max(0, Math.min(tabs.length - 1, active + (dx < 0 ? 1 : -1)));
setDetailTab(card, tabs[next].dataset.tab);
suppressImageClick = true;
}
gesture = null;
});
card.addEventListener('pointercancel', function () { gesture = null; });
card.addEventListener('dragstart', function (e) { if (e.target.closest('.ditem-img')) e.preventDefault(); });
card.addEventListener('keydown', function (e) {
var tab = e.target.closest('.dtab');
if (!tab || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
e.preventDefault();
var tabs = Array.from(card.querySelectorAll('.dtab')), index = tabs.indexOf(tab);
index = e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : (index + (e.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
setDetailTab(card, tabs[index].dataset.tab); tabs[index].focus({ preventScroll: true });
});
card.addEventListener('click', function (e) {
if (suppressImageClick) { suppressImageClick = false; e.preventDefault(); return; }
var btn = e.target.closest('.dtab');
if (btn) { setDetailTab(card, btn.dataset.tab); return; }
var img = e.target.closest('.ditem-img');
+50 -3
View File
@@ -17,6 +17,47 @@
var levelMap = { '预警': '改善', '改善': '良好', '良好': '优秀' };
var dimNames = { firm: '紧塑力', vita: '透亮感', stab: '稳韧力' };
function numeric(value) { return typeof value === 'number' && isFinite(value); }
function scalar(value) { return numeric(value) ? value : value && numeric(value.value) ? value.value : null; }
var wrinkleDefs = [
['forehead', '抬头纹(额头纹)', ['forehead_wrinkle_info'], 'forehead_count'],
['left_nasolabial', '左法令纹', ['left_nasolabial_wrinkle_info', 'left_nasolabial_fold_info'], 'left_nasolabial_count'],
['right_nasolabial', '右法令纹', ['right_nasolabial_wrinkle_info', 'right_nasolabial_fold_info'], 'right_nasolabial_count'],
['left_crowsfeet', '左鱼尾纹', ['left_crowsfeet_wrinkle_info'], 'left_crowsfeet_count'],
['right_crowsfeet', '右鱼尾纹', ['right_crowsfeet_wrinkle_info'], 'right_crowsfeet_count'],
['left_eye', '左眼部细纹', ['left_eye_wrinkle_info'], 'left_undereye_count'],
['right_eye', '右眼部细纹', ['right_eye_wrinkle_info'], 'right_undereye_count'],
['left_mouth', '左嘴角纹', ['left_mouth_wrinkle_info'], 'left_mouth_count'],
['right_mouth', '右嘴角纹', ['right_mouth_wrinkle_info'], 'right_mouth_count'],
['glabella', '眉间纹', ['glabella_wrinkle_info'], 'glabella_count'],
['left_cheek', '左脸颊纹', ['left_cheek_wrinkle_info'], 'left_cheek_count'],
['right_cheek', '右脸颊纹', ['right_cheek_wrinkle_info'], 'right_cheek_count']
];
var wrinkleMetrics = [
['wrinkle_score', '皱纹分'], ['wrinkle_severity_level', '严重等级'],
['wrinkle_deep_num', '深纹条数'], ['wrinkle_shallow_num', '浅纹条数'],
['wrinkle_norm_length', '标准化长度'], ['wrinkle_norm_depth', '标准化深度'],
['wrinkle_pixel_density', '像素密度'], ['wrinkle_area_ratio', '面积比例'], ['wrinkle_deep_ratio', '深纹比例']
];
function wrinkleDetails(result) {
result = result || {};
return wrinkleDefs.map(function (def) {
var node = null;
def[2].some(function (key) { if (result[key] && typeof result[key] === 'object') { node = result[key]; return true; } return false; });
var values = {};
wrinkleMetrics.forEach(function (metric) { values[metric[0]] = scalar(node && node[metric[0]]); });
// Region counts are a valid fallback; a missing count is never zero.
if (values.wrinkle_deep_num == null) values.wrinkle_deep_num = scalar(result.wrinkle_count && result.wrinkle_count[def[3]]);
var fine = result.fine_line || result.fine_line_count;
if (values.wrinkle_shallow_num == null) values.wrinkle_shallow_num = scalar(fine && fine[def[3]]);
return { key: def[0], name: def[1], metrics: values };
});
}
function metricText(value) { return value == null ? '未获取' : String(Math.round(value * 10000) / 10000); }
function wrinkleText(rows) {
return '\n分区明细(左右以本人为准;抬头纹与额头纹为同一分区):\n' + rows.map(function (row) {
return row.name + ':' + wrinkleMetrics.map(function (metric) { return metric[1] + ' ' + metricText(row.metrics[metric[0]]); }).join(',') + '。';
}).join('\n');
}
function fill(template, vars) {
return template.replace(/\{([a-z_]+)\}/g, function(_, key) {
var v = vars[key];
@@ -55,9 +96,15 @@
if (vars.sens_area == null) template = template.replace(/泛红面积(?:达 | )\{sens_area\}%[,;]/g, '泛红面积未获取;');
if (vars.pore_cnt == null) template = template.replace(/粗大毛孔 \{pore_cnt\} 颗/g, '毛孔计数未获取');
if (vars.blackhead_cnt == null) template = template.replace(/黑头 \{blackhead_cnt\} 颗/g, '黑头计数未获取');
if (d[0] === 'smooth' && valid && level === '优秀') {
// A high aggregate score does not mean every localized pore is absent.
if (vars.pore_cnt == null || vars.blackhead_cnt == null) template = '肌肤质感整体较好,但毛孔或黑头计数未完整获取,请结合检测图查看。';
else if (vars.pore_cnt > 0 || vars.blackhead_cnt > 0) template = '肌肤质感整体较好;本次检测到粗大毛孔 {pore_cnt} 颗、黑头 {blackhead_cnt} 颗,局部仍有可改善之处。';
}
var details = d[0] === 'wrinkles' ? wrinkleDetails(result) : null;
var advice = level ? Copy.care[d[0]][level] : null;
return { key: d[0], name: d[1], dim: d[2], source: d[3], P: cat.P, level: level || null,
text: valid ? fill(template, vars) : '本次未检出有效数据,请重新拍摄后查看。',
text: (valid ? fill(template, vars) : '本次未检出有效数据,请重新拍摄后查看。') + (details ? wrinkleText(details) : ''),
valid: valid, advice: valid ? advice : null };
});
// Preserve selection of the two weakest original categories. Tone expands into
@@ -67,7 +114,7 @@
var chosen = views.filter(function(v) { return ranked.indexOf(v.source) >= 0 && v.advice; });
var care = chosen.map(function(v) { return { name: v.name, text: v.advice.care + v.advice.lifestyle }; });
var product = products.filter(function(p) { return p.match === vm.essence.name; })[0] || null;
return { version: Copy.version, dims: dimNames, views: views, care: care, product: product };
return { version: Copy.version + '+feedback-2026-10-12', dims: dimNames, views: views, care: care, product: product };
}
return { build: build, products: products, levelMap: levelMap, dimNames: dimNames };
return { build: build, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails };
});