/** * AI 资讯(文章)模块共用逻辑 * * 栏目与路由完全沿用原 PC 站,保证 SEO 不断链: * col_id 1 前沿资讯 → /info/new 详情 /info/new/{id}.html * col_id 2 使用教程 → /info/course 详情 /info/course/{id}.html * col_id 3 AI 问答 → /info/what 详情 /info/what/{id}.html * 标签页 → /info/tag/{key} * 资讯首页(全部) → /info */ import dayjs from 'dayjs' import { LEGACY_DEFAULT_DESCRIPTION, LEGACY_DEFAULT_KEYWORDS, legacyPageTitle, } from '~/composables/useLegacySeo' /** 栏目定义 */ export const COLUMNS = [ { colId: 1, key: 'new', name: '前沿资讯', path: '/info/new', icon: 'i-lucide-newspaper' }, { colId: 2, key: 'course', name: '使用教程', path: '/info/course', icon: 'i-lucide-graduation-cap' }, { colId: 3, key: 'what', name: 'AI 问答', path: '/info/what', icon: 'i-lucide-circle-help' }, ] /** col_id → 详情 URL(保留 .html 后缀,与原站一致) */ export function articleUrl(item) { const col = COLUMNS.find(c => c.colId === Number(item?.col_id)) || COLUMNS[0] return `${col.path}/${item.id}.html` } /** col_id → 栏目名 */ export function colName(colId) { return COLUMNS.find(c => c.colId === Number(colId))?.name || '资讯' } /** col_id → 栏目列表页路径 */ export function colPath(colId) { return COLUMNS.find(c => c.colId === Number(colId))?.path || '/info' } /** 列表分页 URL(沿用原站:/info2、/info/new2、/info/tag/key-2.html) */ export function pageUrl(basePath, page, tagKey) { if (tagKey) return page > 1 ? `/info/tag/${tagKey}-${page}.html` : `/info/tag/${tagKey}.html` return page > 1 ? `${basePath}${page}` : basePath } /** 秒级时间戳格式化 */ export function formatTime(value, fmt = 'YYYY-MM-DD HH:mm') { if (!value) return '' return dayjs(Number(value) * 1000).format(fmt) } /** 从路由参数中取出文章 id(去掉 .html 后缀) */ export function parseArticleId(param) { return String(param || '').replace(/\.html$/i, '') } /** * 资讯页共用数据:列表 + 热门排行 + 热门标签 * 用 useAsyncData 做 SSR,保证爬虫可直接抓到内容 */ export function useArticleData(options) { const { key, colId, tagKey, page, pageSize = 10, isRecommend } = options const { post } = useApi() return useAsyncData( key, async () => { const listParams = { page_no: unref(page) || 1, page_size: pageSize } if (colId) listParams.col_id = colId if (tagKey) listParams.tag_key = unref(tagKey) if (isRecommend) listParams.is_recommend = 1 const [listRes, hotRes, tagRes] = await Promise.all([ post('/article/getlist', listParams).catch(() => ({ list: [], data: {} })), post('/article/gethotlist', { page_no: 1, page_size: 10 }).catch(() => ({ list: [] })), post('/article/gettaglist', { is_hot: 1, page_size: 20 }).catch(() => ({ list: [] })), ]) return { list: (listRes.list || []).map(i => ({ ...i, url: articleUrl(i), time: formatTime(i.publish_time) })), total: Number(listRes.data?.total || 0), tagName: listRes.data?.tag_name || '', hotList: (hotRes.list || []).map((i, idx) => ({ ...i, rank: idx + 1, url: articleUrl(i) })), tagList: tagRes.list || [], } }, { watch: [page, tagKey].filter(Boolean) }, ) } /** * 文章详情共用数据(SSR),并统一处理 SEO * @param {number} colId 当前栏目(用于校验路由与文章栏目是否一致) */ export async function useArticleDetail(colId) { const route = useRoute() const nuxtApp = useNuxtApp() const { post } = useApi() const id = parseArticleId(route.params.id) const asyncData = await useAsyncData(`article-${id}`, async () => { const [detailRes, hotRes, tagRes] = await Promise.all([ post('/article/getdetail', { id, prev_next: 1 }), post('/article/gethotlist', { page_no: 1, page_size: 10 }).catch(() => ({ list: [] })), post('/article/gettaglist', { is_hot: 1, page_size: 20 }).catch(() => ({ list: [] })), ]) return { detail: detailRes.data || {}, hotList: (hotRes.list || []).map((i, idx) => ({ ...i, rank: idx + 1, url: articleUrl(i) })), tagList: tagRes.list || [], } }) const { data, error } = asyncData const detail = computed(() => data.value?.detail || {}) const canonical = computed(() => `https://tool.aionline.cc${articleUrl(detail.value)}`) if (error.value) { console.error('[article:ssr] load detail failed', { id, colId, message: error.value.message, code: error.value.code || error.value.statusCode, cause: error.value.cause?.message, }) throw createError({ statusCode: 502, message: '文章加载失败,请稍后重试', fatal: true, cause: error.value, }) } // 仅接口成功但确实没有数据时返回 404,避免把网络异常误判为文章不存在。 if (!data.value?.detail?.id) { throw createError({ statusCode: 404, message: '文章不存在', fatal: true }) } await nuxtApp.runWithContext(() => { useSeoMeta({ title: () => legacyPageTitle(detail.value.title), description: () => detail.value.info || LEGACY_DEFAULT_DESCRIPTION, keywords: () => detail.value.title || LEGACY_DEFAULT_KEYWORDS, ogTitle: () => detail.value.title, ogDescription: () => detail.value.info || detail.value.title, ogType: 'article', ogImage: () => detail.value.cover || '', articlePublishedTime: () => formatTime(detail.value.publish_time, 'YYYY-MM-DDTHH:mm:ssZ'), }) useHead({ link: [{ rel: 'canonical', href: canonical }], // 结构化数据,利于搜索结果富摘要 script: [{ type: 'application/ld+json', innerHTML: computed(() => JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', headline: detail.value.title, description: detail.value.info || '', image: detail.value.cover ? [detail.value.cover] : undefined, datePublished: formatTime(detail.value.publish_time, 'YYYY-MM-DD'), author: { '@type': 'Organization', name: 'AI在线' }, publisher: { '@type': 'Organization', name: 'AI在线' }, mainEntityOfPage: { '@type': 'WebPage', '@id': canonical.value }, })), }], }) }) // 访问的栏目路径与文章实际栏目不一致 → 301 到规范地址(避免重复内容) if (colId && Number(detail.value.col_id) !== Number(colId)) { await nuxtApp.runWithContext(() => navigateTo(articleUrl(detail.value), { redirectCode: 301, replace: true })) } return { data, detail } }