292 lines
7.9 KiB
Vue
292 lines
7.9 KiB
Vue
<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>
|