Skip to content

新闻资讯应用实战案例

本文将介绍如何使用 uni-app 开发一个功能完善的新闻资讯应用,包括应用架构设计、核心功能实现和关键代码示例。

1. 应用概述

1.1 功能特点

新闻资讯应用是移动端常见的应用类型,主要功能包括:

  • 内容浏览:新闻列表、新闻详情、分类筛选
  • 搜索功能:关键词搜索、热门搜索、搜索历史
  • 个性化服务:推荐阅读、收藏与历史记录
  • 互动功能:评论、点赞、分享
  • 用户系统:登录注册、个人中心

1.2 技术架构

前端技术栈

  • uni-app:跨平台开发框架,实现一次开发多端运行
  • Vue.js:响应式数据绑定,提供组件化开发模式
  • Vuex:状态管理,处理复杂组件间通信
  • uni-ui:官方UI组件库,提供统一的界面设计

后端技术栈

  • 云函数:处理业务逻辑,提供无服务器计算能力
  • 云数据库:存储新闻内容和用户数据
  • 第三方API:获取新闻数据源(如天行数据、聚合数据等)

2. 项目结构

├── components            // 自定义组件
│   ├── news-item         // 新闻列表项组件
│   ├── comment-item      // 评论项组件
│   └── category-tabs     // 分类标签组件
├── pages                 // 页面文件夹
│   ├── index             // 首页(新闻列表)
│   ├── detail            // 新闻详情页
│   ├── search            // 搜索页面
│   ├── category          // 分类页面
│   └── user              // 用户中心
├── static                // 静态资源
├── store                 // Vuex 状态管理
│   ├── index.js          // 组装模块并导出
│   ├── news.js           // 新闻相关状态
│   └── user.js           // 用户相关状态
├── utils                 // 工具函数
│   ├── request.js        // 请求封装
│   ├── date.js           // 日期处理
│   └── share.js          // 分享功能
├── App.vue               // 应用入口
├── main.js               // 主入口
├── manifest.json         // 配置文件
└── pages.json            // 页面配置

3. 核心功能实现

3.1 状态管理设计

使用 Vuex 管理新闻数据和用户数据,实现数据的集中管理和组件间通信。

js
// store/news.js - 新闻状态管理
export default {
  namespaced: true,
  state: {
    categories: [],
    newsList: [],
    banners: [],
    currentNews: null,
    loading: false,
    refreshing: false,
    hasMore: true,
    page: 1
  },
  mutations: {
    SET_CATEGORIES(state, categories) {
      state.categories = categories;
    },
    SET_NEWS_LIST(state, list) {
      state.newsList = list;
    },
    ADD_NEWS_LIST(state, list) {
      state.newsList = [...state.newsList, ...list];
    },
    SET_BANNERS(state, banners) {
      state.banners = banners;
    },
    SET_CURRENT_NEWS(state, news) {
      state.currentNews = news;
    },
    SET_LOADING(state, status) {
      state.loading = status;
    },
    SET_REFRESHING(state, status) {
      state.refreshing = status;
    },
    SET_HAS_MORE(state, status) {
      state.hasMore = status;
    },
    SET_PAGE(state, page) {
      state.page = page;
    }
  },
  actions: {
    // 获取新闻分类
    async getCategories({ commit }) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'news',
          data: { action: 'getCategories' }
        });
        commit('SET_CATEGORIES', result.data);
        return result.data;
      } catch (error) {
        console.error('获取分类失败', error);
        throw error;
      }
    },
    
    // 获取新闻列表
    async getNewsList({ commit, state }, { categoryId, refresh = false }) {
      if (refresh) {
        commit('SET_PAGE', 1);
        commit('SET_HAS_MORE', true);
      } else {
        if (!state.hasMore || state.loading) return [];
      }
      
      commit('SET_LOADING', true);
      
      try {
        const { result } = await uniCloud.callFunction({
          name: 'news',
          data: { 
            action: 'getNewsList',
            categoryId,
            page: state.page,
            pageSize: 10
          }
        });
        
        const list = result.data || [];
        
        if (refresh) {
          commit('SET_NEWS_LIST', list);
        } else {
          commit('ADD_NEWS_LIST', list);
        }
        
        commit('SET_HAS_MORE', list.length === 10);
        commit('SET_PAGE', state.page + 1);
        
        return list;
      } catch (error) {
        console.error('获取新闻列表失败', error);
        throw error;
      } finally {
        commit('SET_LOADING', false);
      }
    },
    
    // 获取轮播图
    async getBanners({ commit }) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'news',
          data: { action: 'getBanners' }
        });
        commit('SET_BANNERS', result.data);
        return result.data;
      } catch (error) {
        console.error('获取轮播图失败', error);
        throw error;
      }
    },
    
    // 获取新闻详情
    async getNewsDetail({ commit }, id) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'news',
          data: { 
            action: 'getNewsDetail',
            id
          }
        });
        
        commit('SET_CURRENT_NEWS', result.data);
        return result.data;
      } catch (error) {
        console.error('获取新闻详情失败', error);
        throw error;
      }
    },
    
    // 搜索新闻
    async searchNews({ commit }, { keyword, page = 1, sortOrder = 'time' }) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'news',
          data: { 
            action: 'searchNews',
            keyword,
            page,
            pageSize: 10,
            sortOrder
          }
        });
        
        return result.data || [];
      } catch (error) {
        console.error('搜索新闻失败', error);
        throw error;
      }
    }
  }
};

// store/user.js - 用户状态管理
export default {
  namespaced: true,
  state: {
    userInfo: uni.getStorageSync('userInfo') ? JSON.parse(uni.getStorageSync('userInfo')) : null,
    token: uni.getStorageSync('token') || '',
    isLogin: Boolean(uni.getStorageSync('token')),
    collections: uni.getStorageSync('collections') ? JSON.parse(uni.getStorageSync('collections')) : [],
    readHistory: uni.getStorageSync('readHistory') ? JSON.parse(uni.getStorageSync('readHistory')) : [],
    searchHistory: uni.getStorageSync('searchHistory') ? JSON.parse(uni.getStorageSync('searchHistory')) : []
  },
  mutations: {
    SET_USER_INFO(state, userInfo) {
      state.userInfo = userInfo;
      state.isLogin = Boolean(userInfo);
      uni.setStorageSync('userInfo', JSON.stringify(userInfo));
    },
    SET_TOKEN(state, token) {
      state.token = token;
      uni.setStorageSync('token', token);
    },
    LOGOUT(state) {
      state.userInfo = null;
      state.token = '';
      state.isLogin = false;
      uni.removeStorageSync('userInfo');
      uni.removeStorageSync('token');
    },
    SET_COLLECTIONS(state, collections) {
      state.collections = collections;
      uni.setStorageSync('collections', JSON.stringify(collections));
    },
    ADD_COLLECTION(state, news) {
      state.collections.unshift(news);
      uni.setStorageSync('collections', JSON.stringify(state.collections));
    },
    REMOVE_COLLECTION(state, id) {
      state.collections = state.collections.filter(item => item.id !== id);
      uni.setStorageSync('collections', JSON.stringify(state.collections));
    },
    ADD_READ_HISTORY(state, news) {
      // 如果已存在,先移除
      state.readHistory = state.readHistory.filter(item => item.id !== news.id);
      
      // 添加到最前面
      state.readHistory.unshift({
        ...news,
        readTime: Date.now()
      });
      
      // 最多保存50条
      if (state.readHistory.length > 50) {
        state.readHistory = state.readHistory.slice(0, 50);
      }
      
      uni.setStorageSync('readHistory', JSON.stringify(state.readHistory));
    },
    ADD_SEARCH_HISTORY(state, keyword) {
      // 如果已存在,先移除
      state.searchHistory = state.searchHistory.filter(item => item !== keyword);
      
      // 添加到最前面
      state.searchHistory.unshift(keyword);
      
      // 最多保存10条
      if (state.searchHistory.length > 10) {
        state.searchHistory = state.searchHistory.slice(0, 10);
      }
      
      uni.setStorageSync('searchHistory', JSON.stringify(state.searchHistory));
    },
    CLEAR_SEARCH_HISTORY(state) {
      state.searchHistory = [];
      uni.removeStorageSync('searchHistory');
    }
  },
  actions: {
    // 登录
    async login({ commit }, params) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'user',
          data: { 
            action: 'login',
            ...params
          }
        });
        
        commit('SET_USER_INFO', result.data.userInfo);
        commit('SET_TOKEN', result.data.token);
        
        return result.data;
      } catch (error) {
        console.error('登录失败', error);
        throw error;
      }
    },
    
    // 退出登录
    logout({ commit }) {
      commit('LOGOUT');
    },
    
    // 收藏新闻
    collectNews({ commit, state }, news) {
      commit('ADD_COLLECTION', news);
    },
    
    // 取消收藏
    uncollectNews({ commit, state }, id) {
      commit('REMOVE_COLLECTION', id);
    },
    
    // 添加阅读历史
    addReadHistory({ commit }, news) {
      commit('ADD_READ_HISTORY', news);
    },
    
    // 添加搜索历史
    addSearchHistory({ commit }, keyword) {
      commit('ADD_SEARCH_HISTORY', keyword);
    },
    
    // 清空搜索历史
    clearSearchHistory({ commit }) {
      commit('CLEAR_SEARCH_HISTORY');
    }
  },
  getters: {
    // 判断新闻是否已收藏
    isNewsCollected: state => id => {
      return state.collections.some(item => item.id === id);
    }
  }
};

3.2 新闻列表页实现

新闻列表页是应用的首页,展示各类新闻内容,支持下拉刷新和上拉加载更多。

vue
<!-- pages/index/index.vue -->
<template>
  <view class="news-page">
    <!-- 顶部搜索栏 -->
    <view class="search-bar" @tap="goToSearch">
      <text class="iconfont icon-search"></text>
      <text class="placeholder">搜索感兴趣的内容</text>
    </view>
    
    <!-- 分类标签 -->
    <category-tabs 
      :categories="categories" 
      :current="currentCategory"
      @change="changeCategory"
    ></category-tabs>
    
    <!-- 新闻列表 -->
    <scroll-view 
      scroll-y 
      class="news-list"
      @scrolltolower="loadMore"
      @refresherrefresh="refresh"
      refresher-enabled
      :refresher-triggered="refreshing"
    >
      <!-- 轮播图 -->
      <swiper 
        class="banner" 
        indicator-dots 
        autoplay 
        circular
        v-if="currentCategory === 0 && banners.length > 0"
      >
        <swiper-item v-for="(item, index) in banners" :key="index">
          <image 
            :src="item.image" 
            mode="aspectFill" 
            class="banner-image"
            @tap="goToDetail(item)"
          ></image>
          <view class="banner-title">{{item.title}}</view>
        </swiper-item>
      </swiper>
      
      <!-- 新闻列表 -->
      <view class="list-container">
        <news-item 
          v-for="(item, index) in newsList" 
          :key="index"
          :news="item"
          @click="goToDetail(item)"
        ></news-item>
      </view>
      
      <!-- 加载更多 -->
      <uni-load-more :status="loadMoreStatus"></uni-load-more>
    </scroll-view>
  </view>
</template>

<script>
import categoryTabs from '@/components/category-tabs/category-tabs.vue';
import newsItem from '@/components/news-item/news-item.vue';
import uniLoadMore from '@/components/uni-load-more/uni-load-more.vue';
import { mapState, mapActions } from 'vuex';

export default {
  components: {
    categoryTabs,
    newsItem,
    uniLoadMore
  },
  data() {
    return {
      currentCategory: 0,
      refreshing: false,
      loadMoreStatus: 'more' // more, loading, noMore
    }
  },
  computed: {
    ...mapState('news', ['categories', 'newsList', 'banners', 'loading', 'hasMore'])
  },
  onLoad() {
    // 初始化数据
    this.initData();
  },
  methods: {
    ...mapActions('news', ['getCategories', 'getNewsList', 'getBanners']),
    
    // 初始化数据
    async initData() {
      uni.showLoading({ title: '加载中' });
      
      try {
        // 加载分类
        await this.getCategories();
        
        // 加载轮播图
        await this.getBanners();
        
        // 加载新闻列表
        await this.loadNewsList(true);
      } catch (e) {
        console.error('初始化数据失败', e);
      } finally {
        uni.hideLoading();
      }
    },
    
    // 加载新闻列表
    async loadNewsList(refresh = false) {
      try {
        this.loadMoreStatus = 'loading';
        
        await this.getNewsList({
          categoryId: this.categories[this.currentCategory].id,
          refresh
        });
        
        this.loadMoreStatus = this.hasMore ? 'more' : 'noMore';
      } catch (e) {
        console.error('加载新闻列表失败', e);
        uni.showToast({
          title: '加载新闻列表失败',
          icon: 'none'
        });
        this.loadMoreStatus = 'more';
      }
    },
    
    // 切换分类
    changeCategory(index) {
      if (this.currentCategory === index) return;
      
      this.currentCategory = index;
      this.loadNewsList(true);
    },
    
    // 下拉刷新
    async refresh() {
      this.refreshing = true;
      await this.loadNewsList(true);
      this.refreshing = false;
    },
    
    // 加载更多
    loadMore() {
      if (this.loadMoreStatus !== 'more') return;
      
      this.loadNewsList();
    },
    
    // 跳转到详情页
    goToDetail(news) {
      uni.navigateTo({
        url: `/pages/detail/detail?id=${news.id}`
      });
    },
    
    // 跳转到搜索页
    goToSearch() {
      uni.navigateTo({
        url: '/pages/search/search'
      });
    }
  }
}
</script>

3.3 新闻列表项组件

新闻列表项组件用于展示单条新闻的预览信息,支持多种布局方式。

vue
<!-- components/news-item/news-item.vue -->
<template>
  <view class="news-item" :class="{ 'no-image': !hasImage, 'multi-image': hasMultiImage }">
    <!-- 标题 -->
    <text class="title">{{news.title}}</text>
    
    <!-- 单图布局 -->
    <view class="content-wrapper" v-if="hasImage && !hasMultiImage">
      <view class="content">
        <text class="summary" v-if="news.summary">{{news.summary}}</text>
        <view class="info">
          <text class="source">{{news.source}}</text>
          <text class="time">{{formatTime(news.publishTime)}}</text>
          <text class="comment-count" v-if="news.commentCount > 0">{{news.commentCount}} 评论</text>
        </view>
      </view>
      
      <image 
        :src="news.images[0]" 
        mode="aspectFill" 
        class="image"
        @error="onImageError"
      ></image>
    </view>
    
    <!-- 多图布局 -->
    <view class="multi-image-wrapper" v-else-if="hasMultiImage">
      <view class="image-list">
        <image 
          v-for="(img, index) in news.images.slice(0, 3)" 
          :key="index"
          :src="img" 
          mode="aspectFill" 
          class="multi-image"
          @error="onImageError"
        ></image>
      </view>
      
      <view class="info">
        <text class="source">{{news.source}}</text>
        <text class="time">{{formatTime(news.publishTime)}}</text>
        <text class="comment-count" v-if="news.commentCount > 0">{{news.commentCount}} 评论</text>
      </view>
    </view>
    
    <!-- 无图布局 -->
    <view class="content" v-else>
      <text class="summary" v-if="news.summary">{{news.summary}}</text>
      <view class="info">
        <text class="source">{{news.source}}</text>
        <text class="time">{{formatTime(news.publishTime)}}</text>
        <text class="comment-count" v-if="news.commentCount > 0">{{news.commentCount}} 评论</text>
      </view>
    </view>
  </view>
</template>

<script>
import { formatTimeAgo } from '@/utils/date.js';

export default {
  props: {
    news: {
      type: Object,
      required: true
    }
  },
  computed: {
    hasImage() {
      return this.news.images && this.news.images.length > 0;
    },
    hasMultiImage() {
      return this.news.images && this.news.images.length >= 3;
    }
  },
  methods: {
    formatTime(timestamp) {
      return formatTimeAgo(timestamp);
    },
    onImageError(e) {
      // 图片加载失败时的处理
      const index = e.currentTarget.dataset.index || 0;
      if (this.news.images && this.news.images[index]) {
        // 可以设置一个默认图片
        this.news.images[index] = '/static/images/default-news.png';
      }
    }
  }
}
</script>

3.4 新闻详情页实现

新闻详情页展示完整的新闻内容,包括标题、正文、图片、评论等,并支持收藏、分享功能。

vue
<!-- pages/detail/detail.vue -->
<template>
  <view class="detail-page">
    <!-- 新闻内容 -->
    <scroll-view scroll-y class="content-scroll">
      <view class="news-content">
        <view class="title">{{newsDetail.title}}</view>
        <view class="info">
          <text class="source">{{newsDetail.source}}</text>
          <text class="time">{{formatTime(newsDetail.publishTime)}}</text>
        </view>
        
        <!-- 正文内容 -->
        <rich-text :nodes="newsDetail.content" class="content"></rich-text>
        
        <!-- 标签 -->
        <view class="tags" v-if="newsDetail.tags && newsDetail.tags.length > 0">
          <text 
            class="tag" 
            v-for="(tag, index) in newsDetail.tags" 
            :key="index"
          >{{tag}}</text>
        </view>
      </view>
      
      <!-- 相关推荐 -->
      <view class="related-news" v-if="relatedNews.length > 0">
        <view class="section-title">相关推荐</view>
        <news-item 
          v-for="(item, index) in relatedNews" 
          :key="index"
          :news="item"
          @click="goToDetail(item)"
        ></news-item>
      </view>
      
      <!-- 评论区 -->
      <view class="comments-section">
        <view class="section-title">
          评论 <text class="comment-count">({{comments.length}})</text>
        </view>
        
        <view class="empty-tip" v-if="comments.length === 0">
          <text>暂无评论,快来发表你的看法吧</text>
        </view>
        
        <comment-item 
          v-for="(item, index) in comments" 
          :key="index"
          :comment="item"
          @reply="replyComment"
          @like="likeComment"
        ></comment-item>
      </view>
    </scroll-view>
    
    <!-- 底部操作栏 -->
    <view class="footer">
      <view class="comment-input">
        <input 
          type="text" 
          v-model="commentText" 
          :placeholder="replyTo ? `回复 ${replyTo.user.nickname}` : '写评论...'"
          confirm-type="send"
          @confirm="submitComment"
        />
      </view>
      
      <view class="action-icons">
        <view class="action-item" @tap="toggleCollect">
          <text 
            class="iconfont" 
            :class="isCollected ? 'icon-star-filled' : 'icon-star'"
          ></text>
          <text>收藏</text>
        </view>
        
        <view class="action-item" @tap="shareNews">
          <text class="iconfont icon-share"></text>
          <text>分享</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
import newsItem from '@/components/news-item/news-item.vue';
import commentItem from '@/components/comment-item/comment-item.vue';
import { formatTime } from '@/utils/date.js';
import { share } from '@/utils/share.js';
import { mapActions, mapGetters } from 'vuex';

export default {
  components: {
    newsItem,
    commentItem
  },
  data() {
    return {
      newsId: '',
      newsDetail: {
        title: '',
        source: '',
        publishTime: 0,
        content: '',
        tags: []
      },
      relatedNews: [],
      comments: [],
      commentText: '',
      replyTo: null
    }
  },
  computed: {
    ...mapGetters('user', ['isNewsCollected']),
    
    isCollected() {
      return this.isNewsCollected(this.newsId);
    }
  },
  onLoad(options) {
    this.newsId = options.id;
    
    // 加载新闻详情
    this.loadNewsDetail();
    
    // 加载评论
    this.loadComments();
    
    // 加载相关推荐
    this.loadRelatedNews();
    
    // 添加到阅读历史
    this.addToHistory();
  },
  methods: {
    ...mapActions('news', ['getNewsDetail', 'getNewsComments', 'getRelatedNews', 'addComment']),
    ...mapActions('user', ['collectNews', 'uncollectNews', 'addReadHistory']),
    
    // 加载新闻详情
    async loadNewsDetail() {
      try {
        const detail = await this.getNewsDetail(this.newsId);
        this.newsDetail = detail;
      } catch (e) {
        console.error('加载新闻详情失败', e);
        uni.showToast({
          title: '加载新闻详情失败',
          icon: 'none'
        });
      }
    },
    
    // 加载评论
    async loadComments() {
      try {
        const comments = await this.getNewsComments(this.newsId);
        this.comments = comments;
      } catch (e) {
        console.error('加载评论失败', e);
      }
    },
    
    // 加载相关推荐
    async loadRelatedNews() {
      try {
        const related = await this.getRelatedNews(this.newsId);
        this.relatedNews = related;
      } catch (e) {
        console.error('加载相关推荐失败', e);
      }
    },
    
    // 添加到阅读历史
    addToHistory() {
      this.addReadHistory({
        id: this.newsId,
        title: this.newsDetail.title,
        source: this.newsDetail.source,
        publishTime: this.newsDetail.publishTime,
        image: this.newsDetail.images && this.newsDetail.images.length > 0 ? this.newsDetail.images[0] : ''
      });
    },
    
    // 格式化时间
    formatTime(timestamp) {
      return formatTime(timestamp);
    },
    
    // 回复评论
    replyComment(comment) {
      this.replyTo = comment;
      // 聚焦输入框
      this.$nextTick(() => {
        const inputEl = this.$el.querySelector('input');
        if (inputEl) {
          inputEl.focus();
        }
      });
    },
    
    // 点赞评论
    likeComment(comment) {
      // 实现点赞逻辑
    },
    
    // 提交评论
    async submitComment() {
      if (!this.commentText.trim()) return;
      
      try {
        const commentData = {
          newsId: this.newsId,
          content: this.commentText,
          replyTo: this.replyTo ? this.replyTo.id : null
        };
        
        await this.addComment(commentData);
        
        // 清空输入
        this.commentText = '';
        this.replyTo = null;
        
        // 重新加载评论
        this.loadComments();
        
        uni.showToast({
          title: '评论成功',
          icon: 'success'
        });
      } catch (e) {
        console.error('提交评论失败', e);
        uni.showToast({
          title: '评论失败,请重试',
          icon: 'none'
        });
      }
    },
    
    // 切换收藏状态
    toggleCollect() {
      if (this.isCollected) {
        this.uncollectNews(this.newsId);
        uni.showToast({
          title: '已取消收藏',
          icon: 'none'
        });
      } else {
        this.collectNews({
          id: this.newsId,
          title: this.newsDetail.title,
          source: this.newsDetail.source,
          publishTime: this.newsDetail.publishTime,
          image: this.newsDetail.images && this.newsDetail.images.length > 0 ? this.newsDetail.images[0] : ''
        });
        uni.showToast({
          title: '已收藏',
          icon: 'success'
        });
      }
    },
    
    // 分享新闻
    shareNews() {
      share({
        title: this.newsDetail.title,
        path: `/pages/detail/detail?id=${this.newsId}`,
        imageUrl: this.newsDetail.images && this.newsDetail.images.length > 0 ? this.newsDetail.images[0] : ''
      });
    },
    
    // 跳转到详情页
    goToDetail(news) {
      uni.navigateTo({
        url: `/pages/detail/detail?id=${news.id}`
      });
    }
  }
}
</script>

3.5 搜索功能实现

搜索页面提供新闻搜索功能,包括搜索历史和热门搜索推荐。

vue
<!-- pages/search/search.vue -->
<template>
  <view class="search-page">
    <!-- 搜索栏 -->
    <view class="search-header">
      <view class="search-input">
        <text class="iconfont icon-search"></text>
        <input 
          type="text" 
          v-model="keyword" 
          placeholder="搜索感兴趣的内容" 
          confirm-type="search"
          focus
          @confirm="search"
        />
        <text 
          class="clear-icon" 
          v-if="keyword" 
          @tap="clearKeyword"
        >×</text>
      </view>
      <text class="cancel-btn" @tap="goBack">取消</text>
    </view>
    
    <!-- 搜索历史和热门搜索 -->
    <view class="search-content" v-if="!showResults">
      <!-- 搜索历史 -->
      <view class="search-section" v-if="searchHistory.length > 0">
        <view class="section-header">
          <text class="section-title">搜索历史</text>
          <text class="clear-btn" @tap="clearHistory">清空</text>
        </view>
        <view class="keyword-list">
          <text 
            class="keyword-item" 
            v-for="(item, index) in searchHistory" 
            :key="index"
            @tap="useKeyword(item)"
          >{{item}}</text>
        </view>
      </view>
      
      <!-- 热门搜索 -->
      <view class="search-section">
        <view class="section-header">
          <text class="section-title">热门搜索</text>
        </view>
        <view class="keyword-list">
          <text 
            class="keyword-item" 
            v-for="(item, index) in hotKeywords" 
            :key="index"
            @tap="useKeyword(item)"
          >{{item}}</text>
        </view>
      </view>
    </view>
    
    <!-- 搜索结果 -->
    <view class="search-results" v-else>
      <view class="result-header">
        <text class="result-count">共找到 {{searchResults.length}} 条结果</text>
        <text class="sort-btn" @tap="toggleSortOrder">
          {{sortOrder === 'time' ? '按时间排序' : '按相关度排序'}}
          <text class="iconfont icon-down"></text>
        </text>
      </view>
      
      <scroll-view 
        scroll-y 
        class="result-list"
        @scrolltolower="loadMoreResults"
      >
        <news-item 
          v-for="(item, index) in searchResults" 
          :key="index"
          :news="item"
          @click="goToDetail(item)"
        ></news-item>
        
        <!-- 加载更多 -->
        <uni-load-more :status="loadMoreStatus"></uni-load-more>
      </scroll-view>
    </view>
  </view>
</template>

<script>
import newsItem from '@/components/news-item/news-item.vue';
import uniLoadMore from '@/components/uni-load-more/uni-load-more.vue';
import { mapState, mapActions } from 'vuex';

export default {
  components: {
    newsItem,
    uniLoadMore
  },
  data() {
    return {
      keyword: '',
      showResults: false,
      searchResults: [],
      sortOrder: 'time', // time, relevance
      page: 1,
      loadMoreStatus: 'more', // more, loading, noMore
      hotKeywords: ['疫情防控', '经济复苏', '科技创新', '教育改革', '体育赛事', '娱乐新闻']
    }
  },
  computed: {
    ...mapState('user', ['searchHistory'])
  },
  methods: {
    ...mapActions('news', ['searchNews']),
    ...mapActions('user', ['addSearchHistory', 'clearSearchHistory']),
    
    // 返回上一页
    goBack() {
      uni.navigateBack();
    },
    
    // 清空关键词
    clearKeyword() {
      this.keyword = '';
      this.showResults = false;
    },
    
    // 使用关键词
    useKeyword(keyword) {
      this.keyword = keyword;
      this.search();
    },
    
    // 搜索
    async search() {
      if (!this.keyword.trim()) return;
      
      // 添加到搜索历史
      this.addSearchHistory(this.keyword);
      
      this.showResults = true;
      this.page = 1;
      this.loadMoreStatus = 'loading';
      
      try {
        const results = await this.searchNews({
          keyword: this.keyword,
          page: this.page,
          sortOrder: this.sortOrder
        });
        
        this.searchResults = results;
        this.loadMoreStatus = results.length < 10 ? 'noMore' : 'more';
      } catch (e) {
        console.error('搜索失败', e);
        uni.showToast({
          title: '搜索失败,请重试',
          icon: 'none'
        });
        this.loadMoreStatus = 'more';
      }
    },
    
    // 加载更多结果
    async loadMoreResults() {
      if (this.loadMoreStatus !== 'more') return;
      
      this.loadMoreStatus = 'loading';
      this.page++;
      
      try {
        const results = await this.searchNews({
          keyword: this.keyword,
          page: this.page,
          sortOrder: this.sortOrder
        });
        
        if (results.length > 0) {
          this.searchResults = [...this.searchResults, ...results];
        }
        
        this.loadMoreStatus = results.length < 10 ? 'noMore' : 'more';
      } catch (e) {
        console.error('加载更多结果失败', e);
        this.loadMoreStatus = 'more';
      }
    },
    
    // 切换排序方式
    toggleSortOrder() {
      this.sortOrder = this.sortOrder === 'time' ? 'relevance' : 'time';
      this.search();
    },
    
    // 清空搜索历史
    clearHistory() {
      uni.showModal({
        title: '提示',
        content: '确定要清空搜索历史吗?',
        success: (res) => {
          if (res.confirm) {
            this.clearSearchHistory();
          }
        }
      });
    },
    
    // 跳转到详情页
    goToDetail(news) {
      uni.navigateTo({
        url: `/pages/detail/detail?id=${news.id}`
      });
    }
  }
}
</script>

4. 应用优化与最佳实践

4.1 性能优化

新闻资讯应用需要处理大量的内容和图片,性能优化尤为重要:

  1. 图片懒加载:只加载可视区域内的图片,减少初始加载时间
  2. 数据缓存:缓存已加载的新闻数据,避免重复请求
  3. 分页加载:采用分页方式加载数据,减轻服务器压力
  4. 骨架屏:在内容加载过程中显示骨架屏,提升用户体验
  5. 资源预加载:预加载可能需要的资源,如下一页的新闻列表
js
// 图片懒加载示例
export default {
  data() {
    return {
      observer: null
    }
  },
  mounted() {
    // 创建交叉观察器
    this.observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          // 元素进入可视区域,加载图片
          const img = entry.target;
          const realSrc = img.dataset.src;
          if (realSrc) {
            img.src = realSrc;
            img.removeAttribute('data-src');
            // 停止观察该元素
            this.observer.unobserve(img);
          }
        }
      });
    });
    
    // 开始观察所有图片元素
    this.$nextTick(() => {
      const imgs = document.querySelectorAll('img[data-src]');
      imgs.forEach(img => {
        this.observer.observe(img);
      });
    });
  },
  beforeDestroy() {
    // 销毁观察器
    if (this.observer) {
      this.observer.disconnect();
      this.observer = null;
    }
  }
}

4.2 离线支持

为了提升用户体验,可以实现基本的离线浏览功能:

  1. 本地存储:将已加载的新闻内容存储在本地
  2. 离线提示:在无网络状态下提示用户正在使用离线模式
  3. 数据同步:网络恢复后自动同步最新内容
js
// 离线支持示例
export default {
  data() {
    return {
      isOnline: navigator.onLine
    }
  },
  created() {
    // 监听网络状态变化
    window.addEventListener('online', this.updateOnlineStatus);
    window.addEventListener('offline', this.updateOnlineStatus);
  },
  destroyed() {
    // 移除监听器
    window.removeEventListener('online', this.updateOnlineStatus);
    window.removeEventListener('offline', this.updateOnlineStatus);
  },
  methods: {
    // 更新网络状态
    updateOnlineStatus() {
      this.isOnline = navigator.onLine;
      
      if (this.isOnline) {
        uni.showToast({
          title: '网络已恢复',
          icon: 'none'
        });
        
        // 同步最新数据
        this.syncData();
      } else {
        uni.showToast({
          title: '网络已断开,正在使用离线模式',
          icon: 'none'
        });
      }
    },
    
    // 同步数据
    syncData() {
      // 实现数据同步逻辑
    },
    
    // 获取新闻数据(优先使用缓存)
    async getNewsData(params) {
      try {
        if (this.isOnline) {
          // 在线模式,从服务器获取数据
          const data = await this.fetchFromServer(params);
          
          // 缓存数据
          this.cacheData(params, data);
          
          return data;
        } else {
          // 离线模式,从缓存获取数据
          return this.getFromCache(params);
        }
      } catch (e) {
        // 获取失败,尝试从缓存获取
        return this.getFromCache(params);
      }
    }
  }
}

4.3 个性化推荐

基于用户的阅读历史和兴趣标签,实现个性化内容推荐:

  1. 用户画像:根据用户阅读历史和行为构建用户画像
  2. 内容标签:为新闻内容添加标签,便于匹配用户兴趣
  3. 协同过滤:基于相似用户的行为推荐内容
  4. 实时更新:根据用户最新行为动态调整推荐内容
js
// 个性化推荐示例
export default {
  methods: {
    // 获取推荐新闻
    async getRecommendNews() {
      try {
        // 获取用户兴趣标签
        const userTags = this.getUserTags();
        
        // 获取用户阅读历史
        const readHistory = this.getReadHistory();
        
        const { result } = await uniCloud.callFunction({
          name: 'news',
          data: { 
            action: 'getRecommendNews',
            userTags,
            readHistory
          }
        });
        
        return result.data || [];
      } catch (error) {
        console.error('获取推荐新闻失败', error);
        throw error;
      }
    },
    
    // 获取用户兴趣标签
    getUserTags() {
      // 从用户阅读历史中提取兴趣标签
      const readHistory = uni.getStorageSync('readHistory') || '[]';
      const history = JSON.parse(readHistory);
      
      // 统计标签出现频率
      const tagCount = {};
      history.forEach(item => {
        if (item.tags) {
          item.tags.forEach(tag => {
            tagCount[tag] = (tagCount[tag] || 0) + 1;
          });
        }
      });
      
      // 按频率排序
      const sortedTags = Object.keys(tagCount).sort((a, b) => tagCount[b] - tagCount[a]);
      
      // 返回前5个标签
      return sortedTags.slice(0, 5);
    },
    
    // 获取用户阅读历史ID列表
    getReadHistory() {
      const readHistory = uni.getStorageSync('readHistory') || '[]';
      const history = JSON.parse(readHistory);
      
      // 返回最近20条阅读记录的ID
      return history.slice(0, 20).map(item => item.id);
    }
  }
}

5. 总结与拓展

5.1 开发要点总结

  1. 模块化设计:将应用拆分为多个功能模块,提高代码可维护性
  2. 状态管理:使用Vuex集中管理应用状态,处理复杂的数据流
  3. 组件复用:设计可复用的组件,如新闻列表项、评论项等
  4. 性能优化:实现图片懒加载、数据缓存等优化措施
  5. 用户体验:注重细节,如骨架屏、下拉刷新、上拉加载等

5.2 功能拓展方向

基于新闻资讯应用的基础功能,可以考虑以下拓展方向:

  1. 个性化推荐:基于机器学习的内容推荐系统
  2. 语音播报:支持新闻内容的语音播报功能
  3. 视频新闻:集成短视频形式的新闻内容
  4. 社区互动:增加用户讨论区,提高用户粘性
  5. 数据可视化:通过图表展示热点新闻和数据分析

5.3 商业化思路

新闻资讯应用的商业化路径通常包括:

  1. 广告变现:信息流广告、开屏广告、原生广告等
  2. 会员订阅:提供优质内容和增值服务的付费订阅
  3. 电商导流:与电商平台合作,导流获取佣金
  4. 数据服务:在合规前提下,提供数据分析服务
  5. 内容付费:优质原创内容的付费阅读

6. 参考资源

一次开发,多端部署 - 让跨平台开发更简单