Files
bdrp-app/src/components/QRcode.vue

1088 lines
32 KiB
Vue
Raw Normal View History

2026-04-20 16:42:28 +08:00
<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: [
2026-04-23 14:57:35 +08:00
{ 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
2026-04-20 16:42:28 +08:00
],
// 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') -->
2026-04-23 14:57:35 +08:00
<view v-if="!isWebPlatform" class="poster-renderjs-anchor" :poster-merge-req="posterMergeRequest"
:change:poster-merge-req="posterRender.onMergeReqChange" aria-hidden="true" />
2026-04-20 16:42:28 +08:00
<wd-popup v-model="show" round position="bottom" :style="{ maxHeight: '95vh' }">
<view class="qrcode-popup-container">
<view class="qrcode-content">
2026-04-23 14:57:35 +08:00
<swiper class="poster-swiper rounded-lg shadow sm:rounded-xl" indicator-color="white" circular indicator-dots
@change="onSwipeChange">
2026-04-20 16:42:28 +08:00
<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">
2026-04-23 14:57:35 +08:00
<canvas :id="getCanvasId(index)" :ref="(el) => (posterCanvasRefs[index] = el)"
2026-04-20 16:42:28 +08:00
:canvas-id="getCanvasId(index)" :width="posterCanvasSizes[index]?.width ?? 300"
2026-04-23 14:57:35 +08:00
:height="posterCanvasSizes[index]?.height ?? 300" :style="posterCanvasContainStyle(index)"
class="poster-canvas poster-canvas--h5-contain rounded-lg sm:rounded-xl" />
2026-04-20 16:42:28 +08:00
</view>
</template>
<template v-else>
<!-- App预览与保存同源aspectFit 在轮播高度内整图可见等比宽可不拉满 -->
<view v-if="posterCompositePath[index]" class="poster-app-preview">
2026-04-23 14:57:35 +08:00
<image :src="posterCompositePath[index]" mode="aspectFit"
class="poster-app-composite rounded-lg sm:rounded-xl" />
2026-04-20 16:42:28 +08:00
</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>
<!-- 图片保存指引遮罩层 -->
2026-04-23 14:57:35 +08:00
<ImageSaveGuide :show="showImageGuide" :image-url="currentImageUrl" :title="imageGuideTitle"
@close="closeImageGuide" />
2026-04-20 16:42:28 +08:00
</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;
}
2026-04-23 14:57:35 +08:00
.poster-canvas-box>view {
2026-04-20 16:42:28 +08:00
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);
}
}
2026-04-23 14:57:35 +08:00
/* 优化 wd-divider 在小屏幕上的间距 */
:deep(.wd-divider) {
2026-04-20 16:42:28 +08:00
margin: 0.5rem 0;
}
@media (min-width: 640px) {
2026-04-23 14:57:35 +08:00
:deep(.wd-divider) {
2026-04-20 16:42:28 +08:00
margin: 0.75rem 0;
}
}
</style>