Face++ 集成(高阶版 skinanalyze_advanced): - h5/server.js:零依赖 Node 后端,静态托管 + /api/skin-analyze 中转, api_secret 只存服务端(从 apikey.bash/env 读取,.gitignore 已排除) - 实时相机:getUserMedia 自拍取景,不可用时降级系统相机; 相机令牌防授权回调泄漏摄像头;离开拍照页即释放 - 检测流:三连拍仅送检正面照(压至 1280px JPEG),错误码→友好提示 - 结果映射:肌肤年龄=skin_age 真实值;紧塑力/活力感/稳定力由真实 维度合成;具体描述按检出项动态生成;检测图用用户实拍 - ?mock=1 内置样本供离线/自动化验证;README 补充部署与 HTTPS 说明 三轮迭代修复(QA+美术并行审计→开发统一修复,共 ~28 项): - 功能:P1 鼠标拖拽轮播(Pointer Events+setPointerCapture)、 快门/返回竞态(camSeq 令牌)、报告页状态重置、弹窗 ESC/遮罩关闭 - 视觉:还原度 80%→94%,档案页布局/弹窗比例/报告页产品卡等 结构性偏差修正,字号/间距逐项实测核销 - 工程化:首屏 701KB→386KB(懒加载+preload+清理 212KB 无用资源)、 favicon/SEO meta/隐私协议占位层/README Review 修复:server.js 路径穿越前缀绕过(ROOT+sep 校验)、 getUserMedia 迟到回调释放相机(camSeqCam 令牌)
487 lines
22 KiB
JavaScript
487 lines
22 KiB
JavaScript
/* ═════ 巨子测肤 H5 — 交互逻辑 ═════ */
|
|
(function () {
|
|
'use strict';
|
|
|
|
var $ = function (sel, root) { return (root || document).querySelector(sel); };
|
|
|
|
/* ─── Toast ─── */
|
|
var toastEl = $('#toast'), toastTimer = null;
|
|
function toast(msg) {
|
|
toastEl.textContent = msg;
|
|
toastEl.classList.add('show');
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, 1800);
|
|
}
|
|
|
|
/* ─── 屏幕管理 ─── */
|
|
var currentScreen = 's-splash';
|
|
function showScreen(id) {
|
|
document.querySelectorAll('.screen').forEach(function (s) {
|
|
s.classList.toggle('active', s.id === id);
|
|
});
|
|
var sc = document.getElementById(id);
|
|
sc.scrollTop = 0;
|
|
currentScreen = id;
|
|
if (id === 's-report') resetReport(); // 每次进入报告页都还原初始状态
|
|
}
|
|
|
|
/* ─── 报告页状态重置(Tab 回到「紧塑力」、产品横滑归零) ─── */
|
|
function resetReport() {
|
|
document.querySelectorAll('.dtab').forEach(function (t, k) {
|
|
t.classList.toggle('active', k === 0);
|
|
});
|
|
document.querySelectorAll('.dpanel').forEach(function (p, k) {
|
|
p.classList.toggle('active', k === 0);
|
|
});
|
|
var ps = $('#prod-scroll');
|
|
if (ps) ps.scrollLeft = 0;
|
|
}
|
|
|
|
/* ─── 弹窗管理 ─── */
|
|
function openOverlay(id) { $('#' + id).classList.add('show'); }
|
|
function closeOverlay(id) { $('#' + id).classList.remove('show'); }
|
|
|
|
// 点遮罩空白处关闭(与各自关闭按钮同行为:隐私→回启动页、拍照引导→留在档案页)
|
|
// e.target === overlay 确保只响应遮罩本身,不误伤弹窗卡片内交互
|
|
function bindBackdropClose(id) {
|
|
var ov = $('#' + id);
|
|
ov.addEventListener('click', function (e) {
|
|
if (e.target === ov) closeOverlay(id);
|
|
});
|
|
}
|
|
bindBackdropClose('p-privacy');
|
|
bindBackdropClose('p-photoguide');
|
|
bindBackdropClose('p-policy');
|
|
// ESC 关闭(仅这三层;分析中过渡不响应;协议层最顶,优先关)
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key !== 'Escape' && e.key !== 'Esc') return;
|
|
if ($('#p-policy').classList.contains('show')) closeOverlay('p-policy');
|
|
else if ($('#p-privacy').classList.contains('show')) closeOverlay('p-privacy');
|
|
else if ($('#p-photoguide').classList.contains('show')) closeOverlay('p-photoguide');
|
|
});
|
|
|
|
/* ─── 肤质轮播 ─── */
|
|
var SKINS = [
|
|
{ img: 'assets/img/skin-0-dry.png', name: '干性肌肤', desc: '全脸干燥,容易紧绷、缺乏光泽' },
|
|
{ img: 'assets/img/skin-1-mixeddry.png', name: '混合偏干肌肤', desc: '两颊偏干,T区油脂较少或偶尔出油' },
|
|
{ img: 'assets/img/skin-2-neutral.png', name: '中性肌肤', desc: '水油平衡,肤感舒适,无明显干燥出油' },
|
|
{ img: 'assets/img/skin-3-oily.png', name: '油性肌肤', desc: '全脸易出油,容易泛油光、毛孔明显' },
|
|
{ img: 'assets/img/skin-4-mixedoily.png', name: '混合偏油肌肤', desc: 'T区易出油,两颊相对清爽或轻微干燥' }
|
|
];
|
|
var skinIndex = 0;
|
|
var track = $('#skin-track'), dotsBox = $('#skin-dots');
|
|
|
|
SKINS.forEach(function (s, i) {
|
|
var item = document.createElement('div');
|
|
item.className = 'skin-item';
|
|
item.innerHTML =
|
|
'<img src="' + s.img + '"' + (i === 0 ? '' : ' loading="lazy"') + ' alt="' + s.name + '">' +
|
|
'<span class="skin-name">' + s.name + '</span>' +
|
|
'<p class="skin-desc">' + s.desc + '</p>';
|
|
track.appendChild(item);
|
|
var dot = document.createElement('i');
|
|
if (i === 0) dot.className = 'on';
|
|
dot.addEventListener('click', function () { goSkin(i); });
|
|
dotsBox.appendChild(dot);
|
|
});
|
|
|
|
function goSkin(i) {
|
|
skinIndex = Math.max(0, Math.min(SKINS.length - 1, i));
|
|
track.style.transform = 'translateX(-' + skinIndex * 100 + '%)';
|
|
dotsBox.querySelectorAll('i').forEach(function (d, k) {
|
|
d.classList.toggle('on', k === skinIndex);
|
|
});
|
|
}
|
|
|
|
// 拖拽切换(Pointer Events 统一鼠标 + 触摸)
|
|
var drag = { id: null, startX: 0, startY: 0, horizontal: false, suppress: false };
|
|
var carousel = $('#skin-carousel');
|
|
carousel.addEventListener('pointerdown', function (e) {
|
|
if (e.cancelable) e.preventDefault(); // 阻止原生图片拖拽/文字选中引发 pointercancel(click 仍会正常触发)
|
|
try { carousel.setPointerCapture(e.pointerId); } catch (err) { /* 非活动指针时忽略,退化为普通监听 */ }
|
|
// 捕获指针:斜拖出卡片、卡外松手也能收到 pointerup
|
|
drag.id = e.pointerId;
|
|
drag.startX = e.clientX;
|
|
drag.startY = e.clientY;
|
|
drag.horizontal = false;
|
|
drag.suppress = false;
|
|
});
|
|
carousel.addEventListener('pointermove', function (e) {
|
|
if (e.pointerId !== drag.id) return;
|
|
var dx = e.clientX - drag.startX, dy = e.clientY - drag.startY;
|
|
// 横向位移明显大于纵向时才判定为拖拽,避免吞掉纵向滚动
|
|
if (!drag.horizontal && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) drag.horizontal = true;
|
|
if (drag.horizontal && e.cancelable) e.preventDefault();
|
|
});
|
|
carousel.addEventListener('pointerup', function (e) {
|
|
if (e.pointerId !== drag.id) return;
|
|
var dx = e.clientX - drag.startX;
|
|
if (drag.horizontal) {
|
|
if (Math.abs(dx) > 30) goSkin(skinIndex + (dx < 0 ? 1 : -1));
|
|
drag.suppress = true; // 拖拽后吞掉紧随的 click,防止点击穿透
|
|
}
|
|
drag.id = null;
|
|
drag.horizontal = false;
|
|
});
|
|
carousel.addEventListener('pointercancel', function (e) {
|
|
if (e.pointerId !== drag.id) return; // 其它指针的 cancel 不打断当前拖拽
|
|
drag.id = null; // 拖拽被系统取消(滚轮/新指针等):干净结束,不残留状态
|
|
drag.horizontal = false;
|
|
});
|
|
carousel.addEventListener('click', function (e) {
|
|
if (drag.suppress) { e.preventDefault(); e.stopPropagation(); drag.suppress = false; }
|
|
}, true);
|
|
|
|
/* ─── 地区选项 ─── */
|
|
var REGIONS = ['北京', '上海', '广州', '深圳', '杭州', '成都', '重庆', '武汉', '西安', '南京', '天津', '苏州', '长沙', '郑州', '青岛', '其他'];
|
|
var regionSel = $('#f-region');
|
|
REGIONS.forEach(function (r) {
|
|
var o = document.createElement('option');
|
|
o.textContent = r;
|
|
regionSel.appendChild(o);
|
|
});
|
|
|
|
/* ─── 实时相机(getUserMedia)+ 系统相机降级 ─── */
|
|
var camVideo = $('#cam-video'), camFile = $('#cam-file');
|
|
var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头
|
|
var photos = []; // 三连拍的照片(dataURL),photos[0] 为正面照,送检用
|
|
|
|
function startCamera() {
|
|
photos = [];
|
|
if (camStream) { stopCamera(); }
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; }
|
|
camSeqCam++;
|
|
var token = camSeqCam;
|
|
navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 1280 } },
|
|
audio: false
|
|
}).then(function (stream) {
|
|
if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放
|
|
camStream = stream;
|
|
camVideo.srcObject = stream;
|
|
camLive = true;
|
|
$('#s-cam').classList.add('cam-live');
|
|
}).catch(function () {
|
|
if (token !== camSeqCam) return;
|
|
enableFallback();
|
|
});
|
|
}
|
|
function stopCamera() {
|
|
camSeqCam++; // 使进行中的 getUserMedia 回调失效
|
|
if (camStream) { camStream.getTracks().forEach(function (t) { t.stop(); }); camStream = null; }
|
|
camVideo.srcObject = null;
|
|
camLive = false;
|
|
$('#s-cam').classList.remove('cam-live');
|
|
}
|
|
function enableFallback() {
|
|
camFallback = true;
|
|
toast('已切换为系统相机拍摄');
|
|
}
|
|
// 从视频帧截取照片(镜像绘制,与预览一致;压到最长边 1280 的 JPEG)
|
|
function captureFromVideo() {
|
|
var vw = camVideo.videoWidth, vh = camVideo.videoHeight;
|
|
if (!vw || !vh) return null;
|
|
var scale = Math.min(1, 1280 / Math.max(vw, vh));
|
|
var cw = Math.round(vw * scale), ch = Math.round(vh * scale);
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = cw; canvas.height = ch;
|
|
var ctx = canvas.getContext('2d');
|
|
ctx.translate(cw, 0); ctx.scale(-1, 1); // 镜像
|
|
ctx.drawImage(camVideo, 0, 0, cw, ch);
|
|
return canvas.toDataURL('image/jpeg', 0.88);
|
|
}
|
|
// 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程
|
|
camFile.addEventListener('change', function () {
|
|
var f = camFile.files && camFile.files[0];
|
|
camFile.value = '';
|
|
if (!f) return;
|
|
var reader = new FileReader();
|
|
reader.onload = function () {
|
|
// 统一压成最长边 1280 的 JPEG
|
|
var img = new Image();
|
|
img.onload = function () {
|
|
var scale = Math.min(1, 1280 / Math.max(img.width, img.height));
|
|
var c = document.createElement('canvas');
|
|
c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale);
|
|
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
|
|
onPhotoCaptured(c.toDataURL('image/jpeg', 0.88));
|
|
};
|
|
img.src = reader.result;
|
|
};
|
|
reader.readAsDataURL(f);
|
|
});
|
|
|
|
/* ─── Face++ 结果 → 设计稿报告数据(映射层) ─── */
|
|
function num(node, key) {
|
|
if (node == null) return 0;
|
|
if (key != null) return Number(node[key] && (node[key].value != null ? node[key].value : node[key])) || 0;
|
|
return Number(node.value != null ? node.value : node) || 0;
|
|
}
|
|
function one(x) { return Math.round(x * 10) / 10; }
|
|
function clamp(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); }
|
|
|
|
function mapSkinResult(r) {
|
|
var fw = num(r.forehead_wrinkle, 'value') === 1;
|
|
var cf = num(r.crows_feet, 'value') === 1;
|
|
var ef = num(r.eye_finelines, 'value') === 1;
|
|
var gw = num(r.glabella_wrinkle, 'value') === 1;
|
|
var nf = num(r.nasolabial_fold, 'value') === 1, nfSev = num(r.nasolabial_fold_severity, 'value');
|
|
var ep = num(r.eye_pouch, 'value') === 1, epSev = num(r.eye_pouch_severity, 'value');
|
|
var dc = num(r.dark_circle, 'value');
|
|
var bh = num(r.blackhead, 'value');
|
|
var poreCount = ['pores_forehead', 'pores_left_cheek', 'pores_right_cheek', 'pores_jaw']
|
|
.filter(function (k) { return num(r[k], 'value') === 1; }).length;
|
|
var acneN = (r.acne && r.acne.rectangle) ? r.acne.rectangle.length : 0;
|
|
var ccN = (r.closed_comedones && r.closed_comedones.rectangle) ? r.closed_comedones.rectangle.length : 0;
|
|
var spotN = (r.skin_spot && r.skin_spot.rectangle) ? r.skin_spot.rectangle.length : 0;
|
|
var sens = r.sensitivity || {};
|
|
var sensArea = Number(sens.sensitivity_area) || 0;
|
|
var sensInt = Number(sens.sensitivity_intensity) || 0;
|
|
var age = num(r.skin_age, 'value');
|
|
|
|
// 紧塑力:法令纹 + 眼袋(松弛下垂类)
|
|
var firm = 10 - (nf ? [0.9, 1.5, 2.2][clamp(nfSev, 0, 2)] : 0) - (ep ? [0.7, 1.2, 1.8][clamp(epSev, 0, 2)] : 0);
|
|
// 活力感:黑眼圈 + 眼部细纹/鱼尾纹/抬头纹/眉间纹(倦怠神采类)
|
|
var vita = 10 - (dc > 0 ? (dc === 2 ? 1.3 : 1.0) : 0) - (ef ? 0.6 : 0) - (cf ? 0.5 : 0) - (fw ? 0.4 : 0) - (gw ? 0.3 : 0);
|
|
// 稳定力:敏感泛红 + 痘痘/闭口/毛孔/黑头(屏障稳定类)
|
|
var stab = 10 - (sensArea * 2.5 + sensInt / 100 * 2.0) - Math.min(acneN, 5) * 0.5 - Math.min(ccN, 5) * 0.35 - poreCount * 0.4 - bh * 0.3;
|
|
firm = one(clamp(firm, 4, 9.8)); vita = one(clamp(vita, 4, 9.8)); stab = one(clamp(stab, 3.5, 9.8));
|
|
var total = Math.round(clamp((firm * 0.35 + vita * 0.33 + stab * 0.32) * 10, 40, 98));
|
|
|
|
var dims = [
|
|
{ name: '紧塑力', score: firm, tip: '轮廓支撑与紧致度,护肤配合由下向上的提拉手法' },
|
|
{ name: '活力感', score: vita, tip: '神采与倦怠感,规律作息改善眼周循环' },
|
|
{ name: '稳定力', score: stab, tip: '强韧稳态力,锁水保湿抵御刺激' }
|
|
].sort(function (a, b) { return a.score - b.score; });
|
|
|
|
// 具体描述两行文案
|
|
var sevName = ['轻度', '中度', '重度'];
|
|
var para1;
|
|
if (!nf && !ep) {
|
|
para1 = '面部轮廓饱满舒展,下颌线清晰立体,中面部容积充足,肌肤回弹速度快;这种紧致状态,通常和内在胶原合成充足、整体作息节奏平稳直接相关。';
|
|
} else {
|
|
var parts = [];
|
|
if (nf) parts.push('观察到' + sevName[clamp(nfSev, 0, 2)] + '法令纹痕迹,面部支撑有松弛趋势');
|
|
if (ep) parts.push('眼部有' + sevName[clamp(epSev, 0, 2)] + '眼袋');
|
|
para1 = parts.join(',') + '。护肤时请避免用力向下拉扯皮肤,涂抹护肤品时配合由下向上的提拉手法,重点照顾下颌线与苹果肌位置。';
|
|
}
|
|
var para2;
|
|
var wrinkles = [];
|
|
if (fw) wrinkles.push('抬头纹'); if (cf) wrinkles.push('鱼尾纹');
|
|
if (ef) wrinkles.push('眼部细纹'); if (gw) wrinkles.push('眉间纹'); if (nf) wrinkles.push('法令纹');
|
|
if (wrinkles.length === 0) {
|
|
para2 = '面部各区域平整舒展,没有明显的纹路沟壑,只有做夸张表情时会出现极浅的动态纹路;纹路的多少,和内在胶原支撑力、日常面部肌肉活动习惯都有关系。';
|
|
} else {
|
|
para2 = '当前观察到' + wrinkles.join('、') + '的痕迹;纹路的多少,和内在胶原支撑力、日常面部肌肉活动习惯都有关系,日常注意表情管理与防晒,有助于减缓纹路加深。';
|
|
}
|
|
var found = [];
|
|
if (spotN > 0) found.push('斑点' + spotN + '处');
|
|
if (acneN > 0) found.push('痘痘' + acneN + '处');
|
|
if (ccN > 0) found.push('闭口' + ccN + '处');
|
|
if (found.length) para2 += '(本次检测到' + found.join(',') + ')';
|
|
|
|
return {
|
|
total: total, age: age,
|
|
firm: firm, vita: vita, stab: stab,
|
|
focusName: dims[0].name, focusTip: dims[0].tip,
|
|
para1: para1, para2: para2,
|
|
lowest: dims[0].score
|
|
};
|
|
}
|
|
|
|
function renderReport(m, frontPhoto, sidePhoto) {
|
|
$('#stat-score').textContent = m.total + '分';
|
|
$('#stat-age').textContent = m.age + '岁';
|
|
var setDim = function (id, score) {
|
|
var el = $(id);
|
|
el.textContent = score.toFixed(1);
|
|
el.classList.toggle('dim-bad', score < 6);
|
|
};
|
|
setDim('#dim-firm', m.firm); setDim('#dim-vita', m.vita); setDim('#dim-stab', m.stab);
|
|
$('#dims-note').innerHTML = '近期优先关注:' + m.focusName + '<br>' + m.focusTip;
|
|
$('#ditem-para1').textContent = m.para1;
|
|
$('#ditem-para2').textContent = m.para2;
|
|
if (frontPhoto) $('#ditem-img1').src = frontPhoto;
|
|
if (sidePhoto) $('#ditem-img2').src = sidePhoto;
|
|
}
|
|
|
|
/* ─── 分析请求(mock=1 用内置样本,便于离线/自动化验证) ─── */
|
|
var MOCK_RESULT = {"skin_color":{"value":2,"confidence":0.65},"skin_age":{"value":42},"left_eyelids":{"value":0,"confidence":0.99},"right_eyelids":{"value":0,"confidence":0.99},"eye_pouch":{"value":0,"confidence":0.75},"dark_circle":{"value":1,"confidence":0.63},"forehead_wrinkle":{"value":0,"confidence":0.76},"crows_feet":{"value":1,"confidence":0.76},"eye_finelines":{"value":1,"confidence":0.86},"glabella_wrinkle":{"value":0,"confidence":0.76},"nasolabial_fold":{"value":1,"confidence":0.80},"nasolabial_fold_severity":{"value":1,"confidence":0.30},"skin_type":{"skin_type":3},"pores_forehead":{"value":0,"confidence":0.82},"pores_left_cheek":{"value":0,"confidence":0.76},"pores_right_cheek":{"value":0,"confidence":0.95},"pores_jaw":{"value":0,"confidence":0.98},"blackhead":{"value":0,"confidence":0.99},"acne":{"rectangle":[],"confidence":[]},"closed_comedones":{"rectangle":[],"confidence":[]},"mole":{"rectangle":[],"confidence":[]},"skin_spot":{"rectangle":[{"left":356,"top":494,"width":11,"height":13}],"confidence":[0.50]},"sensitivity":{"sensitivity_area":0.26,"sensitivity_intensity":65.36}};
|
|
|
|
function requestAnalyze(imageDataUrl, seq, done) {
|
|
if (/[?&]mock=1/.test(location.search)) {
|
|
setTimeout(function () { done(null, MOCK_RESULT); }, 900);
|
|
return;
|
|
}
|
|
fetch('/api/skin-analyze', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ image_base64: imageDataUrl })
|
|
}).then(function (res) { return res.json(); })
|
|
.then(function (data) {
|
|
if (seq !== camSeq) return; // 流程已被重置,丢弃过期响应
|
|
if (!data.ok) done(data.message || '检测失败,请重试');
|
|
else done(null, data.result);
|
|
})
|
|
.catch(function () {
|
|
if (seq !== camSeq) return;
|
|
done('网络异常,请检查网络后重试');
|
|
});
|
|
}
|
|
|
|
/* ─── 拍照流程 ─── */
|
|
var CAM_STEPS = [
|
|
{ bg: 'assets/img/cam-front.jpg', step: '1/3<br>请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'] },
|
|
{ bg: 'assets/img/cam-left.jpg', step: '2/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'] },
|
|
{ bg: 'assets/img/cam-right.jpg', step: '3/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'] }
|
|
];
|
|
var camStep = 0;
|
|
|
|
function renderCamStep() {
|
|
var cfg = CAM_STEPS[camStep];
|
|
$('#cam-bg').style.backgroundImage = 'url(' + cfg.bg + ')';
|
|
$('#cam-step').innerHTML = cfg.step;
|
|
$('#cam-guide').innerHTML = cfg.guides
|
|
.map(function (g) { return '<span>' + g + '</span>'; })
|
|
.join('<i></i>');
|
|
}
|
|
|
|
var camSeq = 0; // 流程令牌:cam-back/开始拍照时递增,使未决的快门/分析回调失效
|
|
var shutterLock = false; // 快门防连点锁:流程进行中直接忽略
|
|
|
|
// 照片就绪后推进流程(实时截帧与降级选图共用)
|
|
function onPhotoCaptured(dataUrl) {
|
|
if (shutterLock && !camFallback) return;
|
|
shutterLock = true;
|
|
photos[camStep] = dataUrl;
|
|
var flash = $('#cam-flash');
|
|
flash.classList.add('on');
|
|
setTimeout(function () { flash.classList.remove('on'); }, 220);
|
|
var seq = camSeq;
|
|
setTimeout(function () {
|
|
if (seq !== camSeq) return; // 期间已 cam-back/重新开始:放弃本次推进
|
|
if (camStep < CAM_STEPS.length - 1) {
|
|
camStep++;
|
|
renderCamStep();
|
|
shutterLock = false; // 进入下一步即解锁
|
|
} else {
|
|
// 三张拍完:送检正面照
|
|
renderCamStep();
|
|
camStep = 0;
|
|
openOverlay('p-analyzing');
|
|
var t0 = Date.now();
|
|
requestAnalyze(photos[0], seq, function (errMsg, result) {
|
|
if (seq !== camSeq) return; // 流程已被重置:丢弃
|
|
var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁
|
|
setTimeout(function () {
|
|
if (seq !== camSeq) return;
|
|
closeOverlay('p-analyzing');
|
|
stopCamera();
|
|
if (errMsg) {
|
|
toast(errMsg);
|
|
showScreen('s-profile'); // 回档案页,允许重新提交再拍
|
|
shutterLock = false;
|
|
return;
|
|
}
|
|
renderReport(mapSkinResult(result), photos[0], photos[1]);
|
|
showScreen('s-report');
|
|
shutterLock = false; // 流程结束进入报告后解锁
|
|
}, wait);
|
|
});
|
|
}
|
|
}, 260);
|
|
}
|
|
|
|
function shutter() {
|
|
if (shutterLock) return;
|
|
if (camLive) {
|
|
var shot = captureFromVideo();
|
|
if (!shot) { toast('相机尚未就绪,请稍候'); return; }
|
|
onPhotoCaptured(shot);
|
|
} else if (camFallback) {
|
|
camFile.click(); // 降级:调起系统相机,回调里推进
|
|
} else {
|
|
// 相机还在启动中
|
|
toast('相机启动中,请稍候');
|
|
}
|
|
}
|
|
|
|
/* ─── 隐私勾选 ─── */
|
|
var agreed = false;
|
|
$('#privacy-row').addEventListener('click', function (e) {
|
|
if (e.target.closest('#privacy-policy-link')) return; // 点协议链接:只开弹层,不切换勾选
|
|
e.preventDefault();
|
|
agreed = !agreed;
|
|
$('#privacy-check').classList.toggle('checked', agreed);
|
|
});
|
|
|
|
/* ─── 隐私协议占位弹层(QA P3-2) ─── */
|
|
$('#privacy-policy-link').addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation(); // 阻止冒泡到勾选行,避免误切换
|
|
openOverlay('p-policy');
|
|
});
|
|
|
|
/* ─── 报告页 Tab ─── */
|
|
$('#detail-tabs').addEventListener('click', function (e) {
|
|
var btn = e.target.closest('.dtab');
|
|
if (!btn) return;
|
|
var idx = btn.dataset.tab;
|
|
document.querySelectorAll('.dtab').forEach(function (t) { t.classList.toggle('active', t === btn); });
|
|
document.querySelectorAll('.dpanel').forEach(function (p) {
|
|
p.classList.toggle('active', p.dataset.panel === idx);
|
|
});
|
|
});
|
|
|
|
/* ─── 动作分发 ─── */
|
|
var actions = {
|
|
'open-privacy': function () { openOverlay('p-privacy'); },
|
|
'close-privacy': function () { closeOverlay('p-privacy'); },
|
|
'close-policy': function () { closeOverlay('p-policy'); },
|
|
'agree-continue': function () {
|
|
if (!agreed) { toast('请先阅读并同意隐私协议'); return; }
|
|
closeOverlay('p-privacy');
|
|
showScreen('s-profile');
|
|
},
|
|
'back-splash': function () { showScreen('s-splash'); },
|
|
'submit-profile': function () {
|
|
var age = $('#f-age').value.trim();
|
|
if (!age || !/^\d{1,2}$/.test(age) || +age < 1 || +age > 99) { toast('请输入有效年龄'); return; }
|
|
if (!$('#f-gender').value) { toast('请选择性别'); return; }
|
|
if (!regionSel.value) { toast('请选择所在地区'); return; }
|
|
openOverlay('p-photoguide');
|
|
},
|
|
'close-photoguide': function () { closeOverlay('p-photoguide'); },
|
|
'start-camera': function () {
|
|
closeOverlay('p-photoguide');
|
|
camSeq++; // 使旧的快门/分析回调失效
|
|
camStep = 0;
|
|
shutterLock = false; // 重新进入流程,保险解锁
|
|
renderCamStep();
|
|
showScreen('s-cam');
|
|
startCamera();
|
|
},
|
|
'cam-back': function () {
|
|
camSeq++; // 使未决的快门/分析回调失效,避免回退被旧定时器顶回
|
|
shutterLock = false; // 中途退出也解锁
|
|
if (camStep > 0) { camStep--; renderCamStep(); }
|
|
else { stopCamera(); showScreen('s-profile'); }
|
|
},
|
|
'shutter': shutter,
|
|
'album': function () { toast('相册功能开发中'); },
|
|
'flip': function () { toast('切换镜头功能开发中'); },
|
|
'back-profile': function () { showScreen('s-profile'); },
|
|
'more': function () { toast('了解更多功能开发中'); }
|
|
};
|
|
|
|
document.addEventListener('click', function (e) {
|
|
var el = e.target.closest('[data-action]');
|
|
if (!el) return;
|
|
var fn = actions[el.dataset.action];
|
|
if (fn) fn();
|
|
});
|
|
|
|
/* ─── 初始化 ─── */
|
|
renderCamStep();
|
|
})();
|