fix: 拍照页对齐 Figma(正→右→左/镜像预览/WebView 口径定位/85%遮罩) + 语音调度重写 + 正脸照不镜像
- 拍照顺序改为正脸→右脸→左脸(对齐 Figma),左右以用户本人为准;送检前按 target 重排为 [正,左,右] - 三步预览均镜像(Figma 侧脸轮廓按镜像绘制),后置镜头侧脸轮廓自动翻转;抓帧恒为原始视角 - 蒙版 viewBox 取 Figma 帧 y86–812(扣除微信导航栏)+ meet 缩放,文案/引导/提示按 726 高定位;框外 85% 黑,静态态白底 - 语音:流程音排队不截断、姿态提示挂起补播、倒计时等播报收尾、拍摄完成打断过时引导、出报告/退出清队列;去重仅限姿态提示 - 语音文件改名 step2-rightface / step3-leftface,同步清单、生成脚本与设计文档 - 精华卡去重复正文、推荐依据写明具体原因;年龄仅数字;toast 按字数延时;备注去内部质量码 - 新增无头浏览器冒烟测试 h5/test/e2e-browser.mjs Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+148
-62
@@ -10,7 +10,8 @@
|
||||
toastEl.textContent = msg;
|
||||
toastEl.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, 1800);
|
||||
// 长文案(如检测失败原因)按字数延长停留,短提示仍 1.8s
|
||||
toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, Math.max(1800, String(msg).length * 130));
|
||||
}
|
||||
|
||||
/* ─── 屏幕管理 ─── */
|
||||
@@ -64,6 +65,7 @@
|
||||
$('#p-analyzing').addEventListener('click', function (e) {
|
||||
if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片
|
||||
camSeq++;
|
||||
voice.stop(true);
|
||||
closeOverlay('p-analyzing');
|
||||
stopCamera();
|
||||
shutterLock = false; // 流程终止解锁
|
||||
@@ -164,20 +166,30 @@
|
||||
});
|
||||
document.addEventListener('click', rsClose); // 点空白收起
|
||||
|
||||
// 年龄只收数字:inputmode=numeric 在部分安卓输入法仍可切到全键盘
|
||||
$('#f-age').addEventListener('input', function () {
|
||||
var v = this.value.replace(/\D/g, '').slice(0, 2);
|
||||
if (v !== this.value) this.value = v;
|
||||
});
|
||||
|
||||
/* ─── 实时相机(getUserMedia)+ 系统相机降级 ─── */
|
||||
var IS_MOCK = /[?&]mock=1/.test(location.search); // mock:不加载 face-api、不跑检测,analyze 直返内置样本
|
||||
var camVideo = $('#cam-video'), camFile = $('#cam-file'), albumFile = $('#album-file');
|
||||
var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头
|
||||
var photos = []; // 三连拍(dataURL),与 CAM_STEPS 同序:[0]正脸 [1]左脸(头右转) [2]右脸(头左转)
|
||||
var photos = []; // 三连拍(dataURL),拍摄中与 CAM_STEPS 同序;送检前经 photosByView 重排为 [正脸, 左脸, 右脸]
|
||||
|
||||
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
|
||||
var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换)
|
||||
var camMirrored = true; // 前置镜像开关:仅正脸步生效(自拍习惯);侧脸步始终不镜像(左右不再迷惑,与取景轮廓方向一致)
|
||||
var camMirrored = true; // 预览镜像:前置 true(照镜子习惯,Figma 侧脸轮廓即按镜像画面绘制),后置 false
|
||||
var sideYawFlip = false; // 侧脸步语义 yaw 反向标志:个别机型前置帧本身已镜像时由自校准翻转
|
||||
var mirrorCalibrated = false; // 侧脸自校准:本轮是否已翻转过一次(防振荡,一次校准管整个会话)
|
||||
var oppFrames = 0; // 侧脸步语义 yaw 连续反向帧计数
|
||||
// 预览/抓帧是否镜像:正脸步跟随 camMirrored,侧脸步恒不镜像
|
||||
function effMirror() { return camMirrored && CAM_STEPS[camStep].target === 'front'; }
|
||||
// 预览是否镜像(仅预览;抓帧恒为原始视角)。后置不镜像时侧脸轮廓需水平翻转,才与画面中的脸朝向一致
|
||||
function effMirror() { return camMirrored; }
|
||||
function applyMirror() {
|
||||
camVideo.classList.toggle('mirror', effMirror());
|
||||
$('#cam-mask').classList.toggle('flip', !effMirror() && CAM_STEPS[camStep].target !== 'front');
|
||||
}
|
||||
function getCamStream() {
|
||||
return navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: camFacing }, width: { ideal: 1280 }, height: { ideal: 1280 } },
|
||||
@@ -210,7 +222,7 @@
|
||||
if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放
|
||||
camStream = stream;
|
||||
camVideo.srcObject = stream;
|
||||
camVideo.classList.toggle('mirror', effMirror());
|
||||
applyMirror();
|
||||
// iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连
|
||||
stream.getTracks().forEach(function (t) {
|
||||
t.onended = function () { onCamTrackEnded(token); };
|
||||
@@ -250,8 +262,9 @@
|
||||
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框
|
||||
toast('已切换为系统相机拍摄');
|
||||
}
|
||||
// 从视频帧截取照片(完整帧,不镜像侧脸步;压到最长边 1280 的 JPEG)
|
||||
// 从视频帧截取照片(完整帧、摄像头原始视角,压到最长边 1280 的 JPEG)
|
||||
// 抓完整帧而非取景框可见区:新 UI 轮廓外全黑,用户只关注轮廓内人脸;按可见区裁切在侧脸/偏站位时会切掉人脸(Face++ INVALID_IMAGE_FACE)
|
||||
// 正脸步预览虽镜像,照片不镜像:Face++ 的左右分区(左嘴角纹/左右脸颊等)按真实左右出文案,且与两张侧脸及真值样本同口径
|
||||
function captureFromVideo() {
|
||||
var vw = camVideo.videoWidth, vh = camVideo.videoHeight;
|
||||
if (!vw || !vh) return null;
|
||||
@@ -260,7 +273,6 @@
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = cw; canvas.height = ch;
|
||||
var ctx = canvas.getContext('2d');
|
||||
if (effMirror()) { ctx.translate(cw, 0); ctx.scale(-1, 1); } // 与预览一致:正脸步前置镜像,侧脸步不镜像(照片=摄像头原始视角)
|
||||
ctx.drawImage(camVideo, 0, 0, vw, vh, 0, 0, cw, ch);
|
||||
return canvas.toDataURL('image/jpeg', 0.88);
|
||||
}
|
||||
@@ -302,7 +314,7 @@
|
||||
/* ─── 语音播报(WebAudio 单通道;assets/voice/<id>.mp3,缺失静默跳过;同条 4s 去重)
|
||||
文案/文件名契约:docs/语音播报内容设计文档_V1.0.md ─── */
|
||||
var VOICE = {
|
||||
step: ['step1-front', 'step2-leftface', 'step3-rightface'],
|
||||
step: ['step1-front', 'step2-rightface', 'step3-leftface'],
|
||||
hint: {
|
||||
'未检测到人脸': 'hint-noface', '请靠近一点': 'hint-near', '请远一点': 'hint-far',
|
||||
'请正对屏幕': 'hint-front', '请向左转头': 'hint-turn-left', '请向右转头': 'hint-turn-right',
|
||||
@@ -310,51 +322,110 @@
|
||||
},
|
||||
countdown: 'countdown-321', shot: 'shot-done', analyzing: 'analyzing', fail: 'analyze-fail'
|
||||
};
|
||||
// 两类播报:流程音(步骤引导/拍摄完成/倒计时/分析中/失败)排队顺序播,互不截断;
|
||||
// 姿态提示可互相打断,但不截断流程音——流程音播放期间只挂起最新一条,收尾后补播
|
||||
var voice = (function () {
|
||||
var ctx = null, lastAt = {}, cur = null, curEnd = 0, files = null; // files:null=清单未加载;{}=无可用语音
|
||||
var ctx = null, lastAt = {}, files = null; // files:null=清单未加载;{}=无可用语音
|
||||
var bufs = {}; // id → Promise<AudioBuffer|null>(解锁后预加载,弱网下首播不等下载)
|
||||
var cur = null; // { id, kind:'flow'|'hint', token, src, end }
|
||||
var queue = []; // 排队中的流程音
|
||||
var pendingHint = null; // 流程音期间到来的姿态提示(只留最新)
|
||||
var waiters = []; // afterCurrent 回调:全部播报收尾后执行
|
||||
// 清单制:只播 assets/voice/manifest.json 里声明的文件,不逐个探文件(避免控制台 404 噪音)
|
||||
fetch('assets/voice/manifest.json')
|
||||
.then(function (r) { return r.ok ? r.json() : { files: [] }; })
|
||||
.then(function (m) { files = {}; (m.files || []).forEach(function (f) { files[f] = 1; }); })
|
||||
.then(function (m) { files = {}; (m.files || []).forEach(function (f) { files[f] = 1; }); preload(); })
|
||||
.catch(function () { files = {}; });
|
||||
function decode(ab) {
|
||||
return new Promise(function (resolve) {
|
||||
try {
|
||||
var p = ctx.decodeAudioData(ab, resolve, function () { resolve(null); });
|
||||
if (p && p.catch) p.catch(function () { resolve(null); });
|
||||
} catch (e) { resolve(null); }
|
||||
});
|
||||
}
|
||||
function load(id) {
|
||||
if (!bufs[id]) {
|
||||
bufs[id] = fetch('assets/voice/' + id + '.mp3')
|
||||
.then(function (r) { return r.ok ? r.arrayBuffer() : null; })
|
||||
.then(function (ab) { return ab ? decode(ab) : null; })
|
||||
.catch(function () { return null; })
|
||||
.then(function (buf) { if (!buf) delete bufs[id]; return buf; }); // 失败不缓存,下次再试
|
||||
}
|
||||
return bufs[id];
|
||||
}
|
||||
function preload() { if (ctx && files) Object.keys(files).forEach(load); }
|
||||
function unlock() { // iOS/微信自动播放限制:必须在用户手势里初始化并 resume
|
||||
if (!ctx) { try { ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { ctx = null; } }
|
||||
if (!ctx) {
|
||||
try { ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { ctx = null; }
|
||||
preload();
|
||||
}
|
||||
if (ctx && ctx.state === 'suspended') ctx.resume();
|
||||
}
|
||||
function play(id) {
|
||||
function stopCur() {
|
||||
if (!cur) return;
|
||||
var c = cur;
|
||||
cur = null;
|
||||
if (c.src) { c.src.onended = null; try { c.src.stop(); } catch (e) {} }
|
||||
}
|
||||
function start(id, kind) {
|
||||
stopCur();
|
||||
lastAt[id] = Date.now();
|
||||
var token = {};
|
||||
cur = { id: id, kind: kind, token: token, src: null, end: 0 };
|
||||
load(id).then(function (buf) {
|
||||
if (!cur || cur.token !== token) return; // 加载期间已被打断
|
||||
if (!buf || !ctx) { cur = null; drain(); return; }
|
||||
var src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.connect(ctx.destination);
|
||||
cur.src = src;
|
||||
cur.end = Date.now() + buf.duration * 1000;
|
||||
src.onended = function () { if (cur && cur.token === token) { cur = null; drain(); } };
|
||||
src.start(0);
|
||||
});
|
||||
}
|
||||
function drain() {
|
||||
if (cur) return;
|
||||
if (queue.length) { start(queue.shift(), 'flow'); return; }
|
||||
if (pendingHint) { var h = pendingHint; pendingHint = null; start(h, 'hint'); return; }
|
||||
var w = waiters;
|
||||
waiters = [];
|
||||
w.forEach(function (fn) { fn(); });
|
||||
}
|
||||
function busyWithFlow() { return (cur && cur.kind === 'flow') || queue.length > 0; }
|
||||
// opts.hint:姿态提示(随检测帧反复触发,同条 4s 去重);opts.cut:打断在播与排队(失败提示不必等「分析中」播完)
|
||||
// 流程音是离散事件,每次都播(连拍的「拍摄完成」、取消后重来的倒计时)
|
||||
function play(id, opts) {
|
||||
opts = opts || {};
|
||||
if (!ctx || !id || ctx.state !== 'running' || !files || !files[id]) return;
|
||||
var now = Date.now();
|
||||
if (lastAt[id] && now - lastAt[id] < 4000) return;
|
||||
lastAt[id] = now;
|
||||
fetch('assets/voice/' + id + '.mp3')
|
||||
.then(function (r) { return r.ok ? r.arrayBuffer() : null; })
|
||||
.then(function (ab) {
|
||||
if (!ab || !ctx) return;
|
||||
ctx.decodeAudioData(ab, function (buf) {
|
||||
if (cur) { try { cur.stop(); } catch (e) {} }
|
||||
var src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.connect(ctx.destination);
|
||||
cur = src;
|
||||
curEnd = Date.now() + buf.duration * 1000;
|
||||
src.onended = function () { if (cur === src) cur = null; };
|
||||
src.start(0);
|
||||
}, function () { /* 解码失败忽略 */ });
|
||||
})
|
||||
.catch(function () { /* 网络异常忽略 */ });
|
||||
if (opts.hint) {
|
||||
if (lastAt[id] && Date.now() - lastAt[id] < 4000) return;
|
||||
if (busyWithFlow()) { pendingHint = id; return; }
|
||||
start(id, 'hint');
|
||||
return;
|
||||
}
|
||||
if (opts.cut) { queue = []; pendingHint = null; start(id, 'flow'); return; }
|
||||
if (busyWithFlow()) { if (queue.indexOf(id) < 0) queue.push(id); return; }
|
||||
start(id, 'flow'); // 在播的姿态提示让位给流程音
|
||||
}
|
||||
// 等当前播报自然收尾后执行 cb(倒计时接续「保持不动」,不拦腰切断);无播报在执行则立即执行
|
||||
// 返回取消函数;注意:cur 被新播报 stop 也会触发 onended,调用方需配合 cancelCountdown 等路径取消
|
||||
function dropHint() { pendingHint = null; } // 提示已清空/换步:挂起的旧提示作废
|
||||
// 流程切走:作废排队/挂起/等待;hard 连在播一并掐断(退出拍照、页面隐藏),否则让在播自然收尾(出报告)
|
||||
function stop(hard) {
|
||||
queue = [];
|
||||
pendingHint = null;
|
||||
waiters = [];
|
||||
if (hard) stopCur();
|
||||
}
|
||||
// 等全部播报(含排队与挂起提示)自然收尾后执行 cb(倒计时接续「保持不动」,不拦腰切断);空闲则立即执行
|
||||
function afterCurrent(cb) {
|
||||
if (!cur || Date.now() >= curEnd - 60) { cb(); return null; }
|
||||
var src = cur, cancelled = false;
|
||||
src.onended = function () {
|
||||
if (cur === src) cur = null;
|
||||
if (!cancelled) cb();
|
||||
};
|
||||
return function () { cancelled = true; };
|
||||
var tail = cur && cur.src && Date.now() >= cur.end - 60; // 只剩尾音视为已收尾
|
||||
if ((!cur || tail) && !queue.length && !pendingHint) { cb(); return null; }
|
||||
var fn = function () { cb(); };
|
||||
waiters.push(fn);
|
||||
return function () { waiters = waiters.filter(function (w) { return w !== fn; }); };
|
||||
}
|
||||
return { unlock: unlock, play: play, afterCurrent: afterCurrent };
|
||||
return { unlock: unlock, play: play, dropHint: dropHint, afterCurrent: afterCurrent, stop: stop };
|
||||
})();
|
||||
document.addEventListener('click', voice.unlock); // 任意点击即完成解锁
|
||||
|
||||
@@ -397,10 +468,10 @@
|
||||
}
|
||||
function setHint(text, ok, silent) {
|
||||
var h = $('#cam-hint');
|
||||
if (!text) { h.classList.remove('show', 'ok'); lastHint = ''; return; }
|
||||
if (!text) { h.classList.remove('show', 'ok'); lastHint = ''; voice.dropHint(); return; }
|
||||
if (text !== lastHint) {
|
||||
h.textContent = text; // 同文案不重复触发布局
|
||||
if (!silent) voice.play(VOICE.hint[text]); // 语音同步(模块内 4s 去重)
|
||||
if (!silent) voice.play(VOICE.hint[text], { hint: true }); // 语音同步(模块内 4s 去重)
|
||||
}
|
||||
h.classList.add('show');
|
||||
h.classList.toggle('ok', !!ok);
|
||||
@@ -516,8 +587,8 @@
|
||||
if (ratio < FACE_NEAR) hint = '请靠近一点';
|
||||
else if (ratio > FACE_FAR) hint = '请远一点';
|
||||
else {
|
||||
// 距离不合格时角度不判 ok。镜像参数:正脸步跟随镜头(前置 true/后置 false);
|
||||
// 侧脸步预览不镜像,特征点即原始视角,标准机型恒为 false,个别前置帧已镜像的机型由自校准翻转 sideYawFlip
|
||||
// 距离不合格时角度不判 ok。特征点取自原始帧(预览的 CSS 镜像不影响),侧脸步语义方向按原始视角判定:
|
||||
// 标准机型恒为 false,个别前置帧本身已镜像的机型由自校准翻转 sideYawFlip;正脸步只看 |yaw|,与符号无关
|
||||
var tgt = CAM_STEPS[camStep].target;
|
||||
var pose = HeadPose.check(d.landmarks.positions, tgt, tgt === 'front' ? camMirrored : sideYawFlip);
|
||||
// 侧脸自校准:个别机型前置帧本身已镜像(语义 yaw 与实际转向持续相反)时自救——
|
||||
@@ -550,19 +621,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 拍照流程(三步:正脸 → 左脸出镜 → 右脸出镜) ─── */
|
||||
// target 即 HeadPose.check 的 target:2/3 左脸出镜=用户头向右转(target 'left',产物作 left_image);
|
||||
// 3/3 右脸出镜=头向左转(target 'right',产物作 right_image)。
|
||||
/* ─── 拍照流程(三步,顺序对齐 Figma:正脸 → 右脸出镜 → 左脸出镜) ─── */
|
||||
// 左右一律按用户本人的左右:右脸出镜=用户头向自己的左侧转(target 'right',产物作 right_image);
|
||||
// 左脸出镜=头向自己的右侧转(target 'left',产物作 left_image)。
|
||||
// mask 为 index.html 取景轮廓编号(1=左脸出镜 耳在画面左,2=右脸出镜 耳在画面右,均按镜像预览绘制)
|
||||
var CAM_STEPS = [
|
||||
{ bg: 'assets/img/cam-front.jpg', step: '1/3<br>请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front' },
|
||||
{ bg: 'assets/img/cam-left.jpg', step: '2/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left' },
|
||||
{ bg: 'assets/img/cam-right.jpg', step: '3/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right' }
|
||||
{ step: '1/3<br>请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front', mask: 0 },
|
||||
{ step: '2/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right', mask: 2 },
|
||||
{ step: '3/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left', mask: 1 }
|
||||
];
|
||||
function photosByView(ph) { // 拍摄顺序 → 送检/报告/历史统一口径 [正脸, 左脸, 右脸]
|
||||
var at = {};
|
||||
CAM_STEPS.forEach(function (s, i) { at[s.target] = ph[i]; });
|
||||
return [at.front, at.left, at.right];
|
||||
}
|
||||
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>'; })
|
||||
@@ -573,8 +649,8 @@
|
||||
dots[i].classList.toggle('done', i < camStep);
|
||||
dots[i].classList.toggle('on', i === camStep);
|
||||
}
|
||||
$('#cam-mask').setAttribute('data-step', camStep); // 蒙版按步骤切换
|
||||
camVideo.classList.toggle('mirror', effMirror()); // 侧脸步预览切回不镜像(正脸步恢复前置镜像)
|
||||
$('#cam-mask').setAttribute('data-step', cfg.mask); // 蒙版按步骤切换轮廓
|
||||
applyMirror();
|
||||
resetDetectState();
|
||||
voice.play(VOICE.step[camStep]); // 步骤引导语音
|
||||
}
|
||||
@@ -587,7 +663,7 @@
|
||||
shutterLock = true; // 推进窗口上锁:降级模式 change 里已解锁,这里重新锁住直到下一步/送检
|
||||
photos[camStep] = dataUrl;
|
||||
$('#cam-dots').children[camStep].classList.add('done');
|
||||
voice.play(VOICE.shot); // 「拍摄完成」
|
||||
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废(自动拍时倒计时本就等播报收尾)
|
||||
var seq = camSeq;
|
||||
setTimeout(function () {
|
||||
if (seq !== camSeq) return; // 期间已 cam-back/重新开始:放弃本次推进
|
||||
@@ -626,9 +702,10 @@
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 三张拍完:三视图送检(正脸=photos[0],左=photos[1],右=photos[2])
|
||||
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
|
||||
function submitPhotos(seq) {
|
||||
camStep = 0;
|
||||
photos = photosByView(photos);
|
||||
openOverlay('p-analyzing');
|
||||
voice.play(VOICE.analyzing); // 「正在生成您的肌肤报告」
|
||||
var t0 = Date.now();
|
||||
@@ -639,9 +716,10 @@
|
||||
if (seq !== camSeq) return;
|
||||
closeOverlay('p-analyzing');
|
||||
stopCamera();
|
||||
voice.stop(); // 结果已出:排队中的「分析中」等不再补播
|
||||
shutterLock = false; // 流程结束解锁
|
||||
if (errMsg) {
|
||||
voice.play(VOICE.fail);
|
||||
voice.play(VOICE.fail, { cut: true });
|
||||
toast(errMsg);
|
||||
showScreen('s-profile'); // 回档案页,允许重新提交再拍
|
||||
return;
|
||||
@@ -969,7 +1047,9 @@
|
||||
// 降级标注:只展示用户可感知/可执行的(下颌线、侧脸、补拍类);
|
||||
// 引擎内部技术项(上限锁未执行/字段未返回等)不面向用户展示
|
||||
var notesEl = $('#vm-notes');
|
||||
var userNotes = (vm.notes || []).filter(function (t) { return /下颌线|侧脸|补拍|重拍/.test(t); });
|
||||
var userNotes = (vm.notes || [])
|
||||
.filter(function (t) { return /下颌线|侧脸|补拍|重拍/.test(t); })
|
||||
.map(function (t) { return t.replace(/\(quality=[^)]*\)/g, ''); }); // 接口质量码仅留在引擎留痕里
|
||||
if (userNotes.length) {
|
||||
notesEl.innerHTML = userNotes.map(function (t) { return '*' + esc(t); }).join('<br>');
|
||||
notesEl.style.display = '';
|
||||
@@ -987,12 +1067,16 @@
|
||||
}
|
||||
|
||||
// 精华推荐卡
|
||||
// 精华推荐卡只放产品名 + 命中判据:推荐正文已是护理建议第 4 段(V2.3 §8.5),卡内不再重复同一句
|
||||
$('#essence-name').textContent = vm.essence.name;
|
||||
$('#essence-text').textContent = vm.essence.text;
|
||||
// 命中判据:引擎内部名 → 用户可读措辞(引擎值被真值回归逐字锁定,仅展示层映射)
|
||||
var TRIGGER_LABEL = { '均分档': '综合均分', '最差项约束': '最低分维度', '年龄组档': '年龄段' };
|
||||
var TRIGGER_LABEL = {
|
||||
'均分档': vm.avgS != null ? '六项平均分 ' + vm.avgS : '六项平均分',
|
||||
'最差项约束': vm.worst ? vm.worst.name + '得分偏低' : '单项得分偏低',
|
||||
'年龄组档': vm.group ? '年龄段 ' + vm.group.replace('组', '') : '年龄段'
|
||||
};
|
||||
$('#essence-triggers').textContent = vm.essence.triggers.length
|
||||
? '推荐依据:' + vm.essence.triggers.map(function (t) { return TRIGGER_LABEL[t] || t; }).join('、')
|
||||
? '推荐依据:' + vm.essence.triggers.map(function (t) { return TRIGGER_LABEL[t] || t; }).join('、')
|
||||
: '';
|
||||
}
|
||||
|
||||
@@ -1077,6 +1161,7 @@
|
||||
},
|
||||
'cam-back': function () {
|
||||
camSeq++; // 使未决的快门/分析回调失效,避免回退被旧定时器顶回
|
||||
voice.stop(true); // 回退/退出:上一步的播报不再接着念
|
||||
shutterLock = false; // 中途退出也解锁
|
||||
if (camStep > 0) { camStep--; renderCamStep(); } // renderCamStep 内含检测状态机重置
|
||||
else { stopCamera(); showScreen('s-profile'); }
|
||||
@@ -1193,6 +1278,7 @@
|
||||
function handleCamHidden() {
|
||||
if (currentScreen !== 's-cam') return;
|
||||
camSeq++; // 使未决的快门/推进/分析回调失效(白屏定时器醒来即自洁退出)
|
||||
voice.stop(true);
|
||||
shutterLock = false;
|
||||
capturing = false;
|
||||
$('#cam-fill').classList.remove('on'); // 白屏若亮着立即熄灭
|
||||
|
||||
Reference in New Issue
Block a user