hookehuyr

feat: unify route metadata sync for og and share

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const wxMocks = vi.hoisted(() => ({
wxReady: vi.fn(callback => callback()),
updateAppMessageShareData: vi.fn(),
updateTimelineShareData: vi.fn(),
onMenuShareWeibo: vi.fn(),
}))
vi.mock('weixin-js-sdk', () => ({
default: {
ready: wxMocks.wxReady,
updateAppMessageShareData: wxMocks.updateAppMessageShareData,
updateTimelineShareData: wxMocks.updateTimelineShareData,
onMenuShareWeibo: wxMocks.onMenuShareWeibo,
},
}))
vi.mock('@/utils/tools', () => ({
wxInfo: () => ({
isWeiXin: true,
}),
}))
import { installWxShareSync, normalizeShareImageUrl, resolveRouteShareData } from '../useShare'
const createRouterMock = (initialRoute = { meta: { title: '首页' } }) => {
let afterEachHook = null
return {
currentRoute: { value: initialRoute },
afterEach: vi.fn(handler => {
afterEachHook = handler
return () => {
afterEachHook = null
}
}),
triggerAfterEach(route) {
this.currentRoute.value = route
afterEachHook?.(route)
},
}
}
const flushMutationObserver = async () => {
await new Promise(resolve => setTimeout(resolve, 0))
}
describe('useShare', () => {
beforeEach(() => {
document.head.innerHTML = '<title>美乐爱觉</title>'
document.title = '美乐爱觉'
window.location.hash = '#/'
wxMocks.wxReady.mockClear()
wxMocks.updateAppMessageShareData.mockClear()
wxMocks.updateTimelineShareData.mockClear()
wxMocks.onMenuShareWeibo.mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
it('根据路由 meta 解析分享数据', () => {
const shareData = resolveRouteShareData({
meta: {
title: '课程详情',
shareDesc: '课程简介',
shareImage: 'https://cdn.ipadbiz.cn/mlaj/course-cover.png',
},
})
expect(shareData.title).toBe('课程详情')
expect(shareData.desc).toBe('课程简介')
expect(shareData.imgUrl).toContain('imageMogr2/thumbnail/200x/strip/quality/70')
expect(shareData.link).toBe(window.location.href)
})
it('未提供描述时不再回退为标题', () => {
const shareData = resolveRouteShareData({
meta: {
title: '课程详情',
},
})
expect(shareData.title).toBe('课程详情')
expect(shareData.desc).toBe('')
})
it('在路由切换和标题变化时同步微信分享数据', async () => {
const router = createRouterMock({
meta: { title: '首页' },
})
const teardown = installWxShareSync(router, { isEnabled: true })
await flushMutationObserver()
expect(wxMocks.updateAppMessageShareData).toHaveBeenCalled()
expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].title).toBe('美乐爱觉')
window.location.hash = '#/courses/2'
router.triggerAfterEach({
meta: {
title: '课程详情',
shareDesc: '课程简介',
},
})
await flushMutationObserver()
expect(wxMocks.updateAppMessageShareData.mock.lastCall[0]).toMatchObject({
title: '课程详情',
desc: '课程简介',
link: window.location.href,
})
expect(wxMocks.updateTimelineShareData.mock.lastCall[0].title).toBe('课程详情')
document.title = '高阶课程'
await flushMutationObserver()
expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].title).toBe('高阶课程')
expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].desc).toBe('课程简介')
teardown()
})
it('支持通过异步 resolver 合并分享描述与图片', async () => {
const router = createRouterMock({
meta: { title: '课程详情' },
})
installWxShareSync(router, {
isEnabled: true,
resolver: vi.fn(() =>
Promise.resolve({
desc: '课程副标题',
imgUrl: 'https://cdn.ipadbiz.cn/mlaj/course-cover.png',
})
),
})
await flushMutationObserver()
expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].desc).toBe('课程副标题')
expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].imgUrl).toContain(
'imageMogr2/thumbnail/200x/strip/quality/70'
)
})
it('非 cdn 图片地址保持不变', () => {
expect(normalizeShareImageUrl('https://example.com/image.png')).toBe(
'https://example.com/image.png'
)
})
})
/*
* @Date: 2022-06-13 17:42:32
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-12-04 13:36:41
* @LastEditTime: 2026-03-30 14:47:11
* @FilePath: /mlaj/src/composables/useShare.js
* @Description: 微信分享相关逻辑
*/
import wx from 'weixin-js-sdk'
import { wxInfo } from '@/utils/tools'
const resolveShareMetaSource = route => route?.meta?.share || route?.meta || {}
const resolveShareTitle = (route, documentObj, { preferDocumentTitle = false } = {}) => {
const meta = route?.meta || {}
const documentTitle = documentObj?.title || ''
if (preferDocumentTitle && documentTitle) {
return documentTitle
}
return meta.shareTitle || meta.ogTitle || meta.title || documentTitle || ''
}
const createTitleObserver = (documentObj, onChange) => {
const titleEl = documentObj?.head?.querySelector('title')
if (!titleEl || typeof MutationObserver === 'undefined') {
return () => {}
}
const observer = new MutationObserver(() => {
onChange()
})
observer.observe(titleEl, {
childList: true,
subtree: true,
characterData: true,
})
return () => {
observer.disconnect()
}
}
/**
* @function normalize_image_url
* @function normalizeShareImageUrl
* @description 规范化分享图片地址;若域名为 cdn.ipadbiz.cn,追加压缩参数。
* @param {string} src 原始图片地址
* @returns {string} 处理后的图片地址
*/
function normalize_image_url(src) {
export function normalizeShareImageUrl(src) {
if (!src) return ''
if (src.includes('cdn.ipadbiz.cn')) {
const compress = 'imageMogr2/thumbnail/200x/strip/quality/70'
......@@ -25,46 +61,214 @@ function normalize_image_url(src) {
return src
}
const buildShareLink = locationObj => locationObj?.href || ''
/**
* @description: 微信分享功能
* @param {*} title 标题
* @param {*} desc 描述
* @param {*} imgUrl 图标
* @return {*}
*/
/**
* @function sharePage
* @description 配置微信分享标题、描述与图标;需在 wx.ready 后调用才会生效。
* @function buildWxSharePayload
* @description 生成微信分享配置,统一补默认值与图片压缩规则。
* @param {Object} params 分享参数
* @param {string} params.title 分享标题
* @param {string} params.desc 分享描述
* @param {string} params.imgUrl 分享图标地址
* @returns {void}
* @param {string} params.link 分享链接
* @param {Object} options 运行选项
* @param {Location} options.locationObj location 对象
* @returns {{title: string, desc: string, link: string, imgUrl: string, success: Function}}
*/
export const sharePage = ({ title = '生命力教育联盟', desc = '', imgUrl = '' }) => {
const shareData = {
title, // 分享标题
desc, // 分享描述
link: location.origin + location.pathname + location.hash, // 分享链接,需与公众号 JS 安全域名一致
imgUrl: normalize_image_url(imgUrl), // 分享图标,按规则追加压缩参数
success() {
// 设置成功回调
},
}
if (wx && typeof wx.ready === 'function') {
wx.ready(() => {
// 分享好友(微信好友或qq好友)
wx.updateAppMessageShareData(shareData)
// 分享到朋友圈或qq空间
wx.updateTimelineShareData(shareData)
// 分享到腾讯微博
if (typeof wx.onMenuShareWeibo === 'function') {
wx.onMenuShareWeibo(shareData)
export function buildWxSharePayload(
{ title = '', desc = '', imgUrl = '', link = '' } = {},
{ locationObj = window.location } = {}
) {
return {
title,
desc,
link: link || buildShareLink(locationObj),
imgUrl: normalizeShareImageUrl(imgUrl),
success() {},
}
})
} else {
// 微信 JSSDK 未初始化或未就绪,分享配置可能不会生效
}
/**
* @function applyWxShareData
* @description 配置微信分享标题、描述与图标;需在 wx.ready 后调用才会生效。
* @param {Object} params 分享参数
* @param {Object} options 运行选项
* @param {any} options.wxApi 微信 SDK 实例
* @param {Location} options.locationObj location 对象
* @returns {{title: string, desc: string, link: string, imgUrl: string, success: Function}}
*/
export function applyWxShareData(params = {}, { wxApi = wx, locationObj = window.location } = {}) {
const shareData = buildWxSharePayload(params, { locationObj })
if (!wxApi || typeof wxApi.ready !== 'function') {
console.warn('微信 JSSDK 未就绪:分享配置可能未生效')
return shareData
}
wxApi.ready(() => {
if (typeof wxApi.updateAppMessageShareData === 'function') {
wxApi.updateAppMessageShareData(shareData)
}
if (typeof wxApi.updateTimelineShareData === 'function') {
wxApi.updateTimelineShareData(shareData)
}
if (typeof wxApi.onMenuShareWeibo === 'function') {
wxApi.onMenuShareWeibo(shareData)
}
})
return shareData
}
/**
* @function resolveRouteShareData
* @description 根据当前路由与文档标题解析分享配置。
* @param {import('vue-router').RouteLocationNormalizedLoaded | null | undefined} route 当前路由
* @param {Object} options 运行选项
* @param {Document} options.documentObj document 对象
* @param {Location} options.locationObj location 对象
* @param {boolean} options.preferDocumentTitle 是否优先使用 document.title
* @returns {{title: string, desc: string, imgUrl: string, link: string}}
*/
export function resolveRouteShareData(
route,
{ documentObj = document, locationObj = window.location, preferDocumentTitle = false } = {}
) {
const metaSource = resolveShareMetaSource(route)
const title = resolveShareTitle(route, documentObj, { preferDocumentTitle })
return {
title,
desc:
metaSource.desc ||
metaSource.shareDesc ||
metaSource.description ||
metaSource.ogDescription ||
'',
imgUrl: normalizeShareImageUrl(
metaSource.imgUrl || metaSource.shareImage || metaSource.image || metaSource.ogImage || ''
),
link:
metaSource.link ||
metaSource.shareLink ||
metaSource.url ||
metaSource.ogUrl ||
buildShareLink(locationObj),
}
}
const mergeResolvedShareData = (
baseShareData,
resolvedShareData,
documentObj,
{ preferDocumentTitle = false } = {}
) => {
const mergedShareData = {
...baseShareData,
...(resolvedShareData || {}),
}
if (resolvedShareData?.description && !resolvedShareData?.desc) {
mergedShareData.desc = resolvedShareData.description
}
if (resolvedShareData?.image && !resolvedShareData?.imgUrl) {
mergedShareData.imgUrl = resolvedShareData.image
}
if (resolvedShareData?.url && !resolvedShareData?.link) {
mergedShareData.link = resolvedShareData.url
}
if (mergedShareData.imgUrl) {
mergedShareData.imgUrl = normalizeShareImageUrl(mergedShareData.imgUrl)
}
if (preferDocumentTitle && documentObj?.title) {
mergedShareData.title = documentObj.title
}
return mergedShareData
}
/**
* @function installWxShareSync
* @description 把微信分享配置绑定到路由与标题变化上,避免页面里重复手动调用。
* @param {import('vue-router').Router} router 路由实例
* @param {Object} options 运行选项
* @param {any} options.wxApi 微信 SDK 实例
* @param {Document} options.documentObj document 对象
* @param {Location} options.locationObj location 对象
* @param {boolean} options.isEnabled 是否启用同步;默认仅在微信环境且非开发环境启用
* @returns {() => void} 清理函数
*/
export function installWxShareSync(
router,
{ wxApi = wx, documentObj = document, locationObj = window.location, isEnabled, resolver } = {}
) {
const enabled =
typeof isEnabled === 'boolean' ? isEnabled : !import.meta.env.DEV && wxInfo().isWeiXin
if (!router || !enabled) {
return () => {}
}
let syncToken = 0
const buildShareData = async (route, { preferDocumentTitle = false } = {}) => {
const baseShareData = resolveRouteShareData(route, {
documentObj,
locationObj,
preferDocumentTitle,
})
const resolvedShareData =
typeof resolver === 'function'
? await resolver(route, { documentObj, locationObj, preferDocumentTitle })
: null
return mergeResolvedShareData(baseShareData, resolvedShareData, documentObj, {
preferDocumentTitle,
})
}
const syncFromRoute = async (route, options = {}) => {
const currentToken = ++syncToken
const shareData = await buildShareData(route, options)
if (currentToken !== syncToken) {
return
}
applyWxShareData(shareData, {
wxApi,
locationObj,
})
}
const syncFromDocumentTitle = () => {
syncFromRoute(router.currentRoute?.value, {
preferDocumentTitle: true,
})
}
const removeAfterEach = router.afterEach?.(to => {
syncFromRoute(to)
})
const stopObserveTitle = createTitleObserver(documentObj, syncFromDocumentTitle)
syncFromDocumentTitle()
return () => {
stopObserveTitle()
removeAfterEach?.()
}
}
/**
* @function sharePage
* @description 兼容旧调用方式的轻量封装。
* @param {Object} params 分享参数
* @returns {{title: string, desc: string, link: string, imgUrl: string, success: Function}}
*/
export const sharePage = (params = {}) => applyWxShareData(params)
......
......@@ -10,6 +10,9 @@ import { routes } from './routes'
import { checkAuth, hasVisitedWelcome, markWelcomeVisited } from './guards'
import { getUserIsLoginAPI } from '@/api/auth'
import { getUserInfoAPI } from '@/api/users'
import { installWxShareSync } from '@/composables/useShare'
import { installOgMetaSync } from '@/utils/ogMeta'
import { resolveRouteRuntimeMeta } from './pageMetaResolver'
const router = createRouter({
history: createWebHashHistory(import.meta.env.VITE_BASE || '/'),
......@@ -42,12 +45,11 @@ router.beforeEach(async (to, from, next) => {
if (hasVisitedWelcome()) {
// 已访问过,跳转到首页
return next('/')
} else {
}
// 首次访问,标记并显示欢迎页
markWelcomeVisited()
}
}
}
// 检查用户是否已登录
const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null')
......@@ -98,4 +100,7 @@ router.beforeEach(async (to, from, next) => {
next()
})
installOgMetaSync(router, { resolver: resolveRouteRuntimeMeta })
installWxShareSync(router, { resolver: resolveRouteRuntimeMeta })
export default router
......
import { getCourseDetailAPI } from '@/api/course'
const routeMetaCache = new Map()
const buildRouteCacheKey = route => {
const routeName = route?.name || route?.path || 'unknown'
const routeParams = JSON.stringify(route?.params || {})
return `${routeName}:${routeParams}`
}
const withRouteMetaCache = (route, resolver) => {
const cacheKey = buildRouteCacheKey(route)
if (!routeMetaCache.has(cacheKey)) {
routeMetaCache.set(
cacheKey,
Promise.resolve()
.then(resolver)
.catch(() => null)
)
}
return routeMetaCache.get(cacheKey)
}
const resolveCourseDetailMeta = async (route, { windowObj = window } = {}) => {
const courseId = route?.params?.id
if (!courseId) {
return null
}
const { code, data } = await getCourseDetailAPI({ i: courseId })
if (code !== 1 || !data) {
return null
}
const title = data.title || '课程详情'
const description = data.subtitle || title
const shareDescription = data.subtitle || ''
const image = data.cover || ''
const url = windowObj?.location?.href || ''
return {
title,
description,
image,
url,
desc: shareDescription,
imgUrl: image,
link: url,
}
}
/**
* 统一解析“路由运行时元信息”。
*
* 说明:
* - 静态页面优先走 route.meta。
* - 动态详情页需要接口数据时,在这里集中补充,避免页面里分散维护。
*/
export function resolveRouteRuntimeMeta(route, { windowObj = window } = {}) {
switch (route?.name) {
case 'CourseDetail':
return withRouteMetaCache(route, () => resolveCourseDetailMeta(route, { windowObj }))
default:
return null
}
}
export function resetRouteRuntimeMetaCache() {
routeMetaCache.clear()
}
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
applyOgMeta,
buildOgImageUrl,
installOgMetaSync,
resetOgMeta,
resetOgMetaManagerState,
} from '../ogMeta'
const createRouterMock = (initialRoute = { meta: { title: '首页' }, fullPath: '/#/' }) => {
let afterEachHook = null
return {
currentRoute: { value: initialRoute },
afterEach: vi.fn(handler => {
afterEachHook = handler
return () => {
afterEachHook = null
}
}),
triggerAfterEach(route) {
this.currentRoute.value = route
afterEachHook?.(route)
},
}
}
const flushMutationObserver = async () => {
await new Promise(resolve => setTimeout(resolve, 0))
}
describe('ogMeta', () => {
beforeEach(() => {
document.head.innerHTML = `
<meta property="og:description" content="默认描述">
<title>美乐爱觉</title>
`
document.title = '美乐爱觉'
window.location.hash = '#/'
resetOgMetaManagerState()
})
afterEach(() => {
resetOgMetaManagerState()
})
it('复用现有 og:description 并在 reset 时恢复初始值', () => {
applyOgMeta({
title: '课程详情',
description: '课程副标题',
image: 'https://cdn.ipadbiz.cn/mlaj/course-cover.png',
url: 'https://example.com/#/courses/1',
})
const descriptionMetas = document.head.querySelectorAll('meta[property="og:description"]')
expect(descriptionMetas).toHaveLength(1)
expect(descriptionMetas[0].getAttribute('content')).toBe('课程副标题')
expect(document.head.querySelector('meta[property="og:title"]')?.getAttribute('content')).toBe(
'课程详情'
)
expect(
document.head.querySelector('meta[property="og:image"]')?.getAttribute('content')
).toContain('imageMogr2/thumbnail/400x/strip/quality/70')
resetOgMeta()
expect(
document.head.querySelector('meta[property="og:description"]')?.getAttribute('content')
).toBe('默认描述')
expect(document.head.querySelector('meta[property="og:title"]')).toBeNull()
expect(document.head.querySelector('meta[property="og:image"]')).toBeNull()
expect(document.head.querySelector('meta[property="og:url"]')).toBeNull()
})
it('在路由切换和 document.title 变化时同步 og:title 与 og:url', async () => {
const router = createRouterMock({
meta: { title: '课程详情' },
fullPath: '/courses/1',
})
const teardown = installOgMetaSync(router)
await flushMutationObserver()
expect(document.head.querySelector('meta[property="og:title"]')?.getAttribute('content')).toBe(
'美乐爱觉'
)
expect(document.head.querySelector('meta[property="og:url"]')?.getAttribute('content')).toBe(
window.location.href
)
window.location.hash = '#/courses/2'
router.triggerAfterEach({
meta: { title: '课程详情' },
fullPath: '/courses/2',
})
await flushMutationObserver()
expect(document.head.querySelector('meta[property="og:title"]')?.getAttribute('content')).toBe(
'课程详情'
)
expect(document.head.querySelector('meta[property="og:url"]')?.getAttribute('content')).toBe(
window.location.href
)
document.title = '高阶课程'
await flushMutationObserver()
expect(document.head.querySelector('meta[property="og:title"]')?.getAttribute('content')).toBe(
'高阶课程'
)
expect(
document.head.querySelector('meta[property="og:description"]')?.getAttribute('content')
).toBe('高阶课程')
teardown()
})
it('支持通过异步 resolver 合并描述与图片', async () => {
const router = createRouterMock({
meta: { title: '课程详情' },
fullPath: '/courses/1',
})
installOgMetaSync(router, {
resolver: vi.fn(() =>
Promise.resolve({
description: '课程副标题',
image: 'https://cdn.ipadbiz.cn/mlaj/course-cover.png',
})
),
})
await flushMutationObserver()
expect(
document.head.querySelector('meta[property="og:description"]')?.getAttribute('content')
).toBe('课程副标题')
expect(
document.head.querySelector('meta[property="og:image"]')?.getAttribute('content')
).toContain('imageMogr2/thumbnail/400x/strip/quality/70')
})
it('为非 cdn.ipadbiz.cn 图片保持原始地址', () => {
expect(buildOgImageUrl('https://example.com/image.png')).toBe('https://example.com/image.png')
})
})
/**
* 统一管理 Open Graph 元标签。
*
* 设计目标:
* 1. 任意路由切换时都能自动同步 OG 信息。
* 2. 兼容 index.html 中已经存在的静态 meta,例如 og:description。
* 3. 页面只负责更新 document.title;OG 的写入和恢复统一放在这里处理。
*/
const MANAGED_OG_TAGS = [
{ id: 'og-title', property: 'og:title', key: 'title' },
{ id: 'og-description', property: 'og:description', key: 'description' },
{ id: 'og-image', property: 'og:image', key: 'image' },
{ id: 'og-url', property: 'og:url', key: 'url' },
]
// 首次接管 OG 标签时,记录页面原始状态,便于 teardown/reset 时恢复。
let originalOgMetaState = null
const getHead = documentObj =>
documentObj?.head || documentObj?.getElementsByTagName?.('head')?.[0] || null
const getMetaByProperty = (documentObj, property) =>
getHead(documentObj)?.querySelector(`meta[property="${property}"]`) || null
const captureOriginalOgMetaState = documentObj => {
if (originalOgMetaState) {
return originalOgMetaState
}
// 只在首次接管时做快照,避免后续多次同步把“运行时状态”误当成“初始状态”。
originalOgMetaState = MANAGED_OG_TAGS.reduce((state, item) => {
const meta = getMetaByProperty(documentObj, item.property)
state[item.property] = {
existed: !!meta,
content: meta?.getAttribute('content') || '',
id: meta?.getAttribute('id') || '',
}
return state
}, {})
return originalOgMetaState
}
const ensureManagedMeta = (documentObj, item) => {
const head = getHead(documentObj)
if (!head) return null
// 优先按 property 查找,兼容原本就写在 index.html 里的静态节点。
// 找不到时再按约定 id 查找我们自己创建的节点。
let meta = getMetaByProperty(documentObj, item.property) || head.querySelector(`meta#${item.id}`)
if (!meta) {
meta = documentObj.createElement('meta')
const titleEl = head.querySelector('title')
if (titleEl) {
head.insertBefore(meta, titleEl)
} else if (head.firstChild) {
head.insertBefore(meta, head.firstChild)
} else {
head.appendChild(meta)
}
}
meta.setAttribute('id', item.id)
meta.setAttribute('property', item.property)
return meta
}
const resolveMetaSource = route => route?.meta?.og || route?.meta || {}
const resolveRouteTitle = (route, documentObj, { preferDocumentTitle = false } = {}) => {
const meta = route?.meta || {}
const documentTitle = documentObj?.title || ''
// 某些详情页会在异步数据返回后通过 useTitle 更新标题。
// 这种情况下优先读取 document.title,才能拿到页面最终标题,而不是路由默认标题。
if (preferDocumentTitle && documentTitle) {
return documentTitle
}
return meta.ogTitle || meta.title || documentTitle || ''
}
const createTitleObserver = (documentObj, onChange) => {
const titleEl = getHead(documentObj)?.querySelector('title')
if (!titleEl || typeof MutationObserver === 'undefined') {
return () => {}
}
const observer = new MutationObserver(() => {
onChange()
})
observer.observe(titleEl, {
childList: true,
subtree: true,
characterData: true,
})
return () => {
observer.disconnect()
}
}
/**
* 统一处理项目里的图片压缩规则。
* 目前仅对 cdn.ipadbiz.cn 做 imageMogr2 参数补充。
*/
export function buildOgImageUrl(src) {
if (!src) return ''
if (src.includes('cdn.ipadbiz.cn')) {
const compressParam = 'imageMogr2/thumbnail/400x/strip/quality/70'
if (src.includes('?')) {
return src.includes(compressParam) ? src : `${src}&${compressParam}`
}
return `${src}?${compressParam}`
}
return src
}
/**
* 写入或更新 OG 标签。
*
* 注意:
* - 所有标签都走统一入口,避免各页面各自 append/remove。
* - description 也会被统一接管,这样恢复逻辑才是对称的。
*/
export function applyOgMeta(payload = {}, { documentObj = document, windowObj = window } = {}) {
const head = getHead(documentObj)
if (!head) return
captureOriginalOgMetaState(documentObj)
const normalizedPayload = {
title: payload.title || documentObj?.title || '',
// description 没传时退化为 title,至少保证分享卡片不是空文案。
description: payload.description || payload.title || documentObj?.title || '',
image: buildOgImageUrl(payload.image || ''),
url: payload.url || windowObj?.location?.href || '',
}
MANAGED_OG_TAGS.forEach(item => {
const meta = ensureManagedMeta(documentObj, item)
if (!meta) return
meta.setAttribute('content', normalizedPayload[item.key] || '')
})
}
/**
* 恢复接管前的 OG 状态。
*
* 规则:
* - 原本存在的节点恢复 content/id。
* - 原本不存在的节点直接删除。
*/
export function resetOgMeta({ documentObj = document } = {}) {
const head = getHead(documentObj)
if (!head || !originalOgMetaState) return
MANAGED_OG_TAGS.forEach(item => {
const snapshot = originalOgMetaState[item.property]
const meta =
getMetaByProperty(documentObj, item.property) || head.querySelector(`meta#${item.id}`)
if (!meta) return
if (snapshot?.existed) {
meta.setAttribute('property', item.property)
meta.setAttribute('content', snapshot.content || '')
if (snapshot.id) {
meta.setAttribute('id', snapshot.id)
} else {
meta.removeAttribute('id')
}
return
}
meta.parentNode?.removeChild(meta)
})
}
export function resetOgMetaManagerState() {
// 仅用于测试或明确需要重置管理器内部状态的场景。
originalOgMetaState = null
}
/**
* 从当前路由解析出应写入的 OG 数据。
*
* 支持两种写法:
* - meta.ogDescription / meta.ogImage / meta.ogTitle
* - meta.og = { description, image, title, url }
*/
export function resolveRouteOgMeta(
route,
{ documentObj = document, windowObj = window, preferDocumentTitle = false } = {}
) {
const metaSource = resolveMetaSource(route)
const title = resolveRouteTitle(route, documentObj, { preferDocumentTitle })
return {
title,
description: metaSource.description || metaSource.ogDescription || title,
image: metaSource.image || metaSource.ogImage || '',
url: metaSource.url || metaSource.ogUrl || windowObj?.location?.href || '',
}
}
const mergeResolvedOgMeta = (
basePayload,
resolvedPayload,
documentObj,
{ preferDocumentTitle = false } = {}
) => {
const mergedPayload = {
...basePayload,
...(resolvedPayload || {}),
}
if (resolvedPayload?.desc && !resolvedPayload?.description) {
mergedPayload.description = resolvedPayload.desc
}
if (resolvedPayload?.imgUrl && !resolvedPayload?.image) {
mergedPayload.image = resolvedPayload.imgUrl
}
if (resolvedPayload?.link && !resolvedPayload?.url) {
mergedPayload.url = resolvedPayload.link
}
if (preferDocumentTitle && documentObj?.title) {
mergedPayload.title = documentObj.title
}
return mergedPayload
}
/**
* 把 OG 同步器挂到 router 上。
*
* 同步时机有两个:
* 1. 路由切换完成后:更新当前页面的默认 OG。
* 2. document.title 发生变化时:把异步详情页最终标题同步回 OG。
*/
export function installOgMetaSync(
router,
{ documentObj = document, windowObj = window, resolver } = {}
) {
if (!router || !documentObj) {
return () => {}
}
let syncToken = 0
const buildPayload = async (route, { preferDocumentTitle = false } = {}) => {
const basePayload = resolveRouteOgMeta(route, { documentObj, windowObj, preferDocumentTitle })
const resolvedPayload =
typeof resolver === 'function'
? await resolver(route, { documentObj, windowObj, preferDocumentTitle })
: null
return mergeResolvedOgMeta(basePayload, resolvedPayload, documentObj, { preferDocumentTitle })
}
const syncFromRoute = async (route, options = {}) => {
const currentToken = ++syncToken
const payload = await buildPayload(route, options)
if (currentToken !== syncToken) {
return
}
applyOgMeta(payload, {
documentObj,
windowObj,
})
}
const syncFromDocumentTitle = () => {
// 这里显式偏向 document.title,确保课程详情、学习详情这类异步标题能覆盖路由默认标题。
syncFromRoute(router.currentRoute?.value, {
preferDocumentTitle: true,
})
}
const removeAfterEach = router.afterEach?.(to => {
syncFromRoute(to)
})
const stopObserveTitle = createTitleObserver(documentObj, syncFromDocumentTitle)
// 首屏也立即同步一次,避免只有路由跳转后才有 OG。
syncFromDocumentTitle()
return () => {
stopObserveTitle()
removeAfterEach?.()
resetOgMeta({ documentObj })
}
}
<template>
<AppLayout :has-title="false">
<div class="pb-24 mb-6">
<div class="mb-6 pb-24">
<!-- Course Image -->
<div class="mb-4">
<img :src="build_og_image_url(course?.cover)" :alt="course?.title"
class="w-full h-auto" />
<img :src="buildOgImageUrl(course?.cover)" :alt="course?.title" class="h-auto w-full" />
</div>
<!-- Course Header -->
<div class="px-4">
<div style="padding-bottom: 1rem;">
<div v-if="course?.group_type_title" class="bg-gray-100 rounded-lg p-2 mb-3 inline-block">
<div class="text-gray-600 text-sm font-semibold">{{ course?.group_type_title }}</div>
<div style="padding-bottom: 1rem">
<div v-if="course?.group_type_title" class="mb-3 inline-block rounded-lg bg-gray-100 p-2">
<div class="text-sm font-semibold text-gray-600">{{ course?.group_type_title }}</div>
</div>
<h1 class="text-2xl text-gray-900 font-bold mb-1">{{ course?.title }}</h1>
<h1 class="mb-1 text-2xl font-bold text-gray-900">{{ course?.title }}</h1>
<h2 class="text-sm text-gray-500">{{ course?.subtitle }}</h2>
<div class="mt-4 flex justify-between items-center">
<div class="mt-4 flex items-center justify-between">
<div class="flex items-baseline gap-2">
<template v-if="course?.pay_type !== 'DESIGNATE'">
<div v-if="course?.price !== '0.00'" class="flex items-baseline">
<span class="text-red-500 font-bold text-2xl">¥{{ course?.price }}</span>
<span class="text-2xl font-bold text-red-500">¥{{ course?.price }}</span>
<!-- <span class="text-gray-500 text-sm ml-1">/人</span> -->
</div>
<div v-else class="text-red-500 text-lg font-bold">
免费
</div>
<div v-else class="text-lg font-bold text-red-500">免费</div>
</template>
<div v-else class="text-red-500 text-sm font-bold">
指定学习
</div>
<div v-else class="text-sm font-bold text-red-500">指定学习</div>
</div>
<div class="text-gray-500 text-sm">
{{ course?.buy_count }}人订阅
<div class="text-sm text-gray-500">{{ course?.buy_count }}人订阅</div>
</div>
</div>
<div v-if="course?.expireDate" class="text-xs text-gray-500 mt-3 border-t border-gray-100 pt-3">
<div
v-if="course?.expireDate"
class="mt-3 border-t border-gray-100 pt-3 text-xs text-gray-500"
>
有效期: {{ course?.expireDate || '没有字段' }}
</div>
</div>
......@@ -49,15 +45,20 @@
</FrostedGlass> -->
<!-- Tab Navigation -->
<FrostedGlass v-if="curriculumItems.length" class="mb-6 rounded-xl overflow-hidden">
<FrostedGlass v-if="curriculumItems.length" class="mb-6 overflow-hidden rounded-xl">
<div class="border-b border-gray-200">
<div class="flex">
<button v-for="(item, index) in curriculumItems" :key="index" @click="activeTab = item.title" :class="[
'flex-1 py-3 font-medium text-center',
<button
v-for="(item, index) in curriculumItems"
:key="index"
@click="activeTab = item.title"
:class="[
'flex-1 py-3 text-center font-medium',
activeTab === item.title
? 'text-green-600 border-b-2 border-green-600 bg-green-50/50'
: 'text-gray-500'
]">
? 'border-b-2 border-green-600 bg-green-50/50 text-green-600'
: 'text-gray-500',
]"
>
{{ item.title }}
</button>
</div>
......@@ -75,30 +76,50 @@
</div>
<div v-if="activeTab === '主讲教师'">
<div v-for="(item, index) in lecturers" :key="index" class="flex items-start" style="margin-bottom: 1rem;">
<div class="w-16 h-16 rounded-full overflow-hidden mr-4 flex-shrink-0">
<img :src="item?.photo || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" alt="lecturer"
class="w-full h-full object-cover" @error="handleImageError" />
<div
v-for="(item, index) in lecturers"
:key="index"
class="flex items-start"
style="margin-bottom: 1rem"
>
<div class="mr-4 h-16 w-16 flex-shrink-0 overflow-hidden rounded-full">
<img
:src="item?.photo || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
alt="lecturer"
class="h-full w-full object-cover"
@error="handleImageError"
/>
</div>
<div class="flex-1 min-w-0">
<div class="min-w-0 flex-1">
<h4 class="font-bold text-gray-900">{{ item?.name }}</h4>
<p class="text-sm text-gray-600">{{ item?.educational }}</p>
<p class="text-xs text-gray-500 mt-1 break-words">{{ item?.introduction }}</p>
<p class="mt-1 break-words text-xs text-gray-500">{{ item?.introduction }}</p>
</div>
</div>
</div>
<div v-if="activeTab === '课程大纲'">
<div class="space-y-4">
<div v-for="(item, index) in displayedSchedule" :key="index" class="border-l-2 border-green-500 pl-3" @click="goToStudyDetail(item)">
<div
v-for="(item, index) in displayedSchedule"
:key="index"
class="border-l-2 border-green-500 pl-3"
@click="goToStudyDetail(item)"
>
<h4 class="font-medium text-gray-800">{{ item.title }}</h4>
<p class="text-sm text-gray-600 mt-1">{{ item.duration }}分钟 · 开课时间: {{ item.schedule_time }}</p>
<p class="mt-1 text-sm text-gray-600">
{{ item.duration }}分钟 · 开课时间: {{ item.schedule_time }}
</p>
</div>
<div v-if="course?.schedule?.length > 4" class="flex justify-center mt-4">
<button @click="toggleSchedule"
class="p-2 rounded-full hover:bg-green-50 text-green-600 hover:text-green-700 transition-all duration-300">
<van-icon :name="isScheduleExpanded ? 'arrow-up' : 'arrow-down'"
class="text-xl transform transition-transform duration-300" />
<div v-if="course?.schedule?.length > 4" class="mt-4 flex justify-center">
<button
@click="toggleSchedule"
class="rounded-full p-2 text-green-600 transition-all duration-300 hover:bg-green-50 hover:text-green-700"
>
<van-icon
:name="isScheduleExpanded ? 'arrow-up' : 'arrow-down'"
class="transform text-xl transition-transform duration-300"
/>
</button>
</div>
</div>
......@@ -107,7 +128,7 @@
<div v-if="activeTab === '打卡互动' && task_list.length > 0">
<!-- 打卡区域 -->
<div class="py-4">
<div class="bg-white rounded-lg p-4 mb-4 cursor-pointer">
<div class="mb-4 cursor-pointer rounded-lg bg-white p-4">
<div class="flex items-center justify-between" @click="goToCheckin()">
<div class="flex items-center gap-3">
<van-icon size="3rem" name="calendar-o" class="text-xl text-gray-600" />
......@@ -153,8 +174,8 @@
</FrostedGlass> -->
<!-- Student Reviews -->
<FrostedGlass class="mb-6 p-4 rounded-xl">
<div class="flex justify-between items-center mb-3">
<FrostedGlass class="mb-6 rounded-xl p-4">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-800">学员评价</h3>
<!-- 立即评论按钮 - 仅在已购买但未评价时显示 -->
<van-button
......@@ -163,51 +184,80 @@
size="small"
round
color="linear-gradient(to right, #3b82f6, #2563eb)"
class="shadow-sm text-xs px-4 py-1.5 min-w-[80px] hover:shadow-md transition-all duration-200"
class="min-w-[80px] px-4 py-1.5 text-xs shadow-sm transition-all duration-200 hover:shadow-md"
>
<van-icon name="edit" size="14" class="mr-1" />
立即评论
</van-button>
</div>
<div class="flex items-center mb-3">
<div class="flex items-center mr-2">
<van-rate v-model="commentScore" readonly allow-half color="#facc15" void-color="#e5e7eb" size="20" />
<div class="mb-3 flex items-center">
<div class="mr-2 flex items-center">
<van-rate
v-model="commentScore"
readonly
allow-half
color="#facc15"
void-color="#e5e7eb"
size="20"
/>
</div>
<div class="text-gray-700">{{ commentScore }} ({{ commentTotal }}条评论)</div>
</div>
<div class="space-y-4">
<div v-for="(item, index) in commentList" :key="index" class="border-b border-gray-100 pb-3">
<div
v-for="(item, index) in commentList"
:key="index"
class="border-b border-gray-100 pb-3"
>
<div class="flex justify-between">
<div class="font-medium text-gray-800">{{ item.name || '匿名用户' }}</div>
<div class="text-xs text-gray-500">{{ formatDate(item.created_time) }}</div>
</div>
<p class="text-sm text-gray-600 mt-1">
<p class="mt-1 text-sm text-gray-600">
{{ item.note }}
</p>
</div>
</div>
<button @click="router.push(`/courses/${course?.id}/reviews`)"
class="w-full text-center text-green-600 mt-3 text-sm">
<button
@click="router.push(`/courses/${course?.id}/reviews`)"
class="mt-3 w-full text-center text-sm text-green-600"
>
查看全部评价
</button>
</FrostedGlass>
</div>
<!-- Bottom Action Bar -->
<div class="fixed bottom-16 left-0 right-0 bg-white shadow-lg p-3 flex justify-between items-center">
<div
class="fixed bottom-16 left-0 right-0 flex items-center justify-between bg-white p-3 shadow-lg"
>
<div class="flex space-x-4">
<button class="flex flex-col items-center text-gray-500 text-xs transition-transform duration-300"
@click="toggleFavorite" :class="{ 'animate-favorite': isFavorite }">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 transition-transform duration-300"
:fill="isFavorite ? 'red' : 'none'" viewBox="0 0 24 24" :stroke="isFavorite ? 'red' : 'currentColor'">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
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" />
<button
class="flex flex-col items-center text-xs text-gray-500 transition-transform duration-300"
@click="toggleFavorite"
:class="{ 'animate-favorite': isFavorite }"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 transition-transform duration-300"
:fill="isFavorite ? 'red' : 'none'"
viewBox="0 0 24 24"
:stroke="isFavorite ? 'red' : 'currentColor'"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
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"
/>
</svg>
收藏
</button>
<button class="flex flex-col items-center text-gray-500 text-xs" @click="open_share_poster">
<button
class="flex flex-col items-center text-xs text-gray-500"
@click="open_share_poster"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
......@@ -224,7 +274,11 @@
</svg>
分享
</button>
<button v-if="consult_contacts.length" class="flex flex-col items-center text-gray-500 text-xs" @click="open_consult_dialog">
<button
v-if="consult_contacts.length"
class="flex flex-col items-center text-xs text-gray-500"
@click="open_consult_dialog"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
......@@ -244,17 +298,31 @@
</div>
<div class="flex items-center">
<div v-if="!course?.is_buy" class="mr-2">
<div v-if="course?.price !== '0.00'" class="text-red-500 font-bold">¥{{ course?.price || 0 }}</div>
<div v-if="course?.price !== '0.00'" class="font-bold text-red-500">
¥{{ course?.price || 0 }}
</div>
<div v-if="course?.price !== '0.00'" class="text-xs text-gray-400 line-through">
¥{{ Math.round((course?.price || 0) * 1.2) }}
</div>
</div>
<van-button v-if="!isPurchased" @click="handlePurchase" round block
color="linear-gradient(to right, #22c55e, #16a34a)" class="shadow-md">
<van-button
v-if="!isPurchased"
@click="handlePurchase"
round
block
color="linear-gradient(to right, #22c55e, #16a34a)"
class="shadow-md"
>
{{ course?.price !== '0.00' ? '立即' : '免费' }}购买
</van-button>
<van-button v-else @click="handleViewCourse" round block
color="linear-gradient(to right, #22c55e, #16a34a)" class="shadow-md">
<van-button
v-else
@click="handleViewCourse"
round
block
color="linear-gradient(to right, #22c55e, #16a34a)"
class="shadow-md"
>
查看课程
</van-button>
</div>
......@@ -281,40 +349,75 @@
>
<div class="ConsultPopup p-4">
<!-- 标题与关闭图标 -->
<div class="flex justify-between items-center mb-3">
<div class="mb-3 flex items-center justify-between">
<h3 class="font-medium">咨询信息</h3>
<van-icon name="cross" @click="close_consult_dialog" />
</div>
<!-- 联系人列表:点击名称直接拨打 -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3 mb-4">
<div class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-3">
<div class="flex items-center">
<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">
<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" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-5 w-5 text-green-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<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"
/>
</svg>
<span class="text-gray-700">联系人</span>
</div>
<ul class="mt-2 divide-y divide-gray-200">
<li v-for="(c, idx) in consult_contacts" :key="idx" class="flex items-center justify-between py-2">
<li
v-for="(c, idx) in consult_contacts"
:key="idx"
class="flex items-center justify-between py-2"
>
<div class="flex items-center">
<a class="text-gray-800 text-base mr-3" :href="`tel:${c.phone}`" @click.prevent="call_phone(c.phone)">{{ c.name }}</a>
<a
class="mr-3 text-base text-gray-800"
:href="`tel:${c.phone}`"
@click.prevent="call_phone(c.phone)"
>{{ c.name }}</a
>
<img
v-if="c?.qrcode"
:src="build_og_image_url(c.qrcode)"
:src="buildOgImageUrl(c.qrcode)"
alt="联系人二维码"
class="w-10 h-10 object-contain rounded cursor-pointer"
class="h-10 w-10 cursor-pointer rounded object-contain"
@click="open_contact_qr_preview(c.qrcode)"
/>
</div>
<a class="text-green-600 text-base" :href="`tel:${c.phone}`" @click.prevent="call_phone(c.phone)">{{ c.phone }}</a>
<a
class="text-base text-green-600"
:href="`tel:${c.phone}`"
@click.prevent="call_phone(c.phone)"
>{{ c.phone }}</a
>
</li>
</ul>
<div v-if="(consult_contacts || []).some(c => c?.qrcode)" class="text-xs text-gray-500 mt-2">点击图片可查看大图</div>
<div
v-if="(consult_contacts || []).some(c => c?.qrcode)"
class="mt-2 text-xs text-gray-500"
>
点击图片可查看大图
</div>
</div>
<!-- 底部关闭按钮(唯一操作) -->
<div class="mt-4">
<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>
<button
class="w-full rounded-lg bg-gradient-to-r from-green-500 to-green-600 py-2 text-white"
@click="close_consult_dialog"
>
关闭
</button>
</div>
</div>
</van-popup>
......@@ -325,16 +428,16 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, defineComponent, h } from 'vue'
import { ref, onMounted, defineComponent, h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCart } from '@/contexts/cart'
import { useAuth } from '@/contexts/auth'
import { useTitle } from '@vueuse/core';
import { useTitle } from '@vueuse/core'
import { wxInfo, formatDate, normalizeCheckinTaskItems } from '@/utils/tools'
import { buildOgImageUrl } from '@/utils/ogMeta'
import { startWxAuth } from '@/router/guards'
import { getAuthInfoAPI, getUserIsLoginAPI } from '@/api/auth'
import { showToast, showDialog, showImagePreview } from 'vant';
import { sharePage } from '@/composables/useShare.js'
import { showToast, showDialog, showImagePreview } from 'vant'
import { useImageLoader } from '@/composables/useImageLoader'
import AppLayout from '@/components/layout/AppLayout.vue'
......@@ -343,127 +446,27 @@ import SharePoster from '@/components/poster/SharePoster.vue'
import CheckInDialog from '@/components/checkin/CheckInDialog.vue'
// 导入接口
import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from "@/api/course";
import { addFavoriteAPI, cancelFavoriteAPI } from "@/api/favorite";
import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from '@/api/course'
import { addFavoriteAPI, cancelFavoriteAPI } from '@/api/favorite'
// 已统一使用通用打卡弹窗,移除未使用的打卡提交接口
// 图片加载错误处理
const { handleImageError } = useImageLoader()
//
// Open Graph 元标签:进入课程详情页时动态插入,离开页面时移除
//
// 原始 og:description 内容缓存(用于离开页面时恢复)
let original_og_desc_content = null;
/**
* @function open_contact_qr_preview
* @description 打开联系人二维码大图预览。
* @param {string} url 二维码图片地址
* @returns {void}
*/
const open_contact_qr_preview = (url) => {
if (!url) return;
const src = build_og_image_url(url)
const open_contact_qr_preview = url => {
if (!url) return
const src = buildOgImageUrl(url)
showImagePreview([src])
}
/**
* @function build_og_image_url
* @description 构建 og:image 地址;若为 cdn.ipadbiz.cn 域名,则追加图片压缩参数。
* @param {string} src 原始图片地址
* @returns {string} 处理后的图片地址
*/
function build_og_image_url(src) {
// 若无地址,直接返回空字符串
if (!src) return '';
// 若为指定 CDN 域名,追加压缩参数(遵循项目图片规则)
if (src.includes('cdn.ipadbiz.cn')) {
const compress_param = 'imageMogr2/thumbnail/400x/strip/quality/70';
if (src.includes('?')) {
if (!src.includes(compress_param)) {
return src + '&' + compress_param;
}
} else {
return src + '?' + compress_param;
}
}
return src;
}
/**
* @function set_og_meta
* @description 在页面 head 中插入或更新 4 个 Open Graph 元标签。
* @param {Object} payload 载荷对象
* @param {string} payload.title 主标题(og:title)
* @param {string} payload.description 副标题/描述(og:description)
* @param {string} payload.image 图片地址(og:image)
* @param {string} payload.url 当前页面URL(og:url)
* @returns {void}
*/
function set_og_meta(payload) {
const head = document.head || document.getElementsByTagName('head')[0];
if (!head) return;
const titleEl = head.querySelector('title');
// 1) 直接修改 index.html 中现有的 og:description(不新建,避免重复)
const descMeta = head.querySelector('meta[property="og:description"]');
if (descMeta) {
// 首次保存原始内容用于恢复
if (original_og_desc_content === null) {
original_og_desc_content = descMeta.getAttribute('content') || '';
}
descMeta.setAttribute('content', payload.description || '');
}
// 2) 其余标签按需创建(若不存在则插入到 <title> 前)
const others = [
{ id: 'og-title', property: 'og:title', content: payload.title || '' },
{ id: 'og-image', property: 'og:image', content: build_og_image_url(payload.image || '') },
{ id: 'og-url', property: 'og:url', content: payload.url || window.location.href }
];
others.forEach(item => {
let meta = head.querySelector(`meta#${item.id}`);
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('id', item.id);
meta.setAttribute('property', item.property);
if (titleEl) {
head.insertBefore(meta, titleEl);
} else if (head.firstChild) {
head.insertBefore(meta, head.firstChild);
} else {
head.appendChild(meta);
}
}
meta.setAttribute('content', item.content);
});
}
/**
* @function remove_og_meta
* @description 从页面 head 中移除进入详情页时插入的 Open Graph 元标签。
* @returns {void}
*/
function remove_og_meta() {
// 恢复 index.html 中现有的 og:description 原始内容
const head = document.head || document.getElementsByTagName('head')[0];
if (head) {
const descMeta = head.querySelector('meta[property="og:description"]');
if (descMeta && original_og_desc_content !== null) {
descMeta.setAttribute('content', original_og_desc_content);
}
}
// 移除运行时创建的其它 OG 标签
['og-title', 'og-image', 'og-url', 'og-description'].forEach(id => {
const meta = document.querySelector(`meta#${id}`);
if (meta && meta.parentNode) {
meta.parentNode.removeChild(meta);
}
});
}
const $route = useRoute();
const $router = useRouter();
const $route = useRoute()
const $router = useRouter()
const route = useRoute()
const router = useRouter()
......@@ -497,20 +500,20 @@ const open_share_poster = () => {
}
// 处理富文本点击事件,实现图片预览
const handleIntroduceClick = (event) => {
const target = event.target;
const handleIntroduceClick = event => {
const { target } = event
if (target.tagName === 'IMG') {
// 阻止默认行为(如果需要)
event.preventDefault();
event.preventDefault()
// 调用 vant 的图片预览
showImagePreview({
images: [target.src],
closeable: true,
showIndex: false, // 单张图片不显示索引
});
})
}
};
}
// 打卡相关状态
const task_list = ref([])
......@@ -552,7 +555,7 @@ const close_consult_dialog = () => {
* @param {string} phone 电话号码
* @returns {void}
*/
const call_phone = (phone) => {
const call_phone = phone => {
const p = phone || ''
if (p) {
window.location.href = `tel:${p}`
......@@ -561,7 +564,6 @@ const call_phone = (phone) => {
const { addToCart, proceedToCheckout } = useCart()
/**
* 收藏/取消收藏操作
* @description 添加收藏接口返回401未登录时,参考全局axios重定向逻辑,跳转到登录页并带上当前路径。
......@@ -570,7 +572,7 @@ const { addToCart, proceedToCheckout } = useCart()
const toggleFavorite = async () => {
if (isFavorite.value) {
const resp = await cancelFavoriteAPI({
group_id: course.value.id
group_id: course.value.id,
})
const code = resp && typeof resp === 'object' ? resp.code : 0
if (code === 1) {
......@@ -582,7 +584,7 @@ const toggleFavorite = async () => {
}
} else {
const resp = await addFavoriteAPI({
group_id: course.value.id
group_id: course.value.id,
})
const code = resp && typeof resp === 'object' ? resp.code : 0
if (code === 1) {
......@@ -605,17 +607,29 @@ const toggleFavorite = async () => {
// Curriculum items
const curriculumItems = computed(() => {
if (!course.value) return [];
if (!course.value) return []
return [
{ title: '课程介绍', active: activeTab.value === '课程介绍', show: !!course.value.introduce },
{ title: '主讲教师', active: activeTab.value === '主讲教师', show: !!(lecturers.value && lecturers.value.length > 0) },
{ title: '课程大纲', active: activeTab.value === '课程大纲', show: !!(course.value.schedule && course.value.schedule.length > 0) },
{
title: '主讲教师',
active: activeTab.value === '主讲教师',
show: !!(lecturers.value && lecturers.value.length > 0),
},
{
title: '课程大纲',
active: activeTab.value === '课程大纲',
show: !!(course.value.schedule && course.value.schedule.length > 0),
},
// { title: '课程亮点', active: activeTab.value === '课程亮点', show: !!course.value.highlights },
// { title: '学习目标', active: activeTab.value === '学习目标', show: !!course.value.learning_goal },
{ title: '打卡互动', active: activeTab.value === '打卡互动', show: !!course.value.is_buy && task_list.value.length > 0 },
].filter(item => item.show);
});
{
title: '打卡互动',
active: activeTab.value === '打卡互动',
show: !!course.value.is_buy && task_list.value.length > 0,
},
].filter(item => item.show)
})
/**
* @function handlePurchase
......@@ -639,7 +653,7 @@ const handlePurchase = async () => {
* 判断是否为微信内置浏览器环境
* 非微信环境提示用户在微信内打开;免费课程跳过校验
*/
const is_free = (course.value?.price === '0.00' || Number(course.value?.price) === 0)
const is_free = course.value?.price === '0.00' || Number(course.value?.price) === 0
if (!is_free && !import.meta.env.DEV && !wxInfo().isWeiXin) {
showToast('请在微信内打开进行购买')
return
......@@ -648,7 +662,7 @@ const handlePurchase = async () => {
// 微信环境内授权检查:未授权自动触发一次授权流程
if (!import.meta.env.DEV && wxInfo().isWeiXin) {
try {
const { code, data } = await getAuthInfoAPI();
const { code, data } = await getAuthInfoAPI()
if (code && !data.openid_has) {
showToast('正在进行微信授权,请稍后...')
await startWxAuth()
......@@ -690,11 +704,11 @@ const handlePurchase = async () => {
}
// 提交评论操作
const handleReviewSubmit = async (review) => {
const handleReviewSubmit = async review => {
const { code, msg } = await addGroupCommentAPI({
group_id: course.value?.id,
note: review.note,
score: review.rating
score: review.rating,
})
if (code === 1) {
showToast('评论提交成功')
......@@ -712,7 +726,7 @@ const fetchCommentList = async () => {
const { code, data } = await getGroupCommentListAPI({
group_id: course.value?.id,
page: 0,
limit: 5
limit: 5,
})
if (code === 1) {
commentList.value = data.comment_list
......@@ -723,24 +737,24 @@ const fetchCommentList = async () => {
// 初始化
onMounted(async () => {
const id = route.params.id
const { id } = route.params
// 调用接口获取课程详情
const { code, data } = await getCourseDetailAPI({ i: id });
const { code, data } = await getCourseDetailAPI({ i: id })
if (code === 1) {
const foundCourse = data;
const foundCourse = data
if (foundCourse) {
course.value = foundCourse;
lecturers.value = foundCourse.lecturer;
isFavorite.value = foundCourse.is_favorite;
isPurchased.value = foundCourse.is_buy;
isReviewed.value = foundCourse.is_comment;
course.value = foundCourse
lecturers.value = foundCourse.lecturer
isFavorite.value = foundCourse.is_favorite
isPurchased.value = foundCourse.is_buy
isReviewed.value = foundCourse.is_comment
useTitle(`${course.value.title || '课程详情'}`);
useTitle(`${course.value.title || '课程详情'}`)
// 设置默认选中的 tab,确保选中的 tab 有内容
const availableTabs = curriculumItems.value;
const availableTabs = curriculumItems.value
if (availableTabs.length > 0 && !availableTabs.some(item => item.title === activeTab.value)) {
activeTab.value = availableTabs[0].title;
activeTab.value = availableTabs[0].title
}
// 获取评论列表
......@@ -754,14 +768,6 @@ onMounted(async () => {
// 统一弹窗组件后不再维护 default_list
// 进入详情页时写入 Open Graph 元标签,提升分享预览效果
set_og_meta({
title: course.value?.title || '',
description: course.value?.subtitle || '',
image: course.value?.cover || '',
url: window.location.href
});
// 咨询联系人:从接口填充 contact_list
// 若图片为 cdn.ipadbiz.cn,显示与预览均追加压缩参数
try {
......@@ -770,14 +776,13 @@ onMounted(async () => {
id: item.id,
name: item.name,
phone: item.phone,
qrcode: item.qrcode || ''
qrcode: item.qrcode || '',
}))
} catch (e) {
consult_contacts.value = []
}
}
}
else {
} else {
// 课程不存在,跳转到课程主页面
showToast('课程不存在')
router.push('/courses')
......@@ -797,12 +802,6 @@ onMounted(async () => {
})
})
// 离开页面时清理 Open Graph 元标签
onUnmounted(() => {
remove_og_meta();
})
const isScheduleExpanded = ref(false)
// 计算显示的课程大纲列表
......@@ -830,17 +829,17 @@ const handleViewCourse = () => {
confirmButtonText: '知道了',
confirmButtonColor: '#4caf50',
})
return;
return
}
router.push(`/profile/studyCourse/${course.value.id}`);
};
router.push(`/profile/studyCourse/${course.value.id}`)
}
// 跳转课程大纲
const goToStudyDetail = (item) => {
console.warn(course.value);
const goToStudyDetail = item => {
console.warn(course.value)
// 如果没有购买过, 禁止操作
if (!course.value.is_buy) {
return;
return
}
// 检查课程审核状态
if (!course.value.is_approval_enable) {
......@@ -850,7 +849,7 @@ const goToStudyDetail = (item) => {
confirmButtonText: '知道了',
confirmButtonColor: '#4caf50',
})
return;
return
}
// 检查课程是否在开课时间内, course_start_time 开课时间, course_end_time 停课时间
if (!course.value.is_in_course_time) {
......@@ -860,7 +859,7 @@ const goToStudyDetail = (item) => {
confirmButtonText: '知道了',
confirmButtonColor: '#4caf50',
})
return;
return
}
// 跳转详情
router.push(`/studyDetail/${item.id}`)
......@@ -883,20 +882,14 @@ const goToCheckin = () => {
confirmButtonText: '知道了',
confirmButtonColor: '#4caf50',
})
return;
return
}
if(!(task_list.value.length || timeout_task_list.value.length)) {
showToast('暂无打卡任务');
return;
if (!(task_list.value.length || timeout_task_list.value.length)) {
showToast('暂无打卡任务')
return
}
showCheckInDialog.value = true;
};
setTimeout(() => {
// TAG:微信分享
// 自定义分享内容
sharePage({ title: `${course.value.title}`, desc: `${course.value.subtitle}`, imgUrl: course.value.cover });
}, 1000)
showCheckInDialog.value = true
}
</script>
<style lang="less">
......@@ -907,9 +900,9 @@ setTimeout(() => {
font-weight: 500;
}
}
.animate-favorite {
.animate-favorite {
animation: favorite-animation 0.5s ease;
}
}
@keyframes favorite-animation {
0% {
......@@ -929,10 +922,5 @@ setTimeout(() => {
background-color: #4caf50;
}
</style>
/**
* 处理打卡成功
* 注释:统一弹窗触发成功事件后,页面提示成功。
*/
const handleCheckInSuccess = () => {
showToast('打卡成功');
}
/** * 处理打卡成功 * 注释:统一弹窗触发成功事件后,页面提示成功。 */ const handleCheckInSuccess = ()
=> { showToast('打卡成功'); }
......