Compare commits
2 Commits
2032246fd0
...
c02e53fc0e
| Author | SHA1 | Date | |
|---|---|---|---|
| c02e53fc0e | |||
| 946e204573 |
@ -1,3 +1,10 @@
|
|||||||
|
# ==================== H5 落地页 Base URL ====================
|
||||||
|
# 分享服务生成的落地页链接前缀 (landingBase + asset_id + from=sharer + s=system_type)
|
||||||
|
# - 本地开发: http://localhost:5173 (Vite H5 默认端口,可在 .env 覆盖)
|
||||||
|
# - 生产环境: 由 deploy/envs/asset.env 注入实际域名
|
||||||
|
# 注意: 与 frontend/.env.production 的 VITE_LANDING_BASE_URL 保持一致
|
||||||
|
LANDING_BASE_URL=http://localhost:5173
|
||||||
|
|
||||||
# ==================== Server Configuration ====================
|
# ==================== Server Configuration ====================
|
||||||
# Gin运行模式: debug, release, test
|
# Gin运行模式: debug, release, test
|
||||||
GIN_MODE=release
|
GIN_MODE=release
|
||||||
|
|||||||
@ -10,3 +10,7 @@ OSS_BUCKET_NAME=top-fans-test
|
|||||||
OSS_ACCESS_KEY_ID=LTAI5t6QcdJHpYbCPxM8SXYE
|
OSS_ACCESS_KEY_ID=LTAI5t6QcdJHpYbCPxM8SXYE
|
||||||
OSS_ACCESS_KEY_SECRET=ybvjSEb7wilMt3qT5nOppYPoNVayCD
|
OSS_ACCESS_KEY_SECRET=ybvjSEb7wilMt3qT5nOppYPoNVayCD
|
||||||
OSS_ROLE_ARN=acs:ram::1387642798143585:role/top-fans-oss-user
|
OSS_ROLE_ARN=acs:ram::1387642798143585:role/top-fans-oss-user
|
||||||
|
|
||||||
|
# H5 落地页 Base URL (生产环境)
|
||||||
|
# 分享服务生成的落地页链接前缀,生产域名
|
||||||
|
LANDING_BASE_URL=https://api.topfans.online
|
||||||
|
|||||||
@ -155,7 +155,11 @@ func main() {
|
|||||||
AccessKeySecret: os.Getenv("OSS_ACCESS_KEY_SECRET"),
|
AccessKeySecret: os.Getenv("OSS_ACCESS_KEY_SECRET"),
|
||||||
}
|
}
|
||||||
qrUploader := ossutil.NewOSSQRUploader(ossCfg)
|
qrUploader := ossutil.NewOSSQRUploader(ossCfg)
|
||||||
landingBase := getEnv("LANDING_BASE_URL", "https://topfans.example.com")
|
// H5 落地页 base URL:
|
||||||
|
// - 本地开发: 默认 http://localhost:5173(Vite H5 端口),可在 backend/.env 覆盖
|
||||||
|
// - 生产环境: 由 deploy/envs/asset.env 注入 https://api.topfans.online
|
||||||
|
// 注意: 与 frontend/.env.production 的 VITE_LANDING_BASE_URL 保持一致
|
||||||
|
landingBase := getEnv("LANDING_BASE_URL", "http://localhost:5173")
|
||||||
shareService := service.NewShareService(shareRepo, database.GetRedis(), qrUploader, landingBase)
|
shareService := service.NewShareService(shareRepo, database.GetRedis(), qrUploader, landingBase)
|
||||||
|
|
||||||
assetService := service.NewAssetService(assetRepo, mintOrderRepo, assetLikeRepo, userClient, database.GetDB(), registryRepo, shareService)
|
assetService := service.NewAssetService(assetRepo, mintOrderRepo, assetLikeRepo, userClient, database.GetDB(), registryRepo, shareService)
|
||||||
|
|||||||
@ -50,6 +50,14 @@ var validResults = map[string]struct{}{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validShareTargets spec § 3 分享目标枚举
|
// 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{}{
|
var validShareTargets = map[string]struct{}{
|
||||||
"weixin_friend": {},
|
"weixin_friend": {},
|
||||||
"weixin_moment": {},
|
"weixin_moment": {},
|
||||||
@ -57,6 +65,7 @@ var validShareTargets = map[string]struct{}{
|
|||||||
"qq_zone": {},
|
"qq_zone": {},
|
||||||
"sinaweibo": {},
|
"sinaweibo": {},
|
||||||
"save_image": {},
|
"save_image": {},
|
||||||
|
"copy_link": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@ -7,3 +7,5 @@ OPENAI_BASE_URL=https://api.weda.cc/v1
|
|||||||
OPENAI_MODEL=gpt-image-2
|
OPENAI_MODEL=gpt-image-2
|
||||||
DIFY_API_BASE=http://172.23.0.1/v1
|
DIFY_API_BASE=http://172.23.0.1/v1
|
||||||
DIFY_API_KEY=app-Ibs7reARanyuYGZ7zrLyiM6e
|
DIFY_API_KEY=app-Ibs7reARanyuYGZ7zrLyiM6e
|
||||||
|
# H5 落地页 Base URL (本地开发, Vite H5 默认端口)
|
||||||
|
LANDING_BASE_URL=http://localhost:5173
|
||||||
|
|||||||
@ -36,3 +36,7 @@ OPENAI_MODEL=gpt-image-1.5
|
|||||||
# ==================== Dify Workflow (laser_card_variants_v1) ====================
|
# ==================== Dify Workflow (laser_card_variants_v1) ====================
|
||||||
DIFY_API_BASE=https://api.dify.ai/v1
|
DIFY_API_BASE=https://api.dify.ai/v1
|
||||||
DIFY_API_KEY=
|
DIFY_API_KEY=
|
||||||
|
|
||||||
|
# ==================== H5 落地页 Base URL ====================
|
||||||
|
# 本地开发: Vite H5 默认端口;覆盖请同步修改 frontend/.env.development 的 VITE_LANDING_BASE_URL
|
||||||
|
LANDING_BASE_URL=http://localhost:5173
|
||||||
|
|||||||
@ -31,6 +31,11 @@ REDIS_PORT=6379
|
|||||||
REDIS_PASSWORD=123456
|
REDIS_PASSWORD=123456
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# ==================== H5 落地页 Base URL ====================
|
||||||
|
# 分享服务生成的落地页链接前缀 (landingBase + asset_id + from=sharer + s=system_type)
|
||||||
|
# 与 frontend/.env.production 的 VITE_LANDING_BASE_URL 保持一致
|
||||||
|
LANDING_BASE_URL=https://api.topfans.online
|
||||||
|
|
||||||
# ==================== 镭射卡生成器 ====================
|
# ==================== 镭射卡生成器 ====================
|
||||||
# LASER_GEN_PROVIDER:
|
# LASER_GEN_PROVIDER:
|
||||||
# openai (默认) - 后端走微达API中转站 /v1/images/edits (5 路并发 + 直接落 OSS)
|
# openai (默认) - 后端走微达API中转站 /v1/images/edits (5 路并发 + 直接落 OSS)
|
||||||
|
|||||||
@ -77,6 +77,8 @@ services:
|
|||||||
OSS_AVATAR_DIR: ${OSS_AVATAR_DIR:-avatar/}
|
OSS_AVATAR_DIR: ${OSS_AVATAR_DIR:-avatar/}
|
||||||
OSS_ASSET_DIR: ${OSS_ASSET_DIR:-asset/}
|
OSS_ASSET_DIR: ${OSS_ASSET_DIR:-asset/}
|
||||||
OSS_TOKEN_EXPIRE_TIME: ${OSS_TOKEN_EXPIRE_TIME:-3600}
|
OSS_TOKEN_EXPIRE_TIME: ${OSS_TOKEN_EXPIRE_TIME:-3600}
|
||||||
|
# H5 落地页 Base URL (分享服务生成链接前缀;与 frontend/.env.development 的 VITE_LANDING_BASE_URL 保持一致)
|
||||||
|
LANDING_BASE_URL: ${LANDING_BASE_URL:-http://localhost:5173}
|
||||||
depends_on:
|
depends_on:
|
||||||
userservice:
|
userservice:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@ -295,6 +295,7 @@ export function useShare(props) {
|
|||||||
const avatarUrl = typeof props.getAvatarUrl === 'function' ? props.getAvatarUrl() : props.avatarUrl;
|
const avatarUrl = typeof props.getAvatarUrl === 'function' ? props.getAvatarUrl() : props.avatarUrl;
|
||||||
const nickname = typeof props.getNickname === 'function' ? props.getNickname() : props.nickname;
|
const nickname = typeof props.getNickname === 'function' ? props.getNickname() : props.nickname;
|
||||||
const assetId = typeof props.getAssetId === 'function' ? props.getAssetId() : props.assetId;
|
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([
|
const localPaths = await Promise.all([
|
||||||
downloadLocal(coverUrl),
|
downloadLocal(coverUrl),
|
||||||
@ -308,7 +309,7 @@ export function useShare(props) {
|
|||||||
console.log('[useShare] avatarLocal =', localPaths[2]);
|
console.log('[useShare] avatarLocal =', localPaths[2]);
|
||||||
const composeKey = computeComposeKey({
|
const composeKey = computeComposeKey({
|
||||||
coverLocalPath: localPaths[0], qrcodeLocalPath: localPaths[1], avatarLocalPath: localPaths[2],
|
coverLocalPath: localPaths[0], qrcodeLocalPath: localPaths[1], avatarLocalPath: localPaths[2],
|
||||||
nickname, slogan: currentSlogan.value
|
nickname, slogan: currentSlogan.value, displayTxHash
|
||||||
});
|
});
|
||||||
console.log(composeKey)
|
console.log(composeKey)
|
||||||
let tempFilePath = l1Cache.get(composeKey);
|
let tempFilePath = l1Cache.get(composeKey);
|
||||||
@ -320,7 +321,7 @@ export function useShare(props) {
|
|||||||
const result = await Promise.race([
|
const result = await Promise.race([
|
||||||
composeShareImage({
|
composeShareImage({
|
||||||
coverLocalPath: localPaths[0], qrcodeLocalPath: localPaths[1], avatarLocalPath: localPaths[2],
|
coverLocalPath: localPaths[0], qrcodeLocalPath: localPaths[1], avatarLocalPath: localPaths[2],
|
||||||
nickname, slogan: currentSlogan.value,
|
nickname, slogan: currentSlogan.value, displayTxHash,
|
||||||
canvasId: props.canvasId || 'shareCanvas'
|
canvasId: props.canvasId || 'shareCanvas'
|
||||||
}, props.vm),
|
}, props.vm),
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error('canvas 合成超时(8s)')), 8000))
|
new Promise((_, reject) => setTimeout(() => reject(new Error('canvas 合成超时(8s)')), 8000))
|
||||||
@ -432,8 +433,11 @@ export function useShare(props) {
|
|||||||
|
|
||||||
if (result === 'success') {
|
if (result === 'success') {
|
||||||
state.value = 'done';
|
state.value = 'done';
|
||||||
// action-aware 文案:save_image 走相册,其他 action 走分享平台
|
// action-aware 文案:save_image 已在成功回调里弹过"已保存到相册",这里跳过避免覆盖;
|
||||||
uni.showToast({ title:'分享成功' });
|
// 其他 action 走分享平台,统一提示"分享成功"
|
||||||
|
if (action !== 'save_image') {
|
||||||
|
uni.showToast({ title: '分享成功' });
|
||||||
|
}
|
||||||
failCount.value = 0;
|
failCount.value = 0;
|
||||||
setTimeout(() => { if (state.value === 'done') state.value = 'idle'; }, 2000);
|
setTimeout(() => { if (state.value === 'done') state.value = 'idle'; }, 2000);
|
||||||
} else if (result === 'cancel') {
|
} else if (result === 'cancel') {
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
<view class="header-actions">
|
<view class="header-actions">
|
||||||
<ShareReportButtons :ownerNickname="assetData.owner_nickname || ''" :assetId="assetIdParam"
|
<ShareReportButtons :ownerNickname="assetData.owner_nickname || ''" :assetId="assetIdParam"
|
||||||
:coverUrl="coverUrl" :coverKey="assetData.cover_url || assetData.image_url || ''"
|
:coverUrl="coverUrl" :coverKey="assetData.cover_url || assetData.image_url || ''"
|
||||||
externalCanvasId="shareCanvas" />
|
:displayTxHash="displayTxHash" externalCanvasId="shareCanvas" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|||||||
@ -53,6 +53,8 @@ import { ref, computed, watch, getCurrentInstance } from 'vue';
|
|||||||
import SharePreviewCard from './SharePreviewCard.vue';
|
import SharePreviewCard from './SharePreviewCard.vue';
|
||||||
import ShareActionBar from './ShareActionBar.vue';
|
import ShareActionBar from './ShareActionBar.vue';
|
||||||
import { useShare } from '@/composables/useShare.js';
|
import { useShare } from '@/composables/useShare.js';
|
||||||
|
import { LANDING_BASE, SHARE_TARGETS } from '@/utils/constants.js';
|
||||||
|
import { trackShareApi } from '@/utils/api.js';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: { type: Boolean, default: false },
|
visible: { type: Boolean, default: false },
|
||||||
@ -61,6 +63,8 @@ const props = defineProps({
|
|||||||
avatarUrl: { type: String, default: '' },
|
avatarUrl: { type: String, default: '' },
|
||||||
nickname: { type: String, default: '' },
|
nickname: { type: String, default: '' },
|
||||||
assetId: { type: [Number, String], required: true },
|
assetId: { type: [Number, String], required: true },
|
||||||
|
// 链上哈希(由父组件 asset-detail 提供,转发给 useShare → image-compositor)
|
||||||
|
displayTxHash: { type: String, default: '' },
|
||||||
// 可选:外部 page 级 canvasId(uni-app 组件内 canvas 跨端不稳,推荐父页面提供)
|
// 可选:外部 page 级 canvasId(uni-app 组件内 canvas 跨端不稳,推荐父页面提供)
|
||||||
externalCanvasId: { type: String, default: '' }
|
externalCanvasId: { type: String, default: '' }
|
||||||
});
|
});
|
||||||
@ -81,6 +85,7 @@ const { state, pick, currentSlogan, systemType } = useShare({
|
|||||||
getAvatarUrl: () => props.avatarUrl,
|
getAvatarUrl: () => props.avatarUrl,
|
||||||
getNickname: () => props.nickname,
|
getNickname: () => props.nickname,
|
||||||
getAssetId: () => props.assetId,
|
getAssetId: () => props.assetId,
|
||||||
|
getDisplayTxHash: () => props.displayTxHash,
|
||||||
// app-plus canvas API 第二个参数需要页面级 vm,getCurrentPages() 当前页是最稳的
|
// app-plus canvas API 第二个参数需要页面级 vm,getCurrentPages() 当前页是最稳的
|
||||||
vm: (() => {
|
vm: (() => {
|
||||||
try {
|
try {
|
||||||
@ -96,12 +101,55 @@ watch(() => props.visible, (v) => { visibleLocal.value = v; });
|
|||||||
function handleClose() { emit('close'); visibleLocal.value = false; }
|
function handleClose() { emit('close'); visibleLocal.value = false; }
|
||||||
function goLogin() { emit('close'); visibleLocal.value = false; uni.navigateTo({ url: '/pages/login/login' }); }
|
function goLogin() { emit('close'); visibleLocal.value = false; uni.navigateTo({ url: '/pages/login/login' }); }
|
||||||
function copyLink() {
|
function copyLink() {
|
||||||
// systemType 从 useShare 拿(已经异步初始化过);兜底 'other' 兜底未就绪场景
|
// 兜底:系统类型未就绪时给个 'other'(与后端 QR 接口校验一致)
|
||||||
// query 参数对齐后端约定:与 api.js getShareQrcodeApi 一致使用 system_type
|
|
||||||
const os = systemType.value || 'other';
|
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({
|
uni.setClipboardData({
|
||||||
data: `https://h5.topfans.com/asset?${props.assetId}&system_type=${os}&share_target=copy_link`,
|
data: url,
|
||||||
success: () => uni.showToast({ title: '链接已复制' })
|
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>
|
</script>
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
:avatarUrl="shareAvatarUrl"
|
:avatarUrl="shareAvatarUrl"
|
||||||
:nickname="ownerNickname"
|
:nickname="ownerNickname"
|
||||||
:assetId="assetId"
|
:assetId="assetId"
|
||||||
|
:displayTxHash="displayTxHash"
|
||||||
:externalCanvasId="externalCanvasId"
|
:externalCanvasId="externalCanvasId"
|
||||||
@close="showShareModal = false"
|
@close="showShareModal = false"
|
||||||
/>
|
/>
|
||||||
@ -65,6 +66,11 @@ const props = defineProps({
|
|||||||
externalCanvasId: {
|
externalCanvasId: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
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_API_BASE_URL(env)→ 默认 `https://api.topfans.online`(兜底值)。
|
||||||
|
* 一旦两侧不一致,扫描二维码 / 复制链接就会跳错地址 —— 改 env 务必同步后端 env。
|
||||||
|
*/
|
||||||
|
export const LANDING_BASE = String(
|
||||||
|
import.meta.env.VITE_API_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 QR_SIZE = 240;
|
||||||
const AVATAR_SIZE = 240;
|
const AVATAR_SIZE = 240;
|
||||||
const COVER_HEIGHT = 1000;
|
const COVER_HEIGHT = 1000;
|
||||||
const QR_Y = 960;
|
const QR_Y = 1050; // 二维码下移到画布中下部(再下移 30),留出底部呼吸空间
|
||||||
|
const QR_BORDER = 20; // 二维码图片自带的白色边框(quiet zone),文字对齐时跳过这个边距,与内部二维码对齐
|
||||||
const AVATAR_Y = 720;
|
const AVATAR_Y = 720;
|
||||||
const SLOGAN_Y = 1200;
|
|
||||||
|
|
||||||
const FALLBACK_QRCODE = '/static/share/mock_qrcode.png';
|
const FALLBACK_QRCODE = '/static/share/mock_qrcode.png';
|
||||||
const FALLBACK_AVATAR = '/static/square/gerenzhongxinkangpinkuang.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)
|
* 合成分享图(pure function — 不发起任何网络 IO)
|
||||||
* @param {Object} opts
|
* @param {Object} opts
|
||||||
@ -23,6 +36,7 @@ const FALLBACK_AVATAR = '/static/square/gerenzhongxinkangpinkuang.png';
|
|||||||
* @param {string} opts.avatarLocalPath - 本地头像临时路径(缺失时回退占位图)
|
* @param {string} opts.avatarLocalPath - 本地头像临时路径(缺失时回退占位图)
|
||||||
* @param {string} opts.nickname - 用户昵称(缺首字母时回退 'T')
|
* @param {string} opts.nickname - 用户昵称(缺首字母时回退 'T')
|
||||||
* @param {{brandLine: string, brandDesc: string}} [opts.slogan] - 品牌文案
|
* @param {{brandLine: string, brandDesc: string}} [opts.slogan] - 品牌文案
|
||||||
|
* @param {string} [opts.displayTxHash] - 链上哈希原文(父组件提供),截断后渲染到二维码侧空白区
|
||||||
* @returns {Promise<{tempFilePath: string, width: number, height: number}>}
|
* @returns {Promise<{tempFilePath: string, width: number, height: number}>}
|
||||||
*/
|
*/
|
||||||
export function composeShareImage(opts, componentThis) {
|
export function composeShareImage(opts, componentThis) {
|
||||||
@ -33,6 +47,7 @@ export function composeShareImage(opts, componentThis) {
|
|||||||
avatarLocalPath = FALLBACK_AVATAR,
|
avatarLocalPath = FALLBACK_AVATAR,
|
||||||
nickname = '',
|
nickname = '',
|
||||||
slogan = { brandLine: BRAND_SLOGANS[0].brandLine, brandDesc: BRAND_SLOGANS[0].brandDesc },
|
slogan = { brandLine: BRAND_SLOGANS[0].brandLine, brandDesc: BRAND_SLOGANS[0].brandDesc },
|
||||||
|
displayTxHash = '',
|
||||||
canvasId = 'shareCanvas'
|
canvasId = 'shareCanvas'
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
@ -70,22 +85,28 @@ export function composeShareImage(opts, componentThis) {
|
|||||||
ctx.setTextAlign('left');
|
ctx.setTextAlign('left');
|
||||||
ctx.fillText(nickname || 'T', avatarX + AVATAR_SIZE + 30, avatarYCenter + 12);
|
ctx.fillText(nickname || 'T', avatarX + AVATAR_SIZE + 30, avatarYCenter + 12);
|
||||||
|
|
||||||
// L3: 二维码
|
// L3: 二维码(右侧边距 30,略往右收,让画面重心向右偏)
|
||||||
const qrX = CANVAS_WIDTH - QR_SIZE - 60;
|
const qrX = CANVAS_WIDTH - QR_SIZE - 30;
|
||||||
ctx.drawImage(qrcodeLocalPath, qrX, QR_Y, QR_SIZE, QR_SIZE);
|
ctx.drawImage(qrcodeLocalPath, qrX, QR_Y, QR_SIZE, QR_SIZE);
|
||||||
|
|
||||||
// L4: slogan
|
// 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.setFillStyle('#333333');
|
||||||
ctx.setFontSize(36);
|
ctx.setFontSize(40);
|
||||||
ctx.setTextAlign('center');
|
ctx.setTextAlign('left');
|
||||||
if (slogan.brandLine) {
|
ctx.fillText(`链上哈希: ${hashText}`, 40, QR_Y + QR_BORDER + 40);
|
||||||
ctx.fillText(slogan.brandLine, CANVAS_WIDTH / 2, SLOGAN_Y + 30);
|
}
|
||||||
|
|
||||||
|
// 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, () => {
|
ctx.draw(false, () => {
|
||||||
uni.canvasToTempFilePath({
|
uni.canvasToTempFilePath({
|
||||||
@ -110,6 +131,6 @@ export function composeShareImage(opts, componentThis) {
|
|||||||
* 每次 pick 都会重合成。代码保留 cache 结构以备将来切回 URL-based key 时复用。
|
* 每次 pick 都会重合成。代码保留 cache 结构以备将来切回 URL-based key 时复用。
|
||||||
*/
|
*/
|
||||||
export function computeComposeKey(opts) {
|
export function computeComposeKey(opts) {
|
||||||
const { coverLocalPath = '', qrcodeLocalPath = '', avatarLocalPath = '', nickname = '', slogan = {} } = opts;
|
const { coverLocalPath = '', qrcodeLocalPath = '', avatarLocalPath = '', nickname = '', slogan = {}, displayTxHash = '' } = opts;
|
||||||
return [coverLocalPath, qrcodeLocalPath, avatarLocalPath, nickname, slogan.brandLine || '', slogan.brandDesc || ''].join('|');
|
return [coverLocalPath, qrcodeLocalPath, avatarLocalPath, nickname, slogan.brandLine || '', slogan.brandDesc || '', displayTxHash].join('|');
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user