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