topfans/frontend/composables/useShare.js
2026-07-15 12:40:39 +08:00

539 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// frontend/composables/useShare.js
// 分享状态机 + 合成调度 + 错误处理 + 监控埋点
// (spec § 5 + § 5.3.1 + § 7 + § 8)
import { ref, nextTick } from 'vue';
import { 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';
import { useAliveGuard } from '@/composables/useAliveGuard.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
// ============ Alive Guard ============
// 分享进行中若用户关闭弹窗/页面,所有原生异步回调(plus.io / uni.saveImageToPhotosAlbum /
// plus.gallery.save)回来时会被 guard() 吞掉,不再触碰 state、不弹 toast、不继续链式调用,
// 避免 iOS WebView 上 "TypeError: undefined is not an object (evaluating 'n.fireEvent')"
const { isAlive, guard } = useAliveGuard();
// ============ 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();
if (!isAlive()) return;
systemType.value = info.platform || 'other';
} catch { /* alive 守卫兜底 */ }
})();
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; }
});
// useAliveGuard 已绑 onUnload 清活守卫,这里再清一次 timers 保持同步
onUnload(() => { 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 },
guard(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/',
guard((docDir) => {
// 解析 _www 源文件
plus.io.resolveLocalFileSystemURL(
srcPath,
guard((srcEntry) => {
srcEntry.copyTo(
docDir,
fileName,
guard((destEntry) => {
console.log('[useShare] static copied to', destEntry.fullPath);
resolve(destEntry.fullPath);
}),
guard((copyErr) => {
console.warn('[useShare] static copy fail, fallback raw', copyErr);
resolve(staticPath);
})
);
}),
guard((resolveErr) => {
console.warn('[useShare] static resolve fail, fallback raw', resolveErr);
resolve(staticPath);
})
);
}),
guard((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,
guard((srcEntry) => {
// 拿沙盒目录 DirectoryEntry
plus.io.resolveLocalFileSystemURL(
sandboxRoot,
guard((sandboxDir) => {
srcEntry.copyTo(
sandboxDir,
fileName,
guard((destEntry) => {
clearTimeout(timeoutId);
console.log('[useShare] copyToSandbox done:', destEntry.fullPath);
resolve(destEntry.fullPath);
}),
guard((copyErr) => {
clearTimeout(timeoutId);
console.warn('[useShare] copyToSandbox fail:', copyErr);
reject(new Error(copyErr.message || 'copyTo failed'));
})
);
}),
guard((dirErr) => {
clearTimeout(timeoutId);
console.warn('[useShare] resolve sandbox dir fail:', dirErr);
reject(new Error(dirErr.message || 'sandbox dir resolve failed'));
})
);
}),
guard((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,
guard((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;
}
// iOS 相册权限预检:仅在用户触发保存动作时调用,不在 App 启动时弹
// iOS PHPhotoLibrary 授权状态:
// 0=NotDetermined(系统未问) 1=Restricted(家长控制/MDM,用户不能改)
// 2=Denied(用户拒绝) 3=Authorized(允许全部) 4=Limited(iOS14+ 部分照片)
// 返回true=可以继续保存false=被拒绝/受限,本次保存终止
async function ensureIosPhotoAlbumPermission() {
if (typeof plus === 'undefined' || plus.os.name !== 'iOS') return true;
const status = plus.ios.invoke('PHPhotoLibrary', 'authorizationStatus');
if (status === 3 || status === 4) return true; // Authorized / Limited
if (status === 0) return true; // NotDetermined由 uni.saveImageToPhotosAlbum 首次调用时系统自动弹
if (status === 1) {
// Restricted — 引导去设置页也无效,直接 toast 提示
uni.showToast({ title: '相册权限被限制,无法保存到相册', icon: 'none' });
return false;
}
// status === 2: Denied — 引导用户去设置页开启
return new Promise((resolve) => {
uni.showModal({
title: '相册权限开启提醒',
content: '您还没有开启相册权限,无法保存分享的卡片到相册,请前往设置开启!',
showCancel: false,
confirmText: '去设置',
success: (res) => {
if (res.confirm) {
const app = plus.ios.invoke('UIApplication', 'sharedApplication');
const setting = plus.ios.invoke('NSURL', 'URLWithString:', 'app-settings:');
plus.ios.invoke(app, 'openURL:', setting);
plus.ios.deleteObject(setting);
plus.ios.deleteObject(app);
}
// 本次保存终止;用户从设置页回来后需重新触发保存动作
resolve(false);
},
});
});
}
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;
// ★ iOS 相册权限预检:仅在用户触发保存动作时检查,不在 App 启动时弹
if (action === 'save_image') {
const canProceed = await ensureIosPhotoAlbumPermission();
if (!canProceed) {
// 跟踪为权限失败(与 fail_app_missing 同类语义)
const userId = getCurrentUser()?.uid;
if (userId) {
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_permission_denied',
client_ts: Date.now()
});
}
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: guard((res) => {
console.log('[useShare] uni.saveImageToPhotosAlbum success', res);
uni.showToast({ title: '已保存到相册' });
resolve();
}),
fail: guard((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,
guard((res) => {
console.log('[useShare] plus.gallery.save success', res);
uni.showToast({ title: '已保存到相册' });
cleanupSandboxFile(sandboxPath);
resolve();
}),
guard((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: guard((r) => resolve({ ok: true, msg: r.errMsg })),
fail: guard((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 };
}