Files
hjc-web/app/composables/useModuleRoute.ts
T
weicw-dshsa d013e64500 fix(cms): 其余 9 套模板一并改读 publishTime,消除静默失效隐患
上一提交只改了 template-07(当时 cms_website.template_id = 7),其余模板
仍读已废弃的 projectTime、文章页仍写 publishTime || createTime。
由于 CaseItem 带索引签名,这些引用不报编译错误,只在换模板时静默显示
系统创建时间——而 template_id 是数据库里可随时改的数字,改它不需发版。

本次补全全部模板:
- template-01..10 的 CaseDetail 改读 publishTime,标签更正为「发布时间」
- 其中 8 套原先没有 formatDate,一并补 dayjs 导入与格式化
  (此前直出 2026-09-23 19:06:37 完整时间戳)
- 文章侧 38 处 publishTime || createTime 统一为 publishTime
- useModuleRoute 的 SEO datePublished 不再回退 createTime
  (结构化数据应与页面展示的发布时间一致)

保留 server/api/sitemap.xml.ts 的 lastmod 回退链——那是抓取提示不是页面
展示,回退到创建时间是合理的保守行为。

现在官网上已不存在读取 createTime 的内容页。ADR 0016 中记录的该技术债
已标记为消除。
2026-09-29 03:50:58 +08:00

232 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
// SEO 的 datePublished 用发布时间(运营可见的对外时间)。
// 不回退 createTime:那是系统记录时间,与页面上展示的发布时间不一致时
// 会让结构化数据与实际内容对不上(createTime 不对外展示,见 ADR 0016)。
const publishedTime = d?.publishTime || 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 }
}