2b69686795
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
229 lines
8.3 KiB
TypeScript
229 lines
8.3 KiB
TypeScript
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<ModuleKey, { list: string; detail: string }> = {
|
||
article: { list: 'NewsList', detail: 'NewsDetail' },
|
||
product: { list: 'ProductList', detail: 'ProductDetail' },
|
||
case: { list: 'CaseList', detail: 'CaseDetail' }
|
||
}
|
||
|
||
const MODULE_TITLE: Record<ModuleKey, string> = {
|
||
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<typeof useRoute>,
|
||
id: string,
|
||
siteInfo: ReturnType<typeof useSite>['siteInfo'],
|
||
currentNav: ReturnType<typeof computed>,
|
||
runWithContext: <T>(fn: () => T) => T
|
||
) {
|
||
return (async () => {
|
||
const detailKey = `${module}-${id}`
|
||
// useFetch 同样依赖 Nuxt 实例:本函数在 useModuleRoute 的多个 await 之后才执行,
|
||
// 异步上下文已丢失,必须显式 runWithContext 包裹,否则 SSR 直接 500。
|
||
const { data: detail } = await runWithContext(() =>
|
||
useFetch<any>(`/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<Set<string>>(() => {
|
||
const set = new Set<string>()
|
||
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<Component | null>(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 }
|
||
}
|