137 lines
5.5 KiB
JavaScript
137 lines
5.5 KiB
JavaScript
// frontend/utils/image-compositor.js
|
|
// 分享图离屏 canvas 合成(spec § 5.3)
|
|
|
|
import { BRAND_SLOGANS } from './brand-slogans.js';
|
|
|
|
const CANVAS_WIDTH = 750;
|
|
const CANVAS_HEIGHT = 1334;
|
|
const QR_SIZE = 240;
|
|
const AVATAR_SIZE = 240;
|
|
const COVER_HEIGHT = 1000;
|
|
const QR_Y = 1050; // 二维码下移到画布中下部(再下移 30),留出底部呼吸空间
|
|
const QR_BORDER = 20; // 二维码图片自带的白色边框(quiet zone),文字对齐时跳过这个边距,与内部二维码对齐
|
|
const AVATAR_Y = 720;
|
|
|
|
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
|
|
* @param {string} opts.coverLocalPath - 本地封面临时路径(uni.downloadFile 后)
|
|
* @param {string} opts.qrcodeLocalPath - 本地二维码临时路径(缺失时回退占位图)
|
|
* @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) {
|
|
return new Promise((resolve, reject) => {
|
|
const {
|
|
coverLocalPath,
|
|
qrcodeLocalPath = FALLBACK_QRCODE,
|
|
avatarLocalPath = FALLBACK_AVATAR,
|
|
nickname = '',
|
|
slogan = { brandLine: BRAND_SLOGANS[0].brandLine, brandDesc: BRAND_SLOGANS[0].brandDesc },
|
|
displayTxHash = '',
|
|
canvasId = 'shareCanvas'
|
|
} = opts;
|
|
|
|
// app-plus 实测: 任何非 undefined 的 vm 参数都会触发 'reading ready' 错误。
|
|
// 唯一稳的调用方式是只传 canvasId,完全省略第二参数(uni-app 默认在当前页找 canvas)
|
|
// 重要:componentThis 参数保留兼容性但完全忽略
|
|
const ctx = uni.createCanvasContext(canvasId);
|
|
|
|
if (!ctx) {
|
|
reject(new Error('canvas context unavailable on this platform'));
|
|
return;
|
|
}
|
|
|
|
// L0: 白底(规避 iOS 离屏 canvas 黑底)
|
|
ctx.setFillStyle('#FFFFFF');
|
|
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
|
|
|
|
// L1: 封面
|
|
if (coverLocalPath) {
|
|
ctx.drawImage(coverLocalPath, 0, 0, CANVAS_WIDTH, COVER_HEIGHT);
|
|
}
|
|
|
|
// L2: 头像 + nickname
|
|
const avatarX = 60;
|
|
const avatarYCenter = AVATAR_Y + AVATAR_SIZE / 2;
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.arc(avatarX + AVATAR_SIZE / 2, avatarYCenter, AVATAR_SIZE / 2, 0, 2 * Math.PI);
|
|
ctx.clip();
|
|
ctx.drawImage(avatarLocalPath, avatarX, AVATAR_Y, AVATAR_SIZE, AVATAR_SIZE);
|
|
ctx.restore();
|
|
|
|
ctx.setFillStyle('#FFFFFF');
|
|
ctx.setFontSize(36);
|
|
ctx.setTextAlign('left');
|
|
ctx.fillText(nickname || 'T', avatarX + AVATAR_SIZE + 30, avatarYCenter + 12);
|
|
|
|
// L3: 二维码(右侧边距 30,略往右收,让画面重心向右偏)
|
|
const qrX = CANVAS_WIDTH - QR_SIZE - 30;
|
|
ctx.drawImage(qrcodeLocalPath, qrX, QR_Y, QR_SIZE, QR_SIZE);
|
|
|
|
// 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.draw(false, () => {
|
|
uni.canvasToTempFilePath({
|
|
canvasId: canvasId,
|
|
x: 0, y: 0,
|
|
width: CANVAS_WIDTH, height: CANVAS_HEIGHT,
|
|
destWidth: CANVAS_WIDTH, destHeight: CANVAS_HEIGHT,
|
|
fileType: 'png', quality: 1,
|
|
success: (res) => resolve({ tempFilePath: res.tempFilePath, width: CANVAS_WIDTH, height: CANVAS_HEIGHT }),
|
|
fail: (err) => reject(new Error('canvasToTempFilePath failed: ' + (err && err.errMsg ? err.errMsg : JSON.stringify(err))))
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 计算 composeKey(spec § 5.4)
|
|
* 用于 L1/L2 缓存查询;任一字段变化则重合成
|
|
*
|
|
* 入参用本地临时路径(_doc/uniapp_temp_<timestamp>/download/...)。
|
|
* ⚠️ 当前 trade-off:temp 路径含时间戳,每次 pick 都不同 → L1/L2 cache 实际不会命中,
|
|
* 每次 pick 都会重合成。代码保留 cache 结构以备将来切回 URL-based key 时复用。
|
|
*/
|
|
export function computeComposeKey(opts) {
|
|
const { coverLocalPath = '', qrcodeLocalPath = '', avatarLocalPath = '', nickname = '', slogan = {}, displayTxHash = '' } = opts;
|
|
return [coverLocalPath, qrcodeLocalPath, avatarLocalPath, nickname, slogan.brandLine || '', slogan.brandDesc || '', displayTxHash].join('|');
|
|
}
|