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数组
|
|
|
|
|
|
/** APP:canvas 绘制缓冲区宽高(须与 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 的 canvas(App 上常取不到 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}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* App:renderjs 视图层合成整图,逻辑层收 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%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* App:renderjs 属性绑定锚点(不占布局) */
|
|
|
|
|
|
.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>
|