topfans/frontend/pages/components/ShareModal.vue

292 lines
7.9 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

<template>
<view v-if="props.visible" class="share-modal-overlay" @tap.self="handleClose">
<view class="share-modal" @tap.stop>
<view class="header">
<text class="title">分享藏品</text>
<view class="close" @tap="handleClose">✕</view>
</view>
<view v-if="!isLoggedIn" class="login-gate">
<text class="lock-icon">🔒</text>
<text class="lock-text">登录后才能分享</text>
<view class="login-actions">
<button class="btn-secondary" @tap="handleClose">再看看</button>
<button class="btn-primary" @tap="goLogin">去登录</button>
</view>
</view>
<template v-else>
<SharePreviewCard :coverUrl="coverUrl" :avatarUrl="avatarUrl" :qrcodeUrl="qrcodeUrl"
:nickname="nickname" :slogan="currentSlogan" />
<view v-if="state === 'composing'" class="overlay">
<view class="spinner" />
<text>正在生成图片...</text>
</view>
<ShareActionBar @pick="pick" />
<view class="degrade-link" @tap="copyLink">
<text class="degrade-icon">🔗</text>
<text class="degrade-text">复制链接分享到更多平台</text>
</view>
</template>
</view>
<!-- canvas 必须放 page (app-plus 组件内 canvas 不被 createCanvasContext 识别)
如果父页面有传 externalCanvasId,这里就不挂 canvas,否则兜底挂一个(可能仍不工作)
width/height HTML 属性(决定 native canvas 绘图表面尺寸),不是 CSS
CSS 设的尺寸 + display:none 会让 surface 尺寸 = 0,触发 'InvalidStateError: width or height of 0' -->
<canvas
v-if="!externalCanvasId"
canvas-id="shareCanvas"
id="shareCanvas"
:width="750"
:height="1334"
class="share-canvas"
/>
</view>
</template>
<script setup>
import { ref, computed, watch, getCurrentInstance } from 'vue';
import SharePreviewCard from './SharePreviewCard.vue';
import ShareActionBar from './ShareActionBar.vue';
import { useShare } from '@/composables/useShare.js';
import { LANDING_BASE, SHARE_TARGETS } from '@/utils/constants.js';
import { trackShareApi } from '@/utils/api.js';
const props = defineProps({
visible: { type: Boolean, default: false },
coverUrl: { type: String, required: true },
qrcodeUrl: { type: String, default: '' },
avatarUrl: { type: String, default: '' },
nickname: { type: String, default: '' },
assetId: { type: [Number, String], required: true },
// 链上哈希(由父组件 asset-detail 提供,转发给 useShare → image-compositor)
displayTxHash: { type: String, default: '' },
// 可选:外部 page 级 canvasId(uni-app 组件内 canvas 跨端不稳,推荐父页面提供)
externalCanvasId: { type: String, default: '' }
});
console.log('[ShareModal] setup props:', { visible: props.visible, coverUrl: props.coverUrl, qrcodeUrl: props.qrcodeUrl, avatarUrl: props.avatarUrl, assetId: props.assetId });
const emit = defineEmits(['close']);
const visibleLocal = ref(props.visible);
watch(() => props.visible, (v) => { visibleLocal.value = v; });
const isLoggedIn = computed(() => {
try { return !!JSON.parse(uni.getStorageSync('user') || '{}').uid; } catch { return false; }
});
const { state, pick, currentSlogan, systemType } = useShare({
// 关键:用 getter functions 读 props,避免 setup snapshot 问题
// (useShare 在 setup 时调用一次,如果直接传 props.coverUrl 会拿到空字符串)
getCoverUrl: () => props.coverUrl,
getQrcodeUrl: () => props.qrcodeUrl,
getAvatarUrl: () => props.avatarUrl,
getNickname: () => props.nickname,
getAssetId: () => props.assetId,
getDisplayTxHash: () => props.displayTxHash,
// app-plus canvas API 第二个参数需要页面级 vm,getCurrentPages() 当前页是最稳的
vm: (() => {
try {
const pages = getCurrentPages();
return pages[pages.length - 1];
} catch { return null; }
})(),
canvasId: props.externalCanvasId || 'shareCanvas'
});
watch(() => props.visible, (v) => { visibleLocal.value = v; });
function handleClose() { emit('close'); visibleLocal.value = false; }
function goLogin() { emit('close'); visibleLocal.value = false; uni.navigateTo({ url: '/pages/login/login' }); }
function copyLink() {
// 兜底:系统类型未就绪时给个 'other'(与后端 QR 接口校验一致)
const os = systemType.value || 'other';
// 当前用户 uid(分享归因 from 参数),从本地存储读取;未登录则不附带
let from = '';
let uid = 0;
try {
const userStr = uni.getStorageSync('user');
if (userStr) {
const userInfo = JSON.parse(userStr);
const rawUid = userInfo?.uid || userInfo?.user_id;
from = String(rawUid || '');
uid = Number(rawUid) || 0;
}
} catch (e) {
console.warn('[ShareModal] parse current user for copyLink failed:', e);
}
// URL 格式与后端 QR 码接口(spec § 3)保持一致:
// {LANDING_BASE}/asset/{id}?from={uid}&s={system}
// 这样扫描二维码 / 复制链接命中同一落地页规则,后端归因、路由解析都走一套
const params = [`s=${encodeURIComponent(os)}`];
if (from) params.push(`from=${encodeURIComponent(from)}`);
// share_target 用枚举常量(后端已接收 copy_link,见 share_service.validShareTargets)
params.push(`share_target=${SHARE_TARGETS.COPY_LINK}`);
const url = `${LANDING_BASE}/asset/${encodeURIComponent(props.assetId)}?${params.join('&')}`;
uni.setClipboardData({
data: url,
success: () => {
uni.showToast({ title: '链接已复制' });
// 上报埋点:让"复制链接"与图形分享走同一统计通道
// 失败不阻塞用户(已成功复制,埋点只是辅助)
const assetIdNum = Number(props.assetId) || 0;
if (assetIdNum > 0 && uid > 0) {
trackShareApi({
asset_id: assetIdNum,
sharer_user_id: uid,
system_type: os,
share_target: SHARE_TARGETS.COPY_LINK,
result: 'success',
client_ts: Date.now()
}).catch((e) => {
console.warn('[ShareModal] trackShare(copy_link) failed:', e);
});
}
},
fail: () => {
uni.showToast({ title: '复制失败', icon: 'none' });
}
});
}
</script>
<style lang="scss" scoped>
.share-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .55);
z-index: 999;
display: flex;
align-items: flex-end;
justify-content: center;
}
.share-modal {
width: 100%;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
padding: 32rpx 24rpx;
position: relative;
min-height: 700rpx;
max-height: 90vh;
overflow-y: auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.title {
font-size: 32rpx;
font-weight: 600;
}
.close {
font-size: 36rpx;
padding: 8rpx 16rpx;
}
.login-gate {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
padding: 80rpx 0;
}
.lock-icon {
font-size: 80rpx;
}
.lock-text {
font-size: 28rpx;
color: #666;
}
.login-actions {
display: flex;
gap: 24rpx;
}
.btn-secondary,
.btn-primary {
font-size: 28rpx;
padding: 16rpx 48rpx;
border-radius: 48rpx;
}
.btn-secondary {
background: #f5f5f5;
color: #333;
}
.btn-primary {
background: #07C160;
color: #fff;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, .85);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
}
.spinner {
width: 64rpx;
height: 64rpx;
border: 6rpx solid #ddd;
border-top-color: #07C160;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.degrade-link {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 24rpx 0;
margin-top: 8rpx;
}
.degrade-icon {
font-size: 32rpx;
line-height: 1;
}
.degrade-text {
font-size: 28rpx;
color: #576b95;
text-decoration: underline;
}
/* 离屏 canvas — 不能用 display:none(会让 layout 尺寸=0,native canvas surface 跟着=0),
用 position:fixed + 负坐标推到视口外,layout 尺寸保留,canvas surface 正常 */
.share-canvas {
position: fixed;
left: -9999px;
top: -9999px;
width: 750px;
height: 1334px;
pointer-events: none;
}
</style>