topfans/frontend/pages/components/ShareModal.vue
zerosaturation 83999995f5 refactor(frontend): consolidate user storage read via getStoredUser helper
uni.getStorageSync('user') 在 key 从未写过的 UniApp 平台下默认返回 ""(空串)
而非 null/undefined,旧式 `JSON.parse(uni.getStorageSync('user')) || {}` 在
parse 阶段直接抛 `SyntaxError: Unexpected end of JSON input`,导致 quickLogin
setup 崩溃,并触发 Vue 二次告警 `Invalid vnode type: undefined`。

新增 utils/getStoredUser.js 单点封装,统一处理:
  - 空值/损坏 JSON 兜底(返回 null,不抛)
  - 与 store/modules/user.js 初始化块同源守卫
  - 失败 warn 级别日志,不影响业务

替换全库 16 处不安全 `JSON.parse(userStr)` 调用:
  composables/useLaserMint.js                  composables/useShare.js
  utils/guideConfig.js                         utils/preloadApi/core.js
  pages/castlove/lenticular/lenticular-result.vue
  pages/support-activity/index.vue             pages/support-activity/components/{TopRanking,ActionBar}.vue
  pages/components/{Header,ShareModal,ShareReportButtons}.vue
  pages/tasks/{GuideModal,daily-tasks}.vue
  pages/asset-detail/asset-detail.vue          pages/exhibition/exhibition.vue
  pages/profile/profile.vue                    pages/login/quickLogin.vue(原崩点)

净 -18 行,17 文件改动 + 1 文件新增。后续如需支持多 key(如 'adminUser'),
把 getStoredUser 扩成泛型 getStoredJSON(key) 即可。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:16:49 +08:00

300 lines
8.4 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 { useAliveGuard } from '@/composables/useAliveGuard.js';
import { LANDING_BASE, SHARE_TARGETS } from '@/utils/constants.js';
import { trackShareApi } from '@/utils/api.js';
import { getStoredUser } from '@/utils/getStoredUser.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(() => {
// ★ 2026-07-27 refactor:统一用 @/utils/getStoredUser(原写法 `JSON.parse(...||'{}')`+try/catch 也安全,这里是为单点真理)
try { return !!getStoredUser()?.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'
});
// ShareModal 自己的异步回调(uni.setClipboardData)也需要守卫
// useShare 内部已自带守卫,这里只覆盖 ShareModal 自己的路径
const { guard } = useAliveGuard();
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 参数),从本地存储读取;未登录则不附带
// ★ 2026-07-27 refactor:委托 @/utils/getStoredUser,统一空值/损坏 JSON 守卫
let from = '';
let uid = 0;
try {
const userInfo = getStoredUser();
if (userInfo) {
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: guard(() => {
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: guard(() => {
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;
inset: 0;
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>