This commit is contained in:
xsl
2026-10-10 14:23:43 +08:00
parent 658b232a39
commit 2a7593b736
11 changed files with 528 additions and 149 deletions
+168 -101
View File
@@ -70,7 +70,10 @@
if (e.key !== 'Escape' && e.key !== 'Esc') return;
var order = ['p-zoom', 'p-policy', 'p-privacy', 'p-photoguide'];
for (var i = 0; i < order.length; i++) {
if ($('#' + order[i]).classList.contains('show')) { closeOverlay(order[i]); return; }
if ($('#' + order[i]).classList.contains('show')) {
if (order[i] === 'p-zoom') closeZoom(); else closeOverlay(order[i]);
return;
}
}
});
@@ -169,13 +172,26 @@
}, true);
/* ─── 档案选择控件:出生年月 / 性别 / 省份城市,统一用 mobile-select 底部滚轮(选中值写入同名 hidden input) ─── */
var NOW = new Date(), NOW_Y = NOW.getFullYear(), NOW_M = NOW.getMonth() + 1;
var YEARS = [], MONTHS = [];
for (var yy = NOW_Y - 99; yy <= NOW_Y - 1; yy++) YEARS.push(yy + '年');
for (var mm = 1; mm <= 12; mm++) MONTHS.push(mm + '月');
var NOW_Y = new Date().getFullYear();
var YEARS = [];
for (var yy = NOW_Y - 100; yy <= NOW_Y - 1; yy++) {
var months = [];
for (var mm = 1; mm <= 12; mm++) {
var selectableAge = ageFromBirth(yy, mm);
if (selectableAge >= 1 && selectableAge <= 99) months.push({ id: mm, value: mm + '月' });
}
if (months.length) YEARS.push({ id: yy, value: yy + '年', childs: months });
}
var regionSel = $('#f-region');
function setPick(sel, text) { $(sel + ' .pick-value').textContent = text; }
function ageFromBirth(y, m) { return NOW_Y - y - (NOW_M < m ? 1 : 0); } // 未过生日月份按少一岁
function setPick(sel, text) { var el = $(sel + ' .pick-value'); el.textContent = text; el.classList.remove('placeholder'); }
function ageFromBirth(y, m) { var now = new Date(); return now.getFullYear() - y - (now.getMonth() + 1 < m ? 1 : 0); }
function refreshAge() {
var birth = $('#f-birth').value.split('-');
if (birth.length !== 2) return;
var age = ageFromBirth(+birth[0], +birth[1]);
$('#f-age').value = age;
$('#profile-age').textContent = '年龄:' + age + '岁(按出生月份计算)';
}
function makePicker(trigger, opts) {
if (typeof MobileSelect === 'undefined') return null;
return new MobileSelect(Object.assign({
@@ -185,12 +201,12 @@
}
makePicker('#pick-birth', {
title: '出生年月',
wheels: [{ data: YEARS }, { data: MONTHS }],
position: [YEARS.indexOf((NOW_Y - 30) + '年'), 0],
wheels: [{ data: YEARS }],
position: [YEARS.map(function (y) { return y.id; }).indexOf(NOW_Y - 30), 0],
onChange: function (val) {
var y = parseInt(val[0], 10), m = parseInt(val[1], 10);
var y = +val[0].id, m = +val[1].id;
$('#f-birth').value = y + '-' + (m < 10 ? '0' : '') + m;
$('#f-age').value = ageFromBirth(y, m);
refreshAge();
setPick('#pick-birth', y + '年' + m + '月');
}
});
@@ -221,9 +237,6 @@
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换)
var camMirrored = true; // 预览镜像:前置 true(照镜子习惯,Figma 侧脸轮廓即按镜像画面绘制),后置 false
var sideYawFlip = false; // 侧脸步语义 yaw 反向标志:个别机型前置帧本身已镜像时由自校准翻转
var mirrorCalibrated = false; // 侧脸自校准:本轮是否已翻转过一次(防振荡,一次校准管整个会话)
var oppFrames = 0; // 侧脸步语义 yaw 连续反向帧计数
// 预览是否镜像(仅预览;抓帧恒为原始视角)。后置不镜像时侧脸轮廓需水平翻转,才与画面中的脸朝向一致
function effMirror() { return camMirrored; }
function applyMirror() {
@@ -297,12 +310,17 @@
camLive = false;
$('#s-cam').classList.remove('cam-live');
$('#cam-mask').classList.remove('scanning');
$('#cam-fill').classList.remove('on');
if (capturing) shutterLock = false;
capturing = false;
okSince = 0;
}
function enableFallback() {
camFallback = true;
$('#cam-status').classList.remove('show'); // 与 stopCamera 对齐:去掉「检测引擎加载中」胶囊
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框,快门出现
toast('已切换为系统相机拍摄');
resetDetectState();
setHint('请允许相机权限后重试', false, true);
toast('相机暂不可用,请重试或按顺序选择照片');
// 系统相机拍回的照片也要校验(有脸、角度对),模型照常预加载
if (!IS_MOCK && typeof faceapi !== 'undefined') loadModels().catch(function () {});
}
@@ -324,10 +342,18 @@
function readPhotoFile(f) {
if (!f) return;
var step = camStep, seq = camSeq;
if (currentScreen !== 's-cam' || capturing) return;
shutterLock = true;
okSince = 0;
var active = function () { return seq === camSeq && step === camStep && currentScreen === 's-cam'; };
function fail(message) { if (!active()) return; shutterLock = false; hideCamStatus(); toast(message); }
var reader = new FileReader();
reader.onerror = function () { fail('照片读取失败,请重新选择'); };
reader.onload = function () {
if (!active()) return;
var img = new Image();
img.onload = function () {
if (!active()) return;
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);
@@ -336,43 +362,39 @@
shutterLock = true; // 校验期间不接受新的拍摄/选图
showCamStatus('照片检查中…');
checkStill(url, CAM_STEPS[step].target).then(function (err) {
if (!active()) return;
hideCamStatus();
if (seq !== camSeq || step !== camStep) return; // 期间已退出/重置
shutterLock = false;
if (err) { toast(err); voice.play(VOICE.fail, { cut: true }); return; } // 不合格:停在本步重拍
onPhotoCaptured(url);
});
};
img.onerror = function () { toast('照片读取失败,请重新选择'); };
img.onerror = function () { fail('照片读取失败,请重新选择'); };
img.src = reader.result;
};
reader.readAsDataURL(f);
}
// 静态照片校验:有人脸、朝向与当前步一致(检测模型不可用时放行,由服务端兜底)
// 系统相机的前置照片是否镜像因机型而异,侧脸只校验转头幅度、不校验左右
var STILL_FRONT_YAW_MAX = 20, STILL_SIDE_YAW_MIN = 20;
// 相册也须等模型就绪后校验单人、正确侧脸和光线,失败停留在当前步骤。
function checkStill(dataUrl, target) {
if (IS_MOCK || !modelsOk || typeof faceapi === 'undefined') return Promise.resolve('');
return new Promise(function (resolve) {
if (IS_MOCK) return Promise.resolve('');
if (typeof faceapi === 'undefined') return Promise.resolve('识别服务未加载,请刷新后重试');
return loadModels().then(function () { return new Promise(function (resolve) {
var img = new Image();
img.onload = function () {
faceapi
.detectSingleFace(img, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
.detectAllFaces(img, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
.withFaceLandmarks()
.then(function (d) {
if (!d) { resolve('照片中未识别到人脸,请重新拍摄'); return; }
var e = null;
try { e = HeadPose.estimate(d.landmarks.positions, false); } catch (err) { e = null; }
if (!e) { resolve(''); return; }
var yaw = Math.abs(e.yaw);
if (target === 'front' && yaw > STILL_FRONT_YAW_MAX) { resolve('请拍摄正脸照片'); return; }
if (target !== 'front' && yaw < STILL_SIDE_YAW_MIN) { resolve('请拍摄' + (target === 'right' ? '右' : '左') + '侧脸照片'); return; }
resolve('');
}, function () { resolve(''); });
.then(function (faces) {
if (!faces.length) { resolve('照片中未识别到人脸,请重新选择'); return; }
if (faces.length !== 1) { resolve('请使用只有一人的照片'); return; }
var d = faces[0], pose = HeadPose.check(d.landmarks.positions, target, false);
if (!pose.ok) { resolve('请按顺序选择' + (target === 'front' ? '正脸' : target === 'right' ? '右侧脸' : '左侧脸') + '照片,' + pose.hint); return; }
resolve(lightingHint(faceLuma(d.detection.box, img)));
}, function () { resolve('照片检查失败,请重新选择'); });
};
img.onerror = function () { resolve('照片读取失败,请重新选择'); };
img.src = dataUrl;
});
}); }).catch(function () { return '识别服务加载失败,请重试'; });
}
function showCamStatus(text) { var s = $('#cam-status'); s.textContent = text; s.classList.add('show'); }
function hideCamStatus() { $('#cam-status').classList.remove('show'); }
@@ -521,13 +543,14 @@
var OK_HOLD_MS = 450; // 条件连续合格时长:滤掉一闪而过的合格帧(防运动模糊),达标立即抓拍
var NO_PROGRESS_MS = 12000; // 单步超过该时长仍未抓拍→浮出调整提示
var PREVIEW_SCALE = 0.85; // 与 CSS #cam-video 的 scale 一致:预览缩小 15%
// 人脸框高/视频高。原标定(取景框≈42% 屏高)为 0.26/0.58;预览缩小 15% 后,同样装满轮廓需要脸在原始帧里大 1/0.85 倍
var FACE_NEAR = 0.30, FACE_FAR = 0.68;
// 以屏幕显示的人脸大小判断距离,预览缩小 15% 后允许用户更靠近镜头。
var FACE_NEAR = 0.26, FACE_FAR = 0.58; // 按缩放后的屏幕占比判断,不受横竖视频分辨率影响
var CENTER_TOL = 0.18; // 人脸中心偏离取景中线超过屏宽 18% → 提示移入取景框
var LUMA_DARK = 65, LUMA_BRIGHT = 235; // 脸部平均亮度(0–255):过暗/过曝提示调整光线
var modelsPromise = null, modelsOk = false, detectDisabled = false; // detectDisabled:face-api 缺失→全程手动;模型加载失败仅当次禁用,下次进拍照页重试
var detRunning = false, detTimer = null; // 检测循环
var detEpoch = 0; // 启停/切换镜头时作废上一轮未完成的异步检测
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
var nudgeTimer = null; // 12s 无进展提示
var capturing = false; // 白屏补光抓帧中(暂停姿态判定)
@@ -572,18 +595,18 @@
}
function hideNudge() { $('#cam-nudge').classList.remove('show'); }
// 手动模式(显示快门):系统相机降级 / 识别不可用 / mock 调试;其余为实时检测自动抓拍
function isManualMode() { return IS_MOCK || camFallback || detectDisabled; }
// 只有 mock 调试显示快门,正式页面在异常时提供重试入口。
function isManualMode() { return IS_MOCK; } // 手动快门只用于 mock 调试,正式流程始终自动捕捉
function syncCamMode() {
$('#s-cam').classList.toggle('cam-manual', isManualMode());
$('#cam-mask').classList.toggle('scanning', camLive && !isManualMode() && modelsOk);
$('#cam-mask').classList.toggle('scanning', camLive && !isManualMode() && modelsOk && !detectDisabled);
$('[data-action="retry-camera"]').hidden = IS_MOCK || (!camFallback && !detectDisabled);
}
// 每步进入/回退时的检测状态机重置(蒙版/提示/无进展计时全部归位)
function resetDetectState() {
okSince = 0;
missFrames = 0;
oppFrames = 0;
lastHint = '';
clearTimeout(nudgeTimer);
hideNudge();
@@ -596,7 +619,7 @@
function armNudge() {
clearTimeout(nudgeTimer);
hideNudge();
if (isManualMode()) return;
if (isManualMode() || camFallback || detectDisabled) return;
if (currentScreen !== 's-cam') return;
nudgeTimer = setTimeout(function () {
if (currentScreen === 's-cam' && !shutterLock) $('#cam-nudge').classList.add('show');
@@ -607,33 +630,35 @@
if (detRunning || IS_MOCK || detectDisabled || !modelsOk || !camLive || currentScreen !== 's-cam') return;
detRunning = true;
syncCamMode();
detTick();
detTick(++detEpoch);
}
function stopDetect() {
detRunning = false;
detEpoch++;
okSince = 0;
clearTimeout(detTimer);
clearTimeout(nudgeTimer);
hideNudge();
}
// 递归 setTimeout 而非 setInterval:检测是异步的,避免帧与帧堆叠
function detTick() {
if (!detRunning) return;
function detTick(epoch) {
if (!detRunning || epoch !== detEpoch) return;
detTimer = setTimeout(function () {
if (!detRunning) return;
detectFrame().then(detTick, detTick);
if (!detRunning || epoch !== detEpoch) return;
detectFrame(epoch).then(function () { detTick(epoch); }, function () { detTick(epoch); });
}, DETECT_INTERVAL);
}
function detectFrame() {
function detectFrame(epoch) {
if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态
var step = camStep, seq = camSeq; // 快照:检测是异步的,帧落地时步骤/流程可能已变
return faceapi
.detectSingleFace(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
.detectAllFaces(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
.withFaceLandmarks()
.then(function (d) {
if (step !== camStep || seq !== camSeq) return; // 跨步骤陈帧/流程已重置:丢弃
if (detRunning && !shutterLock && !capturing) onDetect(d);
.then(function (faces) {
if (step !== camStep || seq !== camSeq || epoch !== detEpoch) return;
if (detRunning && !shutterLock && !capturing) onDetect(faces);
})
.catch(function () { /* 单帧异常忽略,下一帧继续 */ });
.catch(function () { if (epoch === detEpoch) okSince = 0; });
}
// 人脸框中心相对取景中线的水平偏移(占屏宽比例);按 object-fit:cover + 预览缩放换算到屏幕,镜像只影响符号
function faceOffsetX(box) {
@@ -642,58 +667,66 @@
var s = Math.max(W / vw, H / vh) * PREVIEW_SCALE;
return ((box.x + box.width / 2) - vw / 2) * s / W;
}
function displayedFaceRatio(box) {
var vw = camVideo.videoWidth, vh = camVideo.videoHeight, W = camVideo.clientWidth, H = camVideo.clientHeight;
if (!vw || !vh || !W || !H) return 0;
return box.height * Math.max(W / vw, H / vh) * PREVIEW_SCALE / H;
}
function faceOffsetY(box) {
var vw = camVideo.videoWidth, vh = camVideo.videoHeight, W = camVideo.clientWidth, H = camVideo.clientHeight;
if (!vw || !vh || !W || !H) return 1;
var s = Math.max(W / vw, H / vh) * PREVIEW_SCALE;
var maskScale = Math.min(W / 375, H / 726);
var targetY = (H - 726 * maskScale) / 2 + 320 * maskScale;
return (H / 2 + (box.y + box.height / 2 - vh / 2) * s - targetY) / H;
}
function lightingHint(luma) {
if (luma == null) return '画面读取失败,请重试';
if (luma < LUMA_DARK) return '请到光线充足处';
if (luma > LUMA_BRIGHT) return '光线过强,请避开强光';
return '';
}
// 脸部平均亮度:人脸框缩到 16×16 取均值
var lumaCanvas = null;
function faceLuma(box) {
function faceLuma(box, source) {
try {
if (!lumaCanvas) { lumaCanvas = document.createElement('canvas'); lumaCanvas.width = lumaCanvas.height = 16; }
var c = lumaCanvas.getContext('2d', { willReadFrequently: true });
c.drawImage(camVideo, box.x, box.y, box.width, box.height, 0, 0, 16, 16);
c.drawImage(source || camVideo, box.x, box.y, box.width, box.height, 0, 0, 16, 16);
var px = c.getImageData(0, 0, 16, 16).data, sum = 0;
for (var i = 0; i < px.length; i += 4) sum += 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
return sum / (px.length / 4);
} catch (e) { return null; }
}
var missFrames = 0; // 连续漏检帧计数:单帧抖动不报「未检测到人脸」
function onDetect(d) {
function onDetect(faces) {
if (faces.length > 1) {
okSince = 0; setMask(''); setHint('请保持单人出镜', false); return;
}
var d = faces[0];
var hint = '', ok = false;
missFrames = d ? 0 : missFrames + 1;
var hasFace = !!d, noFace = !hasFace && missFrames >= MISS_FRAMES_MAX;
if (noFace) {
hint = '未检测到人脸';
} else if (!hasFace) {
return; // 偶发漏检帧:保持上一条提示与状态,不扰动 ok 累积
okSince = 0; // 文案抗抖,但任何漏检都中断连续合格时长
return;
} else {
// 提示优先级:无人脸 > 距离 > 居中 > 角度 > 光线
var box = d.detection.box;
var ratio = box.height / (camVideo.videoHeight || 1);
var ratio = displayedFaceRatio(box);
if (ratio < FACE_NEAR) hint = '请靠近一点';
else if (ratio > FACE_FAR) hint = '请远一点';
else if (Math.abs(faceOffsetX(box)) > CENTER_TOL) hint = '请将脸部移入取景框';
else if (Math.abs(faceOffsetX(box)) > CENTER_TOL || Math.abs(faceOffsetY(box)) > 0.16) hint = '请将脸部移入取景框';
else {
// 特征点取自原始帧(预览的 CSS 镜像不影响),三步的语义方向都按原始视角判定:
// 标准机型 sideYawFlip 恒为 false,个别前置帧本身已镜像的机型由侧脸自校准翻转
// 按摄像头原始帧判断真实左右;持续转错方向也不能自动交换左右照片。
var tgt = CAM_STEPS[camStep].target;
var pose = HeadPose.check(d.landmarks.positions, tgt, sideYawFlip);
// 侧脸自校准:个别机型前置帧本身已镜像(语义 yaw 与实际转向持续相反)时自救——
// 需连续 30 帧(~10s)强反向才翻转一次:阈值要高,否则用户转错方向 1~2s 就会被误判为镜像机,
// 反而把错误的侧脸当合格抓走(左右图归属错乱)。翻转只动判定方向,不动预览画面
if (tgt !== 'front' && pose.yaw != null && !mirrorCalibrated) {
var wantSign = tgt === 'right' ? 1 : -1;
if (pose.yaw * wantSign < -12) {
if (++oppFrames >= 30) {
sideYawFlip = !sideYawFlip;
mirrorCalibrated = true;
oppFrames = 0;
}
} else oppFrames = 0;
}
var pose = HeadPose.check(d.landmarks.positions, tgt, false);
if (!pose.ok) hint = pose.hint;
else {
var luma = faceLuma(box);
if (luma != null && luma < LUMA_DARK) hint = '请到光线充足处';
else if (luma != null && luma > LUMA_BRIGHT) hint = '光线过强,请避开强光';
else ok = true;
hint = lightingHint(faceLuma(box));
ok = !hint;
}
}
window.__dbgRatio = ratio; // 自动化校准/测试用(检测框高/视频高)
@@ -729,6 +762,11 @@
.map(function (g) { return '<span>' + g + '</span>'; })
.join('<i></i>');
$('#cam-mask').setAttribute('data-step', cfg.mask); // 蒙版按步骤切换轮廓
document.querySelectorAll('[data-capture-step]').forEach(function (el, i) {
el.classList.toggle('done', i < camStep);
el.classList.toggle('current', i === camStep);
if (i === camStep) el.setAttribute('aria-current', 'step'); else el.removeAttribute('aria-current');
});
applyMirror();
resetDetectState();
voice.play(VOICE.step[camStep]); // 步骤引导语音
@@ -739,6 +777,7 @@
// 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步
function onPhotoCaptured(dataUrl) {
if (currentScreen !== 's-cam' || !dataUrl || photos.length !== camStep) return;
shutterLock = true; // 推进窗口上锁,直到下一步/送检
photos[camStep] = dataUrl;
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废
@@ -755,28 +794,26 @@
}, 300);
}
// 白屏补光抓拍:全屏纯白瞬间点亮→约300ms 后抓全分辨率帧→保持300ms 再淡出(总 600–900ms)
// 合格即抓取当前帧,随后闪白反馈;不在延时期间抓取已偏离角度的照片。
function doCapture() {
if (shutterLock) return;
if (!camLive) return;
var shot = captureFromVideo();
if (!shot) { okSince = 0; return; }
shutterLock = true;
capturing = true;
clearTimeout(nudgeTimer);
hideNudge();
var seq = camSeq;
var seq = camSeq, camera = camSeqCam;
var fill = $('#cam-fill');
fill.classList.add('on'); // .on 无过渡,瞬间点亮
if (navigator.vibrate) navigator.vibrate(30);
setTimeout(function () {
var shot = captureFromVideo();
setTimeout(function () {
fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出
capturing = false;
if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出)
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即再抓
onPhotoCaptured(shot);
}, 300);
}, 300);
if (seq !== camSeq || camera !== camSeqCam) return;
fill.classList.remove('on');
capturing = false;
onPhotoCaptured(shot);
}, 180);
}
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
@@ -831,6 +868,7 @@
}
function shutter() {
if (!IS_MOCK) return;
if (shutterLock) return;
if (camLive) {
if (!isManualMode()) return; // 实时检测模式只自动抓拍,不接受手动快门
@@ -1178,26 +1216,29 @@
: '';
}
/* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */
/* ─── 相机可用后加载检测模型,完成前等待,失败可重试 ─── */
function beginDetect() {
var camera = camSeqCam;
if (IS_MOCK || camFallback) { resetDetectState(); return; }
if (typeof faceapi === 'undefined') {
detectDisabled = true; // face-api 本身缺失:全程手动,不再重试
detectDisabled = true;
resetDetectState();
toast('自动识别不可用,已切换手动拍摄');
toast('自动识别暂不可用,请重试');
return;
}
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
resetDetectState();
if (!modelsOk) showCamStatus('检测引擎加载中…');
loadModels().then(function () {
if (camera !== camSeqCam || currentScreen !== 's-cam') return;
$('#cam-status').classList.remove('show');
maybeStartDetect();
}).catch(function () {
if (camera !== camSeqCam || currentScreen !== 's-cam') return;
$('#cam-status').classList.remove('show');
detectDisabled = true; // 仅禁用当次;下次进拍照页 beginDetect 复位后重试
resetDetectState();
if (currentScreen === 's-cam') toast('自动识别不可用,已切换手动拍摄');
if (currentScreen === 's-cam') toast('自动识别暂不可用,请重试');
});
}
@@ -1218,21 +1259,39 @@
});
/* ─── 报告页:每张维度卡各自的子 Tab;点检测图放大,再点返回 ─── */
var zoomSource = null;
function openZoom(img) {
if (!img || !img.src || img.hidden) return;
zoomSource = img;
$('#zoom-img').src = img.src;
$('#zoom-img').alt = img.alt;
openOverlay('p-zoom');
history.pushState({ screen: currentScreen, overlay: 'p-zoom' }, '');
$('.zoom-close').focus();
}
function closeZoom() {
closeOverlay('p-zoom');
if (zoomSource) zoomSource.focus({ preventScroll: true });
if (history.state && history.state.overlay === 'p-zoom') popHistorySilently();
}
document.querySelectorAll('.ditem-img').forEach(function (img) {
img.tabIndex = 0;
img.setAttribute('role', 'button');
img.setAttribute('aria-label', img.alt + ',点击放大');
img.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openZoom(img); }
});
});
document.querySelectorAll('.card-detail').forEach(function (card) {
card.addEventListener('click', function (e) {
var btn = e.target.closest('.dtab');
if (btn) { setDetailTab(card, btn.dataset.tab); return; }
var img = e.target.closest('.ditem-img');
if (img && img.src && !img.hidden) {
$('#zoom-img').src = img.src;
openOverlay('p-zoom');
history.pushState({ screen: currentScreen, overlay: 'p-zoom' }, '');
}
if (img) openZoom(img);
});
});
$('#p-zoom').addEventListener('click', function () {
closeOverlay('p-zoom');
popHistorySilently();
closeZoom();
});
/* ─── 动作分发 ─── */
@@ -1246,6 +1305,7 @@
showScreen('s-profile', 'push');
},
'submit-profile': function () {
refreshAge();
var age = +$('#f-age').value;
if (!$('#f-birth').value || !(age >= 1 && age <= 99)) { toast('请选择出生年月'); return; }
if (!$('#f-gender').value) { toast('请选择性别'); return; }
@@ -1265,16 +1325,23 @@
},
'shutter': shutter,
'album': function () {
if (shutterLock || currentScreen !== 's-cam') return;
okSince = 0;
shutterLock = true; // 与降级快门同策略:前置上锁,change/cancel 解锁
albumFile.click();
},
'flip': function () { // 切换前后镜头
if (shutterLock || currentScreen !== 's-cam') return;
if (camFallback) { toast('系统相机模式下请在系统相机内切换'); return; }
if (!camStream) { toast('相机启动中,请稍候'); return; }
camFacing = camFacing === 'user' ? 'environment' : 'user';
camMirrored = camFacing === 'user';
startCamera(); // 复用启停与预热(camSeqCam 防竞态)
},
'retry-camera': function () {
if (shutterLock || currentScreen !== 's-cam') return;
startCamera();
},
'history': function () { openHistory(); },
'close-history': function () { closeOverlay('p-history'); },
'clear-history': function () {