feat:修改canvas合成图
This commit is contained in:
parent
2032246fd0
commit
946e204573
@ -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 (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: ''
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
34
frontend/utils/constants.js
Normal file
34
frontend/utils/constants.js
Normal 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'
|
||||
});
|
||||
@ -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('|');
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user