feat(分享海报): 新增通用分享海报组件并集成至课程详情页
- 添加 SharePoster 组件,支持通过 Canvas 合成课程海报 - 在课程详情页底部操作栏添加分享按钮并接入海报弹窗 - 海报包含封面图、二维码及课程信息,支持长按保存 - 添加 html2canvas 和 qrcode 依赖用于海报生成 - 更新 README 文档说明组件使用方式
Showing
6 changed files
with
436 additions
and
15 deletions
| ... | @@ -63,3 +63,15 @@ https://oa-dev.onwall.cn/f/mlaj | ... | @@ -63,3 +63,15 @@ https://oa-dev.onwall.cn/f/mlaj |
| 63 | - 原因:父组件 `handleSearch` 对“相同关键字”进行了拦截,导致路由参数变化不执行请求。 | 63 | - 原因:父组件 `handleSearch` 对“相同关键字”进行了拦截,导致路由参数变化不执行请求。 |
| 64 | - 修复:移除拦截逻辑;无论关键字是否变化均触发搜索(防抖控制频率)。 | 64 | - 修复:移除拦截逻辑;无论关键字是否变化均触发搜索(防抖控制频率)。 |
| 65 | - 位置:`/src/views/courses/CourseListPage.vue` 的 `handleSearch`。 | 65 | - 位置:`/src/views/courses/CourseListPage.vue` 的 `handleSearch`。 |
| 66 | + | ||
| 67 | + - 分享海报弹窗(通用组件) | ||
| 68 | + - 入口:课程详情页底部操作栏“分享”按钮。 | ||
| 69 | + - 组件:`/src/components/ui/SharePoster.vue`(支持复用),`v-model:show` 控制显隐,`course` 传入课程信息,`qr_url` 可指定二维码内容地址(默认取当前页面 URL)。 | ||
| 70 | + - 布局:上部封面图;下部信息区左侧二维码,右侧课程标题、副标题、精简介绍与日期范围。 | ||
| 71 | + - 样式:优先使用 TailwindCSS 布局;组件内部使用 Less 做层级嵌套的样式补充。 | ||
| 72 | + - 图片规则:当封面图域名为 `cdn.ipadbiz.cn` 时,自动追加 `?imageMogr2/thumbnail/200x/strip/quality/70` 压缩参数。 | ||
| 73 | + - 接入位置:`/src/views/courses/CourseDetailPage.vue`,导入并渲染 `<SharePoster v-model:show="show_share_poster" :course="course" />`。 | ||
| 74 | + - Canvas 合成:弹窗打开时使用 Canvas 直接合成海报(封面图、二维码、文案),生成 `dataURL` 并以 `<img>` 展示,用户可直接长按图片保存到手机(无需额外按钮)。 | ||
| 75 | + - 依赖:`pnpm add qrcode`(在 Canvas 内本地生成二维码,避免跨域图片导致画布污染)。 | ||
| 76 | + - 跨域:通过 `crossorigin="anonymous"` 加载封面,并追加时间戳防缓存;若封面跨域不允许,则显示降级卡片,仍可长按截图保存。 | ||
| 77 | + - 文案:使用中文字体并自动换行限制行数,末行超出追加省略号。 | ... | ... |
| ... | @@ -34,8 +34,10 @@ | ... | @@ -34,8 +34,10 @@ |
| 34 | "@vue-office/pptx": "^1.0.1", | 34 | "@vue-office/pptx": "^1.0.1", |
| 35 | "browser-md5-file": "^1.1.1", | 35 | "browser-md5-file": "^1.1.1", |
| 36 | "dayjs": "^1.11.13", | 36 | "dayjs": "^1.11.13", |
| 37 | + "html2canvas": "^1.4.1", | ||
| 37 | "lodash": "^4.17.21", | 38 | "lodash": "^4.17.21", |
| 38 | "pdf-vue3": "^1.0.12", | 39 | "pdf-vue3": "^1.0.12", |
| 40 | + "qrcode": "^1.5.4", | ||
| 39 | "swiper": "^11.2.6", | 41 | "swiper": "^11.2.6", |
| 40 | "uuid": "^11.1.0", | 42 | "uuid": "^11.1.0", |
| 41 | "vant": "^4.9.19", | 43 | "vant": "^4.9.19", | ... | ... |
pnpm-lock.yaml
0 → 100644
This diff could not be displayed because it is too large.
| ... | @@ -31,6 +31,7 @@ declare module 'vue' { | ... | @@ -31,6 +31,7 @@ declare module 'vue' { |
| 31 | RouterLink: typeof import('vue-router')['RouterLink'] | 31 | RouterLink: typeof import('vue-router')['RouterLink'] |
| 32 | RouterView: typeof import('vue-router')['RouterView'] | 32 | RouterView: typeof import('vue-router')['RouterView'] |
| 33 | SearchBar: typeof import('./components/ui/SearchBar.vue')['default'] | 33 | SearchBar: typeof import('./components/ui/SearchBar.vue')['default'] |
| 34 | + SharePoster: typeof import('./components/ui/SharePoster.vue')['default'] | ||
| 34 | SummerCampCard: typeof import('./components/ui/SummerCampCard.vue')['default'] | 35 | SummerCampCard: typeof import('./components/ui/SummerCampCard.vue')['default'] |
| 35 | TaskCalendar: typeof import('./components/ui/TaskCalendar.vue')['default'] | 36 | TaskCalendar: typeof import('./components/ui/TaskCalendar.vue')['default'] |
| 36 | TermsPopup: typeof import('./components/ui/TermsPopup.vue')['default'] | 37 | TermsPopup: typeof import('./components/ui/TermsPopup.vue')['default'] | ... | ... |
src/components/ui/SharePoster.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <!-- 弹窗容器:展示分享海报 --> | ||
| 3 | + <van-popup | ||
| 4 | + v-model:show="show_proxy" | ||
| 5 | + round | ||
| 6 | + position="bottom" | ||
| 7 | + :style="{ width: '100%' }" | ||
| 8 | + > | ||
| 9 | + <div class="PosterWrapper p-4"> | ||
| 10 | + <!-- 标题与关闭按钮 --> | ||
| 11 | + <div class="flex justify-between items-center mb-3"> | ||
| 12 | + <h3 class="font-medium">分享海报</h3> | ||
| 13 | + <van-icon name="cross" @click="close" /> | ||
| 14 | + </div> | ||
| 15 | + | ||
| 16 | + <!-- 海报区域:直接使用 Canvas 合成的图片,支持长按保存 --> | ||
| 17 | + <div class="PosterCard bg-white rounded-xl shadow-md overflow-hidden mx-auto" ref="card_ref"> | ||
| 18 | + <img v-if="poster_img_src" :src="poster_img_src" alt="分享海报" class="w-full h-auto object-contain block" /> | ||
| 19 | + <!-- 生成失败或尚未生成时的降级展示(可长按截图保存) --> | ||
| 20 | + <div v-else> | ||
| 21 | + <!-- 上部封面图 --> | ||
| 22 | + <div class="PosterCover"> | ||
| 23 | + <img :src="cover_src" alt="课程封面" class="w-full h-full object-cover" crossorigin="anonymous" /> | ||
| 24 | + </div> | ||
| 25 | + <!-- 下部信息区:左二维码 + 右文案 --> | ||
| 26 | + <div class="PosterInfo p-4"> | ||
| 27 | + <div class="flex items-start"> | ||
| 28 | + <!-- 左侧二维码 --> | ||
| 29 | + <div class="PosterQR mr-4"> | ||
| 30 | + <img :src="qr_src" alt="课程二维码" class="w-24 h-24 rounded" crossorigin="anonymous" /> | ||
| 31 | + </div> | ||
| 32 | + <!-- 右侧文案 --> | ||
| 33 | + <div class="flex-1"> | ||
| 34 | + <div class="text-base font-semibold text-gray-800 truncate">{{ title_text }}</div> | ||
| 35 | + <div class="text-xs text-gray-500 mt-1 truncate" v-if="subtitle_text">{{ subtitle_text }}</div> | ||
| 36 | + <div class="text-sm text-gray-700 mt-2 leading-6 line-clamp-3" v-if="intro_text">{{ intro_text }}</div> | ||
| 37 | + <div class="text-xs text-gray-400 mt-2" v-if="date_range_text">{{ date_range_text }}</div> | ||
| 38 | + </div> | ||
| 39 | + </div> | ||
| 40 | + </div> | ||
| 41 | + </div> | ||
| 42 | + </div> | ||
| 43 | + | ||
| 44 | + <!-- 底部提示与关闭按钮 --> | ||
| 45 | + <div class="mt-3 text-center text-gray-500 text-xs">长按图片保存至手机</div> | ||
| 46 | + <div class="mt-4"> | ||
| 47 | + <button class="w-full bg-white border border-green-500 text-green-600 py-2 rounded-lg" @click="close">关闭</button> | ||
| 48 | + </div> | ||
| 49 | + </div> | ||
| 50 | + </van-popup> | ||
| 51 | + <van-back-top right="5vw" bottom="25vh" offset="600" /> | ||
| 52 | +</template> | ||
| 53 | + | ||
| 54 | +<script setup> | ||
| 55 | +import { ref, computed, watch, nextTick } from 'vue' | ||
| 56 | +import QRCode from 'qrcode' | ||
| 57 | +import { showToast } from 'vant' | ||
| 58 | + | ||
| 59 | +/** | ||
| 60 | + * @typedef {Object} CourseLike | ||
| 61 | + * @property {string} [title] 课程标题 | ||
| 62 | + * @property {string} [subtitle] 课程副标题 | ||
| 63 | + * @property {string} [cover] 课程封面地址 | ||
| 64 | + * @property {string} [introduce] 课程介绍(可包含HTML) | ||
| 65 | + * @property {string} [start_at] 开始日期(可选) | ||
| 66 | + * @property {string} [end_at] 结束日期(可选) | ||
| 67 | + */ | ||
| 68 | + | ||
| 69 | +/** | ||
| 70 | + * @function normalize_image_url | ||
| 71 | + * @description 若图片域名为 `cdn.ipadbiz.cn`,追加压缩参数 `?imageMogr2/thumbnail/200x/strip/quality/70`。 | ||
| 72 | + * @param {string} src 原始图片地址 | ||
| 73 | + * @returns {string} 处理后的图片地址 | ||
| 74 | + */ | ||
| 75 | +function normalize_image_url(src) { | ||
| 76 | + const url = src || '' | ||
| 77 | + if (!url) return '' | ||
| 78 | + try { | ||
| 79 | + const u = new URL(url, window.location.origin) | ||
| 80 | + if (u.hostname === 'cdn.ipadbiz.cn' && !u.search) { | ||
| 81 | + return `${url}?imageMogr2/thumbnail/200x/strip/quality/70` | ||
| 82 | + } | ||
| 83 | + } catch (e) { | ||
| 84 | + // 非绝对路径或无法解析的场景,直接返回原值 | ||
| 85 | + } | ||
| 86 | + return url | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +const props = defineProps({ | ||
| 90 | + /** 弹窗显隐(v-model:show) */ | ||
| 91 | + show: { type: Boolean, default: false }, | ||
| 92 | + /** 课程对象(动态生成海报所需信息) */ | ||
| 93 | + course: { type: Object, default: () => ({}) }, | ||
| 94 | + /** 二维码跳转地址(默认当前页面URL) */ | ||
| 95 | + qr_url: { type: String, default: '' } | ||
| 96 | +}) | ||
| 97 | + | ||
| 98 | +const emit = defineEmits(['update:show']) | ||
| 99 | + | ||
| 100 | +/** | ||
| 101 | + * @function close | ||
| 102 | + * @description 关闭弹窗 | ||
| 103 | + * @returns {void} | ||
| 104 | + */ | ||
| 105 | +const close = () => { | ||
| 106 | + emit('update:show', false) | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +/** | ||
| 110 | + * @function show_proxy | ||
| 111 | + * @description 将 `props.show` 映射为可写的计算属性以支持 v-model:show | ||
| 112 | + */ | ||
| 113 | +const show_proxy = computed({ | ||
| 114 | + get() { return props.show }, | ||
| 115 | + set(v) { emit('update:show', v) } | ||
| 116 | +}) | ||
| 117 | + | ||
| 118 | +/** | ||
| 119 | + * @var {import('vue').Ref<HTMLElement|null>} card_ref | ||
| 120 | + * @description 海报卡片容器引用,用于动态获取容器宽度以计算 Canvas 尺寸 | ||
| 121 | + */ | ||
| 122 | +const card_ref = ref(null) | ||
| 123 | + | ||
| 124 | +/** 标题/副标题/介绍 */ | ||
| 125 | +const title_text = computed(() => props.course?.title || '课程') | ||
| 126 | +const subtitle_text = computed(() => props.course?.subtitle || '') | ||
| 127 | + | ||
| 128 | +/** | ||
| 129 | + * @function strip_html | ||
| 130 | + * @description 将富文本介绍转换为纯文本并截断展示。 | ||
| 131 | + * @param {string} html 原始HTML | ||
| 132 | + * @returns {string} 纯文本 | ||
| 133 | + */ | ||
| 134 | +function strip_html(html) { | ||
| 135 | + return (html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +const intro_text = computed(() => { | ||
| 139 | + const raw = props.course?.introduce || '' | ||
| 140 | + const text = strip_html(raw) | ||
| 141 | + return text | ||
| 142 | +}) | ||
| 143 | + | ||
| 144 | +/** 日期范围(若有) */ | ||
| 145 | +const date_range_text = computed(() => { | ||
| 146 | + const s = props.course?.start_at || '' | ||
| 147 | + const e = props.course?.end_at || '' | ||
| 148 | + if (s && e) return `${s} 至 ${e}` | ||
| 149 | + return '' | ||
| 150 | +}) | ||
| 151 | + | ||
| 152 | +/** 封面图地址(含CDN压缩规则) */ | ||
| 153 | +const cover_src = computed(() => normalize_image_url(props.course?.cover || '')) | ||
| 154 | + | ||
| 155 | +/** 二维码图片(使用在线服务生成) */ | ||
| 156 | +const qr_src = computed(() => { | ||
| 157 | + const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '') | ||
| 158 | + const size = '180x180' | ||
| 159 | + return `https://api.qrserver.com/v1/create-qr-code/?size=${size}&data=${encodeURIComponent(url)}` | ||
| 160 | +}) | ||
| 161 | + | ||
| 162 | +// 海报图片 dataURL(用于长按保存) | ||
| 163 | +const poster_img_src = ref('') | ||
| 164 | + | ||
| 165 | +/** | ||
| 166 | + * @function load_image | ||
| 167 | + * @description 以匿名跨域方式加载图片并追加时间戳防缓存;失败返回 null。 | ||
| 168 | + * @param {string} src 图片地址 | ||
| 169 | + * @returns {Promise<HTMLImageElement|null>} 加载成功的图片对象或 null | ||
| 170 | + */ | ||
| 171 | +function load_image(src) { | ||
| 172 | + return new Promise((resolve) => { | ||
| 173 | + if (!src) return resolve(null) | ||
| 174 | + const img = new Image() | ||
| 175 | + img.crossOrigin = 'anonymous' | ||
| 176 | + const with_ts = src + (src.includes('?') ? '&' : '?') + 'v=' + Date.now() | ||
| 177 | + img.onload = () => resolve(img) | ||
| 178 | + img.onerror = () => resolve(null) | ||
| 179 | + img.src = with_ts | ||
| 180 | + }) | ||
| 181 | +} | ||
| 182 | + | ||
| 183 | +/** | ||
| 184 | + * @function wrap_text | ||
| 185 | + * @description 按最大宽度自动换行并限制最大行数;溢出末行追加省略号。 | ||
| 186 | + * @param {CanvasRenderingContext2D} ctx 画布上下文 | ||
| 187 | + * @param {string} text 文本内容 | ||
| 188 | + * @param {number} max_w 最大宽度 | ||
| 189 | + * @param {string} font 字体样式(如 'bold 32px sans-serif') | ||
| 190 | + * @param {number} line_h 行高 | ||
| 191 | + * @param {number} max_lines 最大行数 | ||
| 192 | + * @returns {string[]} 处理后的行数组 | ||
| 193 | + */ | ||
| 194 | +function wrap_text(ctx, text, max_w, font, line_h, max_lines) { | ||
| 195 | + ctx.font = font | ||
| 196 | + const content = (text || '').trim() | ||
| 197 | + if (!content) return [] | ||
| 198 | + const lines = [] | ||
| 199 | + let cur = '' | ||
| 200 | + const tokens = Array.from(content) | ||
| 201 | + tokens.forEach(ch => { | ||
| 202 | + const test = cur + ch | ||
| 203 | + if (ctx.measureText(test).width <= max_w) { | ||
| 204 | + cur = test | ||
| 205 | + } else { | ||
| 206 | + lines.push(cur) | ||
| 207 | + cur = ch | ||
| 208 | + } | ||
| 209 | + }) | ||
| 210 | + if (cur) lines.push(cur) | ||
| 211 | + if (lines.length > max_lines) { | ||
| 212 | + const truncated = lines.slice(0, max_lines) | ||
| 213 | + const last = truncated[truncated.length - 1] | ||
| 214 | + truncated[truncated.length - 1] = (last || '').slice(0, Math.max(0, (last || '').length - 1)) + '…' | ||
| 215 | + return truncated | ||
| 216 | + } | ||
| 217 | + return lines | ||
| 218 | +} | ||
| 219 | + | ||
| 220 | +/** | ||
| 221 | + * @function compose_poster | ||
| 222 | + * @description 以 Canvas 合成海报:上部封面、左侧二维码、右侧文本信息;生成 dataURL 供长按保存。 | ||
| 223 | + * @returns {Promise<void>} | ||
| 224 | + */ | ||
| 225 | +/** | ||
| 226 | + * @function compose_poster | ||
| 227 | + * @description 以 Canvas 合成海报:根据容器宽度动态计算尺寸;封面高度为底部信息区的 3 倍(整体 3:1 比例),生成 dataURL 供长按保存。 | ||
| 228 | + * @returns {Promise<void>} | ||
| 229 | + */ | ||
| 230 | +async function compose_poster() { | ||
| 231 | + poster_img_src.value = '' | ||
| 232 | + try { | ||
| 233 | + // 固定设计宽度与纵横比,避免因容器导致放大模糊 | ||
| 234 | + const width = 750 | ||
| 235 | + const aspect_ratio = 1.6 // 高度 = 宽度 * 1.6(稳定的长方形比例) | ||
| 236 | + const height = Math.round(width * aspect_ratio) | ||
| 237 | + const cover_h = Math.round(height * 3 / 4) | ||
| 238 | + const info_h = height - cover_h | ||
| 239 | + const padding = 32 | ||
| 240 | + const info_body_h = info_h - padding * 2 | ||
| 241 | + const qr_size = 196 | ||
| 242 | + | ||
| 243 | + const title_font = 'bold 32px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' | ||
| 244 | + const subtitle_font = 'normal 22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' | ||
| 245 | + const intro_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' | ||
| 246 | + const date_font = 'normal 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' | ||
| 247 | + | ||
| 248 | + const measurer = document.createElement('canvas') | ||
| 249 | + const mctx = measurer.getContext('2d') | ||
| 250 | + const text_max_w = width - padding * 2 - qr_size - 20 | ||
| 251 | + | ||
| 252 | + // 先测量固定行数的标题/副标题/日期 | ||
| 253 | + const title_lines = wrap_text(mctx, title_text.value, text_max_w, title_font, 40, 1) | ||
| 254 | + const subtitle_lines = subtitle_text.value ? wrap_text(mctx, subtitle_text.value, text_max_w, subtitle_font, 30, 1) : [] | ||
| 255 | + const date_lines = date_range_text.value ? wrap_text(mctx, date_range_text.value, text_max_w, date_font, 28, 1) : [] | ||
| 256 | + | ||
| 257 | + const reserved_h = (title_lines.length ? title_lines.length * 40 : 0) | ||
| 258 | + + (subtitle_lines.length ? 30 : 0) | ||
| 259 | + + (date_lines.length ? 28 + 8 : 0) | ||
| 260 | + | ||
| 261 | + // 动态计算介绍文本的最大行数以适配信息区高度 | ||
| 262 | + const intro_line_h = 34 | ||
| 263 | + const intro_space = Math.max(0, info_body_h - reserved_h - 8) | ||
| 264 | + const max_intro_lines = Math.max(0, Math.floor(intro_space / intro_line_h)) | ||
| 265 | + 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)) : [] | ||
| 266 | + const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)) | ||
| 267 | + const canvas = document.createElement('canvas') | ||
| 268 | + canvas.width = Math.round(width * dpr) | ||
| 269 | + canvas.height = Math.round(height * dpr) | ||
| 270 | + const ctx = canvas.getContext('2d') | ||
| 271 | + ctx.scale(dpr, dpr) | ||
| 272 | + | ||
| 273 | + // 背景 | ||
| 274 | + ctx.fillStyle = '#ffffff' | ||
| 275 | + ctx.fillRect(0, 0, width, height) | ||
| 276 | + | ||
| 277 | + // 封面图(对象填充) | ||
| 278 | + const cover_img = await load_image(cover_src.value) | ||
| 279 | + if (cover_img) { | ||
| 280 | + const scale = Math.max(width / cover_img.width, cover_h / cover_img.height) | ||
| 281 | + const dw = cover_img.width * scale | ||
| 282 | + const dh = cover_img.height * scale | ||
| 283 | + const dx = (width - dw) / 2 | ||
| 284 | + const dy = (cover_h - dh) / 2 | ||
| 285 | + ctx.drawImage(cover_img, dx, dy, dw, dh) | ||
| 286 | + } else { | ||
| 287 | + ctx.fillStyle = '#f3f4f6' // gray-100 | ||
| 288 | + ctx.fillRect(0, 0, width, cover_h) | ||
| 289 | + ctx.fillStyle = '#9ca3af' // gray-400 | ||
| 290 | + ctx.textAlign = 'center' | ||
| 291 | + ctx.font = 'normal 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' | ||
| 292 | + ctx.fillText('封面加载失败', width / 2, cover_h / 2 + 10) | ||
| 293 | + ctx.textAlign = 'left' | ||
| 294 | + } | ||
| 295 | + | ||
| 296 | + // 二维码(本地生成避免跨域) | ||
| 297 | + const qr_canvas = document.createElement('canvas') | ||
| 298 | + qr_canvas.width = Math.round(qr_size * dpr) | ||
| 299 | + qr_canvas.height = Math.round(qr_size * dpr) | ||
| 300 | + const qr_url_val = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '') | ||
| 301 | + try { | ||
| 302 | + await QRCode.toCanvas(qr_canvas, qr_url_val, { width: Math.round(qr_size * dpr), margin: Math.round(2 * dpr), color: { dark: '#000000', light: '#ffffff' } }) | ||
| 303 | + } catch (e) { | ||
| 304 | + const qctx = qr_canvas.getContext('2d') | ||
| 305 | + qctx.fillStyle = '#ffffff' | ||
| 306 | + qctx.fillRect(0, 0, Math.round(qr_size * dpr), Math.round(qr_size * dpr)) | ||
| 307 | + qctx.strokeStyle = '#e5e7eb' | ||
| 308 | + qctx.strokeRect(0, 0, Math.round(qr_size * dpr), Math.round(qr_size * dpr)) | ||
| 309 | + qctx.fillStyle = '#9ca3af' | ||
| 310 | + qctx.font = `${Math.round(16 * dpr)}px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"` | ||
| 311 | + qctx.fillText('二维码生成失败', Math.round(12 * dpr), Math.round((qr_size * dpr) / 2)) | ||
| 312 | + } | ||
| 313 | + ctx.drawImage(qr_canvas, padding, cover_h + padding, qr_size, qr_size) | ||
| 314 | + | ||
| 315 | + // 文案(右侧) | ||
| 316 | + let tx = padding + qr_size + 20 | ||
| 317 | + let ty = cover_h + padding | ||
| 318 | + ctx.fillStyle = '#1f2937' // gray-800 | ||
| 319 | + ctx.font = title_font | ||
| 320 | + title_lines.forEach(line => { ctx.fillText(line, tx, ty + 30); ty += 40 }) | ||
| 321 | + | ||
| 322 | + ctx.fillStyle = '#6b7280' // gray-500 | ||
| 323 | + ctx.font = subtitle_font | ||
| 324 | + subtitle_lines.forEach(line => { ctx.fillText(line, tx, ty + 18); ty += 30 }) | ||
| 325 | + | ||
| 326 | + ctx.fillStyle = '#374151' // gray-700 | ||
| 327 | + ctx.font = intro_font | ||
| 328 | + intro_lines.forEach(line => { ctx.fillText(line, tx, ty + 22); ty += 34 }) | ||
| 329 | + | ||
| 330 | + ctx.fillStyle = '#9ca3af' // gray-400 | ||
| 331 | + ctx.font = date_font | ||
| 332 | + date_lines.forEach(line => { ctx.fillText(line, tx, ty + 16); ty += 28 }) | ||
| 333 | + | ||
| 334 | + // 生成 dataURL | ||
| 335 | + try { | ||
| 336 | + const data_url = canvas.toDataURL('image/png') | ||
| 337 | + poster_img_src.value = data_url | ||
| 338 | + } catch (e) { | ||
| 339 | + console.error('海报生成失败(跨域):', e) | ||
| 340 | + showToast('海报生成失败,已展示标准卡片,请长按保存截图') | ||
| 341 | + poster_img_src.value = '' | ||
| 342 | + } | ||
| 343 | + } catch (err) { | ||
| 344 | + console.error('compose_poster 异常:', err) | ||
| 345 | + showToast('海报生成失败,请稍后重试') | ||
| 346 | + poster_img_src.value = '' | ||
| 347 | + } | ||
| 348 | +} | ||
| 349 | + | ||
| 350 | +// 弹窗打开时自动生成海报图片 | ||
| 351 | +watch(show_proxy, (v) => { | ||
| 352 | + if (v) { | ||
| 353 | + nextTick(() => compose_poster()) | ||
| 354 | + } else { | ||
| 355 | + poster_img_src.value = '' | ||
| 356 | + } | ||
| 357 | +}) | ||
| 358 | +</script> | ||
| 359 | + | ||
| 360 | +<style lang="less" scoped> | ||
| 361 | +.PosterWrapper { | ||
| 362 | + height: auto; | ||
| 363 | + display: flex; | ||
| 364 | + flex-direction: column; | ||
| 365 | + .PosterCard { | ||
| 366 | + width: 100%; | ||
| 367 | + max-width: 750px; | ||
| 368 | + height: auto; | ||
| 369 | + margin: 0 auto; | ||
| 370 | + display: block; | ||
| 371 | + > img { | ||
| 372 | + width: 100%; | ||
| 373 | + height: auto; | ||
| 374 | + object-fit: contain; | ||
| 375 | + display: block; | ||
| 376 | + } | ||
| 377 | + .PosterInfo { | ||
| 378 | + overflow: hidden; | ||
| 379 | + .PosterQR { | ||
| 380 | + img { | ||
| 381 | + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); | ||
| 382 | + } | ||
| 383 | + } | ||
| 384 | + } | ||
| 385 | + } | ||
| 386 | +} | ||
| 387 | +</style> |
| ... | @@ -197,7 +197,16 @@ | ... | @@ -197,7 +197,16 @@ |
| 197 | <!-- Bottom Action Bar --> | 197 | <!-- Bottom Action Bar --> |
| 198 | <div class="fixed bottom-16 left-0 right-0 bg-white shadow-lg p-3 flex justify-between items-center"> | 198 | <div class="fixed bottom-16 left-0 right-0 bg-white shadow-lg p-3 flex justify-between items-center"> |
| 199 | <div class="flex space-x-4"> | 199 | <div class="flex space-x-4"> |
| 200 | - <!-- <button class="flex flex-col items-center text-gray-500 text-xs"> | 200 | + <button class="flex flex-col items-center text-gray-500 text-xs transition-transform duration-300" |
| 201 | + @click="toggleFavorite" :class="{ 'animate-favorite': isFavorite }"> | ||
| 202 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 transition-transform duration-300" | ||
| 203 | + :fill="isFavorite ? 'red' : 'none'" viewBox="0 0 24 24" :stroke="isFavorite ? 'red' : 'currentColor'"> | ||
| 204 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | ||
| 205 | + 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" /> | ||
| 206 | + </svg> | ||
| 207 | + 收藏 | ||
| 208 | + </button> | ||
| 209 | + <button class="flex flex-col items-center text-gray-500 text-xs" @click="open_consult_dialog"> | ||
| 201 | <svg | 210 | <svg |
| 202 | xmlns="http://www.w3.org/2000/svg" | 211 | xmlns="http://www.w3.org/2000/svg" |
| 203 | class="h-6 w-6" | 212 | class="h-6 w-6" |
| ... | @@ -209,21 +218,12 @@ | ... | @@ -209,21 +218,12 @@ |
| 209 | stroke-linecap="round" | 218 | stroke-linecap="round" |
| 210 | stroke-linejoin="round" | 219 | stroke-linejoin="round" |
| 211 | stroke-width="2" | 220 | stroke-width="2" |
| 212 | - 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" | 221 | + 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" |
| 213 | /> | 222 | /> |
| 214 | </svg> | 223 | </svg> |
| 215 | - 分享 | 224 | + 咨询 |
| 216 | - </button> --> | ||
| 217 | - <button class="flex flex-col items-center text-gray-500 text-xs transition-transform duration-300" | ||
| 218 | - @click="toggleFavorite" :class="{ 'animate-favorite': isFavorite }"> | ||
| 219 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 transition-transform duration-300" | ||
| 220 | - :fill="isFavorite ? 'red' : 'none'" viewBox="0 0 24 24" :stroke="isFavorite ? 'red' : 'currentColor'"> | ||
| 221 | - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | ||
| 222 | - 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" /> | ||
| 223 | - </svg> | ||
| 224 | - 收藏 | ||
| 225 | </button> | 225 | </button> |
| 226 | - <button class="flex flex-col items-center text-gray-500 text-xs" @click="open_consult_dialog"> | 226 | + <button class="flex flex-col items-center text-gray-500 text-xs" @click="open_share_poster"> |
| 227 | <svg | 227 | <svg |
| 228 | xmlns="http://www.w3.org/2000/svg" | 228 | xmlns="http://www.w3.org/2000/svg" |
| 229 | class="h-6 w-6" | 229 | class="h-6 w-6" |
| ... | @@ -235,10 +235,10 @@ | ... | @@ -235,10 +235,10 @@ |
| 235 | stroke-linecap="round" | 235 | stroke-linecap="round" |
| 236 | stroke-linejoin="round" | 236 | stroke-linejoin="round" |
| 237 | stroke-width="2" | 237 | stroke-width="2" |
| 238 | - 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" | 238 | + 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" |
| 239 | /> | 239 | /> |
| 240 | </svg> | 240 | </svg> |
| 241 | - 咨询 | 241 | + 分享 |
| 242 | </button> | 242 | </button> |
| 243 | </div> | 243 | </div> |
| 244 | <div class="flex items-center"> | 244 | <div class="flex items-center"> |
| ... | @@ -364,6 +364,8 @@ | ... | @@ -364,6 +364,8 @@ |
| 364 | </div> | 364 | </div> |
| 365 | </div> | 365 | </div> |
| 366 | </van-popup> | 366 | </van-popup> |
| 367 | + <!-- 分享海报弹窗 --> | ||
| 368 | + <!-- <SharePoster v-if="course" v-model:show="show_share_poster" :course="course" /> --> | ||
| 367 | <van-back-top right="5vw" bottom="25vh" offset="600" /> | 369 | <van-back-top right="5vw" bottom="25vh" offset="600" /> |
| 368 | </AppLayout> | 370 | </AppLayout> |
| 369 | </template> | 371 | </template> |
| ... | @@ -383,6 +385,7 @@ import { sharePage } from '@/composables/useShare.js' | ... | @@ -383,6 +385,7 @@ import { sharePage } from '@/composables/useShare.js' |
| 383 | 385 | ||
| 384 | import AppLayout from '@/components/layout/AppLayout.vue' | 386 | import AppLayout from '@/components/layout/AppLayout.vue' |
| 385 | import FrostedGlass from '@/components/ui/FrostedGlass.vue' | 387 | import FrostedGlass from '@/components/ui/FrostedGlass.vue' |
| 388 | +import SharePoster from '@/components/ui/SharePoster.vue' | ||
| 386 | 389 | ||
| 387 | // 导入接口 | 390 | // 导入接口 |
| 388 | import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from "@/api/course"; | 391 | import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from "@/api/course"; |
| ... | @@ -507,6 +510,22 @@ const isPurchased = ref(false) | ... | @@ -507,6 +510,22 @@ const isPurchased = ref(false) |
| 507 | const isReviewed = ref(false) | 510 | const isReviewed = ref(false) |
| 508 | const showReviewPopup = ref(false) | 511 | const showReviewPopup = ref(false) |
| 509 | 512 | ||
| 513 | +// 分享海报弹窗状态 | ||
| 514 | +/** | ||
| 515 | + * @type {import('vue').Ref<boolean>} | ||
| 516 | + * 展示分享海报弹窗的显隐状态 | ||
| 517 | + */ | ||
| 518 | +const show_share_poster = ref(false) | ||
| 519 | + | ||
| 520 | +/** | ||
| 521 | + * @function open_share_poster | ||
| 522 | + * @description 打开分享海报弹窗 | ||
| 523 | + * @returns {void} | ||
| 524 | + */ | ||
| 525 | +const open_share_poster = () => { | ||
| 526 | + show_share_poster.value = true | ||
| 527 | +} | ||
| 528 | + | ||
| 510 | // 处理富文本点击事件,实现图片预览 | 529 | // 处理富文本点击事件,实现图片预览 |
| 511 | const handleIntroduceClick = (event) => { | 530 | const handleIntroduceClick = (event) => { |
| 512 | const target = event.target; | 531 | const target = event.target; | ... | ... |
-
Please register or login to post a comment