hookehuyr

feat: unify route metadata sync for og and share

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