feat: 完善淘宝小程序拍照、报告与登录流程
接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
This commit is contained in:
+88
-30
@@ -1,7 +1,7 @@
|
||||
/* ═════ 语音播报:my.createInnerAudioContext 播放远程 MP3 ═════
|
||||
* 文案/文件名契约:docs/语音播报内容设计文档_V1.0.md(A 步骤引导 / B 姿态提示 / C 流程音)
|
||||
* 播放策略与 H5 一致:单通道;流程音排队顺序播不互截;姿态提示同条 4s 去重、不打断流程音;
|
||||
* 资源远程化:{ASSET_BASE}voice/<id>.mp3,清单 manifest.json 缺失时默认全量尝试(404 静默) */
|
||||
* 资源远程化:{ASSET_BASE}voice/<id>.mp3;播放失败跳过,不阻断拍照。 */
|
||||
|
||||
var api = require('./api.js');
|
||||
|
||||
@@ -19,53 +19,111 @@ var VOICE = {
|
||||
|
||||
var DEDUPE_MS = 4000; // 同条提示 4s 内不重复播
|
||||
|
||||
var audio = null; // InnerAudioContext 单实例
|
||||
var files = null; // null=清单未加载;{}=清单不可用(默认全量尝试)
|
||||
var audio = null; // At most one live context; stopped contexts are never reused.
|
||||
var lastAt = {}; // id → 上次开播时间(去重)
|
||||
var cur = null; // { id, kind:'flow'|'hint', token }
|
||||
var cur = null; // { id, kind:'flow'|'hint', audio }
|
||||
var queue = []; // 排队中的流程音
|
||||
var pendingHint = null; // 流程音期间挂起的姿态提示(只留最新)
|
||||
var waiters = []; // afterCurrent 回调:全部播报收尾后执行
|
||||
var status = { version: 'VOICE-20261102-04', stage: 'idle', operation: 'none', errorType: 'none', id: '', code: 'NONE', category: 'none', reason: 'none',
|
||||
attempts: 0, canPlayEvents: 0, playEvents: 0, errorEvents: 0 };
|
||||
|
||||
function trace(stage, id, error) {
|
||||
status.stage = stage; status.id = id || '';
|
||||
if (error) {
|
||||
status.errorEvents++;
|
||||
status.errorType = /^(TypeError|ReferenceError|RangeError|Error)$/.test(error.name || '') ? error.name : 'NativeError';
|
||||
var code = error.errCode !== undefined ? error.errCode : error.errorCode !== undefined ? error.errorCode : error.error;
|
||||
status.code = typeof code === 'number' || typeof code === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(code) ? String(code) : 'UNAVAILABLE';
|
||||
var message = String(error.message || error.errorMessage || error.errMsg || '');
|
||||
status.reason = /appxDomain/i.test(message) ? 'APPX_DOMAIN_CONTEXT' : /white.?list|域名.*(允许|合法|白名单)|domain.*(allow|denied|invalid)/i.test(message) ? 'DOMAIN_REJECTED' :
|
||||
/TypeError|undefined|null/i.test(message) ? 'JS_CONTEXT_ERROR' : /not.support|unsupported|不支持/i.test(message) ? 'API_UNSUPPORTED' : 'UNCLASSIFIED';
|
||||
status.category = /domain|whitelist|域名/i.test(message) ? 'domain' : /permission|权限/i.test(message) ? 'permission' :
|
||||
/format|codec|decode|格式/i.test(message) ? 'decode' : /network|timeout|网络/i.test(message) ? 'network' :
|
||||
/unsupported|not.support|不支持/i.test(message) ? 'unsupported' : 'unknown';
|
||||
}
|
||||
// Fixed public clip IDs and numeric error codes only; never log returned
|
||||
// objects, media URLs, account identifiers, raw images or authorization data.
|
||||
try { if (typeof console !== 'undefined' && typeof console.info === 'function') console.info('[voice] ' + status.version +
|
||||
' stage=' + stage + '; operation=' + status.operation + '; errorType=' + status.errorType + '; id=' + status.id + '; code=' + status.code + '; category=' + status.category + '; reason=' + status.reason); } catch (_) {}
|
||||
}
|
||||
|
||||
// 拉取远程清单;失败则放行全量(语音缺失时 InnerAudioContext onError 静默跳过)
|
||||
function init() {
|
||||
if (audio) return;
|
||||
if (typeof my.createInnerAudioContext !== 'function') return;
|
||||
try { audio = my.createInnerAudioContext(); } catch (_) { return; }
|
||||
audio.onEnded(function () { drain(); });
|
||||
audio.onError(function () { drain(); }); // 单条加载失败:跳过,继续队列
|
||||
files = {}; // Known voice IDs; unavailable files are skipped by onError.
|
||||
if (typeof my === 'undefined' || typeof my.createInnerAudioContext !== 'function') { trace('unavailable', '', { error: 'API_MISSING' }); return; }
|
||||
status.operation = 'create';
|
||||
try { audio = my.createInnerAudioContext(); } catch (error) { trace('create_failed', '', error); return; }
|
||||
if (!audio) return;
|
||||
// Hosts differ in writable properties. An unsupported property must never
|
||||
// prevent playback or escape into the camera page.
|
||||
try { audio.autoplay = false; } catch (_) {}
|
||||
try { audio.loop = false; } catch (_) {}
|
||||
try { audio.volume = 1; } catch (_) {}
|
||||
// Taobao's audio specification explicitly does not support obeyMuteSwitch.
|
||||
}
|
||||
|
||||
function ensure() { if (!audio) init(); }
|
||||
|
||||
function playable(id) { return !!id && (files === null || !Object.keys(files).length || files[id]); }
|
||||
function playable(id) { return typeof id === 'string' && /^[a-z0-9-]+$/.test(id); }
|
||||
|
||||
function release(context, stop) {
|
||||
if (!context) return;
|
||||
if (audio === context) audio = null;
|
||||
if (stop) { try { if (typeof context.stop === 'function') context.stop(); } catch (_) {} }
|
||||
try { if (typeof context.destroy === 'function') context.destroy(); } catch (_) {}
|
||||
}
|
||||
|
||||
function finish(record) {
|
||||
// Late ended/error notifications from an interrupted clip cannot finish the
|
||||
// current clip or drain its queue.
|
||||
if (cur !== record) return;
|
||||
cur = null; release(record.audio, false); drain();
|
||||
}
|
||||
|
||||
function start(id, kind) {
|
||||
stopCur();
|
||||
ensure();
|
||||
if (!audio) { drain(); return; }
|
||||
lastAt[id] = Date.now();
|
||||
cur = { id: id, kind: kind, token: {} };
|
||||
var token = cur.token;
|
||||
var context = audio, record = { id: id, kind: kind, audio: context, assigned: false, ready: false, requested: false };
|
||||
cur = record;
|
||||
status.attempts++; status.code = 'NONE'; status.category = 'none'; status.reason = 'none'; status.errorType = 'none'; trace('loading', id);
|
||||
function failed(error) { if (cur !== record) return; trace('error', id, error || { error: 'UNKNOWN' }); finish(record); }
|
||||
function requestPlay() {
|
||||
if (cur !== record || !record.assigned || !record.ready || record.requested) return;
|
||||
record.requested = true; status.operation = 'play'; trace('play_requested', id);
|
||||
try { var result = context.play(); if (result && typeof result.catch === 'function') result.catch(failed); }
|
||||
catch (error) { failed(error); }
|
||||
}
|
||||
try {
|
||||
audio.stop();
|
||||
audio.src = api.assetUrl('voice/' + id + '.mp3');
|
||||
audio.play();
|
||||
} catch (_) {
|
||||
// 资源域名不可用、平台拒绝非 HTTPS src 等情况:静默跳过。
|
||||
// 语音只是辅助,绝不能让异常冒泡成页面崩溃(真机曾整页白屏)。
|
||||
cur = null;
|
||||
drain();
|
||||
return;
|
||||
status.operation = 'listen_ended';
|
||||
context.onEnded(function () { if (cur === record) trace('ended', id); finish(record); });
|
||||
status.operation = 'listen_error';
|
||||
context.onError(failed);
|
||||
status.operation = 'listen_play';
|
||||
if (typeof context.onPlay === 'function') context.onPlay(function () {
|
||||
if (cur !== record) return;
|
||||
record.requested = true; status.playEvents++; trace('playing', id);
|
||||
});
|
||||
var readyEvent = typeof context.onCanPlay === 'function' ? context.onCanPlay : context.onCanplay;
|
||||
status.operation = 'listen_canplay';
|
||||
if (typeof readyEvent === 'function') readyEvent.call(context, function () {
|
||||
if (cur !== record) return;
|
||||
record.ready = true; status.canPlayEvents++; trace('canplay', id); requestPlay();
|
||||
});
|
||||
else record.ready = true;
|
||||
status.operation = 'set_source';
|
||||
context.src = api.assetUrl('voice/' + id + '.mp3');
|
||||
record.assigned = true; requestPlay();
|
||||
} catch (error) {
|
||||
failed(error);
|
||||
}
|
||||
// 播放失败/结束时 cur 清理放在 drain 前,避免排队卡死
|
||||
cur.token = token;
|
||||
}
|
||||
|
||||
function stopCur() {
|
||||
if (!cur) return;
|
||||
cur = null;
|
||||
if (audio) { try { audio.stop(); } catch (e) {} }
|
||||
var record = cur; cur = null;
|
||||
release(record.audio, true);
|
||||
}
|
||||
|
||||
function drain() {
|
||||
@@ -87,9 +145,8 @@ function busyWithFlow() { return (cur && cur.kind === 'flow') || queue.length >
|
||||
function play(id, opts) {
|
||||
opts = opts || {};
|
||||
if (!playable(id)) return;
|
||||
ensure();
|
||||
if (opts.hint) {
|
||||
if (lastAt[id] && Date.now() - lastAt[id] < DEDUPE_MS) return;
|
||||
if (Object.prototype.hasOwnProperty.call(lastAt, id) && Date.now() - lastAt[id] < DEDUPE_MS) return;
|
||||
if (busyWithFlow()) { pendingHint = id; return; }
|
||||
start(id, 'hint');
|
||||
return;
|
||||
@@ -106,7 +163,7 @@ function stop(hard) {
|
||||
queue = [];
|
||||
pendingHint = null;
|
||||
waiters = [];
|
||||
if (hard) stopCur();
|
||||
if (hard) { stopCur(); if (audio) release(audio, true); }
|
||||
}
|
||||
|
||||
// 全部播报(含排队与挂起)自然收尾后执行 cb;空闲则立即执行
|
||||
@@ -121,5 +178,6 @@ module.exports = {
|
||||
play: play,
|
||||
dropHint: dropHint,
|
||||
stop: stop,
|
||||
afterCurrent: afterCurrent
|
||||
afterCurrent: afterCurrent,
|
||||
diagnostics: function () { return Object.assign({}, status); }
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user