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