468 lines
20 KiB
JavaScript
468 lines
20 KiB
JavaScript
// frontend/composables/useShare.js
|
|
// 分享状态机 + 合成调度 + 错误处理 + 监控埋点
|
|
// (spec § 5 + § 5.3.1 + § 7 + § 8)
|
|
|
|
import { ref, nextTick, onBeforeUnmount } from 'vue';
|
|
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app';
|
|
import { composeShareImage, computeComposeKey } from '@/utils/image-compositor.js';
|
|
import { pickRandomSlogan } from '@/utils/brand-slogans.js';
|
|
import { getShareQrcodeApi, trackShareApi } from '@/utils/api.js';
|
|
|
|
const APP_PACKAGES = {
|
|
weixin_friend: { pname: 'com.tencent.mm', bundleid: 'com.tencent.xinWeChat' },
|
|
weixin_moment: { pname: 'com.tencent.mm', bundleid: 'com.tencent.xinWeChat' },
|
|
qq: { pname: 'com.tencent.mobileqq', bundleid: 'com.tencent.mqq' },
|
|
qq_zone: { pname: 'com.tencent.mobileqq', bundleid: 'com.tencent.mqq' },
|
|
sinaweibo: { pname: 'com.sina.weibo', bundleid: 'com.sina.weibo' }
|
|
};
|
|
|
|
const SHARE_TIMEOUT = {
|
|
weixin_friend: 5000, weixin_moment: 5000,
|
|
qq: 5000, qq_zone: 5000,
|
|
sinaweibo: 15000,
|
|
save_image: 8000
|
|
};
|
|
|
|
function reportEvent(eventName, payload) {
|
|
try { uni.report(eventName, payload); } catch { /* dev/test */ }
|
|
}
|
|
|
|
function isUserCancel(errMsg = '') {
|
|
return /cancel|取消/i.test(errMsg);
|
|
}
|
|
|
|
export function useShare(props) {
|
|
// ============ State ============
|
|
const state = ref('idle'); // idle | composing | sharing | done | error
|
|
const errorMsg = ref('');
|
|
const systemType = ref('');
|
|
const currentSlogan = ref(pickRandomSlogan());
|
|
const failCount = ref(0); // L3 degradation counter
|
|
|
|
// ============ Timers ============
|
|
const shareTimeoutTimer = ref(null);
|
|
const fallbackTimer = ref(null);
|
|
function clearShareTimers() {
|
|
if (shareTimeoutTimer.value) { clearTimeout(shareTimeoutTimer.value); shareTimeoutTimer.value = null; }
|
|
if (fallbackTimer.value) { clearTimeout(fallbackTimer.value); fallbackTimer.value = null; }
|
|
}
|
|
|
|
// ============ Lifecycle ============
|
|
(async () => {
|
|
try { const info = await uni.getSystemInfo(); systemType.value = info.platform || 'other'; }
|
|
catch { systemType.value = 'other'; }
|
|
})();
|
|
onShow(() => {
|
|
if (state.value === 'sharing') {
|
|
fallbackTimer.value = setTimeout(() => { if (state.value === 'sharing') state.value = 'idle'; }, 8000);
|
|
}
|
|
});
|
|
onHide(() => {
|
|
if (shareTimeoutTimer.value) { clearTimeout(shareTimeoutTimer.value); shareTimeoutTimer.value = null; }
|
|
});
|
|
onBeforeUnmount(clearShareTimers);
|
|
|
|
// ============ L1 Cache ============
|
|
const l1Cache = new Map();
|
|
|
|
// ============ Helpers ============
|
|
function getCurrentUser() {
|
|
const userStr = uni.getStorageSync('user');
|
|
if (!userStr) return null;
|
|
try { return JSON.parse(userStr); } catch { return null; }
|
|
}
|
|
|
|
// 后端 TrackShareRequest 字段是 int64,前端 props 是 string,转 number
|
|
function toInt64(v) {
|
|
if (v === null || v === undefined || v === '') return 0;
|
|
const n = typeof v === 'string' ? parseInt(v, 10) : Number(v);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
async function isAppInstalled(action) {
|
|
if (typeof plus === 'undefined' || !plus.runtime) return true;
|
|
const pkg = APP_PACKAGES[action];
|
|
if (!pkg) return true;
|
|
return new Promise(resolve => {
|
|
plus.runtime.isApplicationExist({ pname: pkg.pname, bundleid: pkg.bundleid }, e => resolve(!!e.exist));
|
|
});
|
|
}
|
|
|
|
async function downloadLocal(remoteUrl) {
|
|
if (!remoteUrl) return '';
|
|
if (typeof remoteUrl !== 'string') {
|
|
console.warn('[useShare] downloadLocal expected string, got', typeof remoteUrl);
|
|
return '';
|
|
}
|
|
// data: URL 直接返回
|
|
if (remoteUrl.startsWith('data:')) return remoteUrl;
|
|
// /static/... 是 app 包内静态资源。
|
|
// canvas drawImage 需要真实文件路径(不接受 /static/ 相对路径或 data URI)
|
|
// 把 /static/foo 拷贝到 _doc/(可写目录)拿到绝对路径给 canvas 用
|
|
if (remoteUrl.startsWith('/static/')) {
|
|
return await copyStaticToDoc(remoteUrl);
|
|
}
|
|
// http/https 用 uni.getImageInfo 拿本地临时文件 res.path
|
|
// (相较 uni.downloadFile,getImageInfo 专用于图片,内部走图像解码路径,
|
|
// 返回的本地 path canvas drawImage 直接可用,且拿图 + 解析一步到位)
|
|
const res = await uni.getImageInfo({ src: remoteUrl });
|
|
if (!res || !res.path) throw new Error('getImageInfo failed: no path returned');
|
|
// Android 上 res.path 是 'file:///storage/...',app-plus canvas drawImage 不接受 file:// 前缀,
|
|
// 会解析失败报 'image argument is a canvas element with a width or height of 0'。
|
|
// 统一剥成裸路径 '/storage/...' 与 copyStaticToDoc 返回格式一致
|
|
return res.path.replace(/^file:\/\//, '');
|
|
}
|
|
|
|
// 把 /static/ 静态资源复制到 _doc/ 可写目录(canvas drawImage 需要绝对文件路径,
|
|
// 且 native canvas 不接受 data: URI 也不接受 _www/ 前缀)
|
|
// 复用 plus.io.copyTo 把文件从 _www 拷到 _doc
|
|
function copyStaticToDoc(staticPath) {
|
|
return new Promise((resolve) => {
|
|
if (typeof plus === 'undefined' || !plus.io) {
|
|
resolve(staticPath);
|
|
return;
|
|
}
|
|
const fileName = staticPath.split('/').pop();
|
|
const srcPath = '_www/' + staticPath.replace(/^\//, '');
|
|
const dstPath = `_doc/${fileName}`;
|
|
// 先确保 _doc 存在
|
|
plus.io.resolveLocalFileSystemURL(
|
|
'_doc/',
|
|
(docDir) => {
|
|
// 解析 _www 源文件
|
|
plus.io.resolveLocalFileSystemURL(
|
|
srcPath,
|
|
(srcEntry) => {
|
|
srcEntry.copyTo(
|
|
docDir,
|
|
fileName,
|
|
(destEntry) => {
|
|
console.log('[useShare] static copied to', destEntry.fullPath);
|
|
resolve(destEntry.fullPath);
|
|
},
|
|
(copyErr) => {
|
|
console.warn('[useShare] static copy fail, fallback raw', copyErr);
|
|
resolve(staticPath);
|
|
}
|
|
);
|
|
},
|
|
(resolveErr) => {
|
|
console.warn('[useShare] static resolve fail, fallback raw', resolveErr);
|
|
resolve(staticPath);
|
|
}
|
|
);
|
|
},
|
|
(docErr) => {
|
|
console.warn('[useShare] _doc resolve fail', docErr);
|
|
resolve(staticPath);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
// 把文件复制到 plus.io.PRIVATE_DOC 沙盒目录(Android 10+ 分区存储要求)
|
|
// 返回沙盒内的目标绝对路径,可供 plus.gallery.save 使用
|
|
function copyToSandbox(tempPath) {
|
|
return new Promise((resolve, reject) => {
|
|
if (typeof plus === 'undefined' || !plus.io) {
|
|
return reject(new Error('plus.io unavailable'));
|
|
}
|
|
const fileName = `share_${Date.now()}.png`;
|
|
const sandboxRoot = plus.io.PRIVATE_DOC; // 应用沙盒 doc 目录
|
|
const targetPath = `${sandboxRoot}/${fileName}`;
|
|
|
|
// 8s 超时兜底(避免 plus.io 内部 hang 永远不回调)
|
|
const timeoutId = setTimeout(() => {
|
|
console.warn('[useShare] copyToSandbox timeout');
|
|
reject(new Error('copyToSandbox timeout(8s)'));
|
|
}, 8000);
|
|
|
|
// plus.io.resolveLocalFileSystemURL 拿到 source FileEntry
|
|
plus.io.resolveLocalFileSystemURL(
|
|
tempPath,
|
|
(srcEntry) => {
|
|
// 拿沙盒目录 DirectoryEntry
|
|
plus.io.resolveLocalFileSystemURL(
|
|
sandboxRoot,
|
|
(sandboxDir) => {
|
|
srcEntry.copyTo(
|
|
sandboxDir,
|
|
fileName,
|
|
(destEntry) => {
|
|
clearTimeout(timeoutId);
|
|
console.log('[useShare] copyToSandbox done:', destEntry.fullPath);
|
|
resolve(destEntry.fullPath);
|
|
},
|
|
(copyErr) => {
|
|
clearTimeout(timeoutId);
|
|
console.warn('[useShare] copyToSandbox fail:', copyErr);
|
|
reject(new Error(copyErr.message || 'copyTo failed'));
|
|
}
|
|
);
|
|
},
|
|
(dirErr) => {
|
|
clearTimeout(timeoutId);
|
|
console.warn('[useShare] resolve sandbox dir fail:', dirErr);
|
|
reject(new Error(dirErr.message || 'sandbox dir resolve failed'));
|
|
}
|
|
);
|
|
},
|
|
(srcErr) => {
|
|
clearTimeout(timeoutId);
|
|
console.warn('[useShare] resolve temp fail:', srcErr);
|
|
reject(new Error(srcErr.message || 'temp resolve failed'));
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
// 清理沙盒文件(异步,失败不抛)
|
|
function cleanupSandboxFile(sandboxPath) {
|
|
if (typeof plus === 'undefined' || !plus.io || !sandboxPath) return;
|
|
plus.io.resolveLocalFileSystemURL(
|
|
sandboxPath,
|
|
(entry) => {
|
|
try { entry.remove(() => {}, () => {}); } catch {}
|
|
},
|
|
() => {}
|
|
);
|
|
}
|
|
|
|
async function fetchQrcode(assetId) {
|
|
const user = getCurrentUser();
|
|
if (!user?.uid) throw new Error('未登录');
|
|
return getShareQrcodeApi(assetId, user.uid, systemType.value);
|
|
}
|
|
|
|
function trackShare(payload) {
|
|
return trackShareApi(payload);
|
|
}
|
|
|
|
async function ensureLogin() {
|
|
const user = getCurrentUser();
|
|
if (!user?.uid) {
|
|
uni.showToast({ title: '请先登录', icon: 'none' });
|
|
setTimeout(() => uni.navigateTo({ url: '/pages/login/login' }), 800);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function startShareTimeout(action) {
|
|
const ms = SHARE_TIMEOUT[action] || 5000;
|
|
shareTimeoutTimer.value = setTimeout(() => {
|
|
if (state.value === 'sharing') {
|
|
state.value = 'error';
|
|
errorMsg.value = '分享超时,请重试';
|
|
uni.showToast({ title: errorMsg.value, icon: 'none' });
|
|
const userId = getCurrentUser()?.uid || 0;
|
|
trackShare({ asset_id: toInt64(typeof props.getAssetId === 'function' ? props.getAssetId() : props.assetId), sharer_user_id: toInt64(userId), system_type: systemType.value, share_target: action, result: 'fail_other', client_ts: Date.now() });
|
|
}
|
|
}, ms);
|
|
}
|
|
|
|
// ============ Core ============
|
|
async function pick(action) {
|
|
if (!(await ensureLogin())) return;
|
|
|
|
state.value = 'composing';
|
|
errorMsg.value = '';
|
|
|
|
// 关键:等 Vue 把 canvas 元素挂载到 DOM(ShareModal template 用 v-if 控制)
|
|
// 否则 uni.createCanvasContext 找不到 canvas,后续 drawImage 全无效
|
|
console.log('[useShare] pick start, action=', action);
|
|
await nextTick();
|
|
await new Promise(r => setTimeout(r, 50)); // 多等 50ms 让 uni-app 完成 native canvas 初始化
|
|
console.log('[useShare] after nextTick + 50ms');
|
|
|
|
try {
|
|
// 1. App 探测
|
|
if (action !== 'save_image') {
|
|
const installed = await isAppInstalled(action);
|
|
if (!installed) {
|
|
const labelMap = { weixin_friend: '微信', weixin_moment: '微信', qq: 'QQ', qq_zone: 'QQ', sinaweibo: '微博' };
|
|
uni.showToast({ title: `请先安装${labelMap[action]}`, icon: 'none' });
|
|
state.value = 'idle';
|
|
const userId = getCurrentUser().uid;
|
|
await trackShare({ asset_id: toInt64(typeof props.getAssetId === 'function' ? props.getAssetId() : props.assetId), sharer_user_id: toInt64(userId), system_type: systemType.value, share_target: action, result: 'fail_app_missing', client_ts: Date.now() });
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 2. 缓存查询 — props 通过 getter functions 取实时值(避免 setup snapshot 拿到空)
|
|
const coverUrl = typeof props.getCoverUrl === 'function' ? props.getCoverUrl() : props.coverUrl;
|
|
const qrcodeUrl = typeof props.getQrcodeUrl === 'function' ? props.getQrcodeUrl() : props.qrcodeUrl;
|
|
const avatarUrl = typeof props.getAvatarUrl === 'function' ? props.getAvatarUrl() : props.avatarUrl;
|
|
const nickname = typeof props.getNickname === 'function' ? props.getNickname() : props.nickname;
|
|
const assetId = typeof props.getAssetId === 'function' ? props.getAssetId() : props.assetId;
|
|
const displayTxHash = typeof props.getDisplayTxHash === 'function' ? props.getDisplayTxHash() : props.displayTxHash;
|
|
|
|
const localPaths = await Promise.all([
|
|
downloadLocal(coverUrl),
|
|
downloadLocal(qrcodeUrl),
|
|
downloadLocal(avatarUrl)
|
|
]);
|
|
console.log('[useShare] qrcodeUrl =', qrcodeUrl);
|
|
console.log('[useShare] avatarUrl =', avatarUrl);
|
|
console.log('[useShare] coverLocal =', localPaths[0]);
|
|
console.log('[useShare] qrcodeLocal =', localPaths[1]);
|
|
console.log('[useShare] avatarLocal =', localPaths[2]);
|
|
const composeKey = computeComposeKey({
|
|
coverLocalPath: localPaths[0], qrcodeLocalPath: localPaths[1], avatarLocalPath: localPaths[2],
|
|
nickname, slogan: currentSlogan.value, displayTxHash
|
|
});
|
|
console.log(composeKey)
|
|
let tempFilePath = l1Cache.get(composeKey);
|
|
// 跳过 canvas 的条件:只有 cover 是 /static/ 才跳过(native canvas 不支持相对路径)
|
|
// avatar/qrcode 即使是 /static/ 也让 canvas 跑(它能容忍某图层缺失,留空白)
|
|
const coverIsStatic = localPaths[0] && localPaths[0].startsWith('/static/');
|
|
if (!tempFilePath && !coverIsStatic) {
|
|
try {
|
|
const result = await Promise.race([
|
|
composeShareImage({
|
|
coverLocalPath: localPaths[0], qrcodeLocalPath: localPaths[1], avatarLocalPath: localPaths[2],
|
|
nickname, slogan: currentSlogan.value, displayTxHash,
|
|
canvasId: props.canvasId || 'shareCanvas'
|
|
}, props.vm),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('canvas 合成超时(8s)')), 8000))
|
|
]);
|
|
tempFilePath = result.tempFilePath;
|
|
if (tempFilePath) l1Cache.set(composeKey, tempFilePath);
|
|
} catch (canvasErr) {
|
|
console.warn('[useShare] canvas 合成失败,降级用 cover 图:', canvasErr.message || canvasErr);
|
|
tempFilePath = localPaths[0];
|
|
if (!tempFilePath) {
|
|
throw new Error('cover 图也下载失败,请检查 props.coverUrl 是否有效');
|
|
}
|
|
uni.showToast({ title: '水印图生成失败,使用原图', icon: 'none' });
|
|
}
|
|
} else if (!tempFilePath) {
|
|
// cover 是 /static/ 相对路径,native canvas 无法加载,直接用 cover 原图
|
|
console.log('[useShare] cover 是 /static/ 路径,跳过 canvas 直接用 cover');
|
|
tempFilePath = localPaths[0];
|
|
if (!tempFilePath) {
|
|
throw new Error('cover 图也下载失败,请检查 props.coverUrl 是否有效');
|
|
}
|
|
}
|
|
|
|
// 3. 分发 + 监控
|
|
state.value = 'sharing';
|
|
const startTs = Date.now();
|
|
const userId = getCurrentUser().uid;
|
|
const commonPayload = { asset_id: toInt64(typeof props.getAssetId === 'function' ? props.getAssetId() : props.assetId), user_id: toInt64(userId), system_type: systemType.value };
|
|
|
|
if (action === 'save_image') {
|
|
reportEvent('save_image_click', commonPayload);
|
|
} else {
|
|
reportEvent('share_action_click', { ...commonPayload, target: action });
|
|
}
|
|
|
|
startShareTimeout(action);
|
|
|
|
let result = 'fail_other';
|
|
let errMsg = '';
|
|
if (action === 'save_image') {
|
|
console.log('[useShare] before saveImageToPhotosAlbum, filePath =', tempFilePath, 'state=', state.value);
|
|
try {
|
|
// 优先:uni.saveImageToPhotosAlbum 直接试(快,在 Android 10- 或非严格分区存储下能成功)
|
|
await new Promise((resolve, reject) => {
|
|
uni.saveImageToPhotosAlbum({
|
|
filePath: tempFilePath,
|
|
success: (res) => {
|
|
console.log('[useShare] uni.saveImageToPhotosAlbum success', res);
|
|
uni.showToast({ title: '已保存到相册' });
|
|
resolve();
|
|
},
|
|
fail: (e) => {
|
|
console.warn('[useShare] uni.saveImageToPhotosAlbum fail, fallback to plus.gallery.save', e);
|
|
reject(new Error(e.errMsg || JSON.stringify(e) || 'save failed'));
|
|
}
|
|
});
|
|
});
|
|
} catch (eUni) {
|
|
// 兜底:Android 10+ 严格分区存储场景,uni.saveImageToPhotosAlbum 会因 code=12 失败
|
|
// 此时需要走沙盒路径:plus.io.copyTo + plus.gallery.save
|
|
if (typeof plus === 'undefined' || !plus.gallery || typeof plus.gallery.save !== 'function') {
|
|
throw new Error('plus.gallery unavailable and uni.saveImageToPhotosAlbum failed: ' + eUni.message);
|
|
}
|
|
try {
|
|
const sandboxPath = await copyToSandbox(tempFilePath);
|
|
console.log('[useShare] sandbox path =', sandboxPath);
|
|
await new Promise((resolve, reject) => {
|
|
plus.gallery.save(
|
|
sandboxPath,
|
|
(res) => {
|
|
console.log('[useShare] plus.gallery.save success', res);
|
|
uni.showToast({ title: '已保存到相册' });
|
|
cleanupSandboxFile(sandboxPath);
|
|
resolve();
|
|
},
|
|
(err) => {
|
|
console.warn('[useShare] plus.gallery.save fail', err);
|
|
cleanupSandboxFile(sandboxPath);
|
|
reject(err);
|
|
}
|
|
);
|
|
});
|
|
} catch (eSandbox) {
|
|
// 两条路都失败 — 把详细错误抛出
|
|
throw new Error(`uni.saveImageToPhotosAlbum: ${eUni.message}; plus.gallery.save: ${eSandbox.message}`);
|
|
}
|
|
}
|
|
// errMsg 分类:区分 cancel / permission / 其他失败
|
|
errMsg = '';
|
|
result = 'success';
|
|
reportEvent('save_image_result', { ...commonPayload, result, duration_ms: Date.now() - startTs });
|
|
} else {
|
|
const provider = action.startsWith('weixin') ? 'weixin' : (action === 'qq' || action === 'qq_zone') ? 'qq' : 'sinaweibo';
|
|
const scene = action === 'weixin_moment' ? 'WXSceneTimeline' : action.startsWith('weixin') ? 'WXSceneSession' : undefined;
|
|
const shareRes = await new Promise((resolve) => {
|
|
uni.share({
|
|
provider, scene, imageUrl: tempFilePath,
|
|
success: (r) => resolve({ ok: true, msg: r.errMsg }),
|
|
fail: (e) => resolve({ ok: false, msg: e.errMsg || 'share failed' })
|
|
});
|
|
});
|
|
if (shareRes.ok) result = 'success';
|
|
else if (isUserCancel(shareRes.msg)) result = 'cancel';
|
|
errMsg = shareRes.msg;
|
|
reportEvent('share_result', { ...commonPayload, target: action, result, duration_ms: Date.now() - startTs });
|
|
}
|
|
|
|
clearShareTimers();
|
|
|
|
if (result === 'success') {
|
|
state.value = 'done';
|
|
// action-aware 文案:save_image 已在成功回调里弹过"已保存到相册",这里跳过避免覆盖;
|
|
// 其他 action 走分享平台,统一提示"分享成功"
|
|
if (action !== 'save_image') {
|
|
uni.showToast({ title: '分享成功' });
|
|
}
|
|
failCount.value = 0;
|
|
setTimeout(() => { if (state.value === 'done') state.value = 'idle'; }, 2000);
|
|
} else if (result === 'cancel') {
|
|
state.value = 'idle';
|
|
} else {
|
|
state.value = 'error';
|
|
errorMsg.value = '分享失败,请稍后再试';
|
|
uni.showToast({ title: errorMsg.value, icon: 'none' });
|
|
failCount.value += 1;
|
|
}
|
|
|
|
await trackShare({
|
|
asset_id: toInt64(typeof props.getAssetId === 'function' ? props.getAssetId() : props.assetId), sharer_user_id: toInt64(userId),
|
|
system_type: systemType.value, share_target: action, result,
|
|
client_ts: startTs, extra: { duration_ms: Date.now() - startTs, err: errMsg }
|
|
});
|
|
} catch (e) {
|
|
clearShareTimers();
|
|
state.value = 'error';
|
|
errorMsg.value = e.message || '未知错误';
|
|
uni.showToast({ title: errorMsg.value, icon: 'none' });
|
|
failCount.value += 1;
|
|
}
|
|
}
|
|
|
|
return { state, errorMsg, systemType, currentSlogan, failCount, pick };
|
|
}
|