hookehuyr

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

替换原有的动态 favicon 功能,改为在课程详情页动态设置 Open Graph 元标签
优化微信分享功能,添加图片地址规范化处理
......@@ -25,11 +25,11 @@ https://oa-dev.onwall.cn/f/mlaj
- 登录页:微信图标绑定点击事件,非微信环境提示“请在微信内打开”。
- 使用方式:进入登录页,点击微信图标进行授权登录。
- 课程详情页动态 favicon
- 行为:进入课程详情页时,在 `index.html``<head>` 中插入 `<link rel="icon" type="image/svg+xml" href="..." />`,图标为该课程 `cover`;离开页面时移除。
- 课程详情页动态 Open Graph 元标签
- 行为:进入课程详情页时,在 `<head>` 中插入 4 个 `meta` 标签:`og:title`(课程 `title`)、`og:description`(课程 `subtitle`)、`og:image`(课程 `cover`)、`og:url`(当前页面 URL);这些 `meta` 必须插在 `<title>` 标签的前面;离开页面时移除。
- CDN 规则:若图片域名为 `cdn.ipadbiz.cn`,自动追加 `?imageMogr2/thumbnail/200x/strip/quality/70`
- 位置:`/src/views/courses/CourseDetailPage.vue`使用生命周期与 `watch` 监听封面变化来设置与清理。
- 函数:`build_favicon_url(src)``set_favicon(href)``remove_favicon()`
- 位置:`/src/views/courses/CourseDetailPage.vue``onMounted` 插入,`onUnmounted` 清理。
- 函数:`build_og_image_url(src)``set_og_meta(payload)``remove_og_meta()`
- 401拦截策略优化(公开页面不再跳登录)
- 行为:接口返回 `code=401` 时,不再对公开页面(如课程详情 `/courses/:id`)进行登录重定向;仅当当前路由确实需要登录权限时才跳转至登录页。
......
......@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<!-- <link rel="icon" type="image/svg+xml" href="/vite.png" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover" />
<meta property="og:description" content="副标题/描述内容">
<title>美乐爱觉</title>
</head>
<body>
......
......@@ -8,33 +8,63 @@
import wx from 'weixin-js-sdk';
/**
* @function normalize_image_url
* @description 规范化分享图片地址;若域名为 cdn.ipadbiz.cn,追加压缩参数。
* @param {string} src 原始图片地址
* @returns {string} 处理后的图片地址
*/
function normalize_image_url(src) {
if (!src) return '';
if (src.includes('cdn.ipadbiz.cn')) {
const compress = 'imageMogr2/thumbnail/200x/strip/quality/70';
if (src.includes('?')) {
return src.includes(compress) ? src : `${src}&${compress}`;
}
return `${src}?${compress}`;
}
return src;
}
/**
* @description: 微信分享功能
* @param {*} title 标题
* @param {*} desc 描述
* @param {*} imgUrl 图标
* @return {*}
*/
export const sharePage = ({title = '美乐爱觉', desc = '', imgUrl = ''}) => {
const shareData = {
title, // 分享标题
desc, // 分享描述
link: location.origin + location.pathname + location.hash, // 分享链接,该链接域名或路径必须与当前页面对应的公众号 JS 安全域名一致
imgUrl, // 分享图标
success: function () {
// console.warn('设置成功');
/**
* @function sharePage
* @description 配置微信分享标题、描述与图标;需在 wx.ready 后调用才会生效。
* @param {Object} params 分享参数
* @param {string} params.title 分享标题
* @param {string} params.desc 分享描述
* @param {string} params.imgUrl 分享图标地址
* @returns {void}
*/
export const sharePage = ({ title = '美乐爱觉', desc = '', imgUrl = '' }) => {
const shareData = {
title, // 分享标题
desc, // 分享描述
link: location.origin + location.pathname + location.hash, // 分享链接,需与公众号 JS 安全域名一致
imgUrl: normalize_image_url(imgUrl), // 分享图标,按规则追加压缩参数
success: function () {
// 设置成功回调
}
}
}
// 分享好友(微信好友或qq好友)
wx.updateAppMessageShareData(shareData);
// 分享到朋友圈或qq空间
wx.updateTimelineShareData(shareData);
// 分享到腾讯微博
wx.onMenuShareWeibo(shareData);
// // 获取“分享给朋友”按钮点击状态及自定义分享内容接口(即将废弃)
// wx.onMenuShareAppMessage(shareData);
// // 获取“分享到朋友圈”按钮点击状态及自定义分享内容接口(即将废弃)
// wx.onMenuShareTimeline(shareData);
// // 获取“分享到QQ”按钮点击状态及自定义分享内容接口(即将废弃)
// wx.onMenuShareQQ(shareData);
if (wx && typeof wx.ready === 'function') {
wx.ready(() => {
// 分享好友(微信好友或qq好友)
wx.updateAppMessageShareData(shareData);
// 分享到朋友圈或qq空间
wx.updateTimelineShareData(shareData);
// 分享到腾讯微博
if (typeof wx.onMenuShareWeibo === 'function') {
wx.onMenuShareWeibo(shareData);
}
});
} else {
// 微信 JSSDK 未初始化或未就绪,分享配置可能不会生效
console.warn('微信 JSSDK 未就绪:分享配置可能未生效');
}
}
......
......@@ -332,7 +332,7 @@
</template>
<script setup lang="jsx">
import { ref, onMounted, onUnmounted, defineComponent, h, watch } from 'vue'
import { ref, onMounted, onUnmounted, defineComponent, h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCart } from '@/contexts/cart'
import { useAuth } from '@/contexts/auth'
......@@ -349,13 +349,19 @@ import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from "
import { addFavoriteAPI, cancelFavoriteAPI } from "@/api/favorite";
import { checkinTaskAPI } from '@/api/checkin';
//
// Open Graph 元标签:进入课程详情页时动态插入,离开页面时移除
//
// 原始 og:description 内容缓存(用于离开页面时恢复)
let original_og_desc_content = null;
/**
* @function build_favicon_url
* @description 构建 favicon 链接地址;若为 cdn.ipadbiz.cn 域名,则追加图片压缩参数。
* @function build_og_image_url
* @description 构建 og:image 地址;若为 cdn.ipadbiz.cn 域名,则追加图片压缩参数。
* @param {string} src 原始图片地址
* @returns {string} 处理后的图片地址
*/
function build_favicon_url(src) {
function build_og_image_url(src) {
// 若无地址,直接返回空字符串
if (!src) return '';
// 若为指定 CDN 域名,追加压缩参数(遵循项目图片规则)
......@@ -373,64 +379,74 @@ function build_favicon_url(src) {
}
/**
* @function set_favicon
* @description 在页面 head 内动态插入或更新 favicon 链接标签。
* @param {string} href 图片地址(course 封面)
* @function set_og_meta
* @description 在页面 head 中插入或更新 4 个 Open Graph 元标签。
* @param {Object} payload 载荷对象
* @param {string} payload.title 主标题(og:title)
* @param {string} payload.description 副标题/描述(og:description)
* @param {string} payload.image 图片地址(og:image)
* @param {string} payload.url 当前页面URL(og:url)
* @returns {void}
*/
function set_favicon(href) {
function set_og_meta(payload) {
const head = document.head || document.getElementsByTagName('head')[0];
if (!head) return;
const icon_id = 'course-favicon';
const normalized = build_favicon_url(href);
// 若无可用地址,移除已存在的图标
if (!normalized) {
remove_favicon();
return;
}
let link = document.querySelector('link#' + icon_id);
if (!link) {
link = document.createElement('link');
link.setAttribute('rel', 'icon');
// 按用户要求使用 image/svg+xml 类型
link.setAttribute('type', 'image/svg+xml');
link.setAttribute('id', icon_id);
head.appendChild(link);
const titleEl = head.querySelector('title');
// 1) 直接修改 index.html 中现有的 og:description(不新建,避免重复)
const descMeta = head.querySelector('meta[property="og:description"]');
if (descMeta) {
// 首次保存原始内容用于恢复
if (original_og_desc_content === null) {
original_og_desc_content = descMeta.getAttribute('content') || '';
}
descMeta.setAttribute('content', payload.description || '');
}
link.setAttribute('href', normalized);
}
/**
* @function remove_favicon
* @description 从页面 head 中移除动态插入的课程 favicon。
*/
function remove_favicon() {
const icon_id = 'course-favicon';
const link = document.querySelector('link#' + icon_id);
if (link && link.parentNode) {
link.parentNode.removeChild(link);
}
// 移除后恢复默认图标,避免浏览器继续沿用上一次缓存的图标
set_default_favicon();
// 2) 其余标签按需创建(若不存在则插入到 <title> 前)
const others = [
{ id: 'og-title', property: 'og:title', content: payload.title || '' },
{ id: 'og-image', property: 'og:image', content: build_og_image_url(payload.image || '') },
{ id: 'og-url', property: 'og:url', content: payload.url || window.location.href }
];
others.forEach(item => {
let meta = head.querySelector(`meta#${item.id}`);
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('id', item.id);
meta.setAttribute('property', item.property);
if (titleEl) {
head.insertBefore(meta, titleEl);
} else if (head.firstChild) {
head.insertBefore(meta, head.firstChild);
} else {
head.appendChild(meta);
}
}
meta.setAttribute('content', item.content);
});
}
/**
* @function set_default_favicon
* @description 设置默认 favicon 为 /vite.jpg,并通过时间戳参数避免缓存导致图标不更新。
* @function remove_og_meta
* @description 从页面 head 中移除进入详情页时插入的 Open Graph 元标签。
* @returns {void}
*/
function set_default_favicon() {
function remove_og_meta() {
// 恢复 index.html 中现有的 og:description 原始内容
const head = document.head || document.getElementsByTagName('head')[0];
if (!head) return;
const default_id = 'default-favicon';
let link = document.querySelector('link#' + default_id);
if (!link) {
link = document.createElement('link');
link.setAttribute('rel', 'icon');
link.setAttribute('type', 'image/svg+xml');
link.setAttribute('id', default_id);
head.appendChild(link);
if (head) {
const descMeta = head.querySelector('meta[property="og:description"]');
if (descMeta && original_og_desc_content !== null) {
descMeta.setAttribute('content', original_og_desc_content);
}
}
// 加上时间戳,确保浏览器立即刷新图标而非使用旧缓存
link.setAttribute('href', `/vite.png?_ts=${Date.now()}`);
// 移除运行时创建的其它 OG 标签
['og-title', 'og-image', 'og-url', 'og-description'].forEach(id => {
const meta = document.querySelector(`meta#${id}`);
if (meta && meta.parentNode) {
meta.parentNode.removeChild(meta);
}
});
}
const $route = useRoute();
......@@ -480,42 +496,6 @@ const checkInSuccess = ref(false)
const { addToCart, proceedToCheckout } = useCart()
//
// 监听课程封面,进入详情页后动态设置 favicon,离开页面时移除
//
/**
* @function init_course_favicon_watch
* @description 初始化对 course 封面变化的监听,封面可用时设置 favicon。
*/
const init_course_favicon_watch = () => {
// 监听封面地址变化,立即触发以覆盖初次进入场景
const stop = watch(
() => (course.value && course.value.cover) || '',
(new_cover) => {
if (new_cover) {
// 封面可用时设置 favicon
set_favicon(new_cover);
}
},
{ immediate: true }
);
return stop;
};
// 进入页面时绑定监听;离开时清理并移除 favicon
let stop_watch_cover = null;
onMounted(() => {
stop_watch_cover = init_course_favicon_watch();
});
onUnmounted(() => {
if (typeof stop_watch_cover === 'function') {
stop_watch_cover();
stop_watch_cover = null;
}
// 离开详情页移除 favicon
remove_favicon();
});
// Handle favorite toggle
// 收藏/取消收藏操作
......@@ -703,6 +683,14 @@ onMounted(async () => {
}
default_list.value = task_list.value;
// 进入详情页时写入 Open Graph 元标签,提升分享预览效果
set_og_meta({
title: course.value?.title || '',
description: course.value?.subtitle || '',
image: course.value?.cover || '',
url: window.location.href
});
}
}
else {
......@@ -725,6 +713,11 @@ onMounted(async () => {
})
})
// 离开页面时清理 Open Graph 元标签
onUnmounted(() => {
remove_og_meta();
})
const isScheduleExpanded = ref(false)
......