feat:修改canvas合成图

This commit is contained in:
zheng020 2026-06-26 17:58:37 +08:00
parent 2032246fd0
commit 946e204573
8 changed files with 150 additions and 26 deletions

View File

@ -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 (

View File

@ -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

View File

@ -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') {

View File

@ -11,7 +11,7 @@
<view class="header-actions">
<ShareReportButtons :ownerNickname="assetData.owner_nickname || ''" :assetId="assetIdParam"
:coverUrl="coverUrl" :coverKey="assetData.cover_url || assetData.image_url || ''"
externalCanvasId="shareCanvas" />
:displayTxHash="displayTxHash" externalCanvasId="shareCanvas" />
</view>
</view>

View File

@ -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' });
}
});
}
</script>

View File

@ -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: ''
}
});

View File

@ -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'
});

View File

@ -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('|');
}