feat(admin): track capture-to-report conversion funnel
This commit is contained in:
@@ -169,6 +169,23 @@ function loadRecords() {
|
||||
}
|
||||
|
||||
/* ═══════════ 失败与异常 ═══════════ */
|
||||
function renderCaptureFunnel(st) {
|
||||
var funnel = st.capture_funnel;
|
||||
var panel = $('#capture-funnel');
|
||||
if (funnel && panel) {
|
||||
var labels = [['started','开始拍照'],['front','正面拍完'],['right','右脸拍完'],['left','三张拍完'],['submitted','提交检测'],['response','收到成功结果'],['report','进入报告页']];
|
||||
var html = '<table><thead><tr><th>阶段</th><th>累计流程</th><th>今日流程</th></tr></thead><tbody>';
|
||||
labels.forEach(function (row) { html += '<tr><td>' + row[1] + '</td><td>' + funnel.total.counts[row[0]] + '</td><td>' + funnel.today.counts[row[0]] + '</td></tr>'; });
|
||||
html += '</tbody></table>';
|
||||
html += '<p class="hint">首次采集:' + (funnel.since ? fmtTime(new Date(funnel.since).toISOString()) : '暂无数据') + '</p>';
|
||||
[['total','累计'],['today','今日']].forEach(function (row) {
|
||||
var f = funnel[row[0]];
|
||||
html += '<p>' + row[1] + ':进入报告转化率 ' + f.rate + '% · 成功进入平均耗时 ' + (f.avg_ms == null ? '—' : (f.avg_ms / 1000).toFixed(1) + ' 秒') +
|
||||
' · 成功结果但未进入 ' + f.response_without_report + ' · 明确失败 ' + f.counts.failed + ' · 主动取消 ' + f.counts.canceled + ' · 发生后台中断 ' + f.counts.interrupted + ' · 尚未完成 ' + f.unfinished + '</p>';
|
||||
});
|
||||
panel.innerHTML = html;
|
||||
}
|
||||
}
|
||||
function loadFails() {
|
||||
api('/api/admin/stats').then(function (st) {
|
||||
var box = $('#fail-dist');
|
||||
@@ -228,6 +245,7 @@ function doRetry(id) {
|
||||
var PIE_COLORS = ['#2563eb', '#2fa84f', '#f0a020', '#8a5cf6', '#d93026'];
|
||||
function loadStats() {
|
||||
api('/api/admin/stats').then(function (st) {
|
||||
renderCaptureFunnel(st);
|
||||
var cards = [
|
||||
['今日检测', st.today_total],
|
||||
['累计检测', st.total],
|
||||
|
||||
@@ -4,6 +4,26 @@
|
||||
|
||||
var $ = function (sel, root) { return (root || document).querySelector(sel); };
|
||||
|
||||
var captureJourney = null;
|
||||
function beginCaptureJourney() {
|
||||
captureJourney = null;
|
||||
if (IS_MOCK) return;
|
||||
try {
|
||||
var bytes = new Uint8Array(16); window.crypto.getRandomValues(bytes);
|
||||
captureJourney = { id: Array.from(bytes).map(function (b) { return ('0' + b.toString(16)).slice(-2); }).join(''), started: Date.now(), events: [] };
|
||||
trackCapture('started');
|
||||
} catch (_) {} // Telemetry must never block capture on old kernels.
|
||||
}
|
||||
function trackCapture(stage) {
|
||||
try {
|
||||
var j = captureJourney;
|
||||
if (!j || IS_MOCK || j.events.some(function (e) { return e.stage === stage || e.stage === 'report' || e.stage === 'failed' || e.stage === 'canceled'; })) return;
|
||||
j.events.push({ stage: stage, ms: stage === 'started' ? 0 : Math.min(86400000, Math.max(j.events.length ? j.events[j.events.length - 1].ms : 0, Date.now() - j.started)) });
|
||||
fetch('api/capture-funnel', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: j.id, events: j.events }), keepalive: true }).catch(function () {});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/* camera-outline-pure-begin */
|
||||
// Round the exported polygon's corners without changing its position/size.
|
||||
// Quadratic control points stay within the original corner's convex hull.
|
||||
@@ -250,6 +270,7 @@
|
||||
|
||||
// 退出拍照流程:作废未决快门/分析回调、掐断播报、释放摄像头
|
||||
function leaveCamera() {
|
||||
trackCapture('canceled');
|
||||
camSeq++;
|
||||
voice.stop(true);
|
||||
closeOverlay('p-analyzing');
|
||||
@@ -1063,6 +1084,7 @@
|
||||
|
||||
// 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步
|
||||
function onPhotoCaptured(dataUrl) {
|
||||
trackCapture(['front', 'right', 'left'][camStep]);
|
||||
shutterLock = true; // 推进窗口上锁(停顿期间不检测、不接受快门),直到下一步/送检
|
||||
photos[camStep] = dataUrl;
|
||||
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废
|
||||
@@ -1162,6 +1184,7 @@
|
||||
|
||||
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
|
||||
function submitPhotos(seq) {
|
||||
trackCapture('submitted');
|
||||
camStep = 0;
|
||||
photos = photosByView(photos);
|
||||
openOverlay('p-analyzing');
|
||||
@@ -1176,6 +1199,7 @@
|
||||
var animationComplete = scanCycle.promise;
|
||||
requestAnalyze(photos, seq, profile, function (errMsg, payload) {
|
||||
if (seq !== camSeq) return; // 流程已被重置:丢弃
|
||||
trackCapture(errMsg ? 'failed' : 'response');
|
||||
// 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 () {
|
||||
@@ -1199,6 +1223,7 @@
|
||||
});
|
||||
} catch (e) { vm = null; }
|
||||
if (!vm) {
|
||||
trackCapture('failed');
|
||||
toast('报告生成失败,请重新拍摄');
|
||||
showScreen('s-profile');
|
||||
popHistorySilently();
|
||||
@@ -1210,6 +1235,10 @@
|
||||
else if (!payload.report_saved) toast('报告生成成功,但保存失败,请勿关闭页面并联系管理员');
|
||||
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
|
||||
showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机
|
||||
if (currentScreen === 's-report' && $('#s-report').classList.contains('active')) trackCapture('report');
|
||||
}).catch(function (error) {
|
||||
trackCapture('failed');
|
||||
console.error('Report presentation failed', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1863,6 +1892,7 @@
|
||||
},
|
||||
'close-photoguide': function () { closeOverlay('p-photoguide'); },
|
||||
'start-camera': function () {
|
||||
beginCaptureJourney();
|
||||
closeOverlay('p-photoguide');
|
||||
camSeq++; // 使旧的快门/分析回调失效
|
||||
camStep = 0;
|
||||
@@ -1992,6 +2022,7 @@
|
||||
/* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 photos[] 与 camStep) ─── */
|
||||
function handleCamHidden() {
|
||||
if (currentScreen !== 's-cam') return;
|
||||
trackCapture('interrupted');
|
||||
camSeq++; // 使未决的快门/推进/分析回调失效(白屏定时器醒来即自洁退出)
|
||||
voice.stop(true);
|
||||
shutterLock = false;
|
||||
|
||||
Reference in New Issue
Block a user