Files
bdrp-app/src/components/QRcode.vue
2026-04-23 14:57:35 +08:00

1088 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import QRCode from 'qrcode'
import UQRCode from 'uqrcodejs'
import { computed, getCurrentInstance, nextTick, onMounted, ref, toRefs, watch } from 'vue'
import { envConfig } from '@/constants/env'
import { readLocalFileAsBase64 } from '@/utils/appLocalFile'
import { setPosterMergePending } from '@/utils/posterRenderMergeBridge'
import ImageSaveGuide from './ImageSaveGuide.vue'
import '@/utils/textEncoderPolyfill'
const props = defineProps({
linkIdentifier: {
type: String,
required: true,
},
mode: {
type: String,
default: 'promote', // 例如 "promote" | "invitation"
},
})
const { linkIdentifier, mode } = toRefs(props)
/** 自定义组件内 canvas API 需传入实例;勿在异步回调里再调 getCurrentInstance()(常为 null */
const componentInstance = getCurrentInstance()
const posterCanvasRefs = ref([]) // 用于绘制海报的canvas数组
/** APPcanvas 绘制缓冲区宽高(须与 drawImage 使用的一致,否则可能空白或裁切异常) */
const posterCanvasSizes = ref(/** @type {Record<number, { width: number, height: number }>} */({}))
/** App二维码临时路径用于叠在底图上预览不依赖 canvas 导出图) */
const posterQrPath = ref(/** @type {Record<number, string>} */({}))
/** App离屏 canvas 合成后导出的整图路径,仅用于保存相册 */
const posterCompositePath = ref(/** @type {Record<number, string>} */({}))
const currentIndex = ref(0) // 当前显示的海报索引
const postersGenerated = ref([]) // 标记海报是否已经生成过将在onMounted中初始化
const show = defineModel('show')
const canvasIdPrefix = 'posterCanvas'
/**
* App大图合成用 renderjs视图层真实 DOM + HTML5 Canvas避免 legacy canvas 大图只导出左上角。
* 二维码仍用下方小 legacy 画布 uqrcode + canvasToTempFilePath。
* 合成在 renderjs 内 document.createElement('canvas'),不依赖页面里挂 id 的 canvasApp 上常取不到 DOM
*/
const posterMergeRequest = ref(/** @type {null | Record<string, unknown>} */(null))
/** APP 端生成二维码专用(与海报主 canvas 分离),供 uqrcodejs + uni.createCanvasContext 使用 */
const QR_GEN_CANVAS_ID = 'poster-qr-gen'
const QR_GEN_SIZE = 150
/** 设为 false 可关闭海报合成相关 console真机调试时在 HBuilderX / 自定义基座 控制台看 [QRcode] */
const QR_POSTER_DEBUG = true
function qlog(...args) {
if (QR_POSTER_DEBUG)
console.warn('[QRcode]', ...args)
}
const isWebPlatform = computed(() => {
try {
return uni.getSystemInfoSync().uniPlatform === 'web'
}
catch {
return typeof window !== 'undefined'
}
})
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
// 微信环境检测
const isWeChat = computed(() => {
if (typeof navigator === 'undefined')
return false
return /MicroMessenger/i.test(navigator.userAgent)
})
// 图片保存指引遮罩层相关
const showImageGuide = ref(false)
const currentImageUrl = ref('')
const imageGuideTitle = ref('')
const url = computed(() => {
const baseUrl = (envConfig.siteOrigin || '').replace(/\/$/, '') || (typeof window !== 'undefined' ? window.location.origin : '')
return mode.value === 'promote'
? `${baseUrl}/agent/promotionInquire/`
: `${baseUrl}/agent/invitationAgentApply/`
})
// 海报图片数组
const posterImages = ref([])
/**
* 二维码在设计稿中的坐标系(与 tg_qrcode / yq 等正式海报资源像素一致)。
* 占位图等小尺寸资源必须通过 scaleQrRectForPoster 换算,否则 y=1620 等会画在画布外,看起来像「没有二维码」。
*/
const POSTER_DESIGN_PX = {
promote: { width: 1242, height: 2208 },
invitation: { width: 1242, height: 2208 },
}
/** getImageInfo 仍异常时的兜底(与 POSTER_DESIGN_PX 一致,便于坐标换算) */
const POSTER_SIZE_FALLBACK_PX = { width: 1242, height: 2208 }
/**
* getImageInfo 在部分平台对 SVG 等异常资源返回 width/height ≤0需兜底后再参与绘制与坐标换算。
*/
function normalizeImageSize(info, fallback) {
const w = typeof info.width === 'number' ? info.width : 0
const h = typeof info.height === 'number' ? info.height : 0
if (w > 0 && h > 0)
return info
qlog('normalizeImageSize: invalid size, using fallback', { w, h, fallback })
return { ...info, width: fallback.width, height: fallback.height }
}
/** 将设计稿上的二维码矩形换算为当前底图实际像素 */
function scaleQrRectForPoster(position, posterInfo, modeKey) {
const design = POSTER_DESIGN_PX[modeKey] || POSTER_DESIGN_PX.promote
const sx = posterInfo.width / design.width
const sy = posterInfo.height / design.height
const scale = Math.min(sx, sy)
const x = position.x * sx
const y = position.y < 0
? posterInfo.height + position.y * sy
: position.y * sy
const size = position.size * scale
return { x, y, size }
}
// QR码位置配置为每个海报单独配置
const qrCodePositions = ref({
promote: [
{ x: 405, y: 1270, size: 440 }, // tg_qrcode_1.png
{ x: 405, y: 1270, size: 440 }, // tg_qrcode_2.jpg
{ x: 405, y: 1270, size: 440 }, // tg_qrcode_3.jpg
{ x: 405, y: 1270, size: 440 }, // tg_qrcode_4.jpg
{ x: 405, y: 1270, size: 440 }, // tg_qrcode_5.jpg
{ x: 210, y: 1660, size: 340 }, // tg_qrcode_6.jpg
{ x: 405, y: 1270, size: 440 }, // tg_qrcode_7.jpg
{ x: 405, y: 1270, size: 440 }, // tg_qrcode_8.jpg
],
// invitation模式的配置 (yq_qrcode)
invitation: [
{ x: 405, y: -1590, size: 435 }, // yq_qrcode_1.png
],
})
function getCanvasId(index) {
return `${canvasIdPrefix}-${index}`
}
/** H5预览区中等比展示整图contain在弹窗轮播高度内完整可见、宽可不拉满 */
function posterCanvasContainStyle(index) {
const w = posterCanvasSizes.value[index]?.width ?? POSTER_SIZE_FALLBACK_PX.width
const h = posterCanvasSizes.value[index]?.height ?? POSTER_SIZE_FALLBACK_PX.height
return {
aspectRatio: `${w} / ${h}`,
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto',
objectFit: 'contain',
display: 'block',
}
}
function dataUrlFromBase64(mime, base64) {
return `data:${mime};base64,${base64}`
}
function imageMimeFromInfoType(type) {
const t = (type || 'png').toLowerCase()
if (t === 'jpg' || t === 'jpeg')
return 'image/jpeg'
return `image/${t}`
}
/**
* Apprenderjs 视图层合成整图,逻辑层收 dataUrl 后写入 _doc。
* 底图/二维码均以 data URL 传入,避免跨源 file:// 导致画布 tainted。
*/
async function compositePosterWithRenderJs(index) {
const posterSrc = posterImages.value[index]
const posterInfoRaw = await getImageInfo(posterSrc)
const posterInfo = normalizeImageSize(posterInfoRaw, POSTER_SIZE_FALLBACK_PX)
const qrRel = posterQrPath.value[index]
if (!qrRel)
throw new Error('missing qr path')
const qrInfoRaw = await getImageInfo(qrRel)
const qrInfo = normalizeImageSize(qrInfoRaw, { width: QR_GEN_SIZE, height: QR_GEN_SIZE })
const positions = qrCodePositions.value[mode.value]
const position = positions[index] || positions[0]
const { x: qrX, y: qrY, size: qrSize } = scaleQrRectForPoster(position, posterInfo, mode.value)
const pw = posterInfo.width
const ph = posterInfo.height
posterCanvasSizes.value = {
...posterCanvasSizes.value,
[index]: { width: pw, height: ph },
}
const posterB64 = await readLocalFileAsBase64(posterInfo.path)
const qrB64 = await readLocalFileAsBase64(qrInfo.path)
const posterDataUrl = dataUrlFromBase64(imageMimeFromInfoType(posterInfo.type), posterB64)
const qrDataUrl = dataUrlFromBase64('image/png', qrB64)
const path = await new Promise((resolve, reject) => {
setPosterMergePending({ resolve, reject, index })
posterMergeRequest.value = null
nextTick(() => {
posterMergeRequest.value = {
posterDataUrl,
qrDataUrl,
w: pw,
h: ph,
qrx: qrX,
qry: qrY,
qrs: qrSize,
nonce: Date.now(),
}
})
})
qlog('poster renderjs ok', { index, path: String(path).slice(0, 72), wh: [pw, ph] })
return path
}
/** App合成导出预览与保存共用。有缓存则复用。 */
async function runPosterCompositeExport(index) {
const cached = posterCompositePath.value[index]
if (cached)
return cached
const path = await compositePosterWithRenderJs(index)
posterCompositePath.value = { ...posterCompositePath.value, [index]: path }
return path
}
/**
* 单例合成 canvas并发调用会互相踩缓冲区所有合成生成预览 / 保存)串行排队。
*/
let _compositeExportQueueTail = Promise.resolve()
function scheduleCompositeExport(index) {
const p = _compositeExportQueueTail.then(() => runPosterCompositeExport(index))
_compositeExportQueueTail = p.catch(() => { })
return p
}
// 处理轮播图切换事件
function onSwipeChange(payload) {
const nextIndex = typeof payload === 'number'
? payload
: Number(payload?.detail?.current ?? 0)
currentIndex.value = nextIndex
if (!postersGenerated.value[nextIndex]) {
generatePoster(nextIndex)
}
}
/** 推广模式 8 张底图,邀请模式 1 张;文件放在 `src/static/images/`,运行时为 `/static/images/...` */
const PROMOTE_POSTER_URLS = [
'/static/images/tg_qrcode_1.png',
'/static/images/tg_qrcode_2.png',
'/static/images/tg_qrcode_3.png',
'/static/images/tg_qrcode_4.png',
'/static/images/tg_qrcode_5.png',
'/static/images/tg_qrcode_6.png',
'/static/images/tg_qrcode_7.png',
'/static/images/tg_qrcode_8.png',
]
const INVITATION_POSTER_URLS = ['/static/images/yq_qrcode_1.png']
function loadPosterImages() {
return mode.value === 'promote' ? [...PROMOTE_POSTER_URLS] : [...INVITATION_POSTER_URLS]
}
onMounted(() => {
posterImages.value = loadPosterImages()
postersGenerated.value = Array.from({ length: posterImages.value.length }).fill(false)
})
watch(mode, () => {
posterImages.value = loadPosterImages()
postersGenerated.value = Array.from({ length: posterImages.value.length }).fill(false)
currentIndex.value = 0
posterCanvasSizes.value = {}
posterQrPath.value = {}
posterCompositePath.value = {}
if (show.value) {
nextTick(() => {
generatePoster(currentIndex.value)
})
}
})
watch(linkIdentifier, () => {
postersGenerated.value = Array.from({ length: posterImages.value.length }).fill(false)
posterCanvasSizes.value = {}
posterQrPath.value = {}
posterCompositePath.value = {}
if (show.value) {
nextTick(() => {
generatePoster(currentIndex.value)
})
}
})
async function getImageInfo(src) {
return await new Promise((resolve, reject) => {
uni.getImageInfo({
src,
success: resolve,
fail: reject,
})
})
}
async function getQRCodeDataUrl() {
return await new Promise((resolve, reject) => {
QRCode.toDataURL(
generalUrl(),
{ width: QR_GEN_SIZE, margin: 0 },
(err, qrCodeUrl) => {
if (err) {
reject(err)
return
}
resolve(qrCodeUrl)
},
)
})
}
/**
* APP 端qrcode 的 toDataURL 依赖 document.createElement('canvas'),在 App 中会报
* “You need to specify a canvas element”。改用 uqrcodejs + uni canvas 生成临时图片路径。
*/
async function getQRCodeTempPathForApp() {
const instance = componentInstance?.proxy
const link = generalUrl()
qlog('getQRCodeTempPathForApp start', { linkLen: link.length, hasProxy: !!instance, canvasId: QR_GEN_CANVAS_ID })
const qr = new UQRCode()
qr.data = link
qr.size = QR_GEN_SIZE
qr.margin = 0
qr.make()
const ctx = uni.createCanvasContext(QR_GEN_CANVAS_ID, instance)
qr.canvasContext = ctx
await qr.drawCanvas()
await new Promise(resolve => setTimeout(resolve, 300))
return await new Promise((resolve, reject) => {
uni.canvasToTempFilePath({
canvasId: QR_GEN_CANVAS_ID,
width: QR_GEN_SIZE,
height: QR_GEN_SIZE,
destWidth: QR_GEN_SIZE,
destHeight: QR_GEN_SIZE,
fileType: 'png',
success: (res) => {
qlog('canvasToTempFilePath(qr) ok', String(res.tempFilePath).slice(0, 80))
resolve(res.tempFilePath)
},
fail: (err) => {
qlog('canvasToTempFilePath(qr) fail', err)
reject(err)
},
}, instance)
})
}
async function generatePosterByWeb(index) {
const canvas = posterCanvasRefs.value[index]
if (!canvas)
return
const ctx = canvas.getContext('2d')
const posterImg = new Image()
posterImg.src = posterImages.value[index]
posterImg.onload = async () => {
canvas.width = posterImg.width
canvas.height = posterImg.height
ctx.drawImage(posterImg, 0, 0)
try {
const qrCodeUrl = await getQRCodeDataUrl()
const qrCodeImg = new Image()
qrCodeImg.src = qrCodeUrl
qrCodeImg.onload = () => {
const positions = qrCodePositions.value[mode.value]
const position = positions[index] || positions[0]
const qrY = position.y < 0 ? posterImg.height + position.y : position.y
ctx.drawImage(qrCodeImg, position.x, qrY, position.size, position.size)
postersGenerated.value[index] = true
}
}
catch (error) {
console.error('生成二维码失败:', error)
}
}
}
async function generatePosterByUni(index) {
try {
let sys = {}
try {
sys = uni.getSystemInfoSync()
}
catch (e) {
qlog('getSystemInfoSync fail', e)
}
qlog('generatePosterByUni start', {
index,
canvasId: getCanvasId(index),
mode: mode.value,
hasProxy: !!componentInstance?.proxy,
uniPlatform: sys.uniPlatform,
windowWidth: sys.windowWidth,
})
const posterSrc = posterImages.value[index]
const posterInfoRaw = await getImageInfo(posterSrc)
const posterInfo = normalizeImageSize(posterInfoRaw, POSTER_SIZE_FALLBACK_PX)
qlog('poster getImageInfo', {
src: typeof posterSrc === 'string' ? posterSrc.slice(0, 120) : posterSrc,
path: posterInfo.path,
w: posterInfo.width,
h: posterInfo.height,
rawWh: [posterInfoRaw.width, posterInfoRaw.height],
type: posterInfo.type,
})
posterCanvasSizes.value = {
...posterCanvasSizes.value,
[index]: { width: posterInfo.width, height: posterInfo.height },
}
await nextTick()
const qrTempPath = await getQRCodeTempPathForApp()
posterQrPath.value = { ...posterQrPath.value, [index]: qrTempPath }
qlog('qr temp for composite', { index, path: String(qrTempPath).slice(0, 80) })
const positions = qrCodePositions.value[mode.value]
const position = positions[index] || positions[0]
const { x: qrX, y: qrY, size: qrSize } = scaleQrRectForPoster(position, posterInfo, mode.value)
qlog('scaled qr rect', { raw: position, qrX, qrY, qrSize, posterWh: [posterInfo.width, posterInfo.height] })
await scheduleCompositeExport(index)
postersGenerated.value[index] = true
}
catch (error) {
qlog('generatePosterByUni ERROR', error)
console.error('App海报绘制失败:', error)
showToast('海报生成失败,请稍后重试')
}
}
// 生成海报并合成二维码
async function generatePoster(index) {
if (postersGenerated.value[index])
return
await nextTick()
qlog('generatePoster', { index, isWeb: isWebPlatform.value, show: show.value })
if (isWebPlatform.value)
await generatePosterByWeb(index)
else
await generatePosterByUni(index)
}
// 监听 show 变化show 为 true 时生成海报
watch(show, (newVal) => {
if (newVal && !postersGenerated.value[currentIndex.value]) {
generatePoster(currentIndex.value) // 当弹窗显示且当前海报未生成时生成海报
}
})
// 微信环境保存图片
async function exportCurrentPosterDataUrl() {
const canvas = posterCanvasRefs.value[currentIndex.value]
if (!canvas)
throw new Error('海报尚未生成完成')
return canvas.toDataURL('image/png')
}
async function exportCurrentPosterTempFilePath() {
const idx = currentIndex.value
try {
return await scheduleCompositeExport(idx)
}
catch (e) {
console.error('导出合成海报失败:', e)
throw e
}
}
async function savePosterForWeChat() {
try {
const dataURL = await exportCurrentPosterDataUrl()
currentImageUrl.value = dataURL
imageGuideTitle.value = '保存图片到相册'
showImageGuide.value = true
}
catch (error) {
console.error('获取海报失败:', error)
showToast('海报尚未生成完成,请稍后重试')
}
}
// 关闭图片保存指引
function closeImageGuide() {
showImageGuide.value = false
}
// 保存海报图片 - 多种保存方式(非微信环境)
async function savePosterByUni() {
try {
const tempFilePath = await exportCurrentPosterTempFilePath()
await new Promise((resolve, reject) => {
uni.saveImageToPhotosAlbum({
filePath: tempFilePath,
success: resolve,
fail: reject,
})
})
showToast({ message: '图片已保存到相册' })
}
catch (error) {
console.error('App 保存图片失败:', error)
showToast({ message: '保存失败,请检查相册权限' })
}
}
async function savePoster() {
if (!isWebPlatform.value) {
await savePosterByUni()
return
}
try {
const dataURL = await exportCurrentPosterDataUrl()
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
if (isMobile)
await saveForMobile(dataURL)
else
saveForPC(dataURL)
}
catch (error) {
console.error('导出海报失败:', error)
showToast({ message: '海报尚未生成完成,请稍后重试' })
}
}
// PC浏览器保存方式
function saveForPC(dataURL) {
const a = document.createElement('a')
a.href = dataURL
a.download = '赤眉海报.png'
a.click()
}
// 手机浏览器保存方式
async function saveForMobile(dataURL) {
// 方法1: 尝试使用 File System Access API (Chrome 86+)
const fileSystemSuccess = await saveWithFileSystemAPI(dataURL)
if (fileSystemSuccess)
return
// 方法2: 尝试使用 Blob 和 URL.createObjectURL
try {
const blob = dataURLToBlob(dataURL)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '赤眉海报.png'
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
// 清理 URL 对象
setTimeout(() => URL.revokeObjectURL(url), 100)
showToast({ message: '图片已保存到相册' })
}
catch (error) {
console.error('Blob保存失败:', error)
// 方法3: 尝试使用 share API (支持分享到其他应用)
const shareSuccess = await tryShareAPI(dataURL)
if (!shareSuccess) {
// 方法4: 降级到长按保存提示
showLongPressTip(dataURL)
}
}
}
// 显示长按保存提示(非微信环境使用)
function showLongPressTip(dataURL) {
// 设置遮罩层内容
currentImageUrl.value = dataURL
imageGuideTitle.value = '保存图片到相册'
// 显示遮罩层
showImageGuide.value = true
}
// 将 dataURL 转换为 Blob
function dataURLToBlob(dataURL) {
const arr = dataURL.split(',')
const mime = arr[0].match(/:(.*?);/)[1]
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
return new Blob([u8arr], { type: mime })
}
// 备用保存方法 - 使用 File System Access API (现代浏览器)
async function saveWithFileSystemAPI(dataURL) {
if ('showSaveFilePicker' in window) {
try {
const blob = dataURLToBlob(dataURL)
const fileHandle = await window.showSaveFilePicker({
suggestedName: '赤眉海报.png',
types: [{
description: 'PNG images',
accept: { 'image/png': ['.png'] },
}],
})
const writable = await fileHandle.createWritable()
await writable.write(blob)
await writable.close()
showToast({ message: '图片已保存' })
return true
}
catch (error) {
console.error('File System API 保存失败:', error)
return false
}
}
return false
}
// 尝试使用 Share API
async function tryShareAPI(dataURL) {
if (navigator.share && navigator.canShare) {
try {
const blob = dataURLToBlob(dataURL)
const file = new File([blob], '赤眉海报.png', { type: 'image/png' })
if (navigator.canShare({ files: [file] })) {
await navigator.share({
title: '赤眉海报',
text: '分享海报图片',
files: [file],
})
showToast({ message: '图片已分享' })
return true
}
}
catch (error) {
console.error('Share API 失败:', error)
}
}
return false
}
function generalUrl() {
return url.value + encodeURIComponent(linkIdentifier.value)
}
function copyUrl() {
copyToClipboard(generalUrl())
}
// 复制链接
function copyToClipboard(text) {
if (!isWebPlatform.value) {
uni.setClipboardData({
data: text,
success: () => showToast({ message: '链接已复制!' }),
fail: () => showToast({ message: '复制失败,请手动复制' }),
})
return
}
if (navigator.clipboard && window.isSecureContext) {
// 支持 Clipboard API
navigator.clipboard
.writeText(text)
.then(() => {
showToast({ message: '链接已复制!' })
})
.catch((err) => {
console.error('复制失败:', err)
})
}
else {
// 对于不支持 Clipboard API 的浏览器,使用 fallback 方法
const textArea = document.createElement('textarea')
textArea.value = text
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy')
showToast({ message: '链接已复制!' })
}
catch (err) {
console.error('复制失败:', err)
}
finally {
document.body.removeChild(textArea)
}
}
}
</script>
<script>
import { handlePosterRenderMergeDone, handlePosterRenderMergeFailed } from '@/utils/posterRenderMergeBridge'
export default {
methods: {
onPosterRenderMergeDone(e) {
handlePosterRenderMergeDone(e?.dataUrl).catch((err) => {
console.error('[QRcode] poster merge write failed', err)
})
},
onPosterRenderMergeFailed(e) {
handlePosterRenderMergeFailed(e?.err)
},
},
}
</script>
<script module="posterRender" lang="renderjs">
export default {
methods: {
onMergeReqChange(newVal, _oldVal, ownerInstance) {
if (!newVal || !newVal.posterDataUrl || !newVal.qrDataUrl)
return
const c = document.createElement('canvas')
c.width = newVal.w
c.height = newVal.h
const ctx = c.getContext('2d')
if (!ctx) {
ownerInstance.callMethod('onPosterRenderMergeFailed', { err: 'no_canvas_2d' })
return
}
const p = new Image()
p.onload = () => {
const q = new Image()
q.onload = () => {
try {
ctx.drawImage(p, 0, 0, newVal.w, newVal.h)
ctx.drawImage(q, newVal.qrx, newVal.qry, newVal.qrs, newVal.qrs)
const dataUrl = c.toDataURL('image/png')
ownerInstance.callMethod('onPosterRenderMergeDone', { dataUrl })
}
catch (err) {
ownerInstance.callMethod('onPosterRenderMergeFailed', { err: String(err) })
}
}
q.onerror = () => ownerInstance.callMethod('onPosterRenderMergeFailed', { err: 'qr_image_load' })
q.src = newVal.qrDataUrl
}
p.onerror = () => ownerInstance.callMethod('onPosterRenderMergeFailed', { err: 'poster_image_load' })
p.src = newVal.posterDataUrl
},
},
}
</script>
<template>
<!-- 放在弹窗外避免 wd-popup 关闭时子树未挂载导致 APP 无法 createCanvasContext -->
<canvas v-if="!isWebPlatform" :id="QR_GEN_CANVAS_ID" :canvas-id="QR_GEN_CANVAS_ID" class="poster-qr-gen-canvas" />
<!-- App仅作 renderjs change 锚点真正画布在 renderjs createElement('canvas') -->
<view v-if="!isWebPlatform" class="poster-renderjs-anchor" :poster-merge-req="posterMergeRequest"
:change:poster-merge-req="posterRender.onMergeReqChange" aria-hidden="true" />
<wd-popup v-model="show" round position="bottom" :style="{ maxHeight: '95vh' }">
<view class="qrcode-popup-container">
<view class="qrcode-content">
<swiper class="poster-swiper rounded-lg shadow sm:rounded-xl" indicator-color="white" circular indicator-dots
@change="onSwipeChange">
<swiper-item v-for="(_, index) in posterImages" :key="index" class="poster-swiper-item">
<view class="poster-canvas-box">
<template v-if="isWebPlatform">
<view class="poster-preview-box web-preview">
<canvas :id="getCanvasId(index)" :ref="(el) => (posterCanvasRefs[index] = el)"
:canvas-id="getCanvasId(index)" :width="posterCanvasSizes[index]?.width ?? 300"
:height="posterCanvasSizes[index]?.height ?? 300" :style="posterCanvasContainStyle(index)"
class="poster-canvas poster-canvas--h5-contain rounded-lg sm:rounded-xl" />
</view>
</template>
<template v-else>
<!-- App预览与保存同源aspectFit 在轮播高度内整图可见等比宽可不拉满 -->
<view v-if="posterCompositePath[index]" class="poster-app-preview">
<image :src="posterCompositePath[index]" mode="aspectFit"
class="poster-app-composite rounded-lg sm:rounded-xl" />
</view>
<view v-else class="poster-preview-placeholder">
<text class="poster-preview-placeholder-text">
海报生成中
</text>
</view>
</template>
</view>
</swiper-item>
</swiper>
</view>
<view v-if="mode === 'promote'" class="swipe-tip mb-1 px-2 text-center text-xs text-gray-700 sm:mb-2 sm:text-sm">
<text class="swipe-icon">
</text> 左右滑动切换海报
<text class="swipe-icon">
</text>
</view>
<wd-divider class="my-2 sm:my-3">
保存与复制
</wd-divider>
<view class="flex items-center justify-around px-4 pb-3 sm:pb-4">
<!-- 微信环境保存与复制 -->
<template v-if="isWeChat">
<view class="flex flex-col cursor-pointer items-center justify-center" @click="savePosterForWeChat">
<image src="/static/images/icon_share_img.svg" class="share-icon h-9 w-9 rounded-full sm:h-10 sm:w-10" />
<view class="mt-1 text-center text-xs text-gray-600">
保存图片
</view>
</view>
<view class="flex flex-col cursor-pointer items-center justify-center" @click="copyUrl">
<image src="/static/images/icon_share_url.svg" class="share-icon h-9 w-9 rounded-full sm:h-10 sm:w-10" />
<view class="mt-1 text-center text-xs text-gray-600">
复制链接
</view>
</view>
</template>
<!-- 非微信环境显示保存和复制按钮 -->
<template v-else>
<view class="flex flex-col cursor-pointer items-center justify-center" @click="savePoster">
<image src="/static/images/icon_share_img.svg" class="share-icon h-9 w-9 rounded-full sm:h-10 sm:w-10" />
<view class="mt-1 text-center text-xs text-gray-600">
保存图片
</view>
</view>
<view class="flex flex-col cursor-pointer items-center justify-center" @click="copyUrl">
<image src="/static/images/icon_share_url.svg" class="share-icon h-9 w-9 rounded-full sm:h-10 sm:w-10" />
<view class="mt-1 text-center text-xs text-gray-600">
复制链接
</view>
</view>
</template>
</view>
</view>
</wd-popup>
<!-- 图片保存指引遮罩层 -->
<ImageSaveGuide :show="showImageGuide" :image-url="currentImageUrl" :title="imageGuideTitle"
@close="closeImageGuide" />
</template>
<style lang="scss" scoped>
.qrcode-popup-container {
display: flex;
flex-direction: column;
max-height: 95vh;
overflow: hidden;
}
/* 移出可视区域,避免与海报轮播重叠;尺寸需与 QR_GEN_SIZE 一致 */
.poster-qr-gen-canvas {
position: fixed;
left: -9999px;
top: 0;
width: 150px;
height: 150px;
overflow: hidden;
}
.qrcode-content {
flex-shrink: 0;
padding: 0.75rem;
}
/* 小屏设备优化 */
@media (max-width: 375px) {
.qrcode-content {
padding: 0.5rem;
}
}
/* 中等及以上屏幕 */
@media (min-width: 640px) {
.qrcode-content {
padding: 1rem;
}
}
.poster-swiper {
height: calc(95vh - 180px);
min-height: 300px;
max-height: 500px;
width: 100%;
}
/* 轮播项铺满高度内部再「contain」缩放海报避免 canvas 被 100%×100% 非等比拉伸、看起来像放大裁切 */
.poster-swiper-item {
box-sizing: border-box;
height: 100%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.poster-canvas-box {
width: 100%;
height: 100%;
display: flex;
align-items: stretch;
justify-content: center;
box-sizing: border-box;
}
.poster-canvas-box>view {
flex: 1;
min-width: 0;
min-height: 0;
width: 100%;
}
/* App在轮播固定高度内等比居中整图可见不强制拉满宽 */
.poster-app-preview {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.poster-app-composite {
width: 100%;
height: 100%;
}
/* Apprenderjs 属性绑定锚点(不占布局) */
.poster-renderjs-anchor {
position: fixed;
left: 0;
top: 0;
width: 0;
height: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
z-index: -9999;
}
.poster-preview-box.web-preview {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
overflow: hidden;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.poster-canvas--h5-contain {
margin: 0 auto;
}
.poster-preview-placeholder {
width: 100%;
height: 100%;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.poster-preview-placeholder-text {
font-size: 14px;
color: #999;
}
/* 小屏设备:更小的海报高度 */
@media (max-width: 375px) {
.poster-swiper {
height: calc(95vh - 160px);
min-height: 280px;
max-height: 400px;
}
}
/* 中等屏幕 */
@media (min-width: 640px) and (max-width: 767px) {
.poster-swiper {
height: calc(95vh - 190px);
max-height: 520px;
}
}
/* 大屏幕 */
@media (min-width: 768px) {
.poster-swiper {
height: calc(95vh - 200px);
max-height: 600px;
}
}
/* H5未加 contain 类名时兜底(当前 web 分支已用 poster-canvas--h5-contain */
.poster-canvas {
display: block;
}
.swipe-tip {
animation: fadeInOut 2s infinite;
flex-shrink: 0;
}
.swipe-icon {
display: inline-block;
animation: slideLeftRight 1.5s infinite;
font-size: 14px;
}
@media (min-width: 640px) {
.swipe-icon {
font-size: 16px;
}
}
.share-icon {
transition: transform 0.2s ease;
}
.share-icon:active {
transform: scale(0.95);
}
@keyframes fadeInOut {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
@keyframes slideLeftRight {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(5px);
}
}
/* 优化 wd-divider 在小屏幕上的间距 */
:deep(.wd-divider) {
margin: 0.5rem 0;
}
@media (min-width: 640px) {
:deep(.wd-divider) {
margin: 0.75rem 0;
}
}
</style>