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']
......
This diff is collapsed. Click to expand it.
...@@ -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;
......