feat(app): 添加多模板关于我们页面及相关路由和404页面
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user