topfans/frontend/composables/useShare.js

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 };
}