hookehuyr

feat(课程详情页): 实现动态 Open Graph 元标签功能

替换原有的动态 favicon 功能,改为在课程详情页动态设置 Open Graph 元标签
优化微信分享功能,添加图片地址规范化处理
...@@ -25,11 +25,11 @@ https://oa-dev.onwall.cn/f/mlaj ...@@ -25,11 +25,11 @@ https://oa-dev.onwall.cn/f/mlaj
25 - 登录页:微信图标绑定点击事件,非微信环境提示“请在微信内打开”。 25 - 登录页:微信图标绑定点击事件,非微信环境提示“请在微信内打开”。
26 - 使用方式:进入登录页,点击微信图标进行授权登录。 26 - 使用方式:进入登录页,点击微信图标进行授权登录。
27 27
28 - - 课程详情页动态 favicon 28 + - 课程详情页动态 Open Graph 元标签
29 - - 行为:进入课程详情页时,在 `index.html``<head>` 中插入 `<link rel="icon" type="image/svg+xml" href="..." />`,图标为该课程 `cover`;离开页面时移除。 29 + - 行为:进入课程详情页时,在 `<head>` 中插入 4 个 `meta` 标签:`og:title`(课程 `title`)、`og:description`(课程 `subtitle`)、`og:image`(课程 `cover`)、`og:url`(当前页面 URL);这些 `meta` 必须插在 `<title>` 标签的前面;离开页面时移除。
30 - CDN 规则:若图片域名为 `cdn.ipadbiz.cn`,自动追加 `?imageMogr2/thumbnail/200x/strip/quality/70` 30 - CDN 规则:若图片域名为 `cdn.ipadbiz.cn`,自动追加 `?imageMogr2/thumbnail/200x/strip/quality/70`
31 - - 位置:`/src/views/courses/CourseDetailPage.vue`使用生命周期与 `watch` 监听封面变化来设置与清理。 31 + - 位置:`/src/views/courses/CourseDetailPage.vue``onMounted` 插入,`onUnmounted` 清理。
32 - - 函数:`build_favicon_url(src)``set_favicon(href)``remove_favicon()` 32 + - 函数:`build_og_image_url(src)``set_og_meta(payload)``remove_og_meta()`
33 33
34 - 401拦截策略优化(公开页面不再跳登录) 34 - 401拦截策略优化(公开页面不再跳登录)
35 - 行为:接口返回 `code=401` 时,不再对公开页面(如课程详情 `/courses/:id`)进行登录重定向;仅当当前路由确实需要登录权限时才跳转至登录页。 35 - 行为:接口返回 `code=401` 时,不再对公开页面(如课程详情 `/courses/:id`)进行登录重定向;仅当当前路由确实需要登录权限时才跳转至登录页。
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
4 <meta charset="UTF-8" /> 4 <meta charset="UTF-8" />
5 <!-- <link rel="icon" type="image/svg+xml" href="/vite.png" /> --> 5 <!-- <link rel="icon" type="image/svg+xml" href="/vite.png" /> -->
6 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover" /> 6 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover" />
7 + <meta property="og:description" content="副标题/描述内容">
7 <title>美乐爱觉</title> 8 <title>美乐爱觉</title>
8 </head> 9 </head>
9 <body> 10 <body>
......
...@@ -8,33 +8,63 @@ ...@@ -8,33 +8,63 @@
8 import wx from 'weixin-js-sdk'; 8 import wx from 'weixin-js-sdk';
9 9
10 /** 10 /**
11 + * @function normalize_image_url
12 + * @description 规范化分享图片地址;若域名为 cdn.ipadbiz.cn,追加压缩参数。
13 + * @param {string} src 原始图片地址
14 + * @returns {string} 处理后的图片地址
15 + */
16 +function normalize_image_url(src) {
17 + if (!src) return '';
18 + if (src.includes('cdn.ipadbiz.cn')) {
19 + const compress = 'imageMogr2/thumbnail/200x/strip/quality/70';
20 + if (src.includes('?')) {
21 + return src.includes(compress) ? src : `${src}&${compress}`;
22 + }
23 + return `${src}?${compress}`;
24 + }
25 + return src;
26 +}
27 +
28 +/**
11 * @description: 微信分享功能 29 * @description: 微信分享功能
12 * @param {*} title 标题 30 * @param {*} title 标题
13 * @param {*} desc 描述 31 * @param {*} desc 描述
14 * @param {*} imgUrl 图标 32 * @param {*} imgUrl 图标
15 * @return {*} 33 * @return {*}
16 */ 34 */
17 -export const sharePage = ({title = '美乐爱觉', desc = '', imgUrl = ''}) => { 35 +/**
18 - const shareData = { 36 + * @function sharePage
19 - title, // 分享标题 37 + * @description 配置微信分享标题、描述与图标;需在 wx.ready 后调用才会生效。
20 - desc, // 分享描述 38 + * @param {Object} params 分享参数
21 - link: location.origin + location.pathname + location.hash, // 分享链接,该链接域名或路径必须与当前页面对应的公众号 JS 安全域名一致 39 + * @param {string} params.title 分享标题
22 - imgUrl, // 分享图标 40 + * @param {string} params.desc 分享描述
23 - success: function () { 41 + * @param {string} params.imgUrl 分享图标地址
24 - // console.warn('设置成功'); 42 + * @returns {void}
43 + */
44 +export const sharePage = ({ title = '美乐爱觉', desc = '', imgUrl = '' }) => {
45 + const shareData = {
46 + title, // 分享标题
47 + desc, // 分享描述
48 + link: location.origin + location.pathname + location.hash, // 分享链接,需与公众号 JS 安全域名一致
49 + imgUrl: normalize_image_url(imgUrl), // 分享图标,按规则追加压缩参数
50 + success: function () {
51 + // 设置成功回调
52 + }
25 } 53 }
26 - }
27 - // 分享好友(微信好友或qq好友)
28 - wx.updateAppMessageShareData(shareData);
29 - // 分享到朋友圈或qq空间
30 - wx.updateTimelineShareData(shareData);
31 - // 分享到腾讯微博
32 - wx.onMenuShareWeibo(shareData);
33 54
34 - // // 获取“分享给朋友”按钮点击状态及自定义分享内容接口(即将废弃) 55 + if (wx && typeof wx.ready === 'function') {
35 - // wx.onMenuShareAppMessage(shareData); 56 + wx.ready(() => {
36 - // // 获取“分享到朋友圈”按钮点击状态及自定义分享内容接口(即将废弃) 57 + // 分享好友(微信好友或qq好友)
37 - // wx.onMenuShareTimeline(shareData); 58 + wx.updateAppMessageShareData(shareData);
38 - // // 获取“分享到QQ”按钮点击状态及自定义分享内容接口(即将废弃) 59 + // 分享到朋友圈或qq空间
39 - // wx.onMenuShareQQ(shareData); 60 + wx.updateTimelineShareData(shareData);
61 + // 分享到腾讯微博
62 + if (typeof wx.onMenuShareWeibo === 'function') {
63 + wx.onMenuShareWeibo(shareData);
64 + }
65 + });
66 + } else {
67 + // 微信 JSSDK 未初始化或未就绪,分享配置可能不会生效
68 + console.warn('微信 JSSDK 未就绪:分享配置可能未生效');
69 + }
40 } 70 }
......
...@@ -332,7 +332,7 @@ ...@@ -332,7 +332,7 @@
332 </template> 332 </template>
333 333
334 <script setup lang="jsx"> 334 <script setup lang="jsx">
335 -import { ref, onMounted, onUnmounted, defineComponent, h, watch } from 'vue' 335 +import { ref, onMounted, onUnmounted, defineComponent, h } from 'vue'
336 import { useRoute, useRouter } from 'vue-router' 336 import { useRoute, useRouter } from 'vue-router'
337 import { useCart } from '@/contexts/cart' 337 import { useCart } from '@/contexts/cart'
338 import { useAuth } from '@/contexts/auth' 338 import { useAuth } from '@/contexts/auth'
...@@ -349,13 +349,19 @@ import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from " ...@@ -349,13 +349,19 @@ import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from "
349 import { addFavoriteAPI, cancelFavoriteAPI } from "@/api/favorite"; 349 import { addFavoriteAPI, cancelFavoriteAPI } from "@/api/favorite";
350 import { checkinTaskAPI } from '@/api/checkin'; 350 import { checkinTaskAPI } from '@/api/checkin';
351 351
352 +//
353 +// Open Graph 元标签:进入课程详情页时动态插入,离开页面时移除
354 +//
355 +// 原始 og:description 内容缓存(用于离开页面时恢复)
356 +let original_og_desc_content = null;
357 +
352 /** 358 /**
353 - * @function build_favicon_url 359 + * @function build_og_image_url
354 - * @description 构建 favicon 链接地址;若为 cdn.ipadbiz.cn 域名,则追加图片压缩参数。 360 + * @description 构建 og:image 地址;若为 cdn.ipadbiz.cn 域名,则追加图片压缩参数。
355 * @param {string} src 原始图片地址 361 * @param {string} src 原始图片地址
356 * @returns {string} 处理后的图片地址 362 * @returns {string} 处理后的图片地址
357 */ 363 */
358 -function build_favicon_url(src) { 364 +function build_og_image_url(src) {
359 // 若无地址,直接返回空字符串 365 // 若无地址,直接返回空字符串
360 if (!src) return ''; 366 if (!src) return '';
361 // 若为指定 CDN 域名,追加压缩参数(遵循项目图片规则) 367 // 若为指定 CDN 域名,追加压缩参数(遵循项目图片规则)
...@@ -373,64 +379,74 @@ function build_favicon_url(src) { ...@@ -373,64 +379,74 @@ function build_favicon_url(src) {
373 } 379 }
374 380
375 /** 381 /**
376 - * @function set_favicon 382 + * @function set_og_meta
377 - * @description 在页面 head 内动态插入或更新 favicon 链接标签。 383 + * @description 在页面 head 中插入或更新 4 个 Open Graph 元标签。
378 - * @param {string} href 图片地址(course 封面) 384 + * @param {Object} payload 载荷对象
385 + * @param {string} payload.title 主标题(og:title)
386 + * @param {string} payload.description 副标题/描述(og:description)
387 + * @param {string} payload.image 图片地址(og:image)
388 + * @param {string} payload.url 当前页面URL(og:url)
389 + * @returns {void}
379 */ 390 */
380 -function set_favicon(href) { 391 +function set_og_meta(payload) {
381 const head = document.head || document.getElementsByTagName('head')[0]; 392 const head = document.head || document.getElementsByTagName('head')[0];
382 if (!head) return; 393 if (!head) return;
383 - const icon_id = 'course-favicon'; 394 + const titleEl = head.querySelector('title');
384 - const normalized = build_favicon_url(href); 395 + // 1) 直接修改 index.html 中现有的 og:description(不新建,避免重复)
385 - // 若无可用地址,移除已存在的图标 396 + const descMeta = head.querySelector('meta[property="og:description"]');
386 - if (!normalized) { 397 + if (descMeta) {
387 - remove_favicon(); 398 + // 首次保存原始内容用于恢复
388 - return; 399 + if (original_og_desc_content === null) {
389 - } 400 + original_og_desc_content = descMeta.getAttribute('content') || '';
390 - let link = document.querySelector('link#' + icon_id); 401 + }
391 - if (!link) { 402 + descMeta.setAttribute('content', payload.description || '');
392 - link = document.createElement('link');
393 - link.setAttribute('rel', 'icon');
394 - // 按用户要求使用 image/svg+xml 类型
395 - link.setAttribute('type', 'image/svg+xml');
396 - link.setAttribute('id', icon_id);
397 - head.appendChild(link);
398 } 403 }
399 - link.setAttribute('href', normalized);
400 -}
401 404
402 -/** 405 + // 2) 其余标签按需创建(若不存在则插入到 <title> 前)
403 - * @function remove_favicon 406 + const others = [
404 - * @description 从页面 head 中移除动态插入的课程 favicon。 407 + { id: 'og-title', property: 'og:title', content: payload.title || '' },
405 - */ 408 + { id: 'og-image', property: 'og:image', content: build_og_image_url(payload.image || '') },
406 -function remove_favicon() { 409 + { id: 'og-url', property: 'og:url', content: payload.url || window.location.href }
407 - const icon_id = 'course-favicon'; 410 + ];
408 - const link = document.querySelector('link#' + icon_id); 411 + others.forEach(item => {
409 - if (link && link.parentNode) { 412 + let meta = head.querySelector(`meta#${item.id}`);
410 - link.parentNode.removeChild(link); 413 + if (!meta) {
411 - } 414 + meta = document.createElement('meta');
412 - // 移除后恢复默认图标,避免浏览器继续沿用上一次缓存的图标 415 + meta.setAttribute('id', item.id);
413 - set_default_favicon(); 416 + meta.setAttribute('property', item.property);
417 + if (titleEl) {
418 + head.insertBefore(meta, titleEl);
419 + } else if (head.firstChild) {
420 + head.insertBefore(meta, head.firstChild);
421 + } else {
422 + head.appendChild(meta);
423 + }
424 + }
425 + meta.setAttribute('content', item.content);
426 + });
414 } 427 }
415 428
416 /** 429 /**
417 - * @function set_default_favicon 430 + * @function remove_og_meta
418 - * @description 设置默认 favicon 为 /vite.jpg,并通过时间戳参数避免缓存导致图标不更新。 431 + * @description 从页面 head 中移除进入详情页时插入的 Open Graph 元标签。
432 + * @returns {void}
419 */ 433 */
420 -function set_default_favicon() { 434 +function remove_og_meta() {
435 + // 恢复 index.html 中现有的 og:description 原始内容
421 const head = document.head || document.getElementsByTagName('head')[0]; 436 const head = document.head || document.getElementsByTagName('head')[0];
422 - if (!head) return; 437 + if (head) {
423 - const default_id = 'default-favicon'; 438 + const descMeta = head.querySelector('meta[property="og:description"]');
424 - let link = document.querySelector('link#' + default_id); 439 + if (descMeta && original_og_desc_content !== null) {
425 - if (!link) { 440 + descMeta.setAttribute('content', original_og_desc_content);
426 - link = document.createElement('link'); 441 + }
427 - link.setAttribute('rel', 'icon');
428 - link.setAttribute('type', 'image/svg+xml');
429 - link.setAttribute('id', default_id);
430 - head.appendChild(link);
431 } 442 }
432 - // 加上时间戳,确保浏览器立即刷新图标而非使用旧缓存 443 + // 移除运行时创建的其它 OG 标签
433 - link.setAttribute('href', `/vite.png?_ts=${Date.now()}`); 444 + ['og-title', 'og-image', 'og-url', 'og-description'].forEach(id => {
445 + const meta = document.querySelector(`meta#${id}`);
446 + if (meta && meta.parentNode) {
447 + meta.parentNode.removeChild(meta);
448 + }
449 + });
434 } 450 }
435 451
436 const $route = useRoute(); 452 const $route = useRoute();
...@@ -480,42 +496,6 @@ const checkInSuccess = ref(false) ...@@ -480,42 +496,6 @@ const checkInSuccess = ref(false)
480 496
481 const { addToCart, proceedToCheckout } = useCart() 497 const { addToCart, proceedToCheckout } = useCart()
482 498
483 -//
484 -// 监听课程封面,进入详情页后动态设置 favicon,离开页面时移除
485 -//
486 -/**
487 - * @function init_course_favicon_watch
488 - * @description 初始化对 course 封面变化的监听,封面可用时设置 favicon。
489 - */
490 -const init_course_favicon_watch = () => {
491 - // 监听封面地址变化,立即触发以覆盖初次进入场景
492 - const stop = watch(
493 - () => (course.value && course.value.cover) || '',
494 - (new_cover) => {
495 - if (new_cover) {
496 - // 封面可用时设置 favicon
497 - set_favicon(new_cover);
498 - }
499 - },
500 - { immediate: true }
501 - );
502 - return stop;
503 -};
504 -
505 -// 进入页面时绑定监听;离开时清理并移除 favicon
506 -let stop_watch_cover = null;
507 -onMounted(() => {
508 - stop_watch_cover = init_course_favicon_watch();
509 -});
510 -
511 -onUnmounted(() => {
512 - if (typeof stop_watch_cover === 'function') {
513 - stop_watch_cover();
514 - stop_watch_cover = null;
515 - }
516 - // 离开详情页移除 favicon
517 - remove_favicon();
518 -});
519 499
520 // Handle favorite toggle 500 // Handle favorite toggle
521 // 收藏/取消收藏操作 501 // 收藏/取消收藏操作
...@@ -703,6 +683,14 @@ onMounted(async () => { ...@@ -703,6 +683,14 @@ onMounted(async () => {
703 } 683 }
704 684
705 default_list.value = task_list.value; 685 default_list.value = task_list.value;
686 +
687 + // 进入详情页时写入 Open Graph 元标签,提升分享预览效果
688 + set_og_meta({
689 + title: course.value?.title || '',
690 + description: course.value?.subtitle || '',
691 + image: course.value?.cover || '',
692 + url: window.location.href
693 + });
706 } 694 }
707 } 695 }
708 else { 696 else {
...@@ -725,6 +713,11 @@ onMounted(async () => { ...@@ -725,6 +713,11 @@ onMounted(async () => {
725 }) 713 })
726 }) 714 })
727 715
716 +// 离开页面时清理 Open Graph 元标签
717 +onUnmounted(() => {
718 + remove_og_meta();
719 +})
720 +
728 721
729 const isScheduleExpanded = ref(false) 722 const isScheduleExpanded = ref(false)
730 723
......