Showing
7 changed files
with
975 additions
and
87 deletions
src/composables/__tests__/useShare.test.js
0 → 100644
| 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 | ... | ... |
src/router/pageMetaResolver.js
0 → 100644
| 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 | +} |
src/utils/__tests__/ogMeta.test.js
0 → 100644
| 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 | +}) |
src/utils/ogMeta.js
0 → 100644
| 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.
-
Please register or login to post a comment