539 lines
23 KiB
JavaScript
539 lines
23 KiB
JavaScript
// 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 };
|
||
}
|