save
This commit is contained in:
+168
-101
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user