hookehuyr

feat(SharePoster): 使用html-to-image生成海报并优化资源加载

重构海报生成逻辑,使用html-to-image替代canvas手动绘制,提升生成稳定性
添加生成中状态提示,优先使用base64资源避免跨域问题
优化封面和二维码加载逻辑,移除冗余canvas代码
......@@ -34,6 +34,7 @@
"@vue-office/pptx": "^1.0.1",
"browser-md5-file": "^1.1.1",
"dayjs": "^1.11.13",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"lodash": "^4.17.21",
"pdf-vue3": "^1.0.12",
......
......@@ -47,6 +47,9 @@ importers:
dayjs:
specifier: ^1.11.13
version: 1.11.19
html-to-image:
specifier: ^1.11.13
version: 1.11.13
html2canvas:
specifier: ^1.4.1
version: 1.4.1
......@@ -1167,6 +1170,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
......@@ -3036,6 +3042,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
html-to-image@1.11.13: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
......
......@@ -13,22 +13,25 @@
<van-icon name="cross" @click="close" />
</div>
<!-- 生成中提示 -->
<div v-if="is_generating" class="text-center text-gray-500 text-sm mb-2">正在生成海报...</div>
<!-- 海报区域:直接使用 Canvas 合成的图片,支持长按保存 -->
<!-- 当已生成海报图时,容器不再应用卡片边框与阴影,避免双重边框视觉效果;降级展示仍保留卡片样式 -->
<div :class="poster_img_src ? 'PosterCard mx-auto' : 'PosterCard bg-white rounded-xl shadow-md overflow-hidden mx-auto'" ref="card_ref">
<div :class="poster_img_src ? 'PosterCard mx-auto' : 'PosterCard bg-white rounded-xl overflow-hidden border border-gray-200 mx-auto'" ref="card_ref">
<img v-if="poster_img_src" :src="poster_img_src" alt="分享海报" class="w-full h-auto object-contain block" />
<!-- 生成失败或尚未生成时的降级展示(可长按截图保存) -->
<div v-else>
<!-- 上部封面图 -->
<div class="PosterCover rounded-t-xl overflow-hidden">
<img :src="cover_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" />
<img :src="cover_final_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" />
</div>
<!-- 下部信息区:左二维码 + 右文案 -->
<div class="PosterInfo p-4">
<div class="flex items-start">
<!-- 左侧二维码 -->
<div class="PosterQR mr-4">
<img :src="qr_src" alt="课程二维码" class="w-24 h-24 rounded" crossorigin="anonymous" />
<img :src="qr_final_src" alt="课程二维码" class="w-24 h-24 rounded" crossorigin="anonymous" />
</div>
<!-- 右侧文案 -->
<div class="flex-1 flex flex-col space-y-2 -mt-1">
......@@ -53,7 +56,8 @@
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import { toPng } from 'html-to-image'
import QRCode from 'qrcode'
import { showToast } from 'vant'
......@@ -78,11 +82,12 @@ function normalize_image_url(src) {
try {
const u = new URL(url, window.location.origin)
if (u.hostname === 'cdn.ipadbiz.cn') {
// CDN 图片统一追加压缩参数,若已存在查询参数则使用 & 连接
// const has_mogr = url.includes('imageMogr2')
// if (!has_mogr) {
// return url + (url.includes('?') ? '&' : '?') + 'imageMogr2/thumbnail/200x/strip/quality/70'
// }
// CDN 图片统一追加压缩参数,若地址未包含 imageMogr2,则追加压缩参数
const param = 'imageMogr2/thumbnail/200x/strip/quality/70'
const has_mogr = url.includes('imageMogr2')
if (!has_mogr) {
return url + (url.includes('?') ? '&' : '?') + param
}
return url
}
} catch (e) {
......@@ -126,6 +131,12 @@ const show_proxy = computed({
*/
const card_ref = ref(null)
/**
* @var {import('vue').Ref<boolean>} is_generating
* @description 是否处于海报生成中状态,用于在弹窗内展示“正在生成海报...”提示。
*/
const is_generating = ref(false)
/** 标题/副标题/介绍 */
const title_text = computed(() => props.course?.title || '课程')
const subtitle_text = computed(() => props.course?.subtitle || '')
......@@ -176,6 +187,13 @@ const date_range_text = computed(() => {
/** 封面图地址(含CDN压缩规则) */
const cover_src = computed(() => normalize_image_url(props.course?.cover || ''))
/**
* @var {import('vue').Ref<string>} cover_data_url
* @description 封面图的 base64;优先用 base64 以避免跨域获取失败
*/
const cover_data_url = ref('')
/** 最终封面图地址:优先使用 base64,否则回退原地址 */
const cover_final_src = computed(() => cover_data_url.value || cover_src.value)
/**
* @function build_qr_service_url
......@@ -199,316 +217,207 @@ const qr_src = computed(() => {
const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
return build_qr_service_url(url)
})
/**
* @var {import('vue').Ref<string>} qr_data_url
* @description 本地生成的二维码 base64,避免跨域图片导致生成失败
*/
const qr_data_url = ref('')
/** 最终二维码地址:优先使用本地生成的 base64,否则回退服务端地址 */
const qr_final_src = computed(() => qr_data_url.value || qr_src.value)
// 海报图片 dataURL(用于长按保存)
const poster_img_src = ref('')
/**
* @function load_image
* @description 以匿名跨域方式加载图片并追加时间戳防缓存;失败返回 null
* @param {string} src 图片地址
* @returns {Promise<HTMLImageElement|null>} 加载成功的图片对象或 null
* @function try_fetch_to_data_url
* @description 尝试将图片地址转为 base64;跨域或失败时返回空串
* @param {string} url 图片地址
* @returns {Promise<string>} base64 dataURL 或空串
*/
function load_image(src) {
return new Promise((resolve) => {
if (!src) return resolve(null)
const img = new Image()
img.crossOrigin = 'anonymous'
const with_ts = src + (src.includes('?') ? '&' : '?') + 'v=' + Date.now()
img.onload = () => resolve(img)
img.onerror = () => resolve(null)
img.src = with_ts
})
}
/**
* @function wrap_text
* @description 按最大宽度自动换行并限制最大行数;溢出末行追加省略号。
* @param {CanvasRenderingContext2D} ctx 画布上下文
* @param {string} text 文本内容
* @param {number} max_w 最大宽度
* @param {string} font 字体样式(如 'bold 32px sans-serif')
* @param {number} line_h 行高
* @param {number} max_lines 最大行数
* @returns {string[]} 处理后的行数组
*/
function wrap_text(ctx, text, max_w, font, line_h, max_lines) {
ctx.font = font
const content = (text || '').trim()
if (!content) return []
const lines = []
let cur = ''
const tokens = Array.from(content)
tokens.forEach(ch => {
const test = cur + ch
if (ctx.measureText(test).width <= max_w) {
cur = test
} else {
lines.push(cur)
cur = ch
}
})
if (cur) lines.push(cur)
if (lines.length > max_lines) {
const truncated = lines.slice(0, max_lines)
const last = truncated[truncated.length - 1]
truncated[truncated.length - 1] = (last || '').slice(0, Math.max(0, (last || '').length - 1)) + '…'
return truncated
async function try_fetch_to_data_url(url) {
if (!url) return ''
try {
const res = await fetch(url, { mode: 'cors', cache: 'no-cache', credentials: 'omit' })
if (!res.ok) return ''
const blob = await res.blob()
return await new Promise((resolve) => {
const reader = new FileReader()
reader.onloadend = () => resolve(String(reader.result || ''))
reader.onerror = () => resolve('')
reader.readAsDataURL(blob)
})
} catch (e) {
return ''
}
return lines
}
/**
* @function rounded_rect_path
* @description 绘制圆角矩形路径(仅定义 path,不进行填充或描边)。
* @param {CanvasRenderingContext2D} ctx 画布上下文
* @param {number} x 起始x
* @param {number} y 起始y
* @param {number} w 宽度
* @param {number} h 高度
* @param {number} r 圆角半径
* @returns {void}
*/
function rounded_rect_path(ctx, x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2))
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
ctx.lineTo(x + w, y + h - rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
ctx.lineTo(x + rr, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
/**
* @function rounded_top_rect_path
* @description 绘制仅顶部两角为圆角、底部直角的矩形路径(用于封面区域裁剪)。
* @param {CanvasRenderingContext2D} ctx 画布上下文
* @param {number} x 起始x
* @param {number} y 起始y
* @param {number} w 宽度
* @param {number} h 高度
* @param {number} r 圆角半径(仅作用于顶部)
* @returns {void}
* @function prepare_assets
* @description 在截图前准备资源:封面尝试转 base64、二维码本地生成为 base64。
* @returns {Promise<void>}
*/
function rounded_top_rect_path(ctx, x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2))
ctx.beginPath()
// 顶部边 + 右上圆角
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
// 右侧直边到底部直角
ctx.lineTo(x + w, y + h)
// 底部直边到左下直角
ctx.lineTo(x, y + h)
// 左侧直边到左上圆角
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
async function prepare_assets() {
// 二维码本地生成
try {
const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
const data_url = await QRCode.toDataURL(url, { margin: 2, width: 256, color: { dark: '#000000', light: '#ffffff' } })
qr_data_url.value = data_url || ''
} catch (e) {
qr_data_url.value = ''
}
// 封面尝试转 base64(若跨域失败则回退原地址)
try {
const b64 = await try_fetch_to_data_url(cover_src.value)
cover_data_url.value = b64 || ''
} catch (e) {
cover_data_url.value = ''
}
}
/**
* @function compose_poster
* @description 以 Canvas 合成海报:上部封面、左侧二维码、右侧文本信息;生成 dataURL 供长按保存。
* @returns {Promise<void>}
*/
/**
* @function compose_poster
* @description 以 Canvas 合成海报:底部仅渲染标题与副标题,右侧居中排版;生成 dataURL 供长按保存。
* @description 使用 html-to-image 对可视卡片 DOM 截图并生成 PNG 的 dataURL,仅在弹窗打开时触发。
* @returns {Promise<void>}
*/
async function compose_poster() {
poster_img_src.value = ''
try {
// 固定设计宽度,整体高度动态计算以避免封面留白
const width = 750
const card_margin = 12
const card_x = card_margin
const card_y = card_margin
const card_w = width - card_margin * 2
const card_radius = 16
// 预加载封面以获取真实宽高,按 contain(宽度占满)动态计算封面高度
const cover_img = await load_image(cover_src.value)
let cover_h = Math.round(card_w * 2 / 3)
if (cover_img) {
const sw = cover_img.width
const sh = cover_img.height
const scale_w = card_w / Math.max(1, sw)
cover_h = Math.round(sh * scale_w)
}
// 文本与二维码区尺寸计算:根据内容动态确定信息区高度
const padding = 32
const qr_size = 160
const title_font = 'bold 36px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const subtitle_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const date_font = 'normal 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const footnote_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const measurer = document.createElement('canvas')
const mctx = measurer.getContext('2d')
const text_max_w = card_w - padding * 2 - qr_size - 20
// 测量标题/副标题/日期,并在信息区顶部对齐排版(脚注单独贴底绘制)
const title_lines = wrap_text(mctx, title_text.value, text_max_w, title_font, 44, 1)
const subtitle_lines = subtitle_text.value ? wrap_text(mctx, subtitle_text.value, text_max_w, subtitle_font, 32, 1) : []
const date_lines = date_range_text.value ? wrap_text(mctx, date_range_text.value, text_max_w, date_font, 40, 1) : []
// 文本行间距:用于增大标题/副标题/日期的间隔
const line_gap = 10
const lines_count = (title_lines.length ? 1 : 0) + (subtitle_lines.length ? 1 : 0) + (date_lines.length ? 1 : 0)
const gaps_count = Math.max(0, lines_count - 1)
const total_text_h = (title_lines.length ? 44 : 0)
+ (subtitle_lines.length ? 32 : 0)
+ (date_lines.length ? 40 : 0)
+ gaps_count * line_gap
const min_gap = 24
const footnote_line_h = 28
const info_h = padding * 2 + Math.max(qr_size, total_text_h + min_gap + footnote_line_h)
const card_h = cover_h + info_h
const text_offset_y = 0
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1))
const canvas = document.createElement('canvas')
canvas.width = Math.round(width * dpr)
canvas.height = Math.round((card_h + card_margin * 2) * dpr)
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
// 卡片阴影与背景(白色圆角卡片 + 灰色边框)
ctx.save()
rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius)
ctx.shadowColor = 'rgba(0,0,0,0.12)'
ctx.shadowBlur = 12
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 4
ctx.fillStyle = '#ffffff'
ctx.fill()
ctx.restore()
// 卡片边框
ctx.save()
rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius)
ctx.strokeStyle = '#e5e7eb' // gray-200
ctx.lineWidth = 2
ctx.stroke()
ctx.restore()
// 卡片内容裁剪区域
ctx.save()
rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius)
ctx.clip()
// 封面图:宽度占满,高度按等比缩放(无留白)
if (cover_img) {
ctx.save()
ctx.beginPath()
// 仅顶部圆角,底部直角,避免封面底部出现弧形
rounded_top_rect_path(ctx, card_x, card_y, card_w, cover_h, card_radius)
ctx.clip()
ctx.drawImage(cover_img, card_x, card_y, card_w, cover_h)
ctx.restore()
} else {
ctx.fillStyle = '#f3f4f6' // gray-100
ctx.fillRect(card_x, card_y, card_w, cover_h)
ctx.fillStyle = '#9ca3af' // gray-400
ctx.textAlign = 'center'
ctx.font = 'normal 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
ctx.fillText('封面加载失败', card_x + card_w / 2, card_y + cover_h / 2 + 10)
ctx.textAlign = 'left'
}
// 二维码(本地生成避免跨域)
const qr_canvas = document.createElement('canvas')
qr_canvas.width = Math.round(qr_size * dpr)
qr_canvas.height = Math.round(qr_size * dpr)
const qr_url_val = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
try {
await QRCode.toCanvas(qr_canvas, qr_url_val, { width: Math.round(qr_size * dpr), margin: Math.round(2 * dpr), color: { dark: '#000000', light: '#ffffff' } })
} catch (e) {
const qctx = qr_canvas.getContext('2d')
qctx.fillStyle = '#ffffff'
qctx.fillRect(0, 0, Math.round(qr_size * dpr), Math.round(qr_size * dpr))
qctx.strokeStyle = '#e5e7eb'
qctx.strokeRect(0, 0, Math.round(qr_size * dpr), Math.round(qr_size * dpr))
qctx.fillStyle = '#9ca3af'
qctx.font = `${Math.round(16 * dpr)}px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"`
qctx.fillText('二维码生成失败', Math.round(12 * dpr), Math.round((qr_size * dpr) / 2))
}
ctx.drawImage(qr_canvas, card_x + padding, card_y + cover_h + padding, qr_size, qr_size)
// 文案(右侧,顶部对齐)
let tx = card_x + padding + qr_size + 20
let ty = card_y + cover_h + padding
ctx.fillStyle = '#1f2937' // gray-800
ctx.font = title_font
// 增加标题与后续文本的间距
title_lines.forEach(line => { ctx.fillText(line, tx, ty + 34); ty += 44 + line_gap })
ctx.fillStyle = '#6b7280' // gray-500
ctx.font = subtitle_font
// 增加副标题与后续文本的间距
subtitle_lines.forEach(line => { ctx.fillText(line, tx, ty + 24); ty += 32 + line_gap })
ctx.fillStyle = '#9ca3af' // gray-400
ctx.font = date_font
// 日期作为最后一行,不再额外叠加间距
date_lines.forEach(line => { ctx.fillText(line, tx, ty + 30); ty += 40 })
// 脚注:底部绿色提示文案(贴底显示)
ctx.fillStyle = '#10b981' // green-500
ctx.font = footnote_font
let foot_y = card_y + cover_h + info_h - padding - 14
const last_text_bottom_y = ty
if (foot_y - last_text_bottom_y < min_gap) {
foot_y = last_text_bottom_y + min_gap
}
ctx.fillText('扫码了解详情', tx, foot_y)
// 恢复裁剪
ctx.restore()
// 生成 dataURL
try {
const data_url = canvas.toDataURL('image/png')
poster_img_src.value = data_url
} catch (e) {
console.error('海报生成失败(跨域):', e)
showToast('海报生成失败,已展示标准卡片,请长按保存截图')
poster_img_src.value = ''
// 标记进入生成流程
is_generating.value = true
// 准备资源,尽量使用 base64 避免跨域失败
await prepare_assets()
await nextTick()
const node = card_ref.value
if (!node) {
is_generating.value = false
return
}
// 等待容器内图片加载完成,避免首次截图丢失封面
await wait_images_loaded(node)
// 轻微延迟,确保弹窗过渡动画结束,布局稳定
await new Promise(r => setTimeout(r, 80))
// 克隆离屏节点,避免弹窗动画与布局抖动影响截图
const { wrapper, clone, size } = create_offscreen_clone(node)
await wait_images_loaded(clone, 1200)
// 设置像素比例,提升清晰度(在高分屏上更清晰)
const pixel_ratio = Math.max(1, Math.min(2.5, window.devicePixelRatio || 1))
// 透明 1x1 PNG 作为占位图,避免资源获取失败直接中断
const placeholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wm3T9kAAAAASUVORK5CYII='
// 生成 PNG dataURL
const data_url = await toPng(clone, {
pixelRatio: pixel_ratio,
cacheBust: true,
backgroundColor: '#ffffff',
imagePlaceholder: placeholder,
fetchRequestInit: { mode: 'cors', cache: 'no-cache', credentials: 'omit' },
// 避免外层 margin 影响截图结果
style: { margin: '0' },
width: Math.round(size.width),
height: Math.round(size.height)
})
poster_img_src.value = data_url
// 清理离屏节点
if (wrapper && wrapper.parentNode) wrapper.parentNode.removeChild(wrapper)
is_generating.value = false
} catch (err) {
console.error('compose_poster 异常:', err)
showToast('海报生成失败,请稍后重试')
console.error('html-to-image 生成海报失败:', err)
showToast('海报生成失败,已展示标准卡片,请长按保存截图')
poster_img_src.value = ''
is_generating.value = false
}
}
/**
* @function init_once
* @description 组件挂载时生成一次海报,避免因弹框开关导致重复计算与变形。
* @function create_offscreen_clone
* @description 创建一个离屏的卡片克隆用于稳定截图,避免受弹窗动画与布局影响。
* @param {HTMLElement} node 原始容器节点
* @returns {{wrapper: HTMLElement, clone: HTMLElement, size: {width: number, height: number}}}
*/
function create_offscreen_clone(node) {
const rect = node.getBoundingClientRect()
const size = { width: rect.width, height: rect.height }
const wrapper = document.createElement('div')
const clone = node.cloneNode(true)
// 离屏包裹容器样式
Object.assign(wrapper.style, {
position: 'fixed',
left: '-99999px',
top: '-99999px',
width: `${Math.round(size.width)}px`,
height: `${Math.round(size.height)}px`,
overflow: 'hidden',
opacity: '0',
pointerEvents: 'none',
background: '#ffffff',
zIndex: '-1000'
})
// 克隆节点样式校正,避免动画、阴影、滤镜干扰
Object.assign(clone.style, {
width: '100%',
height: '100%',
margin: '0',
transform: 'none',
filter: 'none',
boxShadow: 'none'
})
wrapper.appendChild(clone)
document.body.appendChild(wrapper)
return { wrapper, clone, size }
}
/**
* @function watch_open_and_generate
* @description 仅在弹窗打开时开始生成海报;关闭不生成。每次打开都重新生成以保证信息最新。
* @returns {void}
*/
onMounted(() => {
if (!poster_img_src.value) {
watch(show_proxy, (opened) => {
if (opened) {
if (is_generating.value) return
poster_img_src.value = ''
nextTick(() => compose_poster())
}
})
/**
* @function recompose_on_data_change
* @description 仅当课程数据或二维码地址发生变化时重新合成海报;避免因弹窗显隐导致的重复计算
* @description 当弹窗处于打开状态且课程数据或二维码地址发生变化时重新合成海报;弹窗关闭时不生成
* @returns {void}
*/
watch(() => [props.course, props.qr_url], () => {
nextTick(() => compose_poster())
}, { deep: false })
// 已移除对象引用监听,改为监听具体字段(见下方)
/**
* @function wait_images_loaded
* @description 等待卡片容器中的图片加载完成(或超时),避免首次截图遗漏封面。
* @param {HTMLElement} node 容器节点
* @param {number} timeout 超时时间毫秒
* @returns {Promise<void>}
*/
async function wait_images_loaded(node, timeout = 2500) {
try {
if (!node) return
const imgs = Array.from(node.querySelectorAll('img'))
if (!imgs.length) return
await Promise.all(imgs.map(img => new Promise((resolve) => {
if (img.complete) return resolve()
let done = false
const finish = () => { if (!done) { done = true; resolve() } }
img.addEventListener('load', finish, { once: true })
img.addEventListener('error', finish, { once: true })
setTimeout(finish, timeout)
})))
} catch (_) {
// 忽略加载异常,继续生成
}
}
// 调整数据变更监听:仅在封面地址或二维码地址变化时重新生成
watch([() => props.course?.cover, () => props.qr_url], () => {
if (show_proxy.value) {
if (is_generating.value) return
poster_img_src.value = ''
nextTick(() => compose_poster())
}
})
</script>
<style lang="less" scoped>
......@@ -539,7 +448,7 @@ watch(() => [props.course, props.qr_url], () => {
overflow: hidden;
.PosterQR {
img {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
box-shadow: none;
}
}
}
......