Showing
7 changed files
with
1177 additions
and
301 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 | - | ||
| 55 | - if (wx && typeof wx.ready === 'function') { | ||
| 56 | - wx.ready(() => { | ||
| 57 | - // 分享好友(微信好友或qq好友) | ||
| 58 | - wx.updateAppMessageShareData(shareData) | ||
| 59 | - // 分享到朋友圈或qq空间 | ||
| 60 | - wx.updateTimelineShareData(shareData) | ||
| 61 | - // 分享到腾讯微博 | ||
| 62 | - if (typeof wx.onMenuShareWeibo === 'function') { | ||
| 63 | - wx.onMenuShareWeibo(shareData) | ||
| 64 | } | 88 | } |
| 65 | - }) | 89 | +} |
| 66 | - } else { | 90 | + |
| 67 | - // 微信 JSSDK 未初始化或未就绪,分享配置可能不会生效 | 91 | +/** |
| 92 | + * @function applyWxShareData | ||
| 93 | + * @description 配置微信分享标题、描述与图标;需在 wx.ready 后调用才会生效。 | ||
| 94 | + * @param {Object} params 分享参数 | ||
| 95 | + * @param {Object} options 运行选项 | ||
| 96 | + * @param {any} options.wxApi 微信 SDK 实例 | ||
| 97 | + * @param {Location} options.locationObj location 对象 | ||
| 98 | + * @returns {{title: string, desc: string, link: string, imgUrl: string, success: Function}} | ||
| 99 | + */ | ||
| 100 | +export function applyWxShareData(params = {}, { wxApi = wx, locationObj = window.location } = {}) { | ||
| 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) | ||
| 69 | } | 115 | } |
| 116 | + | ||
| 117 | + if (typeof wxApi.onMenuShareWeibo === 'function') { | ||
| 118 | + wxApi.onMenuShareWeibo(shareData) | ||
| 119 | + } | ||
| 120 | + }) | ||
| 121 | + | ||
| 122 | + return shareData | ||
| 70 | } | 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?.() | ||
| 265 | + } | ||
| 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,6 +10,9 @@ import { routes } from './routes' | ... | @@ -10,6 +10,9 @@ 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 || '/'), |
| ... | @@ -42,12 +45,11 @@ router.beforeEach(async (to, from, next) => { | ... | @@ -42,12 +45,11 @@ router.beforeEach(async (to, from, next) => { |
| 42 | if (hasVisitedWelcome()) { | 45 | if (hasVisitedWelcome()) { |
| 43 | // 已访问过,跳转到首页 | 46 | // 已访问过,跳转到首页 |
| 44 | return next('/') | 47 | return next('/') |
| 45 | - } else { | 48 | + } |
| 46 | // 首次访问,标记并显示欢迎页 | 49 | // 首次访问,标记并显示欢迎页 |
| 47 | markWelcomeVisited() | 50 | markWelcomeVisited() |
| 48 | } | 51 | } |
| 49 | } | 52 | } |
| 50 | - } | ||
| 51 | 53 | ||
| 52 | // 检查用户是否已登录 | 54 | // 检查用户是否已登录 |
| 53 | const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null') | 55 | const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null') |
| ... | @@ -98,4 +100,7 @@ router.beforeEach(async (to, from, next) => { | ... | @@ -98,4 +100,7 @@ router.beforeEach(async (to, from, next) => { |
| 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 | +} |
| 1 | <template> | 1 | <template> |
| 2 | <AppLayout :has-title="false"> | 2 | <AppLayout :has-title="false"> |
| 3 | - <div class="pb-24 mb-6"> | 3 | + <div class="mb-6 pb-24"> |
| 4 | <!-- Course Image --> | 4 | <!-- Course Image --> |
| 5 | <div class="mb-4"> | 5 | <div class="mb-4"> |
| 6 | - <img :src="build_og_image_url(course?.cover)" :alt="course?.title" | 6 | + <img :src="buildOgImageUrl(course?.cover)" :alt="course?.title" class="h-auto w-full" /> |
| 7 | - class="w-full h-auto" /> | ||
| 8 | </div> | 7 | </div> |
| 9 | 8 | ||
| 10 | <!-- Course Header --> | 9 | <!-- Course Header --> |
| 11 | <div class="px-4"> | 10 | <div class="px-4"> |
| 12 | - <div style="padding-bottom: 1rem;"> | 11 | + <div style="padding-bottom: 1rem"> |
| 13 | - <div v-if="course?.group_type_title" class="bg-gray-100 rounded-lg p-2 mb-3 inline-block"> | 12 | + <div v-if="course?.group_type_title" class="mb-3 inline-block rounded-lg bg-gray-100 p-2"> |
| 14 | - <div class="text-gray-600 text-sm font-semibold">{{ course?.group_type_title }}</div> | 13 | + <div class="text-sm font-semibold text-gray-600">{{ course?.group_type_title }}</div> |
| 15 | </div> | 14 | </div> |
| 16 | - <h1 class="text-2xl text-gray-900 font-bold mb-1">{{ course?.title }}</h1> | 15 | + <h1 class="mb-1 text-2xl font-bold text-gray-900">{{ course?.title }}</h1> |
| 17 | <h2 class="text-sm text-gray-500">{{ course?.subtitle }}</h2> | 16 | <h2 class="text-sm text-gray-500">{{ course?.subtitle }}</h2> |
| 18 | - <div class="mt-4 flex justify-between items-center"> | 17 | + <div class="mt-4 flex items-center justify-between"> |
| 19 | <div class="flex items-baseline gap-2"> | 18 | <div class="flex items-baseline gap-2"> |
| 20 | <template v-if="course?.pay_type !== 'DESIGNATE'"> | 19 | <template v-if="course?.pay_type !== 'DESIGNATE'"> |
| 21 | <div v-if="course?.price !== '0.00'" class="flex items-baseline"> | 20 | <div v-if="course?.price !== '0.00'" class="flex items-baseline"> |
| 22 | - <span class="text-red-500 font-bold text-2xl">¥{{ course?.price }}</span> | 21 | + <span class="text-2xl font-bold text-red-500">¥{{ course?.price }}</span> |
| 23 | <!-- <span class="text-gray-500 text-sm ml-1">/人</span> --> | 22 | <!-- <span class="text-gray-500 text-sm ml-1">/人</span> --> |
| 24 | </div> | 23 | </div> |
| 25 | - <div v-else class="text-red-500 text-lg font-bold"> | 24 | + <div v-else class="text-lg font-bold text-red-500">免费</div> |
| 26 | - 免费 | ||
| 27 | - </div> | ||
| 28 | </template> | 25 | </template> |
| 29 | - <div v-else class="text-red-500 text-sm font-bold"> | 26 | + <div v-else class="text-sm font-bold text-red-500">指定学习</div> |
| 30 | - 指定学习 | ||
| 31 | - </div> | ||
| 32 | </div> | 27 | </div> |
| 33 | - <div class="text-gray-500 text-sm"> | 28 | + <div class="text-sm text-gray-500">{{ course?.buy_count }}人订阅</div> |
| 34 | - {{ course?.buy_count }}人订阅 | ||
| 35 | </div> | 29 | </div> |
| 36 | - </div> | 30 | + <div |
| 37 | - <div v-if="course?.expireDate" class="text-xs text-gray-500 mt-3 border-t border-gray-100 pt-3"> | 31 | + v-if="course?.expireDate" |
| 32 | + class="mt-3 border-t border-gray-100 pt-3 text-xs text-gray-500" | ||
| 33 | + > | ||
| 38 | 有效期: {{ course?.expireDate || '没有字段' }} | 34 | 有效期: {{ course?.expireDate || '没有字段' }} |
| 39 | </div> | 35 | </div> |
| 40 | </div> | 36 | </div> |
| ... | @@ -49,15 +45,20 @@ | ... | @@ -49,15 +45,20 @@ |
| 49 | </FrostedGlass> --> | 45 | </FrostedGlass> --> |
| 50 | 46 | ||
| 51 | <!-- Tab Navigation --> | 47 | <!-- Tab Navigation --> |
| 52 | - <FrostedGlass v-if="curriculumItems.length" class="mb-6 rounded-xl overflow-hidden"> | 48 | + <FrostedGlass v-if="curriculumItems.length" class="mb-6 overflow-hidden rounded-xl"> |
| 53 | <div class="border-b border-gray-200"> | 49 | <div class="border-b border-gray-200"> |
| 54 | <div class="flex"> | 50 | <div class="flex"> |
| 55 | - <button v-for="(item, index) in curriculumItems" :key="index" @click="activeTab = item.title" :class="[ | 51 | + <button |
| 56 | - 'flex-1 py-3 font-medium text-center', | 52 | + v-for="(item, index) in curriculumItems" |
| 53 | + :key="index" | ||
| 54 | + @click="activeTab = item.title" | ||
| 55 | + :class="[ | ||
| 56 | + 'flex-1 py-3 text-center font-medium', | ||
| 57 | activeTab === item.title | 57 | activeTab === item.title |
| 58 | - ? 'text-green-600 border-b-2 border-green-600 bg-green-50/50' | 58 | + ? 'border-b-2 border-green-600 bg-green-50/50 text-green-600' |
| 59 | - : 'text-gray-500' | 59 | + : 'text-gray-500', |
| 60 | - ]"> | 60 | + ]" |
| 61 | + > | ||
| 61 | {{ item.title }} | 62 | {{ item.title }} |
| 62 | </button> | 63 | </button> |
| 63 | </div> | 64 | </div> |
| ... | @@ -75,30 +76,50 @@ | ... | @@ -75,30 +76,50 @@ |
| 75 | </div> | 76 | </div> |
| 76 | 77 | ||
| 77 | <div v-if="activeTab === '主讲教师'"> | 78 | <div v-if="activeTab === '主讲教师'"> |
| 78 | - <div v-for="(item, index) in lecturers" :key="index" class="flex items-start" style="margin-bottom: 1rem;"> | 79 | + <div |
| 79 | - <div class="w-16 h-16 rounded-full overflow-hidden mr-4 flex-shrink-0"> | 80 | + v-for="(item, index) in lecturers" |
| 80 | - <img :src="item?.photo || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" alt="lecturer" | 81 | + :key="index" |
| 81 | - class="w-full h-full object-cover" @error="handleImageError" /> | 82 | + class="flex items-start" |
| 83 | + style="margin-bottom: 1rem" | ||
| 84 | + > | ||
| 85 | + <div class="mr-4 h-16 w-16 flex-shrink-0 overflow-hidden rounded-full"> | ||
| 86 | + <img | ||
| 87 | + :src="item?.photo || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" | ||
| 88 | + alt="lecturer" | ||
| 89 | + class="h-full w-full object-cover" | ||
| 90 | + @error="handleImageError" | ||
| 91 | + /> | ||
| 82 | </div> | 92 | </div> |
| 83 | - <div class="flex-1 min-w-0"> | 93 | + <div class="min-w-0 flex-1"> |
| 84 | <h4 class="font-bold text-gray-900">{{ item?.name }}</h4> | 94 | <h4 class="font-bold text-gray-900">{{ item?.name }}</h4> |
| 85 | <p class="text-sm text-gray-600">{{ item?.educational }}</p> | 95 | <p class="text-sm text-gray-600">{{ item?.educational }}</p> |
| 86 | - <p class="text-xs text-gray-500 mt-1 break-words">{{ item?.introduction }}</p> | 96 | + <p class="mt-1 break-words text-xs text-gray-500">{{ item?.introduction }}</p> |
| 87 | </div> | 97 | </div> |
| 88 | </div> | 98 | </div> |
| 89 | </div> | 99 | </div> |
| 90 | 100 | ||
| 91 | <div v-if="activeTab === '课程大纲'"> | 101 | <div v-if="activeTab === '课程大纲'"> |
| 92 | <div class="space-y-4"> | 102 | <div class="space-y-4"> |
| 93 | - <div v-for="(item, index) in displayedSchedule" :key="index" class="border-l-2 border-green-500 pl-3" @click="goToStudyDetail(item)"> | 103 | + <div |
| 104 | + v-for="(item, index) in displayedSchedule" | ||
| 105 | + :key="index" | ||
| 106 | + class="border-l-2 border-green-500 pl-3" | ||
| 107 | + @click="goToStudyDetail(item)" | ||
| 108 | + > | ||
| 94 | <h4 class="font-medium text-gray-800">{{ item.title }}</h4> | 109 | <h4 class="font-medium text-gray-800">{{ item.title }}</h4> |
| 95 | - <p class="text-sm text-gray-600 mt-1">{{ item.duration }}分钟 · 开课时间: {{ item.schedule_time }}</p> | 110 | + <p class="mt-1 text-sm text-gray-600"> |
| 111 | + {{ item.duration }}分钟 · 开课时间: {{ item.schedule_time }} | ||
| 112 | + </p> | ||
| 96 | </div> | 113 | </div> |
| 97 | - <div v-if="course?.schedule?.length > 4" class="flex justify-center mt-4"> | 114 | + <div v-if="course?.schedule?.length > 4" class="mt-4 flex justify-center"> |
| 98 | - <button @click="toggleSchedule" | 115 | + <button |
| 99 | - class="p-2 rounded-full hover:bg-green-50 text-green-600 hover:text-green-700 transition-all duration-300"> | 116 | + @click="toggleSchedule" |
| 100 | - <van-icon :name="isScheduleExpanded ? 'arrow-up' : 'arrow-down'" | 117 | + class="rounded-full p-2 text-green-600 transition-all duration-300 hover:bg-green-50 hover:text-green-700" |
| 101 | - class="text-xl transform transition-transform duration-300" /> | 118 | + > |
| 119 | + <van-icon | ||
| 120 | + :name="isScheduleExpanded ? 'arrow-up' : 'arrow-down'" | ||
| 121 | + class="transform text-xl transition-transform duration-300" | ||
| 122 | + /> | ||
| 102 | </button> | 123 | </button> |
| 103 | </div> | 124 | </div> |
| 104 | </div> | 125 | </div> |
| ... | @@ -107,7 +128,7 @@ | ... | @@ -107,7 +128,7 @@ |
| 107 | <div v-if="activeTab === '打卡互动' && task_list.length > 0"> | 128 | <div v-if="activeTab === '打卡互动' && task_list.length > 0"> |
| 108 | <!-- 打卡区域 --> | 129 | <!-- 打卡区域 --> |
| 109 | <div class="py-4"> | 130 | <div class="py-4"> |
| 110 | - <div class="bg-white rounded-lg p-4 mb-4 cursor-pointer"> | 131 | + <div class="mb-4 cursor-pointer rounded-lg bg-white p-4"> |
| 111 | <div class="flex items-center justify-between" @click="goToCheckin()"> | 132 | <div class="flex items-center justify-between" @click="goToCheckin()"> |
| 112 | <div class="flex items-center gap-3"> | 133 | <div class="flex items-center gap-3"> |
| 113 | <van-icon size="3rem" name="calendar-o" class="text-xl text-gray-600" /> | 134 | <van-icon size="3rem" name="calendar-o" class="text-xl text-gray-600" /> |
| ... | @@ -153,8 +174,8 @@ | ... | @@ -153,8 +174,8 @@ |
| 153 | </FrostedGlass> --> | 174 | </FrostedGlass> --> |
| 154 | 175 | ||
| 155 | <!-- Student Reviews --> | 176 | <!-- Student Reviews --> |
| 156 | - <FrostedGlass class="mb-6 p-4 rounded-xl"> | 177 | + <FrostedGlass class="mb-6 rounded-xl p-4"> |
| 157 | - <div class="flex justify-between items-center mb-3"> | 178 | + <div class="mb-3 flex items-center justify-between"> |
| 158 | <h3 class="text-lg font-bold text-gray-800">学员评价</h3> | 179 | <h3 class="text-lg font-bold text-gray-800">学员评价</h3> |
| 159 | <!-- 立即评论按钮 - 仅在已购买但未评价时显示 --> | 180 | <!-- 立即评论按钮 - 仅在已购买但未评价时显示 --> |
| 160 | <van-button | 181 | <van-button |
| ... | @@ -163,51 +184,80 @@ | ... | @@ -163,51 +184,80 @@ |
| 163 | size="small" | 184 | size="small" |
| 164 | round | 185 | round |
| 165 | color="linear-gradient(to right, #3b82f6, #2563eb)" | 186 | color="linear-gradient(to right, #3b82f6, #2563eb)" |
| 166 | - class="shadow-sm text-xs px-4 py-1.5 min-w-[80px] hover:shadow-md transition-all duration-200" | 187 | + class="min-w-[80px] px-4 py-1.5 text-xs shadow-sm transition-all duration-200 hover:shadow-md" |
| 167 | > | 188 | > |
| 168 | <van-icon name="edit" size="14" class="mr-1" /> | 189 | <van-icon name="edit" size="14" class="mr-1" /> |
| 169 | 立即评论 | 190 | 立即评论 |
| 170 | </van-button> | 191 | </van-button> |
| 171 | </div> | 192 | </div> |
| 172 | - <div class="flex items-center mb-3"> | 193 | + <div class="mb-3 flex items-center"> |
| 173 | - <div class="flex items-center mr-2"> | 194 | + <div class="mr-2 flex items-center"> |
| 174 | - <van-rate v-model="commentScore" readonly allow-half color="#facc15" void-color="#e5e7eb" size="20" /> | 195 | + <van-rate |
| 196 | + v-model="commentScore" | ||
| 197 | + readonly | ||
| 198 | + allow-half | ||
| 199 | + color="#facc15" | ||
| 200 | + void-color="#e5e7eb" | ||
| 201 | + size="20" | ||
| 202 | + /> | ||
| 175 | </div> | 203 | </div> |
| 176 | <div class="text-gray-700">{{ commentScore }} ({{ commentTotal }}条评论)</div> | 204 | <div class="text-gray-700">{{ commentScore }} ({{ commentTotal }}条评论)</div> |
| 177 | </div> | 205 | </div> |
| 178 | 206 | ||
| 179 | <div class="space-y-4"> | 207 | <div class="space-y-4"> |
| 180 | - <div v-for="(item, index) in commentList" :key="index" class="border-b border-gray-100 pb-3"> | 208 | + <div |
| 209 | + v-for="(item, index) in commentList" | ||
| 210 | + :key="index" | ||
| 211 | + class="border-b border-gray-100 pb-3" | ||
| 212 | + > | ||
| 181 | <div class="flex justify-between"> | 213 | <div class="flex justify-between"> |
| 182 | <div class="font-medium text-gray-800">{{ item.name || '匿名用户' }}</div> | 214 | <div class="font-medium text-gray-800">{{ item.name || '匿名用户' }}</div> |
| 183 | <div class="text-xs text-gray-500">{{ formatDate(item.created_time) }}</div> | 215 | <div class="text-xs text-gray-500">{{ formatDate(item.created_time) }}</div> |
| 184 | </div> | 216 | </div> |
| 185 | - <p class="text-sm text-gray-600 mt-1"> | 217 | + <p class="mt-1 text-sm text-gray-600"> |
| 186 | {{ item.note }} | 218 | {{ item.note }} |
| 187 | </p> | 219 | </p> |
| 188 | </div> | 220 | </div> |
| 189 | </div> | 221 | </div> |
| 190 | - <button @click="router.push(`/courses/${course?.id}/reviews`)" | 222 | + <button |
| 191 | - class="w-full text-center text-green-600 mt-3 text-sm"> | 223 | + @click="router.push(`/courses/${course?.id}/reviews`)" |
| 224 | + class="mt-3 w-full text-center text-sm text-green-600" | ||
| 225 | + > | ||
| 192 | 查看全部评价 | 226 | 查看全部评价 |
| 193 | </button> | 227 | </button> |
| 194 | </FrostedGlass> | 228 | </FrostedGlass> |
| 195 | - | ||
| 196 | </div> | 229 | </div> |
| 197 | 230 | ||
| 198 | <!-- Bottom Action Bar --> | 231 | <!-- Bottom Action Bar --> |
| 199 | - <div class="fixed bottom-16 left-0 right-0 bg-white shadow-lg p-3 flex justify-between items-center"> | 232 | + <div |
| 233 | + class="fixed bottom-16 left-0 right-0 flex items-center justify-between bg-white p-3 shadow-lg" | ||
| 234 | + > | ||
| 200 | <div class="flex space-x-4"> | 235 | <div class="flex space-x-4"> |
| 201 | - <button class="flex flex-col items-center text-gray-500 text-xs transition-transform duration-300" | 236 | + <button |
| 202 | - @click="toggleFavorite" :class="{ 'animate-favorite': isFavorite }"> | 237 | + class="flex flex-col items-center text-xs text-gray-500 transition-transform duration-300" |
| 203 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 transition-transform duration-300" | 238 | + @click="toggleFavorite" |
| 204 | - :fill="isFavorite ? 'red' : 'none'" viewBox="0 0 24 24" :stroke="isFavorite ? 'red' : 'currentColor'"> | 239 | + :class="{ 'animate-favorite': isFavorite }" |
| 205 | - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | 240 | + > |
| 206 | - d="M4.318 6.318 a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682 a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318 a4.5 4.5 0 00-6.364 0z" /> | 241 | + <svg |
| 242 | + xmlns="http://www.w3.org/2000/svg" | ||
| 243 | + class="h-6 w-6 transition-transform duration-300" | ||
| 244 | + :fill="isFavorite ? 'red' : 'none'" | ||
| 245 | + viewBox="0 0 24 24" | ||
| 246 | + :stroke="isFavorite ? 'red' : 'currentColor'" | ||
| 247 | + > | ||
| 248 | + <path | ||
| 249 | + stroke-linecap="round" | ||
| 250 | + stroke-linejoin="round" | ||
| 251 | + stroke-width="2" | ||
| 252 | + d="M4.318 6.318 a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682 a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318 a4.5 4.5 0 00-6.364 0z" | ||
| 253 | + /> | ||
| 207 | </svg> | 254 | </svg> |
| 208 | 收藏 | 255 | 收藏 |
| 209 | </button> | 256 | </button> |
| 210 | - <button class="flex flex-col items-center text-gray-500 text-xs" @click="open_share_poster"> | 257 | + <button |
| 258 | + class="flex flex-col items-center text-xs text-gray-500" | ||
| 259 | + @click="open_share_poster" | ||
| 260 | + > | ||
| 211 | <svg | 261 | <svg |
| 212 | xmlns="http://www.w3.org/2000/svg" | 262 | xmlns="http://www.w3.org/2000/svg" |
| 213 | class="h-6 w-6" | 263 | class="h-6 w-6" |
| ... | @@ -224,7 +274,11 @@ | ... | @@ -224,7 +274,11 @@ |
| 224 | </svg> | 274 | </svg> |
| 225 | 分享 | 275 | 分享 |
| 226 | </button> | 276 | </button> |
| 227 | - <button v-if="consult_contacts.length" class="flex flex-col items-center text-gray-500 text-xs" @click="open_consult_dialog"> | 277 | + <button |
| 278 | + v-if="consult_contacts.length" | ||
| 279 | + class="flex flex-col items-center text-xs text-gray-500" | ||
| 280 | + @click="open_consult_dialog" | ||
| 281 | + > | ||
| 228 | <svg | 282 | <svg |
| 229 | xmlns="http://www.w3.org/2000/svg" | 283 | xmlns="http://www.w3.org/2000/svg" |
| 230 | class="h-6 w-6" | 284 | class="h-6 w-6" |
| ... | @@ -244,17 +298,31 @@ | ... | @@ -244,17 +298,31 @@ |
| 244 | </div> | 298 | </div> |
| 245 | <div class="flex items-center"> | 299 | <div class="flex items-center"> |
| 246 | <div v-if="!course?.is_buy" class="mr-2"> | 300 | <div v-if="!course?.is_buy" class="mr-2"> |
| 247 | - <div v-if="course?.price !== '0.00'" class="text-red-500 font-bold">¥{{ course?.price || 0 }}</div> | 301 | + <div v-if="course?.price !== '0.00'" class="font-bold text-red-500"> |
| 302 | + ¥{{ course?.price || 0 }} | ||
| 303 | + </div> | ||
| 248 | <div v-if="course?.price !== '0.00'" class="text-xs text-gray-400 line-through"> | 304 | <div v-if="course?.price !== '0.00'" class="text-xs text-gray-400 line-through"> |
| 249 | ¥{{ Math.round((course?.price || 0) * 1.2) }} | 305 | ¥{{ Math.round((course?.price || 0) * 1.2) }} |
| 250 | </div> | 306 | </div> |
| 251 | </div> | 307 | </div> |
| 252 | - <van-button v-if="!isPurchased" @click="handlePurchase" round block | 308 | + <van-button |
| 253 | - color="linear-gradient(to right, #22c55e, #16a34a)" class="shadow-md"> | 309 | + v-if="!isPurchased" |
| 310 | + @click="handlePurchase" | ||
| 311 | + round | ||
| 312 | + block | ||
| 313 | + color="linear-gradient(to right, #22c55e, #16a34a)" | ||
| 314 | + class="shadow-md" | ||
| 315 | + > | ||
| 254 | {{ course?.price !== '0.00' ? '立即' : '免费' }}购买 | 316 | {{ course?.price !== '0.00' ? '立即' : '免费' }}购买 |
| 255 | </van-button> | 317 | </van-button> |
| 256 | - <van-button v-else @click="handleViewCourse" round block | 318 | + <van-button |
| 257 | - color="linear-gradient(to right, #22c55e, #16a34a)" class="shadow-md"> | 319 | + v-else |
| 320 | + @click="handleViewCourse" | ||
| 321 | + round | ||
| 322 | + block | ||
| 323 | + color="linear-gradient(to right, #22c55e, #16a34a)" | ||
| 324 | + class="shadow-md" | ||
| 325 | + > | ||
| 258 | 查看课程 | 326 | 查看课程 |
| 259 | </van-button> | 327 | </van-button> |
| 260 | </div> | 328 | </div> |
| ... | @@ -281,40 +349,75 @@ | ... | @@ -281,40 +349,75 @@ |
| 281 | > | 349 | > |
| 282 | <div class="ConsultPopup p-4"> | 350 | <div class="ConsultPopup p-4"> |
| 283 | <!-- 标题与关闭图标 --> | 351 | <!-- 标题与关闭图标 --> |
| 284 | - <div class="flex justify-between items-center mb-3"> | 352 | + <div class="mb-3 flex items-center justify-between"> |
| 285 | <h3 class="font-medium">咨询信息</h3> | 353 | <h3 class="font-medium">咨询信息</h3> |
| 286 | <van-icon name="cross" @click="close_consult_dialog" /> | 354 | <van-icon name="cross" @click="close_consult_dialog" /> |
| 287 | </div> | 355 | </div> |
| 288 | 356 | ||
| 289 | <!-- 联系人列表:点击名称直接拨打 --> | 357 | <!-- 联系人列表:点击名称直接拨打 --> |
| 290 | - <div class="bg-gray-50 border border-gray-200 rounded-lg p-3 mb-4"> | 358 | + <div class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-3"> |
| 291 | <div class="flex items-center"> | 359 | <div class="flex items-center"> |
| 292 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-500 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor"> | 360 | + <svg |
| 293 | - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h2.28a2 2 0 011.789 1.106l1.152 2.305a2 2 0 01-.42 2.317L9.384 10.09a16.001 16.001 0 006.526 6.526l1.356-1.102a2 2 0 012.317-.42l2.305 1.152A2 2 0 0121 18.72V21a2 2 0 01-2 2h-1a18 18 0 01-17-17V5z" /> | 361 | + xmlns="http://www.w3.org/2000/svg" |
| 362 | + class="mr-2 h-5 w-5 text-green-500" | ||
| 363 | + viewBox="0 0 24 24" | ||
| 364 | + fill="none" | ||
| 365 | + stroke="currentColor" | ||
| 366 | + > | ||
| 367 | + <path | ||
| 368 | + stroke-linecap="round" | ||
| 369 | + stroke-linejoin="round" | ||
| 370 | + stroke-width="2" | ||
| 371 | + d="M3 5a2 2 0 012-2h2.28a2 2 0 011.789 1.106l1.152 2.305a2 2 0 01-.42 2.317L9.384 10.09a16.001 16.001 0 006.526 6.526l1.356-1.102a2 2 0 012.317-.42l2.305 1.152A2 2 0 0121 18.72V21a2 2 0 01-2 2h-1a18 18 0 01-17-17V5z" | ||
| 372 | + /> | ||
| 294 | </svg> | 373 | </svg> |
| 295 | <span class="text-gray-700">联系人</span> | 374 | <span class="text-gray-700">联系人</span> |
| 296 | </div> | 375 | </div> |
| 297 | <ul class="mt-2 divide-y divide-gray-200"> | 376 | <ul class="mt-2 divide-y divide-gray-200"> |
| 298 | - <li v-for="(c, idx) in consult_contacts" :key="idx" class="flex items-center justify-between py-2"> | 377 | + <li |
| 378 | + v-for="(c, idx) in consult_contacts" | ||
| 379 | + :key="idx" | ||
| 380 | + class="flex items-center justify-between py-2" | ||
| 381 | + > | ||
| 299 | <div class="flex items-center"> | 382 | <div class="flex items-center"> |
| 300 | - <a class="text-gray-800 text-base mr-3" :href="`tel:${c.phone}`" @click.prevent="call_phone(c.phone)">{{ c.name }}</a> | 383 | + <a |
| 384 | + class="mr-3 text-base text-gray-800" | ||
| 385 | + :href="`tel:${c.phone}`" | ||
| 386 | + @click.prevent="call_phone(c.phone)" | ||
| 387 | + >{{ c.name }}</a | ||
| 388 | + > | ||
| 301 | <img | 389 | <img |
| 302 | v-if="c?.qrcode" | 390 | v-if="c?.qrcode" |
| 303 | - :src="build_og_image_url(c.qrcode)" | 391 | + :src="buildOgImageUrl(c.qrcode)" |
| 304 | alt="联系人二维码" | 392 | alt="联系人二维码" |
| 305 | - class="w-10 h-10 object-contain rounded cursor-pointer" | 393 | + class="h-10 w-10 cursor-pointer rounded object-contain" |
| 306 | @click="open_contact_qr_preview(c.qrcode)" | 394 | @click="open_contact_qr_preview(c.qrcode)" |
| 307 | /> | 395 | /> |
| 308 | </div> | 396 | </div> |
| 309 | - <a class="text-green-600 text-base" :href="`tel:${c.phone}`" @click.prevent="call_phone(c.phone)">{{ c.phone }}</a> | 397 | + <a |
| 398 | + class="text-base text-green-600" | ||
| 399 | + :href="`tel:${c.phone}`" | ||
| 400 | + @click.prevent="call_phone(c.phone)" | ||
| 401 | + >{{ c.phone }}</a | ||
| 402 | + > | ||
| 310 | </li> | 403 | </li> |
| 311 | </ul> | 404 | </ul> |
| 312 | - <div v-if="(consult_contacts || []).some(c => c?.qrcode)" class="text-xs text-gray-500 mt-2">点击图片可查看大图</div> | 405 | + <div |
| 406 | + v-if="(consult_contacts || []).some(c => c?.qrcode)" | ||
| 407 | + class="mt-2 text-xs text-gray-500" | ||
| 408 | + > | ||
| 409 | + 点击图片可查看大图 | ||
| 410 | + </div> | ||
| 313 | </div> | 411 | </div> |
| 314 | 412 | ||
| 315 | <!-- 底部关闭按钮(唯一操作) --> | 413 | <!-- 底部关闭按钮(唯一操作) --> |
| 316 | <div class="mt-4"> | 414 | <div class="mt-4"> |
| 317 | - <button class="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-2 rounded-lg" @click="close_consult_dialog">关闭</button> | 415 | + <button |
| 416 | + class="w-full rounded-lg bg-gradient-to-r from-green-500 to-green-600 py-2 text-white" | ||
| 417 | + @click="close_consult_dialog" | ||
| 418 | + > | ||
| 419 | + 关闭 | ||
| 420 | + </button> | ||
| 318 | </div> | 421 | </div> |
| 319 | </div> | 422 | </div> |
| 320 | </van-popup> | 423 | </van-popup> |
| ... | @@ -325,16 +428,16 @@ | ... | @@ -325,16 +428,16 @@ |
| 325 | </template> | 428 | </template> |
| 326 | 429 | ||
| 327 | <script setup> | 430 | <script setup> |
| 328 | -import { ref, onMounted, onUnmounted, defineComponent, h } from 'vue' | 431 | +import { ref, onMounted, defineComponent, h } from 'vue' |
| 329 | import { useRoute, useRouter } from 'vue-router' | 432 | import { useRoute, useRouter } from 'vue-router' |
| 330 | import { useCart } from '@/contexts/cart' | 433 | import { useCart } from '@/contexts/cart' |
| 331 | import { useAuth } from '@/contexts/auth' | 434 | import { useAuth } from '@/contexts/auth' |
| 332 | -import { useTitle } from '@vueuse/core'; | 435 | +import { useTitle } from '@vueuse/core' |
| 333 | import { wxInfo, formatDate, normalizeCheckinTaskItems } from '@/utils/tools' | 436 | import { wxInfo, formatDate, normalizeCheckinTaskItems } from '@/utils/tools' |
| 437 | +import { buildOgImageUrl } from '@/utils/ogMeta' | ||
| 334 | import { startWxAuth } from '@/router/guards' | 438 | import { startWxAuth } from '@/router/guards' |
| 335 | import { getAuthInfoAPI, getUserIsLoginAPI } from '@/api/auth' | 439 | import { getAuthInfoAPI, getUserIsLoginAPI } from '@/api/auth' |
| 336 | -import { showToast, showDialog, showImagePreview } from 'vant'; | 440 | +import { showToast, showDialog, showImagePreview } from 'vant' |
| 337 | -import { sharePage } from '@/composables/useShare.js' | ||
| 338 | import { useImageLoader } from '@/composables/useImageLoader' | 441 | import { useImageLoader } from '@/composables/useImageLoader' |
| 339 | 442 | ||
| 340 | import AppLayout from '@/components/layout/AppLayout.vue' | 443 | import AppLayout from '@/components/layout/AppLayout.vue' |
| ... | @@ -343,127 +446,27 @@ import SharePoster from '@/components/poster/SharePoster.vue' | ... | @@ -343,127 +446,27 @@ import SharePoster from '@/components/poster/SharePoster.vue' |
| 343 | import CheckInDialog from '@/components/checkin/CheckInDialog.vue' | 446 | import CheckInDialog from '@/components/checkin/CheckInDialog.vue' |
| 344 | 447 | ||
| 345 | // 导入接口 | 448 | // 导入接口 |
| 346 | -import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from "@/api/course"; | 449 | +import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from '@/api/course' |
| 347 | -import { addFavoriteAPI, cancelFavoriteAPI } from "@/api/favorite"; | 450 | +import { addFavoriteAPI, cancelFavoriteAPI } from '@/api/favorite' |
| 348 | // 已统一使用通用打卡弹窗,移除未使用的打卡提交接口 | 451 | // 已统一使用通用打卡弹窗,移除未使用的打卡提交接口 |
| 349 | 452 | ||
| 350 | // 图片加载错误处理 | 453 | // 图片加载错误处理 |
| 351 | const { handleImageError } = useImageLoader() | 454 | const { handleImageError } = useImageLoader() |
| 352 | 455 | ||
| 353 | -// | ||
| 354 | -// Open Graph 元标签:进入课程详情页时动态插入,离开页面时移除 | ||
| 355 | -// | ||
| 356 | -// 原始 og:description 内容缓存(用于离开页面时恢复) | ||
| 357 | -let original_og_desc_content = null; | ||
| 358 | - | ||
| 359 | /** | 456 | /** |
| 360 | * @function open_contact_qr_preview | 457 | * @function open_contact_qr_preview |
| 361 | * @description 打开联系人二维码大图预览。 | 458 | * @description 打开联系人二维码大图预览。 |
| 362 | * @param {string} url 二维码图片地址 | 459 | * @param {string} url 二维码图片地址 |
| 363 | * @returns {void} | 460 | * @returns {void} |
| 364 | */ | 461 | */ |
| 365 | -const open_contact_qr_preview = (url) => { | 462 | +const open_contact_qr_preview = url => { |
| 366 | - if (!url) return; | 463 | + if (!url) return |
| 367 | - const src = build_og_image_url(url) | 464 | + const src = buildOgImageUrl(url) |
| 368 | showImagePreview([src]) | 465 | showImagePreview([src]) |
| 369 | } | 466 | } |
| 370 | 467 | ||
| 371 | -/** | 468 | +const $route = useRoute() |
| 372 | - * @function build_og_image_url | 469 | +const $router = useRouter() |
| 373 | - * @description 构建 og:image 地址;若为 cdn.ipadbiz.cn 域名,则追加图片压缩参数。 | ||
| 374 | - * @param {string} src 原始图片地址 | ||
| 375 | - * @returns {string} 处理后的图片地址 | ||
| 376 | - */ | ||
| 377 | -function build_og_image_url(src) { | ||
| 378 | - // 若无地址,直接返回空字符串 | ||
| 379 | - if (!src) return ''; | ||
| 380 | - // 若为指定 CDN 域名,追加压缩参数(遵循项目图片规则) | ||
| 381 | - if (src.includes('cdn.ipadbiz.cn')) { | ||
| 382 | - const compress_param = 'imageMogr2/thumbnail/400x/strip/quality/70'; | ||
| 383 | - if (src.includes('?')) { | ||
| 384 | - if (!src.includes(compress_param)) { | ||
| 385 | - return src + '&' + compress_param; | ||
| 386 | - } | ||
| 387 | - } else { | ||
| 388 | - return src + '?' + compress_param; | ||
| 389 | - } | ||
| 390 | - } | ||
| 391 | - return src; | ||
| 392 | -} | ||
| 393 | - | ||
| 394 | -/** | ||
| 395 | - * @function set_og_meta | ||
| 396 | - * @description 在页面 head 中插入或更新 4 个 Open Graph 元标签。 | ||
| 397 | - * @param {Object} payload 载荷对象 | ||
| 398 | - * @param {string} payload.title 主标题(og:title) | ||
| 399 | - * @param {string} payload.description 副标题/描述(og:description) | ||
| 400 | - * @param {string} payload.image 图片地址(og:image) | ||
| 401 | - * @param {string} payload.url 当前页面URL(og:url) | ||
| 402 | - * @returns {void} | ||
| 403 | - */ | ||
| 404 | -function set_og_meta(payload) { | ||
| 405 | - const head = document.head || document.getElementsByTagName('head')[0]; | ||
| 406 | - if (!head) return; | ||
| 407 | - const titleEl = head.querySelector('title'); | ||
| 408 | - // 1) 直接修改 index.html 中现有的 og:description(不新建,避免重复) | ||
| 409 | - const descMeta = head.querySelector('meta[property="og:description"]'); | ||
| 410 | - if (descMeta) { | ||
| 411 | - // 首次保存原始内容用于恢复 | ||
| 412 | - if (original_og_desc_content === null) { | ||
| 413 | - original_og_desc_content = descMeta.getAttribute('content') || ''; | ||
| 414 | - } | ||
| 415 | - descMeta.setAttribute('content', payload.description || ''); | ||
| 416 | - } | ||
| 417 | - | ||
| 418 | - // 2) 其余标签按需创建(若不存在则插入到 <title> 前) | ||
| 419 | - const others = [ | ||
| 420 | - { id: 'og-title', property: 'og:title', content: payload.title || '' }, | ||
| 421 | - { id: 'og-image', property: 'og:image', content: build_og_image_url(payload.image || '') }, | ||
| 422 | - { id: 'og-url', property: 'og:url', content: payload.url || window.location.href } | ||
| 423 | - ]; | ||
| 424 | - others.forEach(item => { | ||
| 425 | - let meta = head.querySelector(`meta#${item.id}`); | ||
| 426 | - if (!meta) { | ||
| 427 | - meta = document.createElement('meta'); | ||
| 428 | - meta.setAttribute('id', item.id); | ||
| 429 | - meta.setAttribute('property', item.property); | ||
| 430 | - if (titleEl) { | ||
| 431 | - head.insertBefore(meta, titleEl); | ||
| 432 | - } else if (head.firstChild) { | ||
| 433 | - head.insertBefore(meta, head.firstChild); | ||
| 434 | - } else { | ||
| 435 | - head.appendChild(meta); | ||
| 436 | - } | ||
| 437 | - } | ||
| 438 | - meta.setAttribute('content', item.content); | ||
| 439 | - }); | ||
| 440 | -} | ||
| 441 | - | ||
| 442 | -/** | ||
| 443 | - * @function remove_og_meta | ||
| 444 | - * @description 从页面 head 中移除进入详情页时插入的 Open Graph 元标签。 | ||
| 445 | - * @returns {void} | ||
| 446 | - */ | ||
| 447 | -function remove_og_meta() { | ||
| 448 | - // 恢复 index.html 中现有的 og:description 原始内容 | ||
| 449 | - const head = document.head || document.getElementsByTagName('head')[0]; | ||
| 450 | - if (head) { | ||
| 451 | - const descMeta = head.querySelector('meta[property="og:description"]'); | ||
| 452 | - if (descMeta && original_og_desc_content !== null) { | ||
| 453 | - descMeta.setAttribute('content', original_og_desc_content); | ||
| 454 | - } | ||
| 455 | - } | ||
| 456 | - // 移除运行时创建的其它 OG 标签 | ||
| 457 | - ['og-title', 'og-image', 'og-url', 'og-description'].forEach(id => { | ||
| 458 | - const meta = document.querySelector(`meta#${id}`); | ||
| 459 | - if (meta && meta.parentNode) { | ||
| 460 | - meta.parentNode.removeChild(meta); | ||
| 461 | - } | ||
| 462 | - }); | ||
| 463 | -} | ||
| 464 | - | ||
| 465 | -const $route = useRoute(); | ||
| 466 | -const $router = useRouter(); | ||
| 467 | 470 | ||
| 468 | const route = useRoute() | 471 | const route = useRoute() |
| 469 | const router = useRouter() | 472 | const router = useRouter() |
| ... | @@ -497,20 +500,20 @@ const open_share_poster = () => { | ... | @@ -497,20 +500,20 @@ const open_share_poster = () => { |
| 497 | } | 500 | } |
| 498 | 501 | ||
| 499 | // 处理富文本点击事件,实现图片预览 | 502 | // 处理富文本点击事件,实现图片预览 |
| 500 | -const handleIntroduceClick = (event) => { | 503 | +const handleIntroduceClick = event => { |
| 501 | - const target = event.target; | 504 | + const { target } = event |
| 502 | if (target.tagName === 'IMG') { | 505 | if (target.tagName === 'IMG') { |
| 503 | // 阻止默认行为(如果需要) | 506 | // 阻止默认行为(如果需要) |
| 504 | - event.preventDefault(); | 507 | + event.preventDefault() |
| 505 | 508 | ||
| 506 | // 调用 vant 的图片预览 | 509 | // 调用 vant 的图片预览 |
| 507 | showImagePreview({ | 510 | showImagePreview({ |
| 508 | images: [target.src], | 511 | images: [target.src], |
| 509 | closeable: true, | 512 | closeable: true, |
| 510 | showIndex: false, // 单张图片不显示索引 | 513 | showIndex: false, // 单张图片不显示索引 |
| 511 | - }); | 514 | + }) |
| 512 | } | 515 | } |
| 513 | -}; | 516 | +} |
| 514 | 517 | ||
| 515 | // 打卡相关状态 | 518 | // 打卡相关状态 |
| 516 | const task_list = ref([]) | 519 | const task_list = ref([]) |
| ... | @@ -552,7 +555,7 @@ const close_consult_dialog = () => { | ... | @@ -552,7 +555,7 @@ const close_consult_dialog = () => { |
| 552 | * @param {string} phone 电话号码 | 555 | * @param {string} phone 电话号码 |
| 553 | * @returns {void} | 556 | * @returns {void} |
| 554 | */ | 557 | */ |
| 555 | -const call_phone = (phone) => { | 558 | +const call_phone = phone => { |
| 556 | const p = phone || '' | 559 | const p = phone || '' |
| 557 | if (p) { | 560 | if (p) { |
| 558 | window.location.href = `tel:${p}` | 561 | window.location.href = `tel:${p}` |
| ... | @@ -561,7 +564,6 @@ const call_phone = (phone) => { | ... | @@ -561,7 +564,6 @@ const call_phone = (phone) => { |
| 561 | 564 | ||
| 562 | const { addToCart, proceedToCheckout } = useCart() | 565 | const { addToCart, proceedToCheckout } = useCart() |
| 563 | 566 | ||
| 564 | - | ||
| 565 | /** | 567 | /** |
| 566 | * 收藏/取消收藏操作 | 568 | * 收藏/取消收藏操作 |
| 567 | * @description 添加收藏接口返回401未登录时,参考全局axios重定向逻辑,跳转到登录页并带上当前路径。 | 569 | * @description 添加收藏接口返回401未登录时,参考全局axios重定向逻辑,跳转到登录页并带上当前路径。 |
| ... | @@ -570,7 +572,7 @@ const { addToCart, proceedToCheckout } = useCart() | ... | @@ -570,7 +572,7 @@ const { addToCart, proceedToCheckout } = useCart() |
| 570 | const toggleFavorite = async () => { | 572 | const toggleFavorite = async () => { |
| 571 | if (isFavorite.value) { | 573 | if (isFavorite.value) { |
| 572 | const resp = await cancelFavoriteAPI({ | 574 | const resp = await cancelFavoriteAPI({ |
| 573 | - group_id: course.value.id | 575 | + group_id: course.value.id, |
| 574 | }) | 576 | }) |
| 575 | const code = resp && typeof resp === 'object' ? resp.code : 0 | 577 | const code = resp && typeof resp === 'object' ? resp.code : 0 |
| 576 | if (code === 1) { | 578 | if (code === 1) { |
| ... | @@ -582,7 +584,7 @@ const toggleFavorite = async () => { | ... | @@ -582,7 +584,7 @@ const toggleFavorite = async () => { |
| 582 | } | 584 | } |
| 583 | } else { | 585 | } else { |
| 584 | const resp = await addFavoriteAPI({ | 586 | const resp = await addFavoriteAPI({ |
| 585 | - group_id: course.value.id | 587 | + group_id: course.value.id, |
| 586 | }) | 588 | }) |
| 587 | const code = resp && typeof resp === 'object' ? resp.code : 0 | 589 | const code = resp && typeof resp === 'object' ? resp.code : 0 |
| 588 | if (code === 1) { | 590 | if (code === 1) { |
| ... | @@ -605,17 +607,29 @@ const toggleFavorite = async () => { | ... | @@ -605,17 +607,29 @@ const toggleFavorite = async () => { |
| 605 | 607 | ||
| 606 | // Curriculum items | 608 | // Curriculum items |
| 607 | const curriculumItems = computed(() => { | 609 | const curriculumItems = computed(() => { |
| 608 | - if (!course.value) return []; | 610 | + if (!course.value) return [] |
| 609 | 611 | ||
| 610 | return [ | 612 | return [ |
| 611 | { title: '课程介绍', active: activeTab.value === '课程介绍', show: !!course.value.introduce }, | 613 | { title: '课程介绍', active: activeTab.value === '课程介绍', show: !!course.value.introduce }, |
| 612 | - { title: '主讲教师', active: activeTab.value === '主讲教师', show: !!(lecturers.value && lecturers.value.length > 0) }, | 614 | + { |
| 613 | - { title: '课程大纲', active: activeTab.value === '课程大纲', show: !!(course.value.schedule && course.value.schedule.length > 0) }, | 615 | + title: '主讲教师', |
| 616 | + active: activeTab.value === '主讲教师', | ||
| 617 | + show: !!(lecturers.value && lecturers.value.length > 0), | ||
| 618 | + }, | ||
| 619 | + { | ||
| 620 | + title: '课程大纲', | ||
| 621 | + active: activeTab.value === '课程大纲', | ||
| 622 | + show: !!(course.value.schedule && course.value.schedule.length > 0), | ||
| 623 | + }, | ||
| 614 | // { title: '课程亮点', active: activeTab.value === '课程亮点', show: !!course.value.highlights }, | 624 | // { title: '课程亮点', active: activeTab.value === '课程亮点', show: !!course.value.highlights }, |
| 615 | // { title: '学习目标', active: activeTab.value === '学习目标', show: !!course.value.learning_goal }, | 625 | // { title: '学习目标', active: activeTab.value === '学习目标', show: !!course.value.learning_goal }, |
| 616 | - { title: '打卡互动', active: activeTab.value === '打卡互动', show: !!course.value.is_buy && task_list.value.length > 0 }, | 626 | + { |
| 617 | - ].filter(item => item.show); | 627 | + title: '打卡互动', |
| 618 | -}); | 628 | + active: activeTab.value === '打卡互动', |
| 629 | + show: !!course.value.is_buy && task_list.value.length > 0, | ||
| 630 | + }, | ||
| 631 | + ].filter(item => item.show) | ||
| 632 | +}) | ||
| 619 | 633 | ||
| 620 | /** | 634 | /** |
| 621 | * @function handlePurchase | 635 | * @function handlePurchase |
| ... | @@ -639,7 +653,7 @@ const handlePurchase = async () => { | ... | @@ -639,7 +653,7 @@ const handlePurchase = async () => { |
| 639 | * 判断是否为微信内置浏览器环境 | 653 | * 判断是否为微信内置浏览器环境 |
| 640 | * 非微信环境提示用户在微信内打开;免费课程跳过校验 | 654 | * 非微信环境提示用户在微信内打开;免费课程跳过校验 |
| 641 | */ | 655 | */ |
| 642 | - const is_free = (course.value?.price === '0.00' || Number(course.value?.price) === 0) | 656 | + const is_free = course.value?.price === '0.00' || Number(course.value?.price) === 0 |
| 643 | if (!is_free && !import.meta.env.DEV && !wxInfo().isWeiXin) { | 657 | if (!is_free && !import.meta.env.DEV && !wxInfo().isWeiXin) { |
| 644 | showToast('请在微信内打开进行购买') | 658 | showToast('请在微信内打开进行购买') |
| 645 | return | 659 | return |
| ... | @@ -648,7 +662,7 @@ const handlePurchase = async () => { | ... | @@ -648,7 +662,7 @@ const handlePurchase = async () => { |
| 648 | // 微信环境内授权检查:未授权自动触发一次授权流程 | 662 | // 微信环境内授权检查:未授权自动触发一次授权流程 |
| 649 | if (!import.meta.env.DEV && wxInfo().isWeiXin) { | 663 | if (!import.meta.env.DEV && wxInfo().isWeiXin) { |
| 650 | try { | 664 | try { |
| 651 | - const { code, data } = await getAuthInfoAPI(); | 665 | + const { code, data } = await getAuthInfoAPI() |
| 652 | if (code && !data.openid_has) { | 666 | if (code && !data.openid_has) { |
| 653 | showToast('正在进行微信授权,请稍后...') | 667 | showToast('正在进行微信授权,请稍后...') |
| 654 | await startWxAuth() | 668 | await startWxAuth() |
| ... | @@ -690,11 +704,11 @@ const handlePurchase = async () => { | ... | @@ -690,11 +704,11 @@ const handlePurchase = async () => { |
| 690 | } | 704 | } |
| 691 | 705 | ||
| 692 | // 提交评论操作 | 706 | // 提交评论操作 |
| 693 | -const handleReviewSubmit = async (review) => { | 707 | +const handleReviewSubmit = async review => { |
| 694 | const { code, msg } = await addGroupCommentAPI({ | 708 | const { code, msg } = await addGroupCommentAPI({ |
| 695 | group_id: course.value?.id, | 709 | group_id: course.value?.id, |
| 696 | note: review.note, | 710 | note: review.note, |
| 697 | - score: review.rating | 711 | + score: review.rating, |
| 698 | }) | 712 | }) |
| 699 | if (code === 1) { | 713 | if (code === 1) { |
| 700 | showToast('评论提交成功') | 714 | showToast('评论提交成功') |
| ... | @@ -712,7 +726,7 @@ const fetchCommentList = async () => { | ... | @@ -712,7 +726,7 @@ const fetchCommentList = async () => { |
| 712 | const { code, data } = await getGroupCommentListAPI({ | 726 | const { code, data } = await getGroupCommentListAPI({ |
| 713 | group_id: course.value?.id, | 727 | group_id: course.value?.id, |
| 714 | page: 0, | 728 | page: 0, |
| 715 | - limit: 5 | 729 | + limit: 5, |
| 716 | }) | 730 | }) |
| 717 | if (code === 1) { | 731 | if (code === 1) { |
| 718 | commentList.value = data.comment_list | 732 | commentList.value = data.comment_list |
| ... | @@ -723,24 +737,24 @@ const fetchCommentList = async () => { | ... | @@ -723,24 +737,24 @@ const fetchCommentList = async () => { |
| 723 | 737 | ||
| 724 | // 初始化 | 738 | // 初始化 |
| 725 | onMounted(async () => { | 739 | onMounted(async () => { |
| 726 | - const id = route.params.id | 740 | + const { id } = route.params |
| 727 | // 调用接口获取课程详情 | 741 | // 调用接口获取课程详情 |
| 728 | - const { code, data } = await getCourseDetailAPI({ i: id }); | 742 | + const { code, data } = await getCourseDetailAPI({ i: id }) |
| 729 | if (code === 1) { | 743 | if (code === 1) { |
| 730 | - const foundCourse = data; | 744 | + const foundCourse = data |
| 731 | if (foundCourse) { | 745 | if (foundCourse) { |
| 732 | - course.value = foundCourse; | 746 | + course.value = foundCourse |
| 733 | - lecturers.value = foundCourse.lecturer; | 747 | + lecturers.value = foundCourse.lecturer |
| 734 | - isFavorite.value = foundCourse.is_favorite; | 748 | + isFavorite.value = foundCourse.is_favorite |
| 735 | - isPurchased.value = foundCourse.is_buy; | 749 | + isPurchased.value = foundCourse.is_buy |
| 736 | - isReviewed.value = foundCourse.is_comment; | 750 | + isReviewed.value = foundCourse.is_comment |
| 737 | 751 | ||
| 738 | - useTitle(`${course.value.title || '课程详情'}`); | 752 | + useTitle(`${course.value.title || '课程详情'}`) |
| 739 | 753 | ||
| 740 | // 设置默认选中的 tab,确保选中的 tab 有内容 | 754 | // 设置默认选中的 tab,确保选中的 tab 有内容 |
| 741 | - const availableTabs = curriculumItems.value; | 755 | + const availableTabs = curriculumItems.value |
| 742 | if (availableTabs.length > 0 && !availableTabs.some(item => item.title === activeTab.value)) { | 756 | if (availableTabs.length > 0 && !availableTabs.some(item => item.title === activeTab.value)) { |
| 743 | - activeTab.value = availableTabs[0].title; | 757 | + activeTab.value = availableTabs[0].title |
| 744 | } | 758 | } |
| 745 | 759 | ||
| 746 | // 获取评论列表 | 760 | // 获取评论列表 |
| ... | @@ -754,14 +768,6 @@ onMounted(async () => { | ... | @@ -754,14 +768,6 @@ onMounted(async () => { |
| 754 | 768 | ||
| 755 | // 统一弹窗组件后不再维护 default_list | 769 | // 统一弹窗组件后不再维护 default_list |
| 756 | 770 | ||
| 757 | - // 进入详情页时写入 Open Graph 元标签,提升分享预览效果 | ||
| 758 | - set_og_meta({ | ||
| 759 | - title: course.value?.title || '', | ||
| 760 | - description: course.value?.subtitle || '', | ||
| 761 | - image: course.value?.cover || '', | ||
| 762 | - url: window.location.href | ||
| 763 | - }); | ||
| 764 | - | ||
| 765 | // 咨询联系人:从接口填充 contact_list | 771 | // 咨询联系人:从接口填充 contact_list |
| 766 | // 若图片为 cdn.ipadbiz.cn,显示与预览均追加压缩参数 | 772 | // 若图片为 cdn.ipadbiz.cn,显示与预览均追加压缩参数 |
| 767 | try { | 773 | try { |
| ... | @@ -770,14 +776,13 @@ onMounted(async () => { | ... | @@ -770,14 +776,13 @@ onMounted(async () => { |
| 770 | id: item.id, | 776 | id: item.id, |
| 771 | name: item.name, | 777 | name: item.name, |
| 772 | phone: item.phone, | 778 | phone: item.phone, |
| 773 | - qrcode: item.qrcode || '' | 779 | + qrcode: item.qrcode || '', |
| 774 | })) | 780 | })) |
| 775 | } catch (e) { | 781 | } catch (e) { |
| 776 | consult_contacts.value = [] | 782 | consult_contacts.value = [] |
| 777 | } | 783 | } |
| 778 | } | 784 | } |
| 779 | - } | 785 | + } else { |
| 780 | - else { | ||
| 781 | // 课程不存在,跳转到课程主页面 | 786 | // 课程不存在,跳转到课程主页面 |
| 782 | showToast('课程不存在') | 787 | showToast('课程不存在') |
| 783 | router.push('/courses') | 788 | router.push('/courses') |
| ... | @@ -797,12 +802,6 @@ onMounted(async () => { | ... | @@ -797,12 +802,6 @@ onMounted(async () => { |
| 797 | }) | 802 | }) |
| 798 | }) | 803 | }) |
| 799 | 804 | ||
| 800 | -// 离开页面时清理 Open Graph 元标签 | ||
| 801 | -onUnmounted(() => { | ||
| 802 | - remove_og_meta(); | ||
| 803 | -}) | ||
| 804 | - | ||
| 805 | - | ||
| 806 | const isScheduleExpanded = ref(false) | 805 | const isScheduleExpanded = ref(false) |
| 807 | 806 | ||
| 808 | // 计算显示的课程大纲列表 | 807 | // 计算显示的课程大纲列表 |
| ... | @@ -830,17 +829,17 @@ const handleViewCourse = () => { | ... | @@ -830,17 +829,17 @@ const handleViewCourse = () => { |
| 830 | confirmButtonText: '知道了', | 829 | confirmButtonText: '知道了', |
| 831 | confirmButtonColor: '#4caf50', | 830 | confirmButtonColor: '#4caf50', |
| 832 | }) | 831 | }) |
| 833 | - return; | 832 | + return |
| 834 | } | 833 | } |
| 835 | - router.push(`/profile/studyCourse/${course.value.id}`); | 834 | + router.push(`/profile/studyCourse/${course.value.id}`) |
| 836 | -}; | 835 | +} |
| 837 | 836 | ||
| 838 | // 跳转课程大纲 | 837 | // 跳转课程大纲 |
| 839 | -const goToStudyDetail = (item) => { | 838 | +const goToStudyDetail = item => { |
| 840 | - console.warn(course.value); | 839 | + console.warn(course.value) |
| 841 | // 如果没有购买过, 禁止操作 | 840 | // 如果没有购买过, 禁止操作 |
| 842 | if (!course.value.is_buy) { | 841 | if (!course.value.is_buy) { |
| 843 | - return; | 842 | + return |
| 844 | } | 843 | } |
| 845 | // 检查课程审核状态 | 844 | // 检查课程审核状态 |
| 846 | if (!course.value.is_approval_enable) { | 845 | if (!course.value.is_approval_enable) { |
| ... | @@ -850,7 +849,7 @@ const goToStudyDetail = (item) => { | ... | @@ -850,7 +849,7 @@ const goToStudyDetail = (item) => { |
| 850 | confirmButtonText: '知道了', | 849 | confirmButtonText: '知道了', |
| 851 | confirmButtonColor: '#4caf50', | 850 | confirmButtonColor: '#4caf50', |
| 852 | }) | 851 | }) |
| 853 | - return; | 852 | + return |
| 854 | } | 853 | } |
| 855 | // 检查课程是否在开课时间内, course_start_time 开课时间, course_end_time 停课时间 | 854 | // 检查课程是否在开课时间内, course_start_time 开课时间, course_end_time 停课时间 |
| 856 | if (!course.value.is_in_course_time) { | 855 | if (!course.value.is_in_course_time) { |
| ... | @@ -860,7 +859,7 @@ const goToStudyDetail = (item) => { | ... | @@ -860,7 +859,7 @@ const goToStudyDetail = (item) => { |
| 860 | confirmButtonText: '知道了', | 859 | confirmButtonText: '知道了', |
| 861 | confirmButtonColor: '#4caf50', | 860 | confirmButtonColor: '#4caf50', |
| 862 | }) | 861 | }) |
| 863 | - return; | 862 | + return |
| 864 | } | 863 | } |
| 865 | // 跳转详情 | 864 | // 跳转详情 |
| 866 | router.push(`/studyDetail/${item.id}`) | 865 | router.push(`/studyDetail/${item.id}`) |
| ... | @@ -883,20 +882,14 @@ const goToCheckin = () => { | ... | @@ -883,20 +882,14 @@ const goToCheckin = () => { |
| 883 | confirmButtonText: '知道了', | 882 | confirmButtonText: '知道了', |
| 884 | confirmButtonColor: '#4caf50', | 883 | confirmButtonColor: '#4caf50', |
| 885 | }) | 884 | }) |
| 886 | - return; | 885 | + return |
| 887 | } | 886 | } |
| 888 | - if(!(task_list.value.length || timeout_task_list.value.length)) { | 887 | + if (!(task_list.value.length || timeout_task_list.value.length)) { |
| 889 | - showToast('暂无打卡任务'); | 888 | + showToast('暂无打卡任务') |
| 890 | - return; | 889 | + return |
| 891 | } | 890 | } |
| 892 | - showCheckInDialog.value = true; | 891 | + showCheckInDialog.value = true |
| 893 | -}; | 892 | +} |
| 894 | - | ||
| 895 | -setTimeout(() => { | ||
| 896 | - // TAG:微信分享 | ||
| 897 | - // 自定义分享内容 | ||
| 898 | - sharePage({ title: `${course.value.title}`, desc: `${course.value.subtitle}`, imgUrl: course.value.cover }); | ||
| 899 | -}, 1000) | ||
| 900 | </script> | 893 | </script> |
| 901 | 894 | ||
| 902 | <style lang="less"> | 895 | <style lang="less"> |
| ... | @@ -907,9 +900,9 @@ setTimeout(() => { | ... | @@ -907,9 +900,9 @@ setTimeout(() => { |
| 907 | font-weight: 500; | 900 | font-weight: 500; |
| 908 | } | 901 | } |
| 909 | } | 902 | } |
| 910 | - .animate-favorite { | 903 | +.animate-favorite { |
| 911 | animation: favorite-animation 0.5s ease; | 904 | animation: favorite-animation 0.5s ease; |
| 912 | - } | 905 | +} |
| 913 | 906 | ||
| 914 | @keyframes favorite-animation { | 907 | @keyframes favorite-animation { |
| 915 | 0% { | 908 | 0% { |
| ... | @@ -929,10 +922,5 @@ setTimeout(() => { | ... | @@ -929,10 +922,5 @@ setTimeout(() => { |
| 929 | background-color: #4caf50; | 922 | background-color: #4caf50; |
| 930 | } | 923 | } |
| 931 | </style> | 924 | </style> |
| 932 | -/** | 925 | +/** * 处理打卡成功 * 注释:统一弹窗触发成功事件后,页面提示成功。 */ const handleCheckInSuccess = () |
| 933 | - * 处理打卡成功 | 926 | +=> { showToast('打卡成功'); } |
| 934 | - * 注释:统一弹窗触发成功事件后,页面提示成功。 | ||
| 935 | - */ | ||
| 936 | -const handleCheckInSuccess = () => { | ||
| 937 | - showToast('打卡成功'); | ||
| 938 | -} | ... | ... |
-
Please register or login to post a comment