Files
juzi/h5/js/app.js
T
xsl 5957ef04fc feat: 接入 Face++ 真实测肤 + 三轮多角色迭代修复
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 令牌)
2026-09-22 21:06:33 +08:00

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();
})();