index.vue 8.36 KB
<!--
 * @Date: 2026-02-27
 * @Description: 文章详情页
-->
<template>
  <view class="article-detail-page">
    <!-- 导航栏 -->
    <NavHeader :title="displayTitle" />

    <!-- 滚动容器 -->
    <view class="flex-1 pb-safe">
      <!-- 加载状态 - 自定义 Tailwind CSS 加载动画 -->
      <view v-if="loading" class="flex items-center justify-center h-screen">
        <view class="flex flex-col items-center">
          <view class="w-[64rpx] h-[64rpx] border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin mb-[24rpx]"></view>
          <text class="text-gray-600 text-[28rpx]">加载中...</text>
        </view>
      </view>

      <!-- 文章内容 -->
      <view v-else-if="article">
        <!-- 封面图 -->
        <view v-if="article.coverUrl" class="cover-image-wrapper">
          <image :src="article.coverUrl" mode="widthFix" class="cover-image" />
        </view>

        <!-- 文章信息 -->
        <view class="article-info px-[32rpx]">
          <!-- 标题 -->
          <view class="article-title">{{ article.title }}</view>

          <!-- 作者和日期 -->
          <view class="article-meta">
            <!-- <text v-if="article.authorName" class="meta-item">{{ article.authorName }}</text>
            <text v-if="article.authorName && article.date" class="meta-separator">·</text> -->
            <text v-if="article.date" class="meta-item">{{ formattedDate }}</text>
          </view>
        </view>

        <!-- 分割线 -->
        <view class="divider"></view>

        <!-- 富文本内容 - 使用 RichTextRenderer 组件 -->
        <view class="article-body px-[32rpx]">
          <RichTextRenderer :content="article.content" />
        </view>
      </view>

      <!-- 加载失败 - 自定义空状态 -->
      <view v-else-if="error" class="error-state">
        <view class="flex flex-col items-center justify-center h-screen">
          <view class="text-[#9CA3AF] text-[120rpx] mb-[24rpx]">⚠️</view>
          <text class="text-gray-600 text-[28rpx] mb-[32rpx]">加载失败</text>
          <view
            class="px-[48rpx] py-[20rpx] bg-blue-600 text-white rounded-full"
            @tap="fetchArticleDetail"
          >
            <text class="text-[28rpx]">重试</text>
          </view>
        </view>
      </view>

      <!-- 底部安全区域 -->
      <view class="safe-area-bottom"></view>
    </view>

    <!-- 底部收藏按钮 -->
    <view class="footer-bar">
      <view class="footer-content">
        <view
          v-if="article"
          :class="['collect-button', article.is_favorite ? 'collected' : '']"
          @tap="toggleCollect"
        >
          <text class="collect-icon">{{ article.is_favorite ? '★' : '☆' }}</text>
          <text class="collect-text">{{ article.is_favorite ? '已收藏' : '收藏' }}</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script setup>
import { ref, computed } from 'vue'
import { useLoad } from '@tarojs/taro'
import NavHeader from '@/components/navigation/NavHeader.vue'
import RichTextRenderer from '@/components/RichTextRenderer.vue'
import { articleDetailAPI } from '@/api/article'
import { addAPI, delAPI } from '@/api/favorite'
import { mockArticleDetailAPI } from '@/utils/mockData'
import eventBus, { Events } from '@/utils/eventBus'
import dayjs from 'dayjs'
import Taro from '@tarojs/taro'
import { USE_MOCK_DATA } from '@/config/app'

/**
 * 文章数据
 */
const article = ref(null)

/**
 * 加载状态
 */
const loading = ref(true)

/**
 * 错误状态
 */
const error = ref(false)

/**
 * 文章 ID
 */
const articleId = ref(null)

/**
 * 格式化日期显示
 */
const formattedDate = computed(() => {
  if (!article.value?.post_date) return ''
  try {
    return dayjs(article.value.post_date).format('YYYY年MM月DD日')
  } catch {
    return article.value.post_date
  }
})

/**
 * 导航栏显示标题(截断过长标题)
 *
 * @description 最多显示 15 个字符,超过则添加省略号
 */
const displayTitle = computed(() => {
  // 加载中或加载失败时显示默认标题
  if (!article.value?.title) return '文章详情'

  const MAX_LENGTH = 15
  const title = article.value.title.trim()

  return title.length > MAX_LENGTH
    ? title.slice(0, MAX_LENGTH) + '...'
    : title
})

/**
 * 获取文章详情
 */
const fetchArticleDetail = async () => {
  if (!articleId.value) return

  loading.value = true
  error.value = false

  try {
    const res = USE_MOCK_DATA
      ? await mockArticleDetailAPI({ i: articleId.value })
      : await articleDetailAPI({ i: articleId.value })

    if (res.code === 1 && res.data) {
      article.value = {
        id: res.data.id,
        title: res.data.post_title || '未命名文章',
        // 直接使用原始内容,由 RichTextRenderer 组件处理
        content: res.data.post_content || '',
        excerpt: res.data.post_excerpt || '',
        coverUrl: res.data.cover_url || res.data.post_thumbnail || '',
        date: res.data.post_date || '',
        authorName: res.data.author_name || '',
        is_favorite: res.data.is_favorite === 1 || res.data.is_favorite === '1'
      }
    } else {
      error.value = true
      Taro.showToast({
        title: res.msg || '获取文章详情失败',
        icon: 'none'
      })
    }
  } catch (err) {
    error.value = true
    Taro.showToast({
      title: '加载失败',
      icon: 'error'
    })
  } finally {
    loading.value = false
  }
}

/**
 * 切换收藏状态
 */
const toggleCollect = async () => {
  if (!article.value) return

  try {
    const newCollectStatus = !article.value.is_favorite

    // 调用收藏 API(使用与文件相同的收藏 API)
    const res = newCollectStatus
      ? await addAPI({ meta_id: article.value.id })
      : await delAPI({ meta_id: article.value.id })

    if (res.code === 1) {
      // 更新本地状态
      article.value.is_favorite = newCollectStatus

      Taro.showToast({
        title: newCollectStatus ? '已收藏' : '已取消收藏',
        icon: 'success',
        duration: 1000
      })

      // 发送收藏更新事件
      eventBus.emit(Events.FAVORITES_UPDATE, {
        metaId: article.value.id,
        collected: newCollectStatus,
        timestamp: Date.now()
      })
    } else {
      Taro.showToast({
        title: res.msg || '操作失败',
        icon: 'none',
        duration: 2000
      })
    }
  } catch (err) {
    Taro.showToast({
      title: '网络错误,请重试',
      icon: 'none',
      duration: 2000
    })
  }
}

/**
 * 页面加载时获取文章详情
 */
useLoad((options) => {
  if (options.id) {
    articleId.value = options.id
    fetchArticleDetail()
  } else {
    error.value = true
    loading.value = false
    Taro.showToast({
      title: '文章ID不存在',
      icon: 'none'
    })
  }
})
</script>

<style lang="less">
.article-detail-page {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  background-color: #F9FAFB;
}

.cover-image-wrapper {
  width: 100%;
  background-color: #F3F4F6;
}

.cover-image {
  width: 100%;
  display: block;
}

.article-info {
  padding-top: 32rpx;
  padding-bottom: 24rpx;
}

.article-title {
  font-size: 40rpx;
  font-weight: bold;
  color: #1F2937;
  line-height: 1.4;
  margin-bottom: 16rpx;
}

.article-meta {
  display: flex;
  align-items: center;
  font-size: 24rpx;
  color: #9CA3AF;
}

.meta-item {
  margin-right: 8rpx;
}

.meta-separator {
  margin-right: 8rpx;
}

.divider {
  height: 1rpx;
  background-color: #E5E7EB;
  margin-bottom: 32rpx;
}

.article-body {
  padding-bottom: 32rpx;
}

.safe-area-bottom {
  height: 120rpx;
}

.footer-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  background-color: #fff;
  border-top: 1rpx solid #E5E7EB;
  padding-bottom: env(safe-area-inset-bottom);
}

.footer-content {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 20rpx 32rpx;
}

.collect-button {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16rpx 48rpx;
  border-radius: 999rpx;
  background-color: #F3F4F6;
  transition: all 0.2s ease;

  &.collected {
    background-color: #FEF3C7;

    .collect-icon {
      color: #F59E0B;
    }

    .collect-text {
      color: #F59E0B;
    }
  }
}

.collect-icon {
  font-size: 32rpx;
  color: #9CA3AF;
  margin-right: 8rpx;
}

.collect-text {
  font-size: 28rpx;
  color: #6B7280;
}

.error-state {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 400rpx;
}
</style>