HarmonyOS应用<节气通>开发第7篇:文章详情页开发

引言

文章详情页是内容型应用的核心页面,负责展示完整的文章内容。在"节气通"应用中,文章详情页需要实现:

  • 文章标题和封面展示
  • 作者信息和发布时间
  • 富文本内容展示
  • 点赞和评论功能
  • 相关文章推荐

通过本文,你将掌握如何在HarmonyOS中实现一个功能完善的文章详情页。


学习目标

完成本文后,你将能够:

  • ✅ 实现文章内容展示
  • ✅ 处理富文本内容
  • ✅ 实现点赞功能
  • ✅ 添加评论列表
  • ✅ 推荐相关文章

需求分析

功能模块设计

模块 功能描述 技术要点
文章头部 封面图、标题、作者信息 Stack布局、渐变遮罩
文章内容 富文本正文展示 Text组件、段落样式
点赞功能 点赞按钮和数量 状态切换、动画效果
评论列表 展示用户评论 List布局、自定义组件
评论输入 发表评论功能 输入框、按钮交互
相关推荐 推荐相似文章 横向滚动、卡片布局

核心实现

步骤1: 页面初始化与数据加载

完整代码
typescript 复制代码
// pages/ArticleDetail.ets

import router from '@ohos.router';
import prompt from '@ohos.prompt';
import { articles } from '../mock/ArticleMockData';
import type { Article } from '../models/ArticleModel';
import type { Comment } from '../models/CommentModel';

@Entry
@Component
struct ArticleDetail {
  // 文章数据
  @State article: Article | null = null;
  @State isLoading: boolean = true;
  
  // 文章ID
  private articleId: string = '';
  
  // 点赞状态
  @State isLiked: boolean = false;
  @State likeCount: number = 0;
  
  // 评论数据
  @State comments: Comment[] = [];
  @State commentInput: string = '';
  
  /**
   * 页面加载时执行
   */
  aboutToAppear() {
    this.loadArticleData();
  }
  
  /**
   * 加载文章数据
   */
  loadArticleData(): void {
    try {
      // 获取路由参数
      const params = router.getParams() as Record<string, string>;
      this.articleId = params?.articleId || '';
      
      console.info('[ArticleDetail] 加载文章: ' + this.articleId);
      
      // 查找对应文章
      const article = articles.find((a: Article) => a.id === this.articleId);
      
      if (article) {
        this.article = article;
        this.likeCount = article.likeCount;
        this.isLiked = false;  // 默认未点赞
      } else {
        // 默认显示第一篇文章
        this.article = articles[0];
        this.likeCount = articles[0].likeCount;
      }
      
      // 加载评论数据
      this.loadComments();
      
      this.isLoading = false;
    } catch (error) {
      console.error('[ArticleDetail] 加载数据失败: ' + JSON.stringify(error));
      this.isLoading = false;
    }
  }
  
  /**
   * 加载评论数据
   */
  loadComments(): void {
    // Mock评论数据
    this.comments = [
      {
        id: '1',
        userId: '1',
        userName: '用户A',
        avatar: 'avatar1',
        content: '这篇文章写得太好了,学到很多知识!',
        time: '2小时前',
        likes: 12
      },
      {
        id: '2',
        userId: '2',
        userName: '用户B',
        avatar: 'avatar2',
        content: '感谢分享,期待更多精彩内容',
        time: '5小时前',
        likes: 8
      },
      {
        id: '3',
        userId: '3',
        userName: '用户C',
        avatar: 'avatar3',
        content: '节气文化博大精深,需要慢慢品味',
        time: '1天前',
        likes: 15
      }
    ];
  }
  
  /**
   * 构建UI
   */
  build() {
    if (this.isLoading) {
      // 加载中状态
      Column() {
        LoadingProgress()
          .width(40)
          .height(40)
        
        Text('加载中...')
          .fontSize(14)
          .fontColor('#999999')
          .margin({ top: 16 })
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
    } else if (!this.article) {
      // 数据为空状态
      Column() {
        Image($r('app.media.ic_empty'))
          .width(80)
          .height(80)
          .opacity(0.5)
        
        Text('暂无文章')
          .fontSize(14)
          .fontColor('#999999')
          .margin({ top: 16 })
        
        Button('返回')
          .width(120)
          .height(40)
          .backgroundColor('#4A9B6D')
          .fontColor('#FFFFFF')
          .borderRadius(20)
          .margin({ top: 24 })
          .onClick(() => {
            try {
              router.back();
            } catch (error) {
              console.error('返回失败: ' + JSON.stringify(error));
            }
          })
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
    } else {
      // 正常内容
      this.buildContent();
    }
  }
  
  /**
   * 构建内容区域
   */
  @Builder
  buildContent(): void {
    Scroll() {
      Column({ space: 0 }) {
        // 1. 文章头部
        this.buildHeader()
        
        // 2. 文章内容
        this.buildContentSection()
        
        // 3. 点赞和分享栏
        this.buildActionBar()
        
        // 4. 评论列表
        this.buildCommentsSection()
        
        // 5. 相关推荐
        this.buildRelatedArticles()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F8F7F2')
  }
}
代码解析

1. 数据加载流程

  • 获取路由参数articleId
  • 根据ID查找文章数据
  • 加载评论数据(Mock)
  • 处理加载状态和空状态

2. 状态管理

  • isLiked: 点赞状态
  • likeCount: 点赞数量
  • comments: 评论列表
  • commentInput: 评论输入内容

步骤2: 文章头部区域

typescript 复制代码
/**
 * 构建文章头部
 */
@Builder
buildHeader(): void {
  Stack({ alignContent: Alignment.BottomStart }) {
    // 封面图
    Image('rawfile://articles/' + this.article!.coverImage)
      .width('100%')
      .height(220)
      .objectFit(ImageFit.Cover)
    
    // 渐变遮罩
    Row()
      .width('100%')
      .height(220)
      .linearGradient({
        angle: 180,
        colors: [
          ['#00000000', 0.3],
          ['#000000DD', 1]
        ]
      })
    
    // 返回按钮
    Row() {
      Image($r('app.media.ic_back'))
        .width(24)
        .height(24)
        .fillColor('#FFFFFF')
    }
    .width(44)
    .height(44)
    .backgroundColor('rgba(255, 255, 255, 0.2)')
    .borderRadius(22)
    .justifyContent(FlexAlign.Center)
    .position({ top: 56, left: 16 })
    .onClick(() => {
      try {
        router.back();
      } catch (error) {
        console.error('返回失败: ' + JSON.stringify(error));
      }
    })
    
    // 内容
    Column({ space: 8 }) {
      // 标题
      Text(this.article!.title)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      
      // 作者信息
      Row({ space: 12 }) {
        // 作者头像
        Image($r('app.media.ic_default_avatar'))
          .width(32)
          .height(32)
          .borderRadius(16)
        
        Column({ space: 2 }) {
          Text(this.article!.author)
            .fontSize(14)
            .fontColor('#FFFFFF')
          
          Text('发布于 ' + this.article!.publishTime)
            .fontSize(12)
            .fontColor('#FFFFFF')
            .opacity(0.8)
        }
        
        // 阅读量
        Row({ space: 4 }) {
          Image($r('app.media.ic_eye'))
            .width(16)
            .height(16)
            .fillColor('#FFFFFF')
            .opacity(0.8)
          
          Text(this.article!.viewCount.toString())
            .fontSize(12)
            .fontColor('#FFFFFF')
            .opacity(0.8)
        }
      }
    }
    .padding({ left: 16, right: 16, bottom: 24 })
    .width('100%')
  }
  .width('100%')
}

设计要点:

  • 大图背景+渐变遮罩
  • 返回按钮固定在左上角
  • 标题、作者信息、阅读量分层展示

步骤3: 文章内容区域

typescript 复制代码
/**
 * 构建文章内容区域
 */
@Builder
buildContentSection(): void {
  Column({ space: 16 }) {
    ForEach(this.article!.content, (paragraph: string, index: number) => {
      // 段落
      if (paragraph.startsWith('## ')) {
        // 二级标题
        Text(paragraph.substring(3))
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .margin({ top: 8 })
      } else if (paragraph.startsWith('### ')) {
        // 三级标题
        Text(paragraph.substring(4))
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#444444')
          .margin({ top: 4 })
      } else if (paragraph.startsWith('![')) {
        // 图片
        const match = paragraph.match(/!\[.*?\]\((.*?)\)/);
        if (match && match[1]) {
          Image('rawfile://articles/images/' + match[1])
            .width('100%')
            .height(200)
            .borderRadius(8)
            .objectFit(ImageFit.Cover)
        }
      } else if (paragraph.startsWith('- ')) {
        // 列表项
        Row({ space: 8 }) {
          Circle()
            .width(8)
            .height(8)
            .fillColor('#4A9B6D')
          
          Text(paragraph.substring(2))
            .fontSize(15)
            .fontColor('#555555')
            .lineHeight(24)
        }
      } else {
        // 普通段落
        Text(paragraph)
          .fontSize(15)
          .fontColor('#555555')
          .lineHeight(28)
          .textAlign(TextAlign.Start)
      }
    }, (_, index) => index.toString())
  }
  .padding(16)
  .width('100%')
  .backgroundColor('#FFFFFF')
}

设计要点:

  • 支持Markdown格式解析
  • 标题、段落、列表、图片都能正确展示
  • 使用ForEach渲染内容数组

步骤4: 点赞和分享栏

typescript 复制代码
/**
 * 构建操作栏
 */
@Builder
buildActionBar(): void {
  Row({ space: 24 }) {
    // 点赞按钮
    Row({ space: 8 }) {
      Image(this.isLiked ? $r('app.media.ic_like_active') : $r('app.media.ic_like'))
        .width(24)
        .height(24)
        .fillColor(this.isLiked ? '#FF5252' : '#999999')
        .scale({ x: this.isLiked ? 1.2 : 1, y: this.isLiked ? 1.2 : 1 })
        .transition({ type: TransitionType.Scale, duration: 200 })
      
      Text(this.likeCount.toString())
        .fontSize(14)
        .fontColor(this.isLiked ? '#FF5252' : '#999999')
    }
    .flexGrow(1)
    .onClick(() => {
      this.toggleLike();
    })
    
    // 评论按钮
    Row({ space: 8 }) {
      Image($r('app.media.ic_comment'))
        .width(24)
        .height(24)
        .fillColor('#999999')
      
      Text(this.comments.length.toString())
        .fontSize(14)
        .fontColor('#999999')
    }
    .flexGrow(1)
    .onClick(() => {
      // 滚动到评论区
    })
    
    // 分享按钮
    Row({ space: 8 }) {
      Image($r('app.media.ic_share'))
        .width(24)
        .height(24)
        .fillColor('#999999')
      
      Text('分享')
        .fontSize(14)
        .fontColor('#999999')
    }
    .flexGrow(1)
    .onClick(() => {
      prompt.showToast({ message: '分享功能开发中' });
    })
    
    // 收藏按钮
    Row({ space: 8 }) {
      Image($r('app.media.ic_collect'))
        .width(24)
        .height(24)
        .fillColor('#999999')
      
      Text('收藏')
        .fontSize(14)
        .fontColor('#999999')
    }
    .flexGrow(1)
    .onClick(() => {
      prompt.showToast({ message: '收藏成功' });
    })
  }
  .width('100%')
  .height(60)
  .backgroundColor('#FFFFFF')
  .padding({ left: 16, right: 16 })
  .borderStyle({ bottom: { width: 1, color: '#EEEEEE' } })
}

/**
 * 切换点赞状态
 */
toggleLike(): void {
  if (this.isLiked) {
    this.likeCount--;
    this.isLiked = false;
  } else {
    this.likeCount++;
    this.isLiked = true;
  }
}

设计要点:

  • 四个操作按钮均匀分布
  • 点赞按钮有缩放动画
  • 点赞数量实时更新

步骤5: 评论列表

typescript 复制代码
/**
 * 构建评论区域
 */
@Builder
buildCommentsSection(): void {
  Column({ space: 12 }) {
    // 标题
    Row({ space: 8 }) {
      Image($r('app.media.ic_comment'))
        .width(20)
        .height(20)
        .fillColor('#4A9B6D')
      
      Text('评论')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
      
      Text('(' + this.comments.length + ')')
        .fontSize(14)
        .fontColor('#999999')
    }
    
    // 评论列表
    Column({ space: 16 }) {
      ForEach(this.comments, (comment: Comment) => {
        this.buildCommentItem(comment)
      }, (comment: Comment) => comment.id)
    }
    
    // 评论输入框
    Row({ space: 12 }) {
      Image($r('app.media.ic_default_avatar'))
        .width(40)
        .height(40)
        .borderRadius(20)
      
      Stack() {
        TextInput({ placeholder: '写下你的评论...' })
          .width('100%')
          .height(40)
          .backgroundColor('#F5F5F5')
          .borderRadius(20)
          .padding({ left: 16, right: 80 })
          .onChange((value: string) => {
            this.commentInput = value;
          })
        
        Button('发送')
          .width(60)
          .height(32)
          .backgroundColor(this.commentInput.trim() ? '#4A9B6D' : '#DDDDDD')
          .fontColor('#FFFFFF')
          .fontSize(13)
          .borderRadius(16)
          .position({ right: 8, top: 4 })
          .onClick(() => {
            this.sendComment();
          })
      }
      .flexGrow(1)
    }
  }
  .width('100%')
  .backgroundColor('#FFFFFF')
  .padding(16)
  .margin({ top: 12 })
}

/**
 * 构建评论项
 */
@Builder
buildCommentItem(comment: Comment): void {
  Column({ space: 8 }) {
    // 用户信息
    Row({ space: 12 }) {
      Image($r('app.media.ic_default_avatar'))
        .width(40)
        .height(40)
        .borderRadius(20)
      
      Column({ space: 4 }) {
        Text(comment.userName)
          .fontSize(14)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333')
        
        Text(comment.time)
          .fontSize(12)
          .fontColor('#999999')
      }
    }
    
    // 评论内容
    Text(comment.content)
      .fontSize(14)
      .fontColor('#555555')
      .lineHeight(24)
    
    // 操作栏
    Row({ space: 24 }) {
      Row({ space: 4 }) {
        Image($r('app.media.ic_like'))
          .width(16)
          .height(16)
          .fillColor('#999999')
        
        Text(comment.likes.toString())
          .fontSize(12)
          .fontColor('#999999')
      }
      
      Text('回复')
        .fontSize(12)
        .fontColor('#999999')
    }
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#FAFAFA')
  .borderRadius(8)
}

/**
 * 发送评论
 */
sendComment(): void {
  if (!this.commentInput.trim()) {
    prompt.showToast({ message: '请输入评论内容' });
    return;
  }
  
  // 添加新评论
  const newComment: Comment = {
    id: Date.now().toString(),
    userId: 'current_user',
    userName: '我',
    avatar: 'current',
    content: this.commentInput,
    time: '刚刚',
    likes: 0
  };
  
  this.comments.unshift(newComment);
  this.commentInput = '';
  
  prompt.showToast({ message: '评论成功' });
}

设计要点:

  • 评论列表使用Column布局
  • 评论输入框固定在底部
  • 发送按钮根据输入状态变灰/变亮
  • 新评论插入到列表顶部

步骤6: 相关文章推荐

typescript 复制代码
/**
 * 构建相关文章区域
 */
@Builder
buildRelatedArticles(): void {
  Column({ space: 12 }) {
    // 标题
    Row({ space: 8 }) {
      Image($r('app.media.ic_article'))
        .width(20)
        .height(20)
        .fillColor('#4A9B6D')
      
      Text('相关文章')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
    }
    
    // 横向滚动列表
    Scroll() {
      Row({ space: 12 }) {
        ForEach(articles.slice(0, 4), (article: Article) => {
          Column({ space: 8 }) {
            Image('rawfile://articles/' + article.coverImage)
              .width(140)
              .height(80)
              .borderRadius(8)
              .objectFit(ImageFit.Cover)
            
            Text(article.title)
              .fontSize(13)
              .fontColor('#333333')
              .maxLines(2)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
              .width(140)
          }
          .width(140)
          .onClick(() => {
            try {
              router.pushUrl({
                url: 'pages/ArticleDetail',
                params: { articleId: article.id }
              });
            } catch (error) {
              console.error('路由跳转失败: ' + JSON.stringify(error));
            }
          })
        }, (article: Article) => article.id)
      }
      .padding({ left: 4, right: 4 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
  }
  .width('100%')
  .backgroundColor('#FFFFFF')
  .padding(16)
  .margin({ top: 12 })
}

设计要点:

  • 横向滚动展示
  • 固定宽度卡片
  • 显示封面图和标题

常见问题与解决方案

问题1: 文章内容格式混乱

现象 :

Markdown格式无法正确解析。

解决方案:

typescript 复制代码
// 使用条件判断处理不同格式
ForEach(content, (paragraph) => {
  if (paragraph.startsWith('## ')) {
    Text(paragraph.substring(3)).fontWeight(FontWeight.Bold)
  } else if (paragraph.startsWith('![')) {
    // 解析图片
  } else {
    Text(paragraph).lineHeight(28)
  }
})

问题2: 点赞状态不持久

现象 :

点赞后返回再进入,点赞状态丢失。

解决方案:

typescript 复制代码
// 使用StorageService保存点赞状态
async toggleLike(): Promise<void> {
  if (this.isLiked) {
    await this.storageService.removeLike(this.articleId);
  } else {
    await this.storageService.addLike(this.articleId);
  }
  this.isLiked = !this.isLiked;
}

问题3: 评论输入框被遮挡

现象 :

评论输入框被键盘遮挡。

解决方案:

typescript 复制代码
// 使用scrollTo方法滚动到输入框
TextInput()
  .onFocus(() => {
    setTimeout(() => {
      this.scroller.scrollTo(0, 1000);
    }, 300);
  })

本章小结

核心知识点

本文完成了文章详情页的实现:

1. 文章头部

  • 大图背景+渐变遮罩
  • 返回按钮固定定位
  • 标题、作者、阅读量展示

2. 文章内容

  • 支持Markdown格式解析
  • 标题、段落、列表、图片都能正确展示
  • 使用ForEach渲染

3. 操作栏

  • 点赞、评论、分享、收藏功能
  • 点赞状态切换动画
  • 实时更新点赞数量

4. 评论功能

  • 评论列表展示
  • 评论输入框
  • 发送评论功能

5. 相关推荐

  • 横向滚动展示
  • 点击跳转详情页

最佳实践总结

文章内容渲染

typescript 复制代码
ForEach(content, (paragraph) => {
  if (paragraph.startsWith('## ')) {
    Text(paragraph.substring(3)).fontWeight(FontWeight.Bold)
  } else {
    Text(paragraph).lineHeight(28)
  }
})

点赞功能

typescript 复制代码
toggleLike(): void {
  this.isLiked = !this.isLiked;
  this.likeCount += this.isLiked ? 1 : -1;
}

评论输入

typescript 复制代码
Row() {
  Image(avatar).width(40).height(40).borderRadius(20)
  
  Stack() {
    TextInput({ placeholder: '写下你的评论...' })
    Button('发送').position({ right: 8 })
  }
}

下一步预告

文章详情页已经完成!在下一篇文章中,我们将学习:

  • 知识百科页开发
  • 搜索功能实现
  • 分类浏览
  • 标签筛选

相关链接

相关推荐
李二。1 小时前
鸿蒙原生ArkTS布局方式之ColumnEnd垂直排列
华为·harmonyos
yumgpkpm1 小时前
华为HUAWEI昇腾910B下千问Qwen3.6-27B在的推理加速实践
sql·华为·langchain·json·ai编程·ai写作·gpu算力
zhangfeng11331 小时前
DeepSeek V4 适配华为昇腾950 难度及开源情况
人工智能·pytorch·python·机器学习·华为·开源
G_dou_2 小时前
Flutter+OpenHarmony实战level_tool水平仪
flutter·harmonyos
TrisighT2 小时前
uni-app鸿蒙原生应用开发实战(下):核心功能实现与技术细节
vue.js·harmonyos
G_dou_2 小时前
Flutter三方库适配OpenHarmony【dice_roller】骰子投掷器项目完整实战
flutter·harmonyos
ICT系统集成阿祥2 小时前
防火墙威胁告警溯源源 IP 完整方法(华为 USG / 华三 SecPath 通用)
网络·tcp/ip·华为
痕忆丶2 小时前
openharmony北向开发问题之HDC端口8710被svchost占用问题
harmonyos