Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

180 linhas
6.6 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. if (error.value) {
  105. console.error('[article:ssr] load detail failed', {
  106. id,
  107. colId,
  108. message: error.value.message,
  109. code: error.value.code || error.value.statusCode,
  110. cause: error.value.cause?.message,
  111. })
  112. throw createError({
  113. statusCode: 502,
  114. message: '文章加载失败,请稍后重试',
  115. fatal: true,
  116. cause: error.value,
  117. })
  118. }
  119. // 仅接口成功但确实没有数据时返回 404,避免把网络异常误判为文章不存在。
  120. if (!data.value?.detail?.id) {
  121. throw createError({ statusCode: 404, message: '文章不存在', fatal: true })
  122. }
  123. await nuxtApp.runWithContext(() => {
  124. useSeoMeta({
  125. title: () => legacyPageTitle(detail.value.title),
  126. description: () => detail.value.info || LEGACY_DEFAULT_DESCRIPTION,
  127. keywords: () => detail.value.title || LEGACY_DEFAULT_KEYWORDS,
  128. ogTitle: () => detail.value.title,
  129. ogDescription: () => detail.value.info || detail.value.title,
  130. ogType: 'article',
  131. ogImage: () => detail.value.cover || '',
  132. articlePublishedTime: () => formatTime(detail.value.publish_time, 'YYYY-MM-DDTHH:mm:ssZ'),
  133. })
  134. useHead({
  135. link: [{ rel: 'canonical', href: canonical }],
  136. // 结构化数据,利于搜索结果富摘要
  137. script: [{
  138. type: 'application/ld+json',
  139. innerHTML: computed(() => JSON.stringify({
  140. '@context': 'https://schema.org',
  141. '@type': 'Article',
  142. headline: detail.value.title,
  143. description: detail.value.info || '',
  144. image: detail.value.cover ? [detail.value.cover] : undefined,
  145. datePublished: formatTime(detail.value.publish_time, 'YYYY-MM-DD'),
  146. author: { '@type': 'Organization', name: 'AI在线' },
  147. publisher: { '@type': 'Organization', name: 'AI在线' },
  148. mainEntityOfPage: { '@type': 'WebPage', '@id': canonical.value },
  149. })),
  150. }],
  151. })
  152. })
  153. // 访问的栏目路径与文章实际栏目不一致 → 301 到规范地址(避免重复内容)
  154. if (colId && Number(detail.value.col_id) !== Number(colId)) {
  155. await nuxtApp.runWithContext(() =>
  156. navigateTo(articleUrl(detail.value), { redirectCode: 301, replace: true }))
  157. }
  158. return { data, detail }
  159. }