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>
300 lines
8.4 KiB
Vue
300 lines
8.4 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 { 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>
|