hookehuyr

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

- 添加 SharePoster 组件,支持通过 Canvas 合成课程海报
- 在课程详情页底部操作栏添加分享按钮并接入海报弹窗
- 海报包含封面图、二维码及课程信息,支持长按保存
- 添加 html2canvas 和 qrcode 依赖用于海报生成
- 更新 README 文档说明组件使用方式
...@@ -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",
......
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']
......
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;
......