import { computed, shallowRef, type Component } from 'vue' import { useNuxtApp, useRoute, useRequestURL } from '#imports' import { useSite } from './useSite' import { useTemplate } from './useTemplate' import { usePageSeo, useJsonLd, useBreadcrumbSeo } from '~/composables/usePageSeo' type ModuleKey = 'article' | 'product' | 'case' const MODULE_COMPONENTS: Record = { article: { list: 'NewsList', detail: 'NewsDetail' }, product: { list: 'ProductList', detail: 'ProductDetail' }, case: { list: 'CaseList', detail: 'CaseDetail' } } const MODULE_TITLE: Record = { article: '新闻资讯', product: '产品中心', case: '案例展示' } /** 取当前站点 origin(服务端/客户端通用),用于结构化数据图片绝对化 */ function getOriginForSeo(): string { if (import.meta.client) return window.location.origin try { return useRequestURL().origin } catch { return '' } } /** 相对 URL 转绝对(结构化数据 image 必须为绝对地址) */ function toAbsUrl(url: string, origin: string): string { if (/^https?:\/\//.test(url)) return url if (!origin) return url try { return new URL(url, origin).toString() } catch { return url } } /** * 详情页 SEO 注入:拉取详情数据并设置 TDK + OG + 结构化数据 + 面包屑。 * 与详情组件内部 useFetch 使用相同 key(article-${id} 等),Nuxt 自动去重,不重复请求。 * 覆盖全部 9 套模板的 article/product/case 详情页,避免逐模板硬编码。 */ function injectDetailSeo( module: ModuleKey, route: ReturnType, id: string, siteInfo: ReturnType['siteInfo'], currentNav: ReturnType, runWithContext: (fn: () => T) => T ) { return (async () => { const detailKey = `${module}-${id}` // useFetch 同样依赖 Nuxt 实例:本函数在 useModuleRoute 的多个 await 之后才执行, // 异步上下文已丢失,必须显式 runWithContext 包裹,否则 SSR 直接 500。 const { data: detail } = await runWithContext(() => useFetch(`/api/${module}/detail?id=${id}`, { key: detailKey }) ) const d = detail.value const title = d?.title || d?.productName || MODULE_TITLE[module] const description = stripHtml( d?.summary || d?.description || d?.subtitle || '' ).slice(0, 160) || undefined const image = d?.image || d?.cover || d?.photo || undefined const keywords = Array.isArray(d?.tags) ? d.tags.join(',') : (typeof d?.tags === 'string' ? d.tags : undefined) const publishedTime = d?.publishTime || d?.createTime || undefined const modifiedTime = d?.updateTime || undefined const origin = getOriginForSeo() const absImage = image ? toAbsUrl(image, origin) : undefined runWithContext(() => { usePageSeo( { title, description, keywords, path: route.path, image: absImage, type: module === 'article' ? 'article' : module === 'product' ? 'product' : 'website', publishedTime, modifiedTime }, siteInfo.value ) // 结构化数据 if (module === 'article') { useJsonLd({ '@context': 'https://schema.org', '@type': 'Article', headline: title, description: description || '', ...(absImage ? { image: [absImage] } : {}), datePublished: publishedTime, dateModified: modifiedTime, author: { '@type': 'Organization', name: siteInfo.value?.websiteName || '' } }) } else if (module === 'product') { useJsonLd({ '@context': 'https://schema.org', '@type': 'Product', name: title, description: description || '', ...(absImage ? { image: [absImage] } : {}), ...(d?.price ? { offers: { '@type': 'Offer', price: d.price, priceCurrency: 'CNY' } } : {}) }) } else { useJsonLd({ '@context': 'https://schema.org', '@type': 'CreativeWork', name: title, description: description || '', ...(absImage ? { image: [absImage] } : {}) }) } // 面包屑 useBreadcrumbSeo([ { name: '首页', url: '/' }, { name: currentNav.value?.title || MODULE_TITLE[module], url: `/${module}` }, { name: title, url: route.path } ]) }) })() } /** * 模块路由(栏目列表 / 详情 二合一)。 * * URL 规范(按模块名单数 + navId 运行时判定): * 列表(栏目):/{module} 或 /{module}/{navigationId} * 详情(条目):/{module}/{id} * 同一 /{module}/{id} 下,靠「id 是否该模块已知栏目 navigationId」判定: * 命中 → 渲染列表组件(按 navigationId 过滤);否则 → 渲染详情组件。 * * 旧链接(/news、/newss、/products、/cases)由 * server/middleware/z-news-detail-redirect.ts 统一 301 到新模块名。 */ export async function useModuleRoute(module: ModuleKey) { const route = useRoute() // 先捕获 Nuxt 实例:下面有 await,之后再直接调用依赖实例的 composable(如 usePageSeo → // useRuntimeConfig)会因失去异步上下文而报 "A composable that requires access to the // Nuxt instance was called outside of...",需用 runWithContext 显式恢复上下文。 const nuxtApp = useNuxtApp() const { loadTemplate } = useTemplate() const { siteInfo, allNavigations, fetchSiteInfo } = useSite() await fetchSiteInfo() const components = await loadTemplate() // 当前模块下所有栏目 navigationId(用于区分「列表」与「详情」) // 关键:用完整导航树 allNavigations(top+bottom,不限 top===1),而非仅顶部导航; // 且以 path 首段 /{module}/ 为主判定(与 getNavLink 生成的链接一致), // model 仅作兜底——CMS 对 case 等模块 model 字段常缺失/不一致,纯靠 model 会漏判。 const moduleNavIds = computed>(() => { const set = new Set() const collect = (items: any[] = []) => { for (const it of items) { if (it.navigationId != null) { const path = it.path || it.categoryPath || '' const firstSeg = path.split('/').filter(Boolean)[0] const isModule = firstSeg === module || // path 前缀 /{module}/(与 getNavLink 一致) it.model === module // model 兜底 if (isModule) set.add(String(it.navigationId)) } if (it.children?.length) collect(it.children) } } collect(allNavigations.value) return set }) const id = String(route.params.id) const isColumn = moduleNavIds.value.has(id) const currentNav = computed(() => { const navId = String(route.params.id) const find = (items: any[] = []): any => { for (const it of items) { if (String(it.navigationId) === navId) return it if (it.children?.length) { const f = find(it.children) if (f) return f } } return null } return find(allNavigations.value) }) const pageComponent = shallowRef(null) const comp = isColumn ? components?.[MODULE_COMPONENTS[module].list as keyof typeof components] : components?.[MODULE_COMPONENTS[module].detail as keyof typeof components] pageComponent.value = (comp as Component | null) || null // ===== SEO 注入 ===== if (!isColumn) { // 详情页:通过统一入口注入 TDK + 结构化数据 + 面包屑(覆盖全部 9 套模板)。 // 与详情组件内部 useFetch 使用相同 key,Nuxt 自动去重,不重复请求。 await injectDetailSeo(module, route, id, siteInfo, currentNav, (fn) => nuxtApp.runWithContext(fn)) } else { // 栏目(列表)页:用栏目标题 + 干净路径(去掉 ?navId 等查询参数)作为 canonical。 nuxtApp.runWithContext(() => { usePageSeo( { title: currentNav.value?.title || MODULE_TITLE[module], path: route.path }, siteInfo.value ) // 搜索结果页(带 ?keywords=)属于低质量/易重复页面,禁止被索引 if (route.query.keywords) { useSeoMeta({ robots: 'noindex, follow' }) } }) } return { route, pageComponent, isColumn, currentNav } }