diff --git a/backend/services/assetService/service/share_service.go b/backend/services/assetService/service/share_service.go index 0964f68..ce5a7e1 100644 --- a/backend/services/assetService/service/share_service.go +++ b/backend/services/assetService/service/share_service.go @@ -50,6 +50,14 @@ var validResults = map[string]struct{}{ } // validShareTargets spec § 3 分享目标枚举 +// +// 包含前端"复制链接"专用值 `copy_link`: +// - GetAssetQrcode: 前端调用方一般不传(QR 接口主要服务图形分享), +// 但保留兼容,允许空字符串或 copy_link 命中校验。 +// - TrackShare: 前端 copyLink() 走 trackShare 埋点时上报 copy_link, +// 这样"复制链接"也能进入分享统计,与图形分享并列归因。 +// +// 同步点:与 frontend/utils/constants.js SHARE_TARGETS 保持一致。 var validShareTargets = map[string]struct{}{ "weixin_friend": {}, "weixin_moment": {}, @@ -57,6 +65,7 @@ var validShareTargets = map[string]struct{}{ "qq_zone": {}, "sinaweibo": {}, "save_image": {}, + "copy_link": {}, } const ( diff --git a/frontend/.env.production b/frontend/.env.production index 302db81..08a26c5 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -1,6 +1,8 @@ # 生产环境配置 # HBuilderX「发行」时自动加载;CLI 用 --mode production VITE_API_BASE_URL=https://api.topfans.online +# H5 落地页根域名:与后端 LANDING_BASE_URL 保持一致(用于扫码 / 复制链接拼接) +VITE_LANDING_BASE_URL=https://h5.topfans.com # WebSocket 地址:生产环境使用 wss(与 HTTPS 对应),如 WS 部署在独立端口/域名可覆盖 # 走 Nginx 反向代理时建议改为同源 wss://api.topfans.online,由 Nginx 转发到后端 ws://gateway:8080 VITE_WS_BASE_URL=wss://api.topfans.online diff --git a/frontend/composables/useShare.js b/frontend/composables/useShare.js index 6e6a90f..83a2e1a 100644 --- a/frontend/composables/useShare.js +++ b/frontend/composables/useShare.js @@ -295,6 +295,7 @@ export function useShare(props) { 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), @@ -308,7 +309,7 @@ export function useShare(props) { console.log('[useShare] avatarLocal =', localPaths[2]); const composeKey = computeComposeKey({ coverLocalPath: localPaths[0], qrcodeLocalPath: localPaths[1], avatarLocalPath: localPaths[2], - nickname, slogan: currentSlogan.value + nickname, slogan: currentSlogan.value, displayTxHash }); console.log(composeKey) let tempFilePath = l1Cache.get(composeKey); @@ -320,7 +321,7 @@ export function useShare(props) { const result = await Promise.race([ composeShareImage({ coverLocalPath: localPaths[0], qrcodeLocalPath: localPaths[1], avatarLocalPath: localPaths[2], - nickname, slogan: currentSlogan.value, + nickname, slogan: currentSlogan.value, displayTxHash, canvasId: props.canvasId || 'shareCanvas' }, props.vm), new Promise((_, reject) => setTimeout(() => reject(new Error('canvas 合成超时(8s)')), 8000)) @@ -432,8 +433,11 @@ export function useShare(props) { if (result === 'success') { state.value = 'done'; - // action-aware 文案:save_image 走相册,其他 action 走分享平台 - uni.showToast({ title:'分享成功' }); + // 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') { diff --git a/frontend/pages/asset-detail/asset-detail.vue b/frontend/pages/asset-detail/asset-detail.vue index 7ac8d48..f9ff182 100644 --- a/frontend/pages/asset-detail/asset-detail.vue +++ b/frontend/pages/asset-detail/asset-detail.vue @@ -11,7 +11,7 @@ + :displayTxHash="displayTxHash" externalCanvasId="shareCanvas" /> diff --git a/frontend/pages/components/ShareModal.vue b/frontend/pages/components/ShareModal.vue index 41f566b..c799a4b 100644 --- a/frontend/pages/components/ShareModal.vue +++ b/frontend/pages/components/ShareModal.vue @@ -53,6 +53,8 @@ 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 }, @@ -61,6 +63,8 @@ const props = defineProps({ 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: '' } }); @@ -81,6 +85,7 @@ const { state, pick, currentSlogan, systemType } = useShare({ getAvatarUrl: () => props.avatarUrl, getNickname: () => props.nickname, getAssetId: () => props.assetId, + getDisplayTxHash: () => props.displayTxHash, // app-plus canvas API 第二个参数需要页面级 vm,getCurrentPages() 当前页是最稳的 vm: (() => { try { @@ -96,12 +101,55 @@ 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() { - // systemType 从 useShare 拿(已经异步初始化过);兜底 'other' 兜底未就绪场景 - // query 参数对齐后端约定:与 api.js getShareQrcodeApi 一致使用 system_type + // 兜底:系统类型未就绪时给个 '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: `https://h5.topfans.com/asset?${props.assetId}&system_type=${os}&share_target=copy_link`, - success: () => uni.showToast({ title: '链接已复制' }) + 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' }); + } }); } diff --git a/frontend/pages/components/ShareReportButtons.vue b/frontend/pages/components/ShareReportButtons.vue index e321352..dbd4c49 100644 --- a/frontend/pages/components/ShareReportButtons.vue +++ b/frontend/pages/components/ShareReportButtons.vue @@ -20,6 +20,7 @@ :avatarUrl="shareAvatarUrl" :nickname="ownerNickname" :assetId="assetId" + :displayTxHash="displayTxHash" :externalCanvasId="externalCanvasId" @close="showShareModal = false" /> @@ -65,6 +66,11 @@ const props = defineProps({ externalCanvasId: { type: String, default: '' + }, + // 链上哈希(原文,由父组件 asset-detail 提供;image-compositor 内部截断渲染) + displayTxHash: { + type: String, + default: '' } }); diff --git a/frontend/utils/constants.js b/frontend/utils/constants.js new file mode 100644 index 0000000..11c4e5b --- /dev/null +++ b/frontend/utils/constants.js @@ -0,0 +1,34 @@ +// frontend/utils/constants.js +// 跨模块共享常量(URL / 枚举 / 阈值等) + +/** + * H5 落地页根域名 + * + * ⚠️ 必须与后端 `LANDING_BASE_URL` 环境变量保持一致(spec § 3): + * - 后端 QR 码服务用此值组装 /asset/{id}?from=...&s=... + * - 前端"复制链接"用此值生成可分享的落地页 URL + * + * 取值优先级:VITE_LANDING_BASE_URL(env)→ 默认 `https://api.topfans.online`(兜底值)。 + * 一旦两侧不一致,扫描二维码 / 复制链接就会跳错地址 —— 改 env 务必同步后端 env。 + */ +export const LANDING_BASE = String( + import.meta.env.VITE_LANDING_BASE_URL || 'https://api.topfans.online' +).replace(/\/+$/, ''); + +/** + * 分享目标枚举(与后端 assetService/validShareTargets 保持一致) + * + * 同步点:后端 backend/services/assetService/service/share_service.go + * validShareTargets 枚举里已包含 copy_link; + * 前端 copyLink 复制成功后会上报 share_target=copy_link 走 TrackShare 通道, + * 让"复制链接"进入与图形分享并列的统计归因。 + */ +export const SHARE_TARGETS = Object.freeze({ + WEIXIN_FRIEND: 'weixin_friend', + WEIXIN_MOMENT: 'weixin_moment', + QQ: 'qq', + QQ_ZONE: 'qq_zone', + SINAWEIBO: 'sinaweibo', + SAVE_IMAGE: 'save_image', + COPY_LINK: 'copy_link' +}); \ No newline at end of file diff --git a/frontend/utils/image-compositor.js b/frontend/utils/image-compositor.js index 5ffd31b..d5c080e 100644 --- a/frontend/utils/image-compositor.js +++ b/frontend/utils/image-compositor.js @@ -8,13 +8,26 @@ const CANVAS_HEIGHT = 1334; const QR_SIZE = 240; const AVATAR_SIZE = 240; const COVER_HEIGHT = 1000; -const QR_Y = 960; +const QR_Y = 1050; // 二维码下移到画布中下部(再下移 30),留出底部呼吸空间 +const QR_BORDER = 20; // 二维码图片自带的白色边框(quiet zone),文字对齐时跳过这个边距,与内部二维码对齐 const AVATAR_Y = 720; -const SLOGAN_Y = 1200; const FALLBACK_QRCODE = '/static/share/mock_qrcode.png'; const FALLBACK_AVATAR = '/static/square/gerenzhongxinkangpinkuang.png'; +/** + * 截断链上哈希(前 6 位 + ... + 后 6 位) + * - 空值返回空串 + * - 长度 <= 12 原样返回(无需省略号) + * - 长度 > 12 显示前 6 + ... + 后 6 + */ +function truncateHash(hash) { + if (!hash) return ''; + const s = String(hash); + if (s.length <= 10) return s; + return `${s.substring(0, 5)}...${s.substring(s.length - 5)}`; +} + /** * 合成分享图(pure function — 不发起任何网络 IO) * @param {Object} opts @@ -23,6 +36,7 @@ const FALLBACK_AVATAR = '/static/square/gerenzhongxinkangpinkuang.png'; * @param {string} opts.avatarLocalPath - 本地头像临时路径(缺失时回退占位图) * @param {string} opts.nickname - 用户昵称(缺首字母时回退 'T') * @param {{brandLine: string, brandDesc: string}} [opts.slogan] - 品牌文案 + * @param {string} [opts.displayTxHash] - 链上哈希原文(父组件提供),截断后渲染到二维码侧空白区 * @returns {Promise<{tempFilePath: string, width: number, height: number}>} */ export function composeShareImage(opts, componentThis) { @@ -33,6 +47,7 @@ export function composeShareImage(opts, componentThis) { avatarLocalPath = FALLBACK_AVATAR, nickname = '', slogan = { brandLine: BRAND_SLOGANS[0].brandLine, brandDesc: BRAND_SLOGANS[0].brandDesc }, + displayTxHash = '', canvasId = 'shareCanvas' } = opts; @@ -70,22 +85,28 @@ export function composeShareImage(opts, componentThis) { ctx.setTextAlign('left'); ctx.fillText(nickname || 'T', avatarX + AVATAR_SIZE + 30, avatarYCenter + 12); - // L3: 二维码 - const qrX = CANVAS_WIDTH - QR_SIZE - 60; + // L3: 二维码(右侧边距 30,略往右收,让画面重心向右偏) + const qrX = CANVAS_WIDTH - QR_SIZE - 30; ctx.drawImage(qrcodeLocalPath, qrX, QR_Y, QR_SIZE, QR_SIZE); - // L4: slogan - ctx.setFillStyle('#333333'); - ctx.setFontSize(36); - ctx.setTextAlign('center'); - if (slogan.brandLine) { - ctx.fillText(slogan.brandLine, CANVAS_WIDTH / 2, SLOGAN_Y + 30); + // L4: 链上哈希 — 顶部对齐内部二维码(跳过白框 QR_BORDER) + // 文字顶部贴 QR_Y + QR_BORDER,baseline = QR_Y + QR_BORDER + 20(ascent for 26px) + const hashText = truncateHash(displayTxHash); + if (hashText) { + ctx.setFillStyle('#333333'); + ctx.setFontSize(40); + ctx.setTextAlign('left'); + ctx.fillText(`链上哈希: ${hashText}`, 40, QR_Y + QR_BORDER + 40); + } + + // L5: 品牌文案 — 底部对齐内部二维码(跳过白框 QR_BORDER) + // 文字底部贴 QR_Y + QR_SIZE - QR_BORDER,baseline = QR_Y + QR_SIZE - QR_BORDER - 8(descent for 30px) + ctx.setFillStyle('#333333'); + ctx.setFontSize(30); + ctx.setTextAlign('left'); + if (slogan.brandLine) { + ctx.fillText(slogan.brandLine, 40, QR_Y + QR_SIZE - QR_BORDER - 25); } - ctx.setFontSize(24); - ctx.setFillStyle('#666666'); - // if (slogan.brandDesc) { - // ctx.fillText(slogan.brandDesc, CANVAS_WIDTH / 2, SLOGAN_Y + 70); - // } ctx.draw(false, () => { uni.canvasToTempFilePath({ @@ -110,6 +131,6 @@ export function composeShareImage(opts, componentThis) { * 每次 pick 都会重合成。代码保留 cache 结构以备将来切回 URL-based key 时复用。 */ export function computeComposeKey(opts) { - const { coverLocalPath = '', qrcodeLocalPath = '', avatarLocalPath = '', nickname = '', slogan = {} } = opts; - return [coverLocalPath, qrcodeLocalPath, avatarLocalPath, nickname, slogan.brandLine || '', slogan.brandDesc || ''].join('|'); + const { coverLocalPath = '', qrcodeLocalPath = '', avatarLocalPath = '', nickname = '', slogan = {}, displayTxHash = '' } = opts; + return [coverLocalPath, qrcodeLocalPath, avatarLocalPath, nickname, slogan.brandLine || '', slogan.brandDesc || '', displayTxHash].join('|'); }