feat(taobao): add native mini program with shared backend
Preserve H5 flows while adding Taobao identity, native capture, reports and isolated local development. Fix canceled province selection and expired-session logout. Record passing local regressions, two pre-existing H5 assertion failures and pending platform validation.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
var config = require('../config'), transport = require('./transport');
|
||||
var token = '', user = null, storageKey = 'juzi.taobao.session.v1';
|
||||
function assetUrl(path) { return config.assetBase.replace(/\/?$/, '/') + path.replace(/^assets\//, ''); }
|
||||
function clear() {
|
||||
token = ''; user = null;
|
||||
try { my.removeStorageSync({ key: storageKey }); } catch (_) {}
|
||||
var app = typeof getApp === 'function' && getApp();
|
||||
if (app) { app.globalData.profile = null; app.globalData.photos = null; app.globalData.payload = null; }
|
||||
}
|
||||
function request(path, body) {
|
||||
var requestToken = token;
|
||||
return transport.request('/api/taobao/' + path, body || {}, requestToken).then(function (data) {
|
||||
if (!data || !data.ok) {
|
||||
var err = new Error(data && data.message || '服务暂不可用,请稍后重试');
|
||||
err.status = data && data.http_status; err.code = data && data.code;
|
||||
if (err.status === 401 && requestToken === token) clear();
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}
|
||||
function authorize() {
|
||||
if (config.transport === 'direct') return Promise.resolve();
|
||||
return new Promise(function (resolve, reject) {
|
||||
my.authorize({ scopes: 'scope.userInfo', success: resolve, fail: function () { reject(new Error('请授权淘宝账号后继续')); } });
|
||||
});
|
||||
}
|
||||
function login(consent) {
|
||||
if (!consent) return Promise.reject(new Error('请先阅读并同意隐私协议'));
|
||||
return authorize().then(function () { return request('challenge'); }).then(function (data) {
|
||||
return request('session', { consent: true, challenge: data.challenge });
|
||||
}).then(function (data) {
|
||||
token = data.token; user = data.user;
|
||||
my.setStorageSync({ key: storageKey, data: { token: token, user: user } });
|
||||
return user;
|
||||
});
|
||||
}
|
||||
function restore() {
|
||||
try { var saved = my.getStorageSync({ key: storageKey }).data; if (saved) { token = saved.token || ''; user = saved.user || null; } } catch (_) {}
|
||||
if (!token) return Promise.resolve(null);
|
||||
var restoredToken = token;
|
||||
return request('me').then(function (data) { if (token === restoredToken) user = data.user; return user; });
|
||||
}
|
||||
function canceled(signal) { if (signal && signal.canceled) { var e = new Error('已取消检测'); e.canceled = true; throw e; } }
|
||||
function wait(ms, signal) { return new Promise(function (resolve) { setTimeout(resolve, ms); }).then(function () { canceled(signal); }); }
|
||||
function poll(job, signal, deadline) {
|
||||
canceled(signal);
|
||||
if (Date.now() >= deadline) throw new Error('检测仍在处理中,请稍后从历史报告查看');
|
||||
return request('analysis', { job: job }).then(function (data) {
|
||||
canceled(signal);
|
||||
if (data.status !== 'complete') return wait(1000, signal).then(function () { return poll(job, signal, deadline); });
|
||||
if (!data.payload || !data.payload.ok) throw new Error(data.payload && data.payload.message || '检测失败,请重试');
|
||||
return data.payload;
|
||||
});
|
||||
}
|
||||
function upload(view, photo, signal) {
|
||||
var b64 = photo.replace(/^data:image\/[^;]+;base64,/, ''), size = 64 * 1024;
|
||||
if (!b64 || b64.length > 4 * 1024 * 1024) return Promise.reject(new Error('照片过大,请重新拍摄'));
|
||||
var id = Date.now().toString(36) + '-' + Math.random().toString(36).slice(2), total = Math.ceil(b64.length / size);
|
||||
var chain = Promise.resolve();
|
||||
for (var i = 0; i < total; i++) (function (index) {
|
||||
chain = chain.then(function () { canceled(signal); return request('photo', { view: view, upload_id: id, index: index, total: total, data: b64.slice(index * size, (index + 1) * size) }); });
|
||||
})(i);
|
||||
return chain.then(function () { return id; });
|
||||
}
|
||||
function analyze(photos, profile, signal) {
|
||||
if (config.mock) {
|
||||
var SS = require('../vendor/skin-score'), UI = require('../vendor/report-ui');
|
||||
var vm = SS.buildViewModel(SS.MOCK_RESULT, SS.MOCK_SIDE, { realAge: profile.real_age, skinType: profile.daily_skin_type });
|
||||
return Promise.resolve({ ok: true, result: SS.MOCK_RESULT, side: SS.MOCK_SIDE, vm: vm, presentation: UI.build(vm, SS.MOCK_RESULT) });
|
||||
}
|
||||
var refs = {};
|
||||
return ['front', 'left', 'right'].reduce(function (chain, view, i) {
|
||||
return chain.then(function () { return upload(view, photos[i], signal); }).then(function (id) { refs[view] = id; });
|
||||
}, Promise.resolve()).then(function () {
|
||||
canceled(signal); return request('skin-analyze', { photo_refs: refs, profile: profile });
|
||||
}).then(function (data) { return poll(data.job, signal, Date.now() + 90000); });
|
||||
}
|
||||
function wrinkleViews(jobToken, signal) {
|
||||
return request('wrinkle-views', { token: jobToken }).then(function (data) { return poll(data.job, signal, Date.now() + 90000); });
|
||||
}
|
||||
function logout() {
|
||||
return request('logout').catch(function (err) {
|
||||
// An expired/revoked session is already signed out; network failures still surface.
|
||||
if (err.status !== 401) throw err;
|
||||
}).then(clear);
|
||||
}
|
||||
module.exports = { assetUrl: assetUrl, analyze: analyze, login: login, restore: restore, request: request,
|
||||
user: function () { return user; }, logout: logout, wrinkleViews: wrinkleViews };
|
||||
@@ -0,0 +1,6 @@
|
||||
var config = require('../config');
|
||||
function cameraDebugEnabled() {
|
||||
return config.environment === 'development' && config.cameraDebug === true && config.transport === 'direct' &&
|
||||
/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(config.baseUrl);
|
||||
}
|
||||
module.exports = { cameraDebugEnabled: cameraDebugEnabled };
|
||||
@@ -0,0 +1,33 @@
|
||||
// Always redraw to JPEG: native compressImage changes quality, not dimensions.
|
||||
var MAX_SIDE = 1280;
|
||||
function compressToBase64(src, page) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
my.getImageInfo({ src: src, success: function (info) {
|
||||
if (page.alive === false) { reject({ canceled: true }); return; }
|
||||
if (!(info.width > 0 && info.height > 0)) { reject(new Error('无法读取照片尺寸,请重新选择')); return; }
|
||||
var scale = Math.min(1, MAX_SIDE / Math.max(info.width, info.height));
|
||||
var w = Math.max(1, Math.round(info.width * scale)), h = Math.max(1, Math.round(info.height * scale));
|
||||
page.setData({ compressWidth: w, compressHeight: h }, function () {
|
||||
if (page.alive === false) { reject({ canceled: true }); return; }
|
||||
try {
|
||||
var ctx = my.createCanvasContext('compress-canvas');
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.drawImage(src, 0, 0, w, h);
|
||||
ctx.draw(false, function () {
|
||||
if (page.alive === false) { reject({ canceled: true }); return; }
|
||||
ctx.toTempFilePath({ x: 0, y: 0, width: w, height: h, destWidth: w, destHeight: h, fileType: 'jpg', quality: 0.88,
|
||||
success: function (result) {
|
||||
var file = result.apFilePath || result.tempFilePath;
|
||||
if (!file) { reject(new Error('照片处理失败,请重试')); return; }
|
||||
try {
|
||||
my.getFileSystemManager().readFile({ filePath: file, encoding: 'base64',
|
||||
success: function (r) { resolve('data:image/jpeg;base64,' + r.data); }, fail: reject });
|
||||
} catch (e) { reject(new Error('当前淘宝版本不支持照片读取,请升级后重试')); }
|
||||
}, fail: reject });
|
||||
});
|
||||
} catch (e) { reject(new Error('照片处理不可用,请升级淘宝后重试')); }
|
||||
});
|
||||
}, fail: reject });
|
||||
});
|
||||
}
|
||||
module.exports = { MAX_SIDE: MAX_SIDE, compressToBase64: compressToBase64 };
|
||||
@@ -0,0 +1,39 @@
|
||||
// Taobao scopes are independent: userInfo login does not grant camera/album.
|
||||
var denied = {};
|
||||
function state(settings, scope) {
|
||||
settings = settings || {};
|
||||
var key = scope.replace(/^scope\./, '');
|
||||
return typeof settings[scope] === 'boolean' ? settings[scope] : settings[key];
|
||||
}
|
||||
function settings() {
|
||||
return new Promise(function (resolve) {
|
||||
if (typeof my.getSetting !== 'function') { resolve({}); return; }
|
||||
my.getSetting({ success: function (res) { resolve(res.authSetting || {}); }, fail: function () { resolve({}); } });
|
||||
});
|
||||
}
|
||||
function rejected(scope) {
|
||||
var err = new Error(scope === 'scope.camera' ? '相机权限未开启,可在授权设置中开启,或使用相册' : '相册权限未开启,请在授权设置中开启');
|
||||
err.permission = scope; return err;
|
||||
}
|
||||
function ensure(scope) {
|
||||
return settings().then(function (current) {
|
||||
if (state(current, scope) === true) { delete denied[scope]; return; }
|
||||
if (state(current, scope) === false || denied[scope]) throw rejected(scope);
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (typeof my.authorize !== 'function') { reject(new Error('当前淘宝版本不支持授权,请升级后重试')); return; }
|
||||
my.authorize({ scopes: scope, success: function () { delete denied[scope]; resolve(); },
|
||||
fail: function () { denied[scope] = true; reject(rejected(scope)); } });
|
||||
});
|
||||
});
|
||||
}
|
||||
function open() {
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (typeof my.openSetting !== 'function') { reject(new Error('请在小程序右上角的授权设置中开启权限')); return; }
|
||||
my.openSetting({ success: function (res) {
|
||||
var current = res.authSetting || {};
|
||||
['scope.camera', 'scope.album'].forEach(function (scope) { if (state(current, scope) === true) delete denied[scope]; });
|
||||
resolve(current);
|
||||
}, fail: function () { reject(new Error('无法打开授权设置,请从小程序右上角进入')); } });
|
||||
});
|
||||
}
|
||||
module.exports = { ensure: ensure, settings: settings, state: state, open: open };
|
||||
@@ -0,0 +1,42 @@
|
||||
/* 省份 → 城市(地级)数据。来源:china-division@2.7.0 dist/pc.json(modood/Administrative-divisions-of-China,WTFPL);
|
||||
直辖市第二列只保留本市,另补港澳台与海外。 */
|
||||
// 小程序无 window 对象,改为 CommonJS 导出(数据本身与 H5 完全一致)
|
||||
var REGION_DATA = [
|
||||
["北京市", ["北京市"]],
|
||||
["天津市", ["天津市"]],
|
||||
["河北省", ["石家庄市","唐山市","秦皇岛市","邯郸市","邢台市","保定市","张家口市","承德市","沧州市","廊坊市","衡水市"]],
|
||||
["山西省", ["太原市","大同市","阳泉市","长治市","晋城市","朔州市","晋中市","运城市","忻州市","临汾市","吕梁市"]],
|
||||
["内蒙古自治区", ["呼和浩特市","包头市","乌海市","赤峰市","通辽市","鄂尔多斯市","呼伦贝尔市","巴彦淖尔市","乌兰察布市","兴安盟","锡林郭勒盟","阿拉善盟"]],
|
||||
["辽宁省", ["沈阳市","大连市","鞍山市","抚顺市","本溪市","丹东市","锦州市","营口市","阜新市","辽阳市","盘锦市","铁岭市","朝阳市","葫芦岛市"]],
|
||||
["吉林省", ["长春市","吉林市","四平市","辽源市","通化市","白山市","松原市","白城市","延边朝鲜族自治州"]],
|
||||
["黑龙江省", ["哈尔滨市","齐齐哈尔市","鸡西市","鹤岗市","双鸭山市","大庆市","伊春市","佳木斯市","七台河市","牡丹江市","黑河市","绥化市","大兴安岭地区"]],
|
||||
["上海市", ["上海市"]],
|
||||
["江苏省", ["南京市","无锡市","徐州市","常州市","苏州市","南通市","连云港市","淮安市","盐城市","扬州市","镇江市","泰州市","宿迁市"]],
|
||||
["浙江省", ["杭州市","宁波市","温州市","嘉兴市","湖州市","绍兴市","金华市","衢州市","舟山市","台州市","丽水市"]],
|
||||
["安徽省", ["合肥市","芜湖市","蚌埠市","淮南市","马鞍山市","淮北市","铜陵市","安庆市","黄山市","滁州市","阜阳市","宿州市","六安市","亳州市","池州市","宣城市"]],
|
||||
["福建省", ["福州市","厦门市","莆田市","三明市","泉州市","漳州市","南平市","龙岩市","宁德市"]],
|
||||
["江西省", ["南昌市","景德镇市","萍乡市","九江市","新余市","鹰潭市","赣州市","吉安市","宜春市","抚州市","上饶市"]],
|
||||
["山东省", ["济南市","青岛市","淄博市","枣庄市","东营市","烟台市","潍坊市","济宁市","泰安市","威海市","日照市","临沂市","德州市","聊城市","滨州市","菏泽市"]],
|
||||
["河南省", ["郑州市","开封市","洛阳市","平顶山市","安阳市","鹤壁市","新乡市","焦作市","濮阳市","许昌市","漯河市","三门峡市","南阳市","商丘市","信阳市","周口市","驻马店市","济源市"]],
|
||||
["湖北省", ["武汉市","黄石市","十堰市","宜昌市","襄阳市","鄂州市","荆门市","孝感市","荆州市","黄冈市","咸宁市","随州市","恩施土家族苗族自治州","仙桃市","潜江市","天门市","神农架林区"]],
|
||||
["湖南省", ["长沙市","株洲市","湘潭市","衡阳市","邵阳市","岳阳市","常德市","张家界市","益阳市","郴州市","永州市","怀化市","娄底市","湘西土家族苗族自治州"]],
|
||||
["广东省", ["广州市","韶关市","深圳市","珠海市","汕头市","佛山市","江门市","湛江市","茂名市","肇庆市","惠州市","梅州市","汕尾市","河源市","阳江市","清远市","东莞市","中山市","潮州市","揭阳市","云浮市"]],
|
||||
["广西壮族自治区", ["南宁市","柳州市","桂林市","梧州市","北海市","防城港市","钦州市","贵港市","玉林市","百色市","贺州市","河池市","来宾市","崇左市"]],
|
||||
["海南省", ["海口市","三亚市","三沙市","儋州市","五指山市","琼海市","文昌市","万宁市","东方市","定安县","屯昌县","澄迈县","临高县","白沙黎族自治县","昌江黎族自治县","乐东黎族自治县","陵水黎族自治县","保亭黎族苗族自治县","琼中黎族苗族自治县"]],
|
||||
["重庆市", ["重庆市"]],
|
||||
["四川省", ["成都市","自贡市","攀枝花市","泸州市","德阳市","绵阳市","广元市","遂宁市","内江市","乐山市","南充市","眉山市","宜宾市","广安市","达州市","雅安市","巴中市","资阳市","阿坝藏族羌族自治州","甘孜藏族自治州","凉山彝族自治州"]],
|
||||
["贵州省", ["贵阳市","六盘水市","遵义市","安顺市","毕节市","铜仁市","黔西南布依族苗族自治州","黔东南苗族侗族自治州","黔南布依族苗族自治州"]],
|
||||
["云南省", ["昆明市","曲靖市","玉溪市","保山市","昭通市","丽江市","普洱市","临沧市","楚雄彝族自治州","红河哈尼族彝族自治州","文山壮族苗族自治州","西双版纳傣族自治州","大理白族自治州","德宏傣族景颇族自治州","怒江傈僳族自治州","迪庆藏族自治州"]],
|
||||
["西藏自治区", ["拉萨市","日喀则市","昌都市","林芝市","山南市","那曲市","阿里地区"]],
|
||||
["陕西省", ["西安市","铜川市","宝鸡市","咸阳市","渭南市","延安市","汉中市","榆林市","安康市","商洛市"]],
|
||||
["甘肃省", ["兰州市","嘉峪关市","金昌市","白银市","天水市","武威市","张掖市","平凉市","酒泉市","庆阳市","定西市","陇南市","临夏回族自治州","甘南藏族自治州"]],
|
||||
["青海省", ["西宁市","海东市","海北藏族自治州","黄南藏族自治州","海南藏族自治州","果洛藏族自治州","玉树藏族自治州","海西蒙古族藏族自治州"]],
|
||||
["宁夏回族自治区", ["银川市","石嘴山市","吴忠市","固原市","中卫市"]],
|
||||
["新疆维吾尔自治区", ["乌鲁木齐市","克拉玛依市","吐鲁番市","哈密市","昌吉回族自治州","博尔塔拉蒙古自治州","巴音郭楞蒙古自治州","阿克苏地区","克孜勒苏柯尔克孜自治州","喀什地区","和田地区","伊犁哈萨克自治州","塔城地区","阿勒泰地区","石河子市","阿拉尔市","图木舒克市","五家渠市","北屯市","铁门关市","双河市","可克达拉市","昆玉市","胡杨河市","新星市","白杨市"]],
|
||||
["香港特别行政区", ["香港特别行政区"]],
|
||||
["澳门特别行政区", ["澳门特别行政区"]],
|
||||
["台湾省", ["台湾省"]],
|
||||
["海外", ["海外"]]
|
||||
];
|
||||
|
||||
module.exports = REGION_DATA;
|
||||
@@ -0,0 +1,56 @@
|
||||
var SS = require('../vendor/skin-score'), UI = require('../vendor/report-ui'), api = require('./api');
|
||||
function mapURL(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
if (/^data:image\/(png|jpeg);base64,/.test(value)) return value;
|
||||
if (value.indexOf('/9j/') === 0) return 'data:image/jpeg;base64,' + value;
|
||||
if (value.indexOf('iVBOR') === 0) return 'data:image/png;base64,' + value;
|
||||
return '';
|
||||
}
|
||||
function model(payload, photos, profile) {
|
||||
var vm = payload.vm || SS.buildViewModel(payload.result, payload.side, { realAge: profile.real_age, skinType: profile.daily_skin_type });
|
||||
vm = SS.withDisplayRecommendation(vm, payload.result);
|
||||
var presentation = UI.forDisplay(vm, payload.result, payload.presentation);
|
||||
var total = SS.weightedTotal(vm.dims), skinAge = SS.reportSkinAge(vm, payload.result);
|
||||
var focus = UI.focusOf(vm);
|
||||
var views = presentation.views.map(function (view) {
|
||||
return { key: view.key, name: view.name, text: UI.customerText(view), score: UI.dimensionScore(view.S), level: view.level || '',
|
||||
dim: view.key === 'wrinkles', alpha: view.key === 'color' ? 0.3 : 1, marks: [] };
|
||||
});
|
||||
var difference = skinAge != null && vm.realAge != null ? skinAge - vm.realAge : null;
|
||||
return { total: total, skinAge: skinAge, ageNote: difference == null ? '' : difference === 0 ? '与实际年龄相当' :
|
||||
difference < 0 ? '比实际年龄年轻' + -difference + '岁' : '比实际年龄大' + difference + '岁',
|
||||
dims: ['firm', 'vita', 'stab'].map(function (key) {
|
||||
var d = vm.dims[key] || {}, value = UI.dimensionScore(d.S), parts = value.split('.');
|
||||
return { key: key, name: UI.dimNames[key], int: parts[0], dec: parts[1] || '',
|
||||
level: UI.levelForScore(d.S) || '', rank: focus && focus.key === key ? 'worst' : '', barWidth: d.S == null ? 0 : Math.min(100, d.S * 10) };
|
||||
}), focusName: focus ? focus.name : '', focusSlogan: focus ? focus.tip : '', views: views, viewIndex: 0, frontPhoto: photos[0] || '', activeLayers: [],
|
||||
careAdvice: presentation.care || [], product: presentation.product ? Object.assign({}, presentation.product, { image: api.assetUrl(presentation.product.image) }) : null };
|
||||
}
|
||||
function marks(result, key, width, height) {
|
||||
if (!(width > 0 && height > 0)) return [];
|
||||
var defs = key === 'acne' ? [['closed_comedones', '#E8A13D'], ['acne', '#D30C0C'], ['acne_pustule', '#D30C0C'],
|
||||
['acne_nodule', '#8E0000'], ['acne_mark', '#8750C8']] : key === 'color' ? [['melanin_mark', '#9F641F'], ['melasma', '#E68A2E'], ['freckle', '#8750C8']] : [];
|
||||
var out = [];
|
||||
defs.forEach(function (def) {
|
||||
var source = result[def[0]] || {}, rectangles = (source.rectangle || []).slice();
|
||||
(source.polygon || []).forEach(function (points) {
|
||||
points = points.filter(function (p) { return p && typeof p.x === 'number' && typeof p.y === 'number'; });
|
||||
if (points.length < 3) return;
|
||||
var xs = points.map(function (p) { return p.x; }), ys = points.map(function (p) { return p.y; });
|
||||
var left = Math.min.apply(Math, xs), top = Math.min.apply(Math, ys);
|
||||
rectangles.push({ left: left, top: top, width: Math.max.apply(Math, xs) - left, height: Math.max.apply(Math, ys) - top });
|
||||
});
|
||||
rectangles.forEach(function (r) {
|
||||
if (![r.left, r.top, r.width, r.height].every(function (n) { return typeof n === 'number' && isFinite(n); }) ||
|
||||
r.left < 0 || r.top < 0 || r.width <= 0 || r.height <= 0 || r.left + r.width > width || r.top + r.height > height) return;
|
||||
out.push({ id: out.length, left: 100 * r.left / width, top: 100 * r.top / height,
|
||||
width: 100 * r.width / width, height: 100 * r.height / height, color: def[1] });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function layers(result, key) {
|
||||
var fields = { wrinkles: ['texture_enhanced_lines'], smooth: ['texture_enhanced_pores', 'texture_enhanced_blackheads'], color: ['brown_area'], water: ['water_area'], sens: ['red_area'] };
|
||||
return (fields[key] || []).map(function (field) { return mapURL((result.face_maps || {})[field]); }).filter(Boolean);
|
||||
}
|
||||
module.exports = { model: model, mapURL: mapURL, marks: marks, layers: layers };
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('../vendor/skin-score');
|
||||
@@ -0,0 +1,44 @@
|
||||
var config = require('../config');
|
||||
var cloudReady;
|
||||
|
||||
function unwrap(result) {
|
||||
if (typeof result === 'string') result = JSON.parse(result);
|
||||
if (result && typeof result.success === 'boolean') {
|
||||
if (!result.success) throw new Error(result.errorMessage || '服务暂不可用');
|
||||
return typeof result.data === 'string' ? JSON.parse(result.data) : result.data;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function initCloud() {
|
||||
if (!cloudReady) {
|
||||
var sdk = require('@tbmp/mp-cloud-sdk'), cloud = sdk.default || sdk;
|
||||
cloudReady = Promise.resolve(cloud.init({ env: config.cloudEnv })).then(function (ok) {
|
||||
if (!ok) throw new Error('淘宝云服务初始化失败');
|
||||
return cloud;
|
||||
}).catch(function (err) { cloudReady = null; throw err; });
|
||||
}
|
||||
return cloudReady;
|
||||
}
|
||||
function request(path, body, token) {
|
||||
var headers = { 'Content-Type': 'application/json;charset=UTF-8' };
|
||||
if (token) headers.Authorization = 'Bearer ' + token;
|
||||
if (config.transport === 'direct') {
|
||||
if (!/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(config.baseUrl)) return Promise.reject(new Error('直连接口仅用于本机联调'));
|
||||
return new Promise(function (resolve, reject) {
|
||||
my.request({ url: config.baseUrl + config.basePath + path, method: 'POST', headers: headers, data: JSON.stringify(body),
|
||||
dataType: 'json', timeout: 10000,
|
||||
success: function (res) {
|
||||
try {
|
||||
if ((res.status || res.statusCode) !== 200) throw new Error('服务暂不可用');
|
||||
resolve(unwrap(res.data));
|
||||
} catch (e) { reject(e); }
|
||||
}, fail: function () { reject(new Error('网络异常,请稍后重试')); } });
|
||||
});
|
||||
}
|
||||
if (!config.cloudAppId || !/^https:\/\//.test(config.baseUrl)) return Promise.reject(new Error('请先配置淘宝云应用和 HTTPS 服务地址'));
|
||||
return initCloud().then(function (cloud) {
|
||||
return cloud.application.httpRequest({ path: config.basePath + path, method: 'POST', headers: headers,
|
||||
params: {}, body: body, exts: { cloudAppId: config.cloudAppId, domain: config.baseUrl, timeout: 10000 } });
|
||||
}).then(unwrap);
|
||||
}
|
||||
module.exports = { request: request, unwrap: unwrap };
|
||||
@@ -0,0 +1,117 @@
|
||||
/* ═════ 语音播报:my.createInnerAudioContext 播放远程 MP3 ═════
|
||||
* 文案/文件名契约:docs/语音播报内容设计文档_V1.0.md(A 步骤引导 / B 姿态提示 / C 流程音)
|
||||
* 播放策略与 H5 一致:单通道;流程音排队顺序播不互截;姿态提示同条 4s 去重、不打断流程音;
|
||||
* 资源远程化:{ASSET_BASE}voice/<id>.mp3,清单 manifest.json 缺失时默认全量尝试(404 静默) */
|
||||
|
||||
var api = require('./api.js');
|
||||
|
||||
// A/B/C 三类清单(与 H5 app.js VOICE 常量一致)
|
||||
var VOICE = {
|
||||
step: ['step1-front', 'step2-rightface', 'step3-leftface'], // A. 步骤引导
|
||||
hint: { // B. 姿态提示(手动快门模式下基本不用,保留契约)
|
||||
'未检测到人脸': 'hint-noface', '请靠近一点': 'hint-near', '请远一点': 'hint-far',
|
||||
'请正对屏幕': 'hint-front', '请向左转头': 'hint-turn-left', '请向右转头': 'hint-turn-right',
|
||||
'保持不动': 'hint-hold'
|
||||
},
|
||||
countdown: 'countdown-321', shot: 'shot-done', // C. 拍摄与流程
|
||||
analyzing: 'analyzing', fail: 'analyze-fail'
|
||||
};
|
||||
|
||||
var DEDUPE_MS = 4000; // 同条提示 4s 内不重复播
|
||||
|
||||
var audio = null; // InnerAudioContext 单实例
|
||||
var files = null; // null=清单未加载;{}=清单不可用(默认全量尝试)
|
||||
var lastAt = {}; // id → 上次开播时间(去重)
|
||||
var cur = null; // { id, kind:'flow'|'hint', token }
|
||||
var queue = []; // 排队中的流程音
|
||||
var pendingHint = null; // 流程音期间挂起的姿态提示(只留最新)
|
||||
var waiters = []; // afterCurrent 回调:全部播报收尾后执行
|
||||
|
||||
// 拉取远程清单;失败则放行全量(语音缺失时 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.
|
||||
}
|
||||
|
||||
function ensure() { if (!audio) init(); }
|
||||
|
||||
function playable(id) { return !!id && (files === null || !Object.keys(files).length || files[id]); }
|
||||
|
||||
function start(id, kind) {
|
||||
ensure();
|
||||
if (!audio) { drain(); return; }
|
||||
lastAt[id] = Date.now();
|
||||
cur = { id: id, kind: kind, token: {} };
|
||||
var token = cur.token;
|
||||
audio.stop();
|
||||
audio.src = api.assetUrl('voice/' + id + '.mp3');
|
||||
audio.play();
|
||||
// 播放失败/结束时 cur 清理放在 drain 前,避免排队卡死
|
||||
cur.token = token;
|
||||
}
|
||||
|
||||
function stopCur() {
|
||||
if (!cur) return;
|
||||
cur = null;
|
||||
if (audio) { try { audio.stop(); } catch (e) {} }
|
||||
}
|
||||
|
||||
function drain() {
|
||||
cur = null;
|
||||
if (queue.length) { start(queue.shift(), 'flow'); return; }
|
||||
if (pendingHint) { var h = pendingHint; pendingHint = null; start(h, 'hint'); return; }
|
||||
var w = waiters;
|
||||
waiters = [];
|
||||
w.forEach(function (fn) { fn(); });
|
||||
}
|
||||
|
||||
function busyWithFlow() { return (cur && cur.kind === 'flow') || queue.length > 0; }
|
||||
|
||||
/**
|
||||
* 播报一条语音
|
||||
* @param {string} id 文件名(去 .mp3)
|
||||
* @param {object} [opts] hint:姿态提示(4s 去重);cut:打断在播与排队(失败提示用)
|
||||
*/
|
||||
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 (busyWithFlow()) { pendingHint = id; return; }
|
||||
start(id, 'hint');
|
||||
return;
|
||||
}
|
||||
if (opts.cut) { queue = []; pendingHint = null; stopCur(); start(id, 'flow'); return; }
|
||||
if (busyWithFlow()) { if (queue.indexOf(id) < 0) queue.push(id); return; }
|
||||
start(id, 'flow');
|
||||
}
|
||||
|
||||
function dropHint() { pendingHint = null; } // 换步/提示清空:挂起的旧提示作废
|
||||
|
||||
// 流程切走:作废排队/挂起/等待;hard 连在播一并掐断(退出拍照页),否则让在播自然收尾(出报告)
|
||||
function stop(hard) {
|
||||
queue = [];
|
||||
pendingHint = null;
|
||||
waiters = [];
|
||||
if (hard) stopCur();
|
||||
}
|
||||
|
||||
// 全部播报(含排队与挂起)自然收尾后执行 cb;空闲则立即执行
|
||||
function afterCurrent(cb) {
|
||||
if (!cur && !queue.length && !pendingHint) { cb(); return; }
|
||||
waiters.push(cb);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VOICE: VOICE,
|
||||
init: init,
|
||||
play: play,
|
||||
dropHint: dropHint,
|
||||
stop: stop,
|
||||
afterCurrent: afterCurrent
|
||||
};
|
||||
Reference in New Issue
Block a user