hookehuyr

feat(分享海报): 新增通用分享海报组件并集成至课程详情页

- 添加 SharePoster 组件,支持通过 Canvas 合成课程海报
- 在课程详情页底部操作栏添加分享按钮并接入海报弹窗
- 海报包含封面图、二维码及课程信息,支持长按保存
- 添加 html2canvas 和 qrcode 依赖用于海报生成
- 更新 README 文档说明组件使用方式
......@@ -63,3 +63,15 @@ https://oa-dev.onwall.cn/f/mlaj
- 原因:父组件 `handleSearch` 对“相同关键字”进行了拦截,导致路由参数变化不执行请求。
- 修复:移除拦截逻辑;无论关键字是否变化均触发搜索(防抖控制频率)。
- 位置:`/src/views/courses/CourseListPage.vue``handleSearch`
- 分享海报弹窗(通用组件)
- 入口:课程详情页底部操作栏“分享”按钮。
- 组件:`/src/components/ui/SharePoster.vue`(支持复用),`v-model:show` 控制显隐,`course` 传入课程信息,`qr_url` 可指定二维码内容地址(默认取当前页面 URL)。
- 布局:上部封面图;下部信息区左侧二维码,右侧课程标题、副标题、精简介绍与日期范围。
- 样式:优先使用 TailwindCSS 布局;组件内部使用 Less 做层级嵌套的样式补充。
- 图片规则:当封面图域名为 `cdn.ipadbiz.cn` 时,自动追加 `?imageMogr2/thumbnail/200x/strip/quality/70` 压缩参数。
- 接入位置:`/src/views/courses/CourseDetailPage.vue`,导入并渲染 `<SharePoster v-model:show="show_share_poster" :course="course" />`
- Canvas 合成:弹窗打开时使用 Canvas 直接合成海报(封面图、二维码、文案),生成 `dataURL` 并以 `<img>` 展示,用户可直接长按图片保存到手机(无需额外按钮)。
- 依赖:`pnpm add qrcode`(在 Canvas 内本地生成二维码,避免跨域图片导致画布污染)。
- 跨域:通过 `crossorigin="anonymous"` 加载封面,并追加时间戳防缓存;若封面跨域不允许,则显示降级卡片,仍可长按截图保存。
- 文案:使用中文字体并自动换行限制行数,末行超出追加省略号。
......
......@@ -34,8 +34,10 @@
"@vue-office/pptx": "^1.0.1",
"browser-md5-file": "^1.1.1",
"dayjs": "^1.11.13",
"html2canvas": "^1.4.1",
"lodash": "^4.17.21",
"pdf-vue3": "^1.0.12",
"qrcode": "^1.5.4",
"swiper": "^11.2.6",
"uuid": "^11.1.0",
"vant": "^4.9.19",
......
This diff could not be displayed because it is too large.
......@@ -31,6 +31,7 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchBar: typeof import('./components/ui/SearchBar.vue')['default']
SharePoster: typeof import('./components/ui/SharePoster.vue')['default']
SummerCampCard: typeof import('./components/ui/SummerCampCard.vue')['default']
TaskCalendar: typeof import('./components/ui/TaskCalendar.vue')['default']
TermsPopup: typeof import('./components/ui/TermsPopup.vue')['default']
......
<template>
<!-- 弹窗容器:展示分享海报 -->
<van-popup
v-model:show="show_proxy"
round
position="bottom"
:style="{ width: '100%' }"
>
<div class="PosterWrapper p-4">
<!-- 标题与关闭按钮 -->
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">分享海报</h3>
<van-icon name="cross" @click="close" />
</div>
<!-- 海报区域:直接使用 Canvas 合成的图片,支持长按保存 -->
<div class="PosterCard bg-white rounded-xl shadow-md overflow-hidden 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">
<img :src="cover_src" alt="课程封面" class="w-full h-full object-cover" 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" />
</div>
<!-- 右侧文案 -->
<div class="flex-1">
<div class="text-base font-semibold text-gray-800 truncate">{{ title_text }}</div>
<div class="text-xs text-gray-500 mt-1 truncate" v-if="subtitle_text">{{ subtitle_text }}</div>
<div class="text-sm text-gray-700 mt-2 leading-6 line-clamp-3" v-if="intro_text">{{ intro_text }}</div>
<div class="text-xs text-gray-400 mt-2" v-if="date_range_text">{{ date_range_text }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 底部提示与关闭按钮 -->
<div class="mt-3 text-center text-gray-500 text-xs">长按图片保存至手机</div>
<div class="mt-4">
<button class="w-full bg-white border border-green-500 text-green-600 py-2 rounded-lg" @click="close">关闭</button>
</div>
</div>
</van-popup>
<van-back-top right="5vw" bottom="25vh" offset="600" />
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import QRCode from 'qrcode'
import { showToast } from 'vant'
/**
* @typedef {Object} CourseLike
* @property {string} [title] 课程标题
* @property {string} [subtitle] 课程副标题
* @property {string} [cover] 课程封面地址
* @property {string} [introduce] 课程介绍(可包含HTML)
* @property {string} [start_at] 开始日期(可选)
* @property {string} [end_at] 结束日期(可选)
*/
/**
* @function normalize_image_url
* @description 若图片域名为 `cdn.ipadbiz.cn`,追加压缩参数 `?imageMogr2/thumbnail/200x/strip/quality/70`。
* @param {string} src 原始图片地址
* @returns {string} 处理后的图片地址
*/
function normalize_image_url(src) {
const url = src || ''
if (!url) return ''
try {
const u = new URL(url, window.location.origin)
if (u.hostname === 'cdn.ipadbiz.cn' && !u.search) {
return `${url}?imageMogr2/thumbnail/200x/strip/quality/70`
}
} catch (e) {
// 非绝对路径或无法解析的场景,直接返回原值
}
return url
}
const props = defineProps({
/** 弹窗显隐(v-model:show) */
show: { type: Boolean, default: false },
/** 课程对象(动态生成海报所需信息) */
course: { type: Object, default: () => ({}) },
/** 二维码跳转地址(默认当前页面URL) */
qr_url: { type: String, default: '' }
})
const emit = defineEmits(['update:show'])
/**
* @function close
* @description 关闭弹窗
* @returns {void}
*/
const close = () => {
emit('update:show', false)
}
/**
* @function show_proxy
* @description 将 `props.show` 映射为可写的计算属性以支持 v-model:show
*/
const show_proxy = computed({
get() { return props.show },
set(v) { emit('update:show', v) }
})
/**
* @var {import('vue').Ref<HTMLElement|null>} card_ref
* @description 海报卡片容器引用,用于动态获取容器宽度以计算 Canvas 尺寸
*/
const card_ref = ref(null)
/** 标题/副标题/介绍 */
const title_text = computed(() => props.course?.title || '课程')
const subtitle_text = computed(() => props.course?.subtitle || '')
/**
* @function strip_html
* @description 将富文本介绍转换为纯文本并截断展示。
* @param {string} html 原始HTML
* @returns {string} 纯文本
*/
function strip_html(html) {
return (html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
}
const intro_text = computed(() => {
const raw = props.course?.introduce || ''
const text = strip_html(raw)
return text
})
/** 日期范围(若有) */
const date_range_text = computed(() => {
const s = props.course?.start_at || ''
const e = props.course?.end_at || ''
if (s && e) return `${s} ${e}`
return ''
})
/** 封面图地址(含CDN压缩规则) */
const cover_src = computed(() => normalize_image_url(props.course?.cover || ''))
/** 二维码图片(使用在线服务生成) */
const qr_src = computed(() => {
const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
const size = '180x180'
return `https://api.qrserver.com/v1/create-qr-code/?size=${size}&data=${encodeURIComponent(url)}`
})
// 海报图片 dataURL(用于长按保存)
const poster_img_src = ref('')
/**
* @function load_image
* @description 以匿名跨域方式加载图片并追加时间戳防缓存;失败返回 null。
* @param {string} src 图片地址
* @returns {Promise<HTMLImageElement|null>} 加载成功的图片对象或 null
*/
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
}
return lines
}
/**
* @function compose_poster
* @description 以 Canvas 合成海报:上部封面、左侧二维码、右侧文本信息;生成 dataURL 供长按保存。
* @returns {Promise<void>}
*/
/**
* @function compose_poster
* @description 以 Canvas 合成海报:根据容器宽度动态计算尺寸;封面高度为底部信息区的 3 倍(整体 3:1 比例),生成 dataURL 供长按保存。
* @returns {Promise<void>}
*/
async function compose_poster() {
poster_img_src.value = ''
try {
// 固定设计宽度与纵横比,避免因容器导致放大模糊
const width = 750
const aspect_ratio = 1.6 // 高度 = 宽度 * 1.6(稳定的长方形比例)
const height = Math.round(width * aspect_ratio)
const cover_h = Math.round(height * 3 / 4)
const info_h = height - cover_h
const padding = 32
const info_body_h = info_h - padding * 2
const qr_size = 196
const title_font = 'bold 32px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const subtitle_font = 'normal 22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const intro_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const date_font = 'normal 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const measurer = document.createElement('canvas')
const mctx = measurer.getContext('2d')
const text_max_w = width - padding * 2 - qr_size - 20
// 先测量固定行数的标题/副标题/日期
const title_lines = wrap_text(mctx, title_text.value, text_max_w, title_font, 40, 1)
const subtitle_lines = subtitle_text.value ? wrap_text(mctx, subtitle_text.value, text_max_w, subtitle_font, 30, 1) : []
const date_lines = date_range_text.value ? wrap_text(mctx, date_range_text.value, text_max_w, date_font, 28, 1) : []
const reserved_h = (title_lines.length ? title_lines.length * 40 : 0)
+ (subtitle_lines.length ? 30 : 0)
+ (date_lines.length ? 28 + 8 : 0)
// 动态计算介绍文本的最大行数以适配信息区高度
const intro_line_h = 34
const intro_space = Math.max(0, info_body_h - reserved_h - 8)
const max_intro_lines = Math.max(0, Math.floor(intro_space / intro_line_h))
const intro_lines = intro_text.value ? wrap_text(mctx, intro_text.value, text_max_w, intro_font, intro_line_h, Math.max(0, max_intro_lines)) : []
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(height * dpr)
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
// 背景
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
// 封面图(对象填充)
const cover_img = await load_image(cover_src.value)
if (cover_img) {
const scale = Math.max(width / cover_img.width, cover_h / cover_img.height)
const dw = cover_img.width * scale
const dh = cover_img.height * scale
const dx = (width - dw) / 2
const dy = (cover_h - dh) / 2
ctx.drawImage(cover_img, dx, dy, dw, dh)
} else {
ctx.fillStyle = '#f3f4f6' // gray-100
ctx.fillRect(0, 0, width, cover_h)
ctx.fillStyle = '#9ca3af' // gray-400
ctx.textAlign = 'center'
ctx.font = 'normal 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
ctx.fillText('封面加载失败', width / 2, 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, padding, cover_h + padding, qr_size, qr_size)
// 文案(右侧)
let tx = padding + qr_size + 20
let ty = cover_h + padding
ctx.fillStyle = '#1f2937' // gray-800
ctx.font = title_font
title_lines.forEach(line => { ctx.fillText(line, tx, ty + 30); ty += 40 })
ctx.fillStyle = '#6b7280' // gray-500
ctx.font = subtitle_font
subtitle_lines.forEach(line => { ctx.fillText(line, tx, ty + 18); ty += 30 })
ctx.fillStyle = '#374151' // gray-700
ctx.font = intro_font
intro_lines.forEach(line => { ctx.fillText(line, tx, ty + 22); ty += 34 })
ctx.fillStyle = '#9ca3af' // gray-400
ctx.font = date_font
date_lines.forEach(line => { ctx.fillText(line, tx, ty + 16); ty += 28 })
// 生成 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 = ''
}
} catch (err) {
console.error('compose_poster 异常:', err)
showToast('海报生成失败,请稍后重试')
poster_img_src.value = ''
}
}
// 弹窗打开时自动生成海报图片
watch(show_proxy, (v) => {
if (v) {
nextTick(() => compose_poster())
} else {
poster_img_src.value = ''
}
})
</script>
<style lang="less" scoped>
.PosterWrapper {
height: auto;
display: flex;
flex-direction: column;
.PosterCard {
width: 100%;
max-width: 750px;
height: auto;
margin: 0 auto;
display: block;
> img {
width: 100%;
height: auto;
object-fit: contain;
display: block;
}
.PosterInfo {
overflow: hidden;
.PosterQR {
img {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
}
}
}
}
</style>
......@@ -197,7 +197,16 @@
<!-- Bottom Action Bar -->
<div class="fixed bottom-16 left-0 right-0 bg-white shadow-lg p-3 flex justify-between items-center">
<div class="flex space-x-4">
<!-- <button class="flex flex-col items-center text-gray-500 text-xs">
<button class="flex flex-col items-center text-gray-500 text-xs transition-transform duration-300"
@click="toggleFavorite" :class="{ 'animate-favorite': isFavorite }">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 transition-transform duration-300"
:fill="isFavorite ? 'red' : 'none'" viewBox="0 0 24 24" :stroke="isFavorite ? 'red' : 'currentColor'">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318 a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682 a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318 a4.5 4.5 0 00-6.364 0z" />
</svg>
收藏
</button>
<button class="flex flex-col items-center text-gray-500 text-xs" @click="open_consult_dialog">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
......@@ -209,21 +218,12 @@
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
分享
</button> -->
<button class="flex flex-col items-center text-gray-500 text-xs transition-transform duration-300"
@click="toggleFavorite" :class="{ 'animate-favorite': isFavorite }">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 transition-transform duration-300"
:fill="isFavorite ? 'red' : 'none'" viewBox="0 0 24 24" :stroke="isFavorite ? 'red' : 'currentColor'">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318 a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682 a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318 a4.5 4.5 0 00-6.364 0z" />
</svg>
收藏
咨询
</button>
<button class="flex flex-col items-center text-gray-500 text-xs" @click="open_consult_dialog">
<button class="flex flex-col items-center text-gray-500 text-xs" @click="open_share_poster">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
......@@ -235,10 +235,10 @@
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
/>
</svg>
咨询
分享
</button>
</div>
<div class="flex items-center">
......@@ -364,6 +364,8 @@
</div>
</div>
</van-popup>
<!-- 分享海报弹窗 -->
<!-- <SharePoster v-if="course" v-model:show="show_share_poster" :course="course" /> -->
<van-back-top right="5vw" bottom="25vh" offset="600" />
</AppLayout>
</template>
......@@ -383,6 +385,7 @@ import { sharePage } from '@/composables/useShare.js'
import AppLayout from '@/components/layout/AppLayout.vue'
import FrostedGlass from '@/components/ui/FrostedGlass.vue'
import SharePoster from '@/components/ui/SharePoster.vue'
// 导入接口
import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from "@/api/course";
......@@ -507,6 +510,22 @@ const isPurchased = ref(false)
const isReviewed = ref(false)
const showReviewPopup = ref(false)
// 分享海报弹窗状态
/**
* @type {import('vue').Ref<boolean>}
* 展示分享海报弹窗的显隐状态
*/
const show_share_poster = ref(false)
/**
* @function open_share_poster
* @description 打开分享海报弹窗
* @returns {void}
*/
const open_share_poster = () => {
show_share_poster.value = true
}
// 处理富文本点击事件,实现图片预览
const handleIntroduceClick = (event) => {
const target = event.target;
......