You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

164 rivejä
6.2 KiB

  1. /**
  2. * AI 资讯(文章)模块共用逻辑
  3. *
  4. * 栏目与路由完全沿用原 PC 站,保证 SEO 不断链:
  5. * col_id 1 前沿资讯 → /info/new 详情 /info/new/{id}.html
  6. * col_id 2 使用教程 → /info/course 详情 /info/course/{id}.html
  7. * col_id 3 AI 问答 → /info/what 详情 /info/what/{id}.html
  8. * 标签页 → /info/tag/{key}
  9. * 资讯首页(全部) → /info
  10. */
  11. import dayjs from 'dayjs'
  12. import {
  13. LEGACY_DEFAULT_DESCRIPTION,
  14. LEGACY_DEFAULT_KEYWORDS,
  15. legacyPageTitle,
  16. } from '~/composables/useLegacySeo'
  17. /** 栏目定义 */
  18. export const COLUMNS = [
  19. { colId: 1, key: 'new', name: '前沿资讯', path: '/info/new', icon: 'i-lucide-newspaper' },
  20. { colId: 2, key: 'course', name: '使用教程', path: '/info/course', icon: 'i-lucide-graduation-cap' },
  21. { colId: 3, key: 'what', name: 'AI 问答', path: '/info/what', icon: 'i-lucide-circle-help' },
  22. ]
  23. /** col_id → 详情 URL(保留 .html 后缀,与原站一致) */
  24. export function articleUrl(item) {
  25. const col = COLUMNS.find(c => c.colId === Number(item?.col_id)) || COLUMNS[0]
  26. return `${col.path}/${item.id}.html`
  27. }
  28. /** col_id → 栏目名 */
  29. export function colName(colId) {
  30. return COLUMNS.find(c => c.colId === Number(colId))?.name || '资讯'
  31. }
  32. /** col_id → 栏目列表页路径 */
  33. export function colPath(colId) {
  34. return COLUMNS.find(c => c.colId === Number(colId))?.path || '/info'
  35. }
  36. /** 列表分页 URL(沿用原站:/info2、/info/new2、/info/tag/key-2.html) */
  37. export function pageUrl(basePath, page, tagKey) {
  38. if (tagKey) return page > 1 ? `/info/tag/${tagKey}-${page}.html` : `/info/tag/${tagKey}.html`
  39. return page > 1 ? `${basePath}${page}` : basePath
  40. }
  41. /** 秒级时间戳格式化 */
  42. export function formatTime(value, fmt = 'YYYY-MM-DD HH:mm') {
  43. if (!value) return ''
  44. return dayjs(Number(value) * 1000).format(fmt)
  45. }
  46. /** 从路由参数中取出文章 id(去掉 .html 后缀) */
  47. export function parseArticleId(param) {
  48. return String(param || '').replace(/\.html$/i, '')
  49. }
  50. /**
  51. * 资讯页共用数据:列表 + 热门排行 + 热门标签
  52. * 用 useAsyncData 做 SSR,保证爬虫可直接抓到内容
  53. */
  54. export function useArticleData(options) {
  55. const { key, colId, tagKey, page, pageSize = 10, isRecommend } = options
  56. const { post } = useApi()
  57. return useAsyncData(
  58. key,
  59. async () => {
  60. const listParams = { page_no: unref(page) || 1, page_size: pageSize }
  61. if (colId) listParams.col_id = colId
  62. if (tagKey) listParams.tag_key = unref(tagKey)
  63. if (isRecommend) listParams.is_recommend = 1
  64. const [listRes, hotRes, tagRes] = await Promise.all([
  65. post('/article/getlist', listParams).catch(() => ({ list: [], data: {} })),
  66. post('/article/gethotlist', { page_no: 1, page_size: 10 }).catch(() => ({ list: [] })),
  67. post('/article/gettaglist', { is_hot: 1, page_size: 20 }).catch(() => ({ list: [] })),
  68. ])
  69. return {
  70. list: (listRes.list || []).map(i => ({ ...i, url: articleUrl(i), time: formatTime(i.publish_time) })),
  71. total: Number(listRes.data?.total || 0),
  72. tagName: listRes.data?.tag_name || '',
  73. hotList: (hotRes.list || []).map((i, idx) => ({ ...i, rank: idx + 1, url: articleUrl(i) })),
  74. tagList: tagRes.list || [],
  75. }
  76. },
  77. { watch: [page, tagKey].filter(Boolean) },
  78. )
  79. }
  80. /**
  81. * 文章详情共用数据(SSR),并统一处理 SEO
  82. * @param {number} colId 当前栏目(用于校验路由与文章栏目是否一致)
  83. */
  84. export async function useArticleDetail(colId) {
  85. const route = useRoute()
  86. const nuxtApp = useNuxtApp()
  87. const { post } = useApi()
  88. const id = parseArticleId(route.params.id)
  89. const asyncData = await useAsyncData(`article-${id}`, async () => {
  90. const [detailRes, hotRes, tagRes] = await Promise.all([
  91. post('/article/getdetail', { id, prev_next: 1 }),
  92. post('/article/gethotlist', { page_no: 1, page_size: 10 }).catch(() => ({ list: [] })),
  93. post('/article/gettaglist', { is_hot: 1, page_size: 20 }).catch(() => ({ list: [] })),
  94. ])
  95. return {
  96. detail: detailRes.data || {},
  97. hotList: (hotRes.list || []).map((i, idx) => ({ ...i, rank: idx + 1, url: articleUrl(i) })),
  98. tagList: tagRes.list || [],
  99. }
  100. })
  101. const { data, error } = asyncData
  102. const detail = computed(() => data.value?.detail || {})
  103. const canonical = computed(() => `https://tool.aionline.cc${articleUrl(detail.value)}`)
  104. // 文章不存在 → 404,避免无效页面被搜索引擎收录
  105. if (error.value || !data.value?.detail?.id) {
  106. throw createError({ statusCode: 404, statusMessage: '文章不存在', fatal: true })
  107. }
  108. await nuxtApp.runWithContext(() => {
  109. useSeoMeta({
  110. title: () => legacyPageTitle(detail.value.title),
  111. description: () => detail.value.info || LEGACY_DEFAULT_DESCRIPTION,
  112. keywords: () => detail.value.title || LEGACY_DEFAULT_KEYWORDS,
  113. ogTitle: () => detail.value.title,
  114. ogDescription: () => detail.value.info || detail.value.title,
  115. ogType: 'article',
  116. ogImage: () => detail.value.cover || '',
  117. articlePublishedTime: () => formatTime(detail.value.publish_time, 'YYYY-MM-DDTHH:mm:ssZ'),
  118. })
  119. useHead({
  120. link: [{ rel: 'canonical', href: canonical }],
  121. // 结构化数据,利于搜索结果富摘要
  122. script: [{
  123. type: 'application/ld+json',
  124. innerHTML: computed(() => JSON.stringify({
  125. '@context': 'https://schema.org',
  126. '@type': 'Article',
  127. headline: detail.value.title,
  128. description: detail.value.info || '',
  129. image: detail.value.cover ? [detail.value.cover] : undefined,
  130. datePublished: formatTime(detail.value.publish_time, 'YYYY-MM-DD'),
  131. author: { '@type': 'Organization', name: 'AI在线' },
  132. publisher: { '@type': 'Organization', name: 'AI在线' },
  133. mainEntityOfPage: { '@type': 'WebPage', '@id': canonical.value },
  134. })),
  135. }],
  136. })
  137. })
  138. // 访问的栏目路径与文章实际栏目不一致 → 301 到规范地址(避免重复内容)
  139. if (colId && Number(detail.value.col_id) !== Number(colId)) {
  140. await nuxtApp.runWithContext(() =>
  141. navigateTo(articleUrl(detail.value), { redirectCode: 301, replace: true }))
  142. }
  143. return { data, detail }
  144. }