feat(课程详情页): 实现动态 Open Graph 元标签功能
替换原有的动态 favicon 功能,改为在课程详情页动态设置 Open Graph 元标签 优化微信分享功能,添加图片地址规范化处理
Showing
4 changed files
with
136 additions
and
112 deletions
| ... | @@ -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 | ... | ... |
-
Please register or login to post a comment