hookehuyr

feat: unify route metadata sync for og and share

1 +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2 +
3 +const wxMocks = vi.hoisted(() => ({
4 + wxReady: vi.fn(callback => callback()),
5 + updateAppMessageShareData: vi.fn(),
6 + updateTimelineShareData: vi.fn(),
7 + onMenuShareWeibo: vi.fn(),
8 +}))
9 +
10 +vi.mock('weixin-js-sdk', () => ({
11 + default: {
12 + ready: wxMocks.wxReady,
13 + updateAppMessageShareData: wxMocks.updateAppMessageShareData,
14 + updateTimelineShareData: wxMocks.updateTimelineShareData,
15 + onMenuShareWeibo: wxMocks.onMenuShareWeibo,
16 + },
17 +}))
18 +
19 +vi.mock('@/utils/tools', () => ({
20 + wxInfo: () => ({
21 + isWeiXin: true,
22 + }),
23 +}))
24 +
25 +import { installWxShareSync, normalizeShareImageUrl, resolveRouteShareData } from '../useShare'
26 +
27 +const createRouterMock = (initialRoute = { meta: { title: '首页' } }) => {
28 + let afterEachHook = null
29 +
30 + return {
31 + currentRoute: { value: initialRoute },
32 + afterEach: vi.fn(handler => {
33 + afterEachHook = handler
34 + return () => {
35 + afterEachHook = null
36 + }
37 + }),
38 + triggerAfterEach(route) {
39 + this.currentRoute.value = route
40 + afterEachHook?.(route)
41 + },
42 + }
43 +}
44 +
45 +const flushMutationObserver = async () => {
46 + await new Promise(resolve => setTimeout(resolve, 0))
47 +}
48 +
49 +describe('useShare', () => {
50 + beforeEach(() => {
51 + document.head.innerHTML = '<title>美乐爱觉</title>'
52 + document.title = '美乐爱觉'
53 + window.location.hash = '#/'
54 + wxMocks.wxReady.mockClear()
55 + wxMocks.updateAppMessageShareData.mockClear()
56 + wxMocks.updateTimelineShareData.mockClear()
57 + wxMocks.onMenuShareWeibo.mockClear()
58 + })
59 +
60 + afterEach(() => {
61 + vi.clearAllMocks()
62 + })
63 +
64 + it('根据路由 meta 解析分享数据', () => {
65 + const shareData = resolveRouteShareData({
66 + meta: {
67 + title: '课程详情',
68 + shareDesc: '课程简介',
69 + shareImage: 'https://cdn.ipadbiz.cn/mlaj/course-cover.png',
70 + },
71 + })
72 +
73 + expect(shareData.title).toBe('课程详情')
74 + expect(shareData.desc).toBe('课程简介')
75 + expect(shareData.imgUrl).toContain('imageMogr2/thumbnail/200x/strip/quality/70')
76 + expect(shareData.link).toBe(window.location.href)
77 + })
78 +
79 + it('未提供描述时不再回退为标题', () => {
80 + const shareData = resolveRouteShareData({
81 + meta: {
82 + title: '课程详情',
83 + },
84 + })
85 +
86 + expect(shareData.title).toBe('课程详情')
87 + expect(shareData.desc).toBe('')
88 + })
89 +
90 + it('在路由切换和标题变化时同步微信分享数据', async () => {
91 + const router = createRouterMock({
92 + meta: { title: '首页' },
93 + })
94 +
95 + const teardown = installWxShareSync(router, { isEnabled: true })
96 +
97 + await flushMutationObserver()
98 +
99 + expect(wxMocks.updateAppMessageShareData).toHaveBeenCalled()
100 + expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].title).toBe('美乐爱觉')
101 +
102 + window.location.hash = '#/courses/2'
103 + router.triggerAfterEach({
104 + meta: {
105 + title: '课程详情',
106 + shareDesc: '课程简介',
107 + },
108 + })
109 +
110 + await flushMutationObserver()
111 +
112 + expect(wxMocks.updateAppMessageShareData.mock.lastCall[0]).toMatchObject({
113 + title: '课程详情',
114 + desc: '课程简介',
115 + link: window.location.href,
116 + })
117 + expect(wxMocks.updateTimelineShareData.mock.lastCall[0].title).toBe('课程详情')
118 +
119 + document.title = '高阶课程'
120 + await flushMutationObserver()
121 +
122 + expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].title).toBe('高阶课程')
123 + expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].desc).toBe('课程简介')
124 +
125 + teardown()
126 + })
127 +
128 + it('支持通过异步 resolver 合并分享描述与图片', async () => {
129 + const router = createRouterMock({
130 + meta: { title: '课程详情' },
131 + })
132 +
133 + installWxShareSync(router, {
134 + isEnabled: true,
135 + resolver: vi.fn(() =>
136 + Promise.resolve({
137 + desc: '课程副标题',
138 + imgUrl: 'https://cdn.ipadbiz.cn/mlaj/course-cover.png',
139 + })
140 + ),
141 + })
142 +
143 + await flushMutationObserver()
144 +
145 + expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].desc).toBe('课程副标题')
146 + expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].imgUrl).toContain(
147 + 'imageMogr2/thumbnail/200x/strip/quality/70'
148 + )
149 + })
150 +
151 + it('非 cdn 图片地址保持不变', () => {
152 + expect(normalizeShareImageUrl('https://example.com/image.png')).toBe(
153 + 'https://example.com/image.png'
154 + )
155 + })
156 +})
1 /* 1 /*
2 * @Date: 2022-06-13 17:42:32 2 * @Date: 2022-06-13 17:42:32
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-12-04 13:36:41 4 + * @LastEditTime: 2026-03-30 14:47:11
5 * @FilePath: /mlaj/src/composables/useShare.js 5 * @FilePath: /mlaj/src/composables/useShare.js
6 * @Description: 微信分享相关逻辑 6 * @Description: 微信分享相关逻辑
7 */ 7 */
8 import wx from 'weixin-js-sdk' 8 import wx from 'weixin-js-sdk'
9 9
10 +import { wxInfo } from '@/utils/tools'
11 +
12 +const resolveShareMetaSource = route => route?.meta?.share || route?.meta || {}
13 +
14 +const resolveShareTitle = (route, documentObj, { preferDocumentTitle = false } = {}) => {
15 + const meta = route?.meta || {}
16 + const documentTitle = documentObj?.title || ''
17 +
18 + if (preferDocumentTitle && documentTitle) {
19 + return documentTitle
20 + }
21 +
22 + return meta.shareTitle || meta.ogTitle || meta.title || documentTitle || ''
23 +}
24 +
25 +const createTitleObserver = (documentObj, onChange) => {
26 + const titleEl = documentObj?.head?.querySelector('title')
27 + if (!titleEl || typeof MutationObserver === 'undefined') {
28 + return () => {}
29 + }
30 +
31 + const observer = new MutationObserver(() => {
32 + onChange()
33 + })
34 +
35 + observer.observe(titleEl, {
36 + childList: true,
37 + subtree: true,
38 + characterData: true,
39 + })
40 +
41 + return () => {
42 + observer.disconnect()
43 + }
44 +}
45 +
10 /** 46 /**
11 - * @function normalize_image_url 47 + * @function normalizeShareImageUrl
12 * @description 规范化分享图片地址;若域名为 cdn.ipadbiz.cn,追加压缩参数。 48 * @description 规范化分享图片地址;若域名为 cdn.ipadbiz.cn,追加压缩参数。
13 * @param {string} src 原始图片地址 49 * @param {string} src 原始图片地址
14 * @returns {string} 处理后的图片地址 50 * @returns {string} 处理后的图片地址
15 */ 51 */
16 -function normalize_image_url(src) { 52 +export function normalizeShareImageUrl(src) {
17 if (!src) return '' 53 if (!src) return ''
18 if (src.includes('cdn.ipadbiz.cn')) { 54 if (src.includes('cdn.ipadbiz.cn')) {
19 const compress = 'imageMogr2/thumbnail/200x/strip/quality/70' 55 const compress = 'imageMogr2/thumbnail/200x/strip/quality/70'
...@@ -25,46 +61,214 @@ function normalize_image_url(src) { ...@@ -25,46 +61,214 @@ function normalize_image_url(src) {
25 return src 61 return src
26 } 62 }
27 63
64 +const buildShareLink = locationObj => locationObj?.href || ''
65 +
28 /** 66 /**
29 - * @description: 微信分享功能 67 + * @function buildWxSharePayload
30 - * @param {*} title 标题 68 + * @description 生成微信分享配置,统一补默认值与图片压缩规则。
31 - * @param {*} desc 描述
32 - * @param {*} imgUrl 图标
33 - * @return {*}
34 - */
35 -/**
36 - * @function sharePage
37 - * @description 配置微信分享标题、描述与图标;需在 wx.ready 后调用才会生效。
38 * @param {Object} params 分享参数 69 * @param {Object} params 分享参数
39 * @param {string} params.title 分享标题 70 * @param {string} params.title 分享标题
40 * @param {string} params.desc 分享描述 71 * @param {string} params.desc 分享描述
41 * @param {string} params.imgUrl 分享图标地址 72 * @param {string} params.imgUrl 分享图标地址
42 - * @returns {void} 73 + * @param {string} params.link 分享链接
74 + * @param {Object} options 运行选项
75 + * @param {Location} options.locationObj location 对象
76 + * @returns {{title: string, desc: string, link: string, imgUrl: string, success: Function}}
43 */ 77 */
44 -export const sharePage = ({ title = '生命力教育联盟', desc = '', imgUrl = '' }) => { 78 +export function buildWxSharePayload(
45 - const shareData = { 79 + { title = '', desc = '', imgUrl = '', link = '' } = {},
46 - title, // 分享标题 80 + { locationObj = window.location } = {}
47 - desc, // 分享描述 81 +) {
48 - link: location.origin + location.pathname + location.hash, // 分享链接,需与公众号 JS 安全域名一致 82 + return {
49 - imgUrl: normalize_image_url(imgUrl), // 分享图标,按规则追加压缩参数 83 + title,
50 - success() { 84 + desc,
51 - // 设置成功回调 85 + link: link || buildShareLink(locationObj),
52 - }, 86 + imgUrl: normalizeShareImageUrl(imgUrl),
53 - } 87 + success() {},
54 - 88 + }
55 - if (wx && typeof wx.ready === 'function') { 89 +}
56 - wx.ready(() => { 90 +
57 - // 分享好友(微信好友或qq好友) 91 +/**
58 - wx.updateAppMessageShareData(shareData) 92 + * @function applyWxShareData
59 - // 分享到朋友圈或qq空间 93 + * @description 配置微信分享标题、描述与图标;需在 wx.ready 后调用才会生效。
60 - wx.updateTimelineShareData(shareData) 94 + * @param {Object} params 分享参数
61 - // 分享到腾讯微博 95 + * @param {Object} options 运行选项
62 - if (typeof wx.onMenuShareWeibo === 'function') { 96 + * @param {any} options.wxApi 微信 SDK 实例
63 - wx.onMenuShareWeibo(shareData) 97 + * @param {Location} options.locationObj location 对象
64 - } 98 + * @returns {{title: string, desc: string, link: string, imgUrl: string, success: Function}}
65 - }) 99 + */
66 - } else { 100 +export function applyWxShareData(params = {}, { wxApi = wx, locationObj = window.location } = {}) {
67 - // 微信 JSSDK 未初始化或未就绪,分享配置可能不会生效 101 + const shareData = buildWxSharePayload(params, { locationObj })
102 +
103 + if (!wxApi || typeof wxApi.ready !== 'function') {
68 console.warn('微信 JSSDK 未就绪:分享配置可能未生效') 104 console.warn('微信 JSSDK 未就绪:分享配置可能未生效')
105 + return shareData
106 + }
107 +
108 + wxApi.ready(() => {
109 + if (typeof wxApi.updateAppMessageShareData === 'function') {
110 + wxApi.updateAppMessageShareData(shareData)
111 + }
112 +
113 + if (typeof wxApi.updateTimelineShareData === 'function') {
114 + wxApi.updateTimelineShareData(shareData)
115 + }
116 +
117 + if (typeof wxApi.onMenuShareWeibo === 'function') {
118 + wxApi.onMenuShareWeibo(shareData)
119 + }
120 + })
121 +
122 + return shareData
123 +}
124 +
125 +/**
126 + * @function resolveRouteShareData
127 + * @description 根据当前路由与文档标题解析分享配置。
128 + * @param {import('vue-router').RouteLocationNormalizedLoaded | null | undefined} route 当前路由
129 + * @param {Object} options 运行选项
130 + * @param {Document} options.documentObj document 对象
131 + * @param {Location} options.locationObj location 对象
132 + * @param {boolean} options.preferDocumentTitle 是否优先使用 document.title
133 + * @returns {{title: string, desc: string, imgUrl: string, link: string}}
134 + */
135 +export function resolveRouteShareData(
136 + route,
137 + { documentObj = document, locationObj = window.location, preferDocumentTitle = false } = {}
138 +) {
139 + const metaSource = resolveShareMetaSource(route)
140 + const title = resolveShareTitle(route, documentObj, { preferDocumentTitle })
141 +
142 + return {
143 + title,
144 + desc:
145 + metaSource.desc ||
146 + metaSource.shareDesc ||
147 + metaSource.description ||
148 + metaSource.ogDescription ||
149 + '',
150 + imgUrl: normalizeShareImageUrl(
151 + metaSource.imgUrl || metaSource.shareImage || metaSource.image || metaSource.ogImage || ''
152 + ),
153 + link:
154 + metaSource.link ||
155 + metaSource.shareLink ||
156 + metaSource.url ||
157 + metaSource.ogUrl ||
158 + buildShareLink(locationObj),
159 + }
160 +}
161 +
162 +const mergeResolvedShareData = (
163 + baseShareData,
164 + resolvedShareData,
165 + documentObj,
166 + { preferDocumentTitle = false } = {}
167 +) => {
168 + const mergedShareData = {
169 + ...baseShareData,
170 + ...(resolvedShareData || {}),
171 + }
172 +
173 + if (resolvedShareData?.description && !resolvedShareData?.desc) {
174 + mergedShareData.desc = resolvedShareData.description
175 + }
176 +
177 + if (resolvedShareData?.image && !resolvedShareData?.imgUrl) {
178 + mergedShareData.imgUrl = resolvedShareData.image
179 + }
180 +
181 + if (resolvedShareData?.url && !resolvedShareData?.link) {
182 + mergedShareData.link = resolvedShareData.url
183 + }
184 +
185 + if (mergedShareData.imgUrl) {
186 + mergedShareData.imgUrl = normalizeShareImageUrl(mergedShareData.imgUrl)
187 + }
188 +
189 + if (preferDocumentTitle && documentObj?.title) {
190 + mergedShareData.title = documentObj.title
191 + }
192 +
193 + return mergedShareData
194 +}
195 +
196 +/**
197 + * @function installWxShareSync
198 + * @description 把微信分享配置绑定到路由与标题变化上,避免页面里重复手动调用。
199 + * @param {import('vue-router').Router} router 路由实例
200 + * @param {Object} options 运行选项
201 + * @param {any} options.wxApi 微信 SDK 实例
202 + * @param {Document} options.documentObj document 对象
203 + * @param {Location} options.locationObj location 对象
204 + * @param {boolean} options.isEnabled 是否启用同步;默认仅在微信环境且非开发环境启用
205 + * @returns {() => void} 清理函数
206 + */
207 +export function installWxShareSync(
208 + router,
209 + { wxApi = wx, documentObj = document, locationObj = window.location, isEnabled, resolver } = {}
210 +) {
211 + const enabled =
212 + typeof isEnabled === 'boolean' ? isEnabled : !import.meta.env.DEV && wxInfo().isWeiXin
213 + if (!router || !enabled) {
214 + return () => {}
215 + }
216 +
217 + let syncToken = 0
218 +
219 + const buildShareData = async (route, { preferDocumentTitle = false } = {}) => {
220 + const baseShareData = resolveRouteShareData(route, {
221 + documentObj,
222 + locationObj,
223 + preferDocumentTitle,
224 + })
225 + const resolvedShareData =
226 + typeof resolver === 'function'
227 + ? await resolver(route, { documentObj, locationObj, preferDocumentTitle })
228 + : null
229 +
230 + return mergeResolvedShareData(baseShareData, resolvedShareData, documentObj, {
231 + preferDocumentTitle,
232 + })
233 + }
234 +
235 + const syncFromRoute = async (route, options = {}) => {
236 + const currentToken = ++syncToken
237 + const shareData = await buildShareData(route, options)
238 + if (currentToken !== syncToken) {
239 + return
240 + }
241 +
242 + applyWxShareData(shareData, {
243 + wxApi,
244 + locationObj,
245 + })
246 + }
247 +
248 + const syncFromDocumentTitle = () => {
249 + syncFromRoute(router.currentRoute?.value, {
250 + preferDocumentTitle: true,
251 + })
252 + }
253 +
254 + const removeAfterEach = router.afterEach?.(to => {
255 + syncFromRoute(to)
256 + })
257 +
258 + const stopObserveTitle = createTitleObserver(documentObj, syncFromDocumentTitle)
259 +
260 + syncFromDocumentTitle()
261 +
262 + return () => {
263 + stopObserveTitle()
264 + removeAfterEach?.()
69 } 265 }
70 } 266 }
267 +
268 +/**
269 + * @function sharePage
270 + * @description 兼容旧调用方式的轻量封装。
271 + * @param {Object} params 分享参数
272 + * @returns {{title: string, desc: string, link: string, imgUrl: string, success: Function}}
273 + */
274 +export const sharePage = (params = {}) => applyWxShareData(params)
......
...@@ -10,17 +10,20 @@ import { routes } from './routes' ...@@ -10,17 +10,20 @@ import { routes } from './routes'
10 import { checkAuth, hasVisitedWelcome, markWelcomeVisited } from './guards' 10 import { checkAuth, hasVisitedWelcome, markWelcomeVisited } from './guards'
11 import { getUserIsLoginAPI } from '@/api/auth' 11 import { getUserIsLoginAPI } from '@/api/auth'
12 import { getUserInfoAPI } from '@/api/users' 12 import { getUserInfoAPI } from '@/api/users'
13 +import { installWxShareSync } from '@/composables/useShare'
14 +import { installOgMetaSync } from '@/utils/ogMeta'
15 +import { resolveRouteRuntimeMeta } from './pageMetaResolver'
13 16
14 const router = createRouter({ 17 const router = createRouter({
15 - history: createWebHashHistory(import.meta.env.VITE_BASE || '/'), 18 + history: createWebHashHistory(import.meta.env.VITE_BASE || '/'),
16 - routes, 19 + routes,
17 - scrollBehavior(to, from, savedPosition) { 20 + scrollBehavior(to, from, savedPosition) {
18 - if (savedPosition) { 21 + if (savedPosition) {
19 - return savedPosition 22 + return savedPosition
20 - } 23 + }
21 - // 每次路由切换后,页面滚动到顶部 24 + // 每次路由切换后,页面滚动到顶部
22 - return { top: 0, left: 0 } 25 + return { top: 0, left: 0 }
23 - }, 26 + },
24 }) 27 })
25 28
26 // 导航守卫 29 // 导航守卫
...@@ -42,10 +45,9 @@ router.beforeEach(async (to, from, next) => { ...@@ -42,10 +45,9 @@ router.beforeEach(async (to, from, next) => {
42 if (hasVisitedWelcome()) { 45 if (hasVisitedWelcome()) {
43 // 已访问过,跳转到首页 46 // 已访问过,跳转到首页
44 return next('/') 47 return next('/')
45 - } else {
46 - // 首次访问,标记并显示欢迎页
47 - markWelcomeVisited()
48 } 48 }
49 + // 首次访问,标记并显示欢迎页
50 + markWelcomeVisited()
49 } 51 }
50 } 52 }
51 53
...@@ -53,49 +55,52 @@ router.beforeEach(async (to, from, next) => { ...@@ -53,49 +55,52 @@ router.beforeEach(async (to, from, next) => {
53 const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null') 55 const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null')
54 const redirectRaw = to.query && to.query.redirect 56 const redirectRaw = to.query && to.query.redirect
55 57
56 - // 登录权限检查(不再自动触发微信授权) 58 + // 登录权限检查(不再自动触发微信授权)
57 - const authResult = checkAuth(to) 59 + const authResult = checkAuth(to)
58 - if (authResult !== true) { 60 + if (authResult !== true) {
59 - next(authResult) 61 + next(authResult)
60 - return 62 + return
61 - } 63 + }
62 64
63 - // 登录页统一处理授权回跳与默认重定向 65 + // 登录页统一处理授权回跳与默认重定向
64 - if (to.path === '/login') { 66 + if (to.path === '/login') {
65 - /** 67 + /**
66 - * 情况1:本地已有登录态 68 + * 情况1:本地已有登录态
67 - * - 有 redirect:跳回来源页 69 + * - 有 redirect:跳回来源页
68 - * - 无 redirect:默认跳首页 70 + * - 无 redirect:默认跳首页
69 - */ 71 + */
70 - if (currentUser) { 72 + if (currentUser) {
71 - const redirect = redirectRaw ? decodeURIComponent(redirectRaw) : '/' 73 + const redirect = redirectRaw ? decodeURIComponent(redirectRaw) : '/'
72 - next(redirect) 74 + next(redirect)
73 - return 75 + return
74 - } 76 + }
75 77
76 - /** 78 + /**
77 - * 情况2:授权回跳但本地未写入登录态 79 + * 情况2:授权回跳但本地未写入登录态
78 - * - 统一在路由层探测登录 80 + * - 统一在路由层探测登录
79 - * - 登录为真:写入用户信息,并按 redirect 或首页跳转 81 + * - 登录为真:写入用户信息,并按 redirect 或首页跳转
80 - */ 82 + */
81 - try { 83 + try {
82 - const { code, data } = await getUserIsLoginAPI() 84 + const { code, data } = await getUserIsLoginAPI()
83 - if (code && data && data.is_login) { 85 + if (code && data && data.is_login) {
84 - const { code: uiCode, data: uiData } = await getUserInfoAPI() 86 + const { code: uiCode, data: uiData } = await getUserInfoAPI()
85 - if (uiCode) { 87 + if (uiCode) {
86 - const mergedUser = { ...uiData.user, ...uiData.checkin } 88 + const mergedUser = { ...uiData.user, ...uiData.checkin }
87 - localStorage.setItem('currentUser', JSON.stringify(mergedUser)) 89 + localStorage.setItem('currentUser', JSON.stringify(mergedUser))
88 - }
89 - const redirect = redirectRaw ? decodeURIComponent(redirectRaw) : '/'
90 - next(redirect)
91 - return
92 - }
93 - } catch (e) {
94 - // 静默失败,进入登录页
95 } 90 }
91 + const redirect = redirectRaw ? decodeURIComponent(redirectRaw) : '/'
92 + next(redirect)
93 + return
94 + }
95 + } catch (e) {
96 + // 静默失败,进入登录页
96 } 97 }
98 + }
97 99
98 - next() 100 + next()
99 }) 101 })
100 102
103 +installOgMetaSync(router, { resolver: resolveRouteRuntimeMeta })
104 +installWxShareSync(router, { resolver: resolveRouteRuntimeMeta })
105 +
101 export default router 106 export default router
......
1 +import { getCourseDetailAPI } from '@/api/course'
2 +
3 +const routeMetaCache = new Map()
4 +
5 +const buildRouteCacheKey = route => {
6 + const routeName = route?.name || route?.path || 'unknown'
7 + const routeParams = JSON.stringify(route?.params || {})
8 + return `${routeName}:${routeParams}`
9 +}
10 +
11 +const withRouteMetaCache = (route, resolver) => {
12 + const cacheKey = buildRouteCacheKey(route)
13 + if (!routeMetaCache.has(cacheKey)) {
14 + routeMetaCache.set(
15 + cacheKey,
16 + Promise.resolve()
17 + .then(resolver)
18 + .catch(() => null)
19 + )
20 + }
21 +
22 + return routeMetaCache.get(cacheKey)
23 +}
24 +
25 +const resolveCourseDetailMeta = async (route, { windowObj = window } = {}) => {
26 + const courseId = route?.params?.id
27 + if (!courseId) {
28 + return null
29 + }
30 +
31 + const { code, data } = await getCourseDetailAPI({ i: courseId })
32 + if (code !== 1 || !data) {
33 + return null
34 + }
35 +
36 + const title = data.title || '课程详情'
37 + const description = data.subtitle || title
38 + const shareDescription = data.subtitle || ''
39 + const image = data.cover || ''
40 + const url = windowObj?.location?.href || ''
41 +
42 + return {
43 + title,
44 + description,
45 + image,
46 + url,
47 + desc: shareDescription,
48 + imgUrl: image,
49 + link: url,
50 + }
51 +}
52 +
53 +/**
54 + * 统一解析“路由运行时元信息”。
55 + *
56 + * 说明:
57 + * - 静态页面优先走 route.meta。
58 + * - 动态详情页需要接口数据时,在这里集中补充,避免页面里分散维护。
59 + */
60 +export function resolveRouteRuntimeMeta(route, { windowObj = window } = {}) {
61 + switch (route?.name) {
62 + case 'CourseDetail':
63 + return withRouteMetaCache(route, () => resolveCourseDetailMeta(route, { windowObj }))
64 + default:
65 + return null
66 + }
67 +}
68 +
69 +export function resetRouteRuntimeMetaCache() {
70 + routeMetaCache.clear()
71 +}
1 +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2 +
3 +import {
4 + applyOgMeta,
5 + buildOgImageUrl,
6 + installOgMetaSync,
7 + resetOgMeta,
8 + resetOgMetaManagerState,
9 +} from '../ogMeta'
10 +
11 +const createRouterMock = (initialRoute = { meta: { title: '首页' }, fullPath: '/#/' }) => {
12 + let afterEachHook = null
13 +
14 + return {
15 + currentRoute: { value: initialRoute },
16 + afterEach: vi.fn(handler => {
17 + afterEachHook = handler
18 + return () => {
19 + afterEachHook = null
20 + }
21 + }),
22 + triggerAfterEach(route) {
23 + this.currentRoute.value = route
24 + afterEachHook?.(route)
25 + },
26 + }
27 +}
28 +
29 +const flushMutationObserver = async () => {
30 + await new Promise(resolve => setTimeout(resolve, 0))
31 +}
32 +
33 +describe('ogMeta', () => {
34 + beforeEach(() => {
35 + document.head.innerHTML = `
36 + <meta property="og:description" content="默认描述">
37 + <title>美乐爱觉</title>
38 + `
39 + document.title = '美乐爱觉'
40 + window.location.hash = '#/'
41 + resetOgMetaManagerState()
42 + })
43 +
44 + afterEach(() => {
45 + resetOgMetaManagerState()
46 + })
47 +
48 + it('复用现有 og:description 并在 reset 时恢复初始值', () => {
49 + applyOgMeta({
50 + title: '课程详情',
51 + description: '课程副标题',
52 + image: 'https://cdn.ipadbiz.cn/mlaj/course-cover.png',
53 + url: 'https://example.com/#/courses/1',
54 + })
55 +
56 + const descriptionMetas = document.head.querySelectorAll('meta[property="og:description"]')
57 +
58 + expect(descriptionMetas).toHaveLength(1)
59 + expect(descriptionMetas[0].getAttribute('content')).toBe('课程副标题')
60 + expect(document.head.querySelector('meta[property="og:title"]')?.getAttribute('content')).toBe(
61 + '课程详情'
62 + )
63 + expect(
64 + document.head.querySelector('meta[property="og:image"]')?.getAttribute('content')
65 + ).toContain('imageMogr2/thumbnail/400x/strip/quality/70')
66 +
67 + resetOgMeta()
68 +
69 + expect(
70 + document.head.querySelector('meta[property="og:description"]')?.getAttribute('content')
71 + ).toBe('默认描述')
72 + expect(document.head.querySelector('meta[property="og:title"]')).toBeNull()
73 + expect(document.head.querySelector('meta[property="og:image"]')).toBeNull()
74 + expect(document.head.querySelector('meta[property="og:url"]')).toBeNull()
75 + })
76 +
77 + it('在路由切换和 document.title 变化时同步 og:title 与 og:url', async () => {
78 + const router = createRouterMock({
79 + meta: { title: '课程详情' },
80 + fullPath: '/courses/1',
81 + })
82 +
83 + const teardown = installOgMetaSync(router)
84 +
85 + await flushMutationObserver()
86 +
87 + expect(document.head.querySelector('meta[property="og:title"]')?.getAttribute('content')).toBe(
88 + '美乐爱觉'
89 + )
90 + expect(document.head.querySelector('meta[property="og:url"]')?.getAttribute('content')).toBe(
91 + window.location.href
92 + )
93 +
94 + window.location.hash = '#/courses/2'
95 + router.triggerAfterEach({
96 + meta: { title: '课程详情' },
97 + fullPath: '/courses/2',
98 + })
99 +
100 + await flushMutationObserver()
101 +
102 + expect(document.head.querySelector('meta[property="og:title"]')?.getAttribute('content')).toBe(
103 + '课程详情'
104 + )
105 + expect(document.head.querySelector('meta[property="og:url"]')?.getAttribute('content')).toBe(
106 + window.location.href
107 + )
108 +
109 + document.title = '高阶课程'
110 + await flushMutationObserver()
111 +
112 + expect(document.head.querySelector('meta[property="og:title"]')?.getAttribute('content')).toBe(
113 + '高阶课程'
114 + )
115 + expect(
116 + document.head.querySelector('meta[property="og:description"]')?.getAttribute('content')
117 + ).toBe('高阶课程')
118 +
119 + teardown()
120 + })
121 +
122 + it('支持通过异步 resolver 合并描述与图片', async () => {
123 + const router = createRouterMock({
124 + meta: { title: '课程详情' },
125 + fullPath: '/courses/1',
126 + })
127 +
128 + installOgMetaSync(router, {
129 + resolver: vi.fn(() =>
130 + Promise.resolve({
131 + description: '课程副标题',
132 + image: 'https://cdn.ipadbiz.cn/mlaj/course-cover.png',
133 + })
134 + ),
135 + })
136 +
137 + await flushMutationObserver()
138 +
139 + expect(
140 + document.head.querySelector('meta[property="og:description"]')?.getAttribute('content')
141 + ).toBe('课程副标题')
142 + expect(
143 + document.head.querySelector('meta[property="og:image"]')?.getAttribute('content')
144 + ).toContain('imageMogr2/thumbnail/400x/strip/quality/70')
145 + })
146 +
147 + it('为非 cdn.ipadbiz.cn 图片保持原始地址', () => {
148 + expect(buildOgImageUrl('https://example.com/image.png')).toBe('https://example.com/image.png')
149 + })
150 +})
1 +/**
2 + * 统一管理 Open Graph 元标签。
3 + *
4 + * 设计目标:
5 + * 1. 任意路由切换时都能自动同步 OG 信息。
6 + * 2. 兼容 index.html 中已经存在的静态 meta,例如 og:description。
7 + * 3. 页面只负责更新 document.title;OG 的写入和恢复统一放在这里处理。
8 + */
9 +const MANAGED_OG_TAGS = [
10 + { id: 'og-title', property: 'og:title', key: 'title' },
11 + { id: 'og-description', property: 'og:description', key: 'description' },
12 + { id: 'og-image', property: 'og:image', key: 'image' },
13 + { id: 'og-url', property: 'og:url', key: 'url' },
14 +]
15 +
16 +// 首次接管 OG 标签时,记录页面原始状态,便于 teardown/reset 时恢复。
17 +let originalOgMetaState = null
18 +
19 +const getHead = documentObj =>
20 + documentObj?.head || documentObj?.getElementsByTagName?.('head')?.[0] || null
21 +
22 +const getMetaByProperty = (documentObj, property) =>
23 + getHead(documentObj)?.querySelector(`meta[property="${property}"]`) || null
24 +
25 +const captureOriginalOgMetaState = documentObj => {
26 + if (originalOgMetaState) {
27 + return originalOgMetaState
28 + }
29 +
30 + // 只在首次接管时做快照,避免后续多次同步把“运行时状态”误当成“初始状态”。
31 + originalOgMetaState = MANAGED_OG_TAGS.reduce((state, item) => {
32 + const meta = getMetaByProperty(documentObj, item.property)
33 + state[item.property] = {
34 + existed: !!meta,
35 + content: meta?.getAttribute('content') || '',
36 + id: meta?.getAttribute('id') || '',
37 + }
38 + return state
39 + }, {})
40 +
41 + return originalOgMetaState
42 +}
43 +
44 +const ensureManagedMeta = (documentObj, item) => {
45 + const head = getHead(documentObj)
46 + if (!head) return null
47 +
48 + // 优先按 property 查找,兼容原本就写在 index.html 里的静态节点。
49 + // 找不到时再按约定 id 查找我们自己创建的节点。
50 + let meta = getMetaByProperty(documentObj, item.property) || head.querySelector(`meta#${item.id}`)
51 + if (!meta) {
52 + meta = documentObj.createElement('meta')
53 + const titleEl = head.querySelector('title')
54 + if (titleEl) {
55 + head.insertBefore(meta, titleEl)
56 + } else if (head.firstChild) {
57 + head.insertBefore(meta, head.firstChild)
58 + } else {
59 + head.appendChild(meta)
60 + }
61 + }
62 +
63 + meta.setAttribute('id', item.id)
64 + meta.setAttribute('property', item.property)
65 +
66 + return meta
67 +}
68 +
69 +const resolveMetaSource = route => route?.meta?.og || route?.meta || {}
70 +
71 +const resolveRouteTitle = (route, documentObj, { preferDocumentTitle = false } = {}) => {
72 + const meta = route?.meta || {}
73 + const documentTitle = documentObj?.title || ''
74 +
75 + // 某些详情页会在异步数据返回后通过 useTitle 更新标题。
76 + // 这种情况下优先读取 document.title,才能拿到页面最终标题,而不是路由默认标题。
77 + if (preferDocumentTitle && documentTitle) {
78 + return documentTitle
79 + }
80 +
81 + return meta.ogTitle || meta.title || documentTitle || ''
82 +}
83 +
84 +const createTitleObserver = (documentObj, onChange) => {
85 + const titleEl = getHead(documentObj)?.querySelector('title')
86 + if (!titleEl || typeof MutationObserver === 'undefined') {
87 + return () => {}
88 + }
89 +
90 + const observer = new MutationObserver(() => {
91 + onChange()
92 + })
93 +
94 + observer.observe(titleEl, {
95 + childList: true,
96 + subtree: true,
97 + characterData: true,
98 + })
99 +
100 + return () => {
101 + observer.disconnect()
102 + }
103 +}
104 +
105 +/**
106 + * 统一处理项目里的图片压缩规则。
107 + * 目前仅对 cdn.ipadbiz.cn 做 imageMogr2 参数补充。
108 + */
109 +export function buildOgImageUrl(src) {
110 + if (!src) return ''
111 +
112 + if (src.includes('cdn.ipadbiz.cn')) {
113 + const compressParam = 'imageMogr2/thumbnail/400x/strip/quality/70'
114 + if (src.includes('?')) {
115 + return src.includes(compressParam) ? src : `${src}&${compressParam}`
116 + }
117 + return `${src}?${compressParam}`
118 + }
119 +
120 + return src
121 +}
122 +
123 +/**
124 + * 写入或更新 OG 标签。
125 + *
126 + * 注意:
127 + * - 所有标签都走统一入口,避免各页面各自 append/remove。
128 + * - description 也会被统一接管,这样恢复逻辑才是对称的。
129 + */
130 +export function applyOgMeta(payload = {}, { documentObj = document, windowObj = window } = {}) {
131 + const head = getHead(documentObj)
132 + if (!head) return
133 +
134 + captureOriginalOgMetaState(documentObj)
135 +
136 + const normalizedPayload = {
137 + title: payload.title || documentObj?.title || '',
138 + // description 没传时退化为 title,至少保证分享卡片不是空文案。
139 + description: payload.description || payload.title || documentObj?.title || '',
140 + image: buildOgImageUrl(payload.image || ''),
141 + url: payload.url || windowObj?.location?.href || '',
142 + }
143 +
144 + MANAGED_OG_TAGS.forEach(item => {
145 + const meta = ensureManagedMeta(documentObj, item)
146 + if (!meta) return
147 + meta.setAttribute('content', normalizedPayload[item.key] || '')
148 + })
149 +}
150 +
151 +/**
152 + * 恢复接管前的 OG 状态。
153 + *
154 + * 规则:
155 + * - 原本存在的节点恢复 content/id。
156 + * - 原本不存在的节点直接删除。
157 + */
158 +export function resetOgMeta({ documentObj = document } = {}) {
159 + const head = getHead(documentObj)
160 + if (!head || !originalOgMetaState) return
161 +
162 + MANAGED_OG_TAGS.forEach(item => {
163 + const snapshot = originalOgMetaState[item.property]
164 + const meta =
165 + getMetaByProperty(documentObj, item.property) || head.querySelector(`meta#${item.id}`)
166 +
167 + if (!meta) return
168 +
169 + if (snapshot?.existed) {
170 + meta.setAttribute('property', item.property)
171 + meta.setAttribute('content', snapshot.content || '')
172 + if (snapshot.id) {
173 + meta.setAttribute('id', snapshot.id)
174 + } else {
175 + meta.removeAttribute('id')
176 + }
177 + return
178 + }
179 +
180 + meta.parentNode?.removeChild(meta)
181 + })
182 +}
183 +
184 +export function resetOgMetaManagerState() {
185 + // 仅用于测试或明确需要重置管理器内部状态的场景。
186 + originalOgMetaState = null
187 +}
188 +
189 +/**
190 + * 从当前路由解析出应写入的 OG 数据。
191 + *
192 + * 支持两种写法:
193 + * - meta.ogDescription / meta.ogImage / meta.ogTitle
194 + * - meta.og = { description, image, title, url }
195 + */
196 +export function resolveRouteOgMeta(
197 + route,
198 + { documentObj = document, windowObj = window, preferDocumentTitle = false } = {}
199 +) {
200 + const metaSource = resolveMetaSource(route)
201 + const title = resolveRouteTitle(route, documentObj, { preferDocumentTitle })
202 +
203 + return {
204 + title,
205 + description: metaSource.description || metaSource.ogDescription || title,
206 + image: metaSource.image || metaSource.ogImage || '',
207 + url: metaSource.url || metaSource.ogUrl || windowObj?.location?.href || '',
208 + }
209 +}
210 +
211 +const mergeResolvedOgMeta = (
212 + basePayload,
213 + resolvedPayload,
214 + documentObj,
215 + { preferDocumentTitle = false } = {}
216 +) => {
217 + const mergedPayload = {
218 + ...basePayload,
219 + ...(resolvedPayload || {}),
220 + }
221 +
222 + if (resolvedPayload?.desc && !resolvedPayload?.description) {
223 + mergedPayload.description = resolvedPayload.desc
224 + }
225 +
226 + if (resolvedPayload?.imgUrl && !resolvedPayload?.image) {
227 + mergedPayload.image = resolvedPayload.imgUrl
228 + }
229 +
230 + if (resolvedPayload?.link && !resolvedPayload?.url) {
231 + mergedPayload.url = resolvedPayload.link
232 + }
233 +
234 + if (preferDocumentTitle && documentObj?.title) {
235 + mergedPayload.title = documentObj.title
236 + }
237 +
238 + return mergedPayload
239 +}
240 +
241 +/**
242 + * 把 OG 同步器挂到 router 上。
243 + *
244 + * 同步时机有两个:
245 + * 1. 路由切换完成后:更新当前页面的默认 OG。
246 + * 2. document.title 发生变化时:把异步详情页最终标题同步回 OG。
247 + */
248 +export function installOgMetaSync(
249 + router,
250 + { documentObj = document, windowObj = window, resolver } = {}
251 +) {
252 + if (!router || !documentObj) {
253 + return () => {}
254 + }
255 +
256 + let syncToken = 0
257 +
258 + const buildPayload = async (route, { preferDocumentTitle = false } = {}) => {
259 + const basePayload = resolveRouteOgMeta(route, { documentObj, windowObj, preferDocumentTitle })
260 + const resolvedPayload =
261 + typeof resolver === 'function'
262 + ? await resolver(route, { documentObj, windowObj, preferDocumentTitle })
263 + : null
264 +
265 + return mergeResolvedOgMeta(basePayload, resolvedPayload, documentObj, { preferDocumentTitle })
266 + }
267 +
268 + const syncFromRoute = async (route, options = {}) => {
269 + const currentToken = ++syncToken
270 + const payload = await buildPayload(route, options)
271 + if (currentToken !== syncToken) {
272 + return
273 + }
274 +
275 + applyOgMeta(payload, {
276 + documentObj,
277 + windowObj,
278 + })
279 + }
280 +
281 + const syncFromDocumentTitle = () => {
282 + // 这里显式偏向 document.title,确保课程详情、学习详情这类异步标题能覆盖路由默认标题。
283 + syncFromRoute(router.currentRoute?.value, {
284 + preferDocumentTitle: true,
285 + })
286 + }
287 +
288 + const removeAfterEach = router.afterEach?.(to => {
289 + syncFromRoute(to)
290 + })
291 +
292 + const stopObserveTitle = createTitleObserver(documentObj, syncFromDocumentTitle)
293 +
294 + // 首屏也立即同步一次,避免只有路由跳转后才有 OG。
295 + syncFromDocumentTitle()
296 +
297 + return () => {
298 + stopObserveTitle()
299 + removeAfterEach?.()
300 + resetOgMeta({ documentObj })
301 + }
302 +}
This diff is collapsed. Click to expand it.