feat(h5): apply fourth feedback and tighten local capture validation

This commit is contained in:
xsl
2026-10-12 20:16:41 +08:00
parent e53c809522
commit 92a3965b7d
33 changed files with 3758 additions and 1385 deletions
+158 -89
View File
@@ -76,7 +76,7 @@
function renderAccountStatus() {
var user = AccountClient.user();
document.querySelectorAll('[data-auth-status]').forEach(function (label) {
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : '本机浏览器已登录') : '尚未登录';
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : '游客已登录') : '尚未登录';
label.parentElement.dataset.kind = user ? user.kind : '';
});
}
@@ -84,7 +84,7 @@
showScreen('s-splash', 'replace');
$('#login-success-message').textContent = AccountClient.user().kind === 'wechat'
? '当前使用:微信登录\n同一微信账号可跨手机查看报告。'
: '当前使用:本机浏览器登录\n报告仅能通过当前浏览器身份查看。';
: '当前使用:游客登录\n游客报告仅可在当前浏览器查看,不能跨手机同步。';
openOverlay('p-login-success');
document.querySelectorAll('.screen').forEach(function (screen) { screen.inert = true; });
$('[data-action="login-confirm"]').focus();
@@ -141,21 +141,69 @@
}
/* ─── 弹窗管理 ─── */
var SCAN_CYCLE_MS = 1000, scanCycle = null, scanMask = null;
function restartScanCycle() {
if (!scanCycle || scanCycle.done) return;
var overlay = $('#p-analyzing'), cycle = scanCycle;
overlay.classList.remove('scan-running');
clearTimeout(cycle.timer);
// Restart from the first painted frame, including a late-loaded PSD image.
requestAnimationFrame(function () { requestAnimationFrame(function () {
if (scanCycle !== cycle || cycle.done) return;
overlay.style.setProperty('--scan-cycle', SCAN_CYCLE_MS + 'ms');
overlay.classList.add('scan-running');
// Legacy browsers without CSS animations still get a bounded visible wait.
if (getComputedStyle($('.scan-beam')).animationName === 'none') cycle.timer = setTimeout(cycle.resolve, SCAN_CYCLE_MS);
}); });
}
function startScanCycle() {
stopScanCycle();
var cycle = { done: false, timer: null };
cycle.promise = new Promise(function (resolve) {
cycle.resolve = function () { if (!cycle.done) { cycle.done = true; clearTimeout(cycle.timer); resolve(); } };
});
scanCycle = cycle;
restartScanCycle();
return cycle.promise;
}
function stopScanCycle() {
if (scanCycle) scanCycle.resolve();
scanCycle = null;
$('#p-analyzing').classList.remove('scan-running');
}
$('#p-analyzing').addEventListener('animationiteration', function (event) {
if (!scanCycle || scanCycle.done) return;
var ready = this.classList.contains('scan-artwork-ready');
if ((ready && event.target.classList.contains('scan-beam')) || (!ready && event.target.classList.contains('scan-status'))) scanCycle.resolve();
});
function prepareScanArtwork() {
var image = $('#scan-artwork');
function ready() {
var wasReady = $('#p-analyzing').classList.contains('scan-artwork-ready');
var loaded = image.complete && image.naturalWidth && scanMask && scanMask.complete && scanMask.naturalWidth;
$('#p-analyzing').classList.toggle('scan-artwork-ready', !!loaded);
if (loaded && !wasReady) restartScanCycle();
}
if (!scanMask || (scanMask.complete && !scanMask.naturalWidth)) {
scanMask = new Image(); scanMask.onload = ready; scanMask.onerror = ready;
scanMask.src = $('#scan-face').dataset.mask;
$('#scan-face').style.webkitMaskImage = 'url("' + scanMask.src + '")';
$('#scan-face').style.maskImage = 'url("' + scanMask.src + '")';
}
if (!image.getAttribute('src') || (image.complete && !image.naturalWidth)) {
image.onload = function () { $('#p-analyzing').classList.add('scan-artwork-ready'); };
image.onerror = function () { $('#p-analyzing').classList.remove('scan-artwork-ready'); };
image.onload = ready;
image.onerror = ready;
image.src = image.dataset.src;
}
ready();
}
function openOverlay(id) {
if (id === 'p-privacy' || id === 'p-photoguide') loadAppFont();
if (id === 'p-analyzing') prepareScanArtwork();
$('#' + id).classList.add('show');
if (id === 'p-analyzing') $('.scan-cancel').focus({ preventScroll: true });
if (id === 'p-analyzing') { startScanCycle(); $('.scan-cancel').focus({ preventScroll: true }); }
}
function closeOverlay(id) { $('#' + id).classList.remove('show'); }
function closeOverlay(id) { $('#' + id).classList.remove('show'); if (id === 'p-analyzing') stopScanCycle(); }
// 点遮罩空白处关闭(与各自关闭按钮同行为:隐私→回启动页、拍照引导→留在档案页)
// 只响应遮罩本身(拍照引导另含底图层),不误伤弹窗卡片内交互
@@ -329,6 +377,7 @@
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 同序;送检前经 photosByView 重排为 [正脸, 左脸, 右脸]
var captureDiagnostics = [], holdState = null;
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换)
@@ -440,6 +489,7 @@
reader.onload = function () {
var img = new Image();
img.onload = function () {
if (seq !== camSeq || step !== camStep) 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);
@@ -447,9 +497,9 @@
var url = c.toDataURL('image/jpeg', 0.88);
shutterLock = true; // 校验期间不接受新的拍摄/选图
showCamStatus('照片检查中…');
checkStill(url, CAM_STEPS[step].target).then(function (err) {
hideCamStatus();
checkStill(url, CAM_STEPS[step].target, { mode: 'file', ignoreDirection: true, step: step, seq: seq }).then(function (err) {
if (seq !== camSeq || step !== camStep) return; // 期间已退出/重置
hideCamStatus();
shutterLock = false;
if (err) { toast(err); voice.play(VOICE.fail, { cut: true }); return; } // 不合格:停在本步重拍
onPhotoCaptured(url);
@@ -460,27 +510,42 @@
};
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('');
// Live final frames and file uploads share all local quality gates. Files may
// be mirrored by the system camera; their side direction remains unverified.
function checkStill(dataUrl, target, options) {
options = options || { mode: 'file', ignoreDirection: true, step: camStep, seq: camSeq };
function remember(sample) { if (options.seq === camSeq) captureDiagnostics[options.step] = sample; }
if (IS_MOCK) { remember({ mode: 'mock' }); return Promise.resolve(''); }
if (!modelsOk || typeof faceapi === 'undefined') {
if (modelsPromise && !options.waited) {
options.waited = true;
return new Promise(function (resolve) {
var timer = setTimeout(resolve, 8000);
modelsPromise.then(function () { clearTimeout(timer); resolve(); }, function () { clearTimeout(timer); resolve(); });
}).then(function () { return checkStill(dataUrl, target, options); });
}
// Keep existing unsupported-device access, explicitly marked for admin.
// Never silently treat an unavailable local engine as a passed check.
remember({ mode: 'unchecked', reason: 'model-unavailable' });
return Promise.resolve('');
}
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; }
.then(function (faces) {
var d = faces[0], box = d && d.detection.box;
var verdict = CaptureQuality.evaluate(d, { count: faces.length, target: target,
width: img.naturalWidth, height: img.naturalHeight,
offsetX: box ? (box.x + box.width / 2) / img.naturalWidth - .5 : 0,
luma: box ? faceLuma(box, img) : null,
mirrored: options.mode === 'live' && sideYawFlip, ignoreDirection: options.ignoreDirection });
if (!verdict.ok) { resolve(verdict.hint + ',请重新拍摄'); return; }
remember(Object.assign({ mode: options.mode, direction_checked: !options.ignoreDirection }, verdict.observation));
resolve('');
}, function () { resolve(''); });
}, function () { resolve('照片检查失败,请重新拍摄'); });
};
img.onerror = function () { resolve('照片读取失败,请重新选择'); };
img.src = dataUrl;
@@ -628,15 +693,10 @@
var MODEL_URL = 'vendor/faceapi/models';
var DETECT_INTERVAL = 150; // 检测帧间隔(ms)
var DETECT_INPUT_SIZE = 416; // tinyFaceDetector 输入尺寸:320 下正脸置信度仅 0.67 且框偏松,416 置信度 ~0.99 框更准(CPU 端略慢,移动端 GPU 无感)
var DETECT_SCORE = 0.3; // 置信度阈值:0.4 在噪声帧易漏检,0.3 配合距离/角度闸门足够抗误检
var DETECT_SCORE = CaptureQuality.config.score;
var MISS_FRAMES_MAX = 3; // 连续漏检 ≥3 帧(~450ms)才报「未检测到人脸」,容忍单帧抖动
var OK_HOLD_MS = 450; // 条件连续合格时长:滤掉一闪而过的合格帧(防运动模糊),达标立即抓拍
var NO_PROGRESS_MS = 12000; // 单步超过该时长仍未抓拍→浮出调整提示
var PREVIEW_SCALE = 1; // 与 CSS #cam-video 铺满预览一致,镜像只改变方向
// 人脸框高/原始视频高;保留现有距离判定阈值。
var FACE_NEAR = 0.30, FACE_FAR = 0.68;
var CENTER_TOL = 0.18; // 人脸中心偏离取景中线超过屏宽 18% → 提示移入取景框
var LUMA_DARK = 65, LUMA_BRIGHT = 235; // 脸部平均亮度(0–255):过暗/过曝提示调整光线
var modelsPromise = null, modelsOk = false, detectDisabled = false;
var faceApiPromise = null;
@@ -716,6 +776,7 @@
// 每步进入/回退时的检测状态机重置(蒙版/提示/无进展计时全部归位)
function resetDetectState() {
okSince = 0;
holdState = null;
readyHapticAt = 0;
missFrames = 0;
oppFrames = 0;
@@ -762,13 +823,13 @@
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);
})
.catch(function () { /* 单帧异常忽略,下一帧继续 */ });
.catch(function () { if (step === camStep && seq === camSeq) { holdState = null; okSince = 0; } }); // Unchecked frames break continuous readiness.
}
// 人脸框中心相对取景中线的水平偏移(占屏宽比例);按 object-fit:cover + 预览缩放换算到屏幕,镜像只影响符号
function faceOffsetX(box) {
@@ -779,11 +840,11 @@
}
// 脸部平均亮度:人脸框缩到 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);
@@ -798,57 +859,51 @@
}
/* camera-haptic-pure-end */
function onDetect(d) {
var count = Array.isArray(d) ? d.length : d ? 1 : 0;
if (Array.isArray(d)) d = d[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 累积
holdState = null; okSince = 0;
return; // Keep the hint quiet, but never count a missing frame as stable.
} else {
// 提示优先级:无人脸 > 距离 > 居中 > 角度 > 光线
var box = d.detection.box;
var ratio = box.height / (camVideo.videoHeight || 1);
if (ratio < FACE_NEAR) hint = '请靠近一点';
else if (ratio > FACE_FAR) hint = '请远一点';
else if (Math.abs(faceOffsetX(box)) > CENTER_TOL) 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;
}
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;
}
// 原始帧判断语义方向;只有已确认镜像的机型才翻转。
var tgt = CAM_STEPS[camStep].target;
var pose = HeadPose.check(d.landmarks.positions, tgt, sideYawFlip);
// 侧脸自校准需连续 30 帧强反向才翻转一次,避免短暂转错方向误判为镜像。
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 verdict = CaptureQuality.evaluate(d, { count: count, target: tgt, mirrored: sideYawFlip,
width: camVideo.videoWidth, height: camVideo.videoHeight,
offsetX: faceOffsetX(box), luma: faceLuma(box) });
hint = verdict.hint; ok = verdict.ok;
window.__dbgRatio = ratio; // 自动化校准/测试用(检测框高/视频高)
}
setMask(ok ? 'ok' : (noFace ? 'no-face' : ''));
// 合格时「保持不动」只显示不播报:随即抓拍,「拍摄完成」会截断它
setHint(ok ? '保持不动' : hint, ok, ok);
if (!ok) { okSince = 0; return; }
if (!okSince) {
okSince = Date.now();
if (!ok) { okSince = 0; holdState = null; return; }
var held = CaptureQuality.hold(holdState, verdict.observation, Date.now());
holdState = held.state;
if (holdState.frames === 1) {
okSince = holdState.since;
if (!readyHapticAt || okSince - readyHapticAt >= 2000) { readyHapticAt = okSince; captureHaptic(navigator); }
}
else if (Date.now() - okSince >= OK_HOLD_MS) { okSince = 0; doCapture(); }
if (held.ready) { okSince = 0; holdState = null; doCapture(); }
}
/* ─── 拍照流程(三步,顺序对齐 Figma:正脸 → 右脸出镜 → 左脸出镜) ─── */
@@ -934,7 +989,15 @@
capturing = false;
if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出)
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即再抓
onPhotoCaptured(shot);
var step = camStep;
checkStill(shot, CAM_STEPS[step].target, { mode: 'live', ignoreDirection: false, step: step, seq: seq }).then(function (error) {
if (seq !== camSeq || step !== camStep) return;
if (error) {
shutterLock = false; holdState = null; okSince = 0;
setHint(error, false); armNudge(); return;
}
onPhotoCaptured(shot);
});
}, 300);
}, 300);
}
@@ -952,11 +1015,12 @@
region: regionSel.value || '',
skinType: SKINS[skinIndex].name
};
var t0 = Date.now();
var animationComplete = scanCycle.promise;
requestAnalyze(photos, seq, profile, function (errMsg, payload) {
if (seq !== camSeq) return; // 流程已被重置:丢弃
var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁
setTimeout(function () {
// A fast successful response waits for one actual visible animation cycle;
// cancellation/errors never get held hostage by the presentation animation.
(errMsg ? Promise.resolve() : animationComplete).then(function () {
if (seq !== camSeq) return;
closeOverlay('p-analyzing');
stopCamera();
@@ -987,7 +1051,7 @@
else if (!payload.report_saved) toast('报告生成成功,但保存失败,请勿关闭页面并联系管理员');
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机
}, wait);
});
});
}
@@ -1031,6 +1095,9 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
front_image: ph[0], left_image: ph[1], right_image: ph[2],
capture_diagnostics: { version: CaptureQuality.config.version, views: {
front: captureDiagnostics[0] || null, right: captureDiagnostics[1] || null, left: captureDiagnostics[2] || null
} },
profile: { // 档案随检测上报,后台记录用(服务端缺省 null)
real_age: profile && profile.age,
gender: profile && profile.gender,
@@ -1333,25 +1400,26 @@
setDim(k, vm.dims[k], k === worstDimKey ? 'worst' : k === bestDimKey ? 'best' : '');
});
var presentation = payload && payload.presentation || ReportUI.build(vm, payload && payload.result);
$('#dims-note').textContent = '';
var focus = ReportUI.focusOf(vm), focusEl = $('#dims-note');
focusEl.hidden = !focus;
focusEl.replaceChildren();
if (focus) {
var first = document.createElement('p'), name = document.createElement('b'), second = document.createElement('p');
first.className = 'dims-note-l1'; first.textContent = '近期优先关注:'; name.textContent = focus.name; first.appendChild(name);
second.className = 'dims-note-l2'; second.textContent = focus.tip;
focusEl.append(first, second);
}
var textIds = { contour: '#ditem-para1', wrinkles: '#ditem-para2', color: '#tx-tone', smooth: '#tx-smooth', water: '#tx-water', sens: '#tx-sens', acne: '#tx-acne' };
presentation.views.forEach(function (v) { $(textIds[v.key]).textContent = v.text; });
presentation.views.forEach(function (v) { $(textIds[v.key]).textContent = ReportUI.customerText(v); });
// 检测可视化:轮廓=正脸实拍,其余按 VIZ_CAT 取 face_maps 图层或叠加 marks(payload 缺失时各卡保留占位)
renderViz(payload, frontPhoto);
// 降级标注:只展示用户可感知/可执行的(下颌线、侧脸、补拍类);
// 引擎内部技术项(上限锁未执行/字段未返回等)不面向用户展示
// Fourth feedback: capture quality belongs in the protected admin record,
// not in the customer report. Preserve vm.notes/snapshots unchanged.
var notesEl = $('#vm-notes');
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 = '';
} else {
notesEl.style.display = 'none';
}
notesEl.textContent = '';
notesEl.style.display = 'none';
$('#care-list').innerHTML = presentation.care.map(function (s, i) {
return '<p class="care-item">' + (i + 1) + '、' + esc(s.text) + '</p>';
@@ -1493,6 +1561,7 @@
camSeq++; // 使旧的快门/分析回调失效
camStep = 0;
photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍)
captureDiagnostics = [];
shutterLock = false; // 重新进入流程,保险解锁
showScreen('s-cam', 'push');
startCamera(); // beginDetect 在其成功回调里:确认相机可用才加载模型
@@ -1515,7 +1584,7 @@
'switch-login': function () {
historySeq++;
$('#login-consent').checked = false;
$('#login-message').textContent = '微信与本机浏览器为独立账号,报告不会自动合并。请选择登录方式;返回可保留当前登录。';
$('#login-message').textContent = '微信与游客为独立账号,报告不会自动合并。请选择登录方式;返回可保留当前登录。';
showScreen('s-login', 'push');
}
};
@@ -1537,7 +1606,7 @@
$('#history-count').textContent = '您最近已进行过' + data.total + '次面部问题检测';
$('#identity-note').textContent = AccountClient.user().kind === 'wechat'
? '保留最近 5 份报告,同一微信账号可跨手机查看。'
: '保留最近 5 份报告。本机身份仅限当前浏览器;更换手机或清除浏览器数据后无法找回。';
: '保留最近 5 份报告。游客报告仅可在当前浏览器查看,不能跨手机同步;清除浏览器数据后无法找回。';
if (!data.items.length) { box.innerHTML = '<p class="history-empty">暂无报告<br>完成一次检测后,报告会自动保存在这里。</p>'; return; }
box.innerHTML = data.items.map(function (item) {
var date = new Date(item.created_at).toLocaleDateString('sv-SE');
@@ -1584,7 +1653,7 @@
function loginWechat() {
if (!loginAllowed()) return;
if (!/MicroMessenger/i.test(navigator.userAgent)) {
$('#login-message').textContent = '请在微信内打开本页面,或使用本机浏览器登录。';
$('#login-message').textContent = '请在微信内打开本页面,或使用游客登录。';
return;
}
loginBusy = true;
@@ -1645,14 +1714,14 @@
$('#login-message').textContent = code === 'state'
? '登录链接已过期或会话丢失,请在同一微信窗口重新点击微信登录。'
: code === 'network' ? '暂时无法连接微信服务,请稍后重新登录。'
: '微信授权未完成,请重试;如仍失败,请将错误页面截图发给管理员。也可选择本机浏览器登录。';
: '微信授权未完成,请重试;如仍失败,请将错误页面截图发给管理员。也可选择游客登录。';
} else if (loginSuccess && (!AccountClient.user() || AccountClient.user().kind !== 'wechat')) {
$('#login-message').textContent = '微信返回后登录会话未能保存,请在同一微信窗口重新登录。';
} else if (AccountClient.user()) {
if (loginSuccess) loginSucceeded();
else showScreen('s-splash', 'replace');
}
else $('#login-message').textContent = '微信登录可跨手机查看报告;本机登录仅限当前浏览器。';
else $('#login-message').textContent = '微信登录可跨手机查看报告;游客登录仅限当前浏览器。';
}).catch(function (e) { $('#login-message').textContent = e.message; })
.finally(function () {
loginBusy = false;