uni-app 内容管理

核心知识点

1. 内容管理系统架构

内容管理系统(CMS)是应用中负责内容创建、编辑、发布和管理的核心模块。在 uni-app 中,我们可以构建适合跨平台应用的 CMS 架构,主要包括以下几个部分:

  • 前端展示层:负责内容的展示和用户交互
  • 后端管理层:负责内容的创建、编辑和管理
  • 数据存储层:负责内容数据的存储和检索
  • 内容同步机制:确保多端内容的一致性

2. 内容类型管理

在内容管理系统中,我们需要定义不同类型的内容,常见的内容类型包括:

  • 文章:包含标题、正文、作者、发布时间等字段
  • 商品:包含名称、价格、描述、库存等字段
  • 图片:包含URL、标题、描述、标签等字段
  • 视频:包含URL、标题、描述、时长等字段
  • 用户生成内容:如评论、点赞、分享等

3. 内容发布流程

一个完整的内容发布流程通常包括以下步骤:

  1. 内容创建:用户或管理员创建内容
  2. 内容编辑:对内容进行编辑和修改
  3. 内容审核:对内容进行审核,确保符合规范
  4. 内容发布:将审核通过的内容发布到应用中
  5. 内容归档:对过期或不再需要的内容进行归档

4. 内容同步机制

在跨平台应用中,内容同步是一个重要的挑战。我们需要确保不同平台上的内容保持一致,主要包括以下几种同步方式:

  • 实时同步:通过WebSocket等技术实现实时内容更新
  • 定期同步:通过定时任务定期拉取最新内容
  • 增量同步:只同步发生变化的内容,减少数据传输量
  • 离线缓存:在离线状态下缓存内容,确保用户体验

5. 内容检索与过滤

为了提高用户体验,我们需要实现高效的内容检索和过滤功能:

  • 关键词搜索:根据关键词搜索相关内容
  • 分类过滤:根据内容分类进行过滤
  • 标签过滤:根据内容标签进行过滤
  • 时间排序:根据发布时间排序
  • 热度排序:根据内容热度排序

实用案例

案例:实现一个简单的内容管理系统

1. 项目结构

src/
  ├── components/
  │   ├── content-list.vue     # 内容列表组件
  │   ├── content-item.vue     # 内容项组件
  │   └── content-editor.vue   # 内容编辑器组件
  ├── pages/
  │   ├── content/
  │   │   ├── list.vue         # 内容列表页
  │   │   ├── detail.vue       # 内容详情页
  │   │   └── edit.vue         # 内容编辑页
  │   └── admin/
  │       └── content-manage.vue # 内容管理后台
  ├── services/
  │   └── content-api.js       # 内容API服务
  └── utils/
      └── content-utils.js     # 内容工具函数

2. 内容数据模型

// 内容数据模型
const contentModel = {
  id: String,           // 内容ID
  title: String,        // 标题
  content: String,      // 内容正文
  type: String,         // 内容类型
  status: String,       // 状态:draft, pending, published, archived
  author: String,       // 作者
  createTime: Date,     // 创建时间
  updateTime: Date,     // 更新时间
  publishTime: Date,    // 发布时间
  tags: Array,          // 标签
  category: String,     // 分类
  coverImage: String,   // 封面图片
  viewCount: Number,    // 浏览量
  likeCount: Number,    // 点赞数
  commentCount: Number  // 评论数
};

3. 内容API服务

// services/content-api.js

// 获取内容列表
export const getContentList = async (params) => {
  const { page = 1, pageSize = 10, type, category, status } = params;
  try {
    const res = await uni.request({
      url: 'https://api.example.com/content/list',
      method: 'GET',
      data: {
        page,
        pageSize,
        type,
        category,
        status
      }
    });
    return res.data;
  } catch (error) {
    console.error('获取内容列表失败:', error);
    throw error;
  }
};

// 获取内容详情
export const getContentDetail = async (id) => {
  try {
    const res = await uni.request({
      url: `https://api.example.com/content/${id}`,
      method: 'GET'
    });
    return res.data;
  } catch (error) {
    console.error('获取内容详情失败:', error);
    throw error;
  }
};

// 创建内容
export const createContent = async (data) => {
  try {
    const res = await uni.request({
      url: 'https://api.example.com/content',
      method: 'POST',
      data
    });
    return res.data;
  } catch (error) {
    console.error('创建内容失败:', error);
    throw error;
  }
};

// 更新内容
export const updateContent = async (id, data) => {
  try {
    const res = await uni.request({
      url: `https://api.example.com/content/${id}`,
      method: 'PUT',
      data
    });
    return res.data;
  } catch (error) {
    console.error('更新内容失败:', error);
    throw error;
  }
};

// 删除内容
export const deleteContent = async (id) => {
  try {
    const res = await uni.request({
      url: `https://api.example.com/content/${id}`,
      method: 'DELETE'
    });
    return res.data;
  } catch (error) {
    console.error('删除内容失败:', error);
    throw error;
  }
};

// 发布内容
export const publishContent = async (id) => {
  try {
    const res = await uni.request({
      url: `https://api.example.com/content/${id}/publish`,
      method: 'POST'
    });
    return res.data;
  } catch (error) {
    console.error('发布内容失败:', error);
    throw error;
  }
};

4. 内容列表组件

<!-- components/content-list.vue -->
<template>
  <view class="content-list">
    <content-item 
      v-for="item in contentList" 
      :key="item.id" 
      :content="item" 
      @click="handleContentClick(item.id)"
    />
    <view v-if="loading" class="loading">加载中...</view>
    <view v-if="!loading && contentList.length === 0" class="empty">暂无内容</view>
    <view v-if="!loading && hasMore" class="load-more" @click="loadMore">加载更多</view>
  </view>
</template>

<script>
export default {
  name: 'ContentList',
  components: {
    ContentItem
  },
  props: {
    contentList: {
      type: Array,
      default: () => []
    },
    loading: {
      type: Boolean,
      default: false
    },
    hasMore: {
      type: Boolean,
      default: true
    }
  },
  methods: {
    handleContentClick(id) {
      uni.navigateTo({
        url: `/pages/content/detail?id=${id}`
      });
    },
    loadMore() {
      this.$emit('load-more');
    }
  }
};
</script>

<style scoped>
.content-list {
  padding: 16rpx;
}

.loading,
.empty,
.load-more {
  text-align: center;
  padding: 32rpx 0;
  color: #999;
}

.load-more {
  color: #007AFF;
  cursor: pointer;
}
</style>

5. 内容编辑组件

<!-- components/content-editor.vue -->
<template>
  <view class="content-editor">
    <view class="form-item">
      <text class="label">标题</text>
      <input 
        v-model="contentForm.title" 
        class="input" 
        placeholder="请输入标题"
      />
    </view>
    
    <view class="form-item">
      <text class="label">分类</text>
      <picker 
        :range="categories" 
        v-model="categoryIndex"
        @change="handleCategoryChange"
      >
        <view class="picker">
          {{ categories[categoryIndex] }}
        </view>
      </picker>
    </view>
    
    <view class="form-item">
      <text class="label">标签</text>
      <input 
        v-model="tagInput" 
        class="input" 
        placeholder="请输入标签,多个标签用逗号分隔"
        @blur="handleTagsInput"
      />
      <view class="tags">
        <view 
          v-for="(tag, index) in contentForm.tags" 
          :key="index"
          class="tag"
        >
          {{ tag }}
          <text class="tag-remove" @click="removeTag(index)">×</text>
        </view>
      </view>
    </view>
    
    <view class="form-item">
      <text class="label">封面图片</text>
      <view class="uploader">
        <image 
          v-if="contentForm.coverImage" 
          :src="contentForm.coverImage"
          class="cover-image"
        />
        <button 
          v-else 
          class="upload-btn"
          @click="chooseImage"
        >
          选择图片
        </button>
      </view>
    </view>
    
    <view class="form-item">
      <text class="label">内容</text>
      <textarea 
        v-model="contentForm.content" 
        class="textarea" 
        placeholder="请输入内容"
        style="height: 400rpx;"
      />
    </view>
    
    <view class="form-actions">
      <button class="btn" @click="saveDraft">保存草稿</button>
      <button class="btn btn-primary" @click="submitContent">提交审核</button>
    </view>
  </view>
</template>

<script>
import { uploadImage } from '@/services/upload-api';

export default {
  name: 'ContentEditor',
  props: {
    initialContent: {
      type: Object,
      default: () => ({})
    }
  },
  data() {
    return {
      contentForm: {
        title: '',
        content: '',
        category: '',
        tags: [],
        coverImage: ''
      },
      tagInput: '',
      categories: ['技术', '生活', '娱乐', '教育', '其他'],
      categoryIndex: 0
    };
  },
  mounted() {
    if (this.initialContent) {
      this.contentForm = { ...this.initialContent };
      this.categoryIndex = this.categories.indexOf(this.contentForm.category);
      this.tagInput = this.contentForm.tags.join(',');
    }
  },
  methods: {
    handleCategoryChange(e) {
      this.categoryIndex = e.detail.value;
      this.contentForm.category = this.categories[this.categoryIndex];
    },
    handleTagsInput() {
      if (this.tagInput) {
        this.contentForm.tags = this.tagInput.split(',').map(tag => tag.trim());
      }
    },
    removeTag(index) {
      this.contentForm.tags.splice(index, 1);
      this.tagInput = this.contentForm.tags.join(',');
    },
    async chooseImage() {
      const [error, res] = await uni.chooseImage({
        count: 1,
        sizeType: ['compressed'],
        sourceType: ['album', 'camera']
      });
      
      if (error) {
        uni.showToast({ title: '选择图片失败', icon: 'none' });
        return;
      }
      
      const tempFilePaths = res.tempFilePaths;
      try {
        const result = await uploadImage(tempFilePaths[0]);
        this.contentForm.coverImage = result.url;
      } catch (err) {
        uni.showToast({ title: '上传图片失败', icon: 'none' });
      }
    },
    saveDraft() {
      this.contentForm.status = 'draft';
      this.$emit('save', this.contentForm);
    },
    submitContent() {
      this.contentForm.status = 'pending';
      this.$emit('submit', this.contentForm);
    }
  }
};
</script>

<style scoped>
.content-editor {
  padding: 16rpx;
}

.form-item {
  margin-bottom: 32rpx;
}

.label {
  display: block;
  margin-bottom: 8rpx;
  font-weight: bold;
}

.input,
.textarea {
  border: 1rpx solid #ddd;
  border-radius: 8rpx;
  padding: 16rpx;
  width: 100%;
  box-sizing: border-box;
}

.picker {
  border: 1rpx solid #ddd;
  border-radius: 8rpx;
  padding: 16rpx;
  background-color: #f5f5f5;
}

.tags {
  display: flex;
  flex-wrap: wrap;
  margin-top: 16rpx;
}

.tag {
  display: inline-flex;
  align-items: center;
  background-color: #f0f0f0;
  border-radius: 16rpx;
  padding: 8rpx 16rpx;
  margin-right: 12rpx;
  margin-bottom: 12rpx;
}

.tag-remove {
  margin-left: 8rpx;
  color: #999;
  font-size: 24rpx;
}

.uploader {
  margin-top: 8rpx;
}

.cover-image {
  width: 200rpx;
  height: 150rpx;
  border-radius: 8rpx;
}

.upload-btn {
  width: 200rpx;
  height: 150rpx;
  border: 1rpx dashed #ddd;
  border-radius: 8rpx;
  background-color: #f5f5f5;
  display: flex;
  align-items: center;
  justify-content: center;
}

.form-actions {
  display: flex;
  justify-content: space-between;
  margin-top: 48rpx;
}

.btn {
  flex: 1;
  margin: 0 16rpx;
  padding: 16rpx;
  border-radius: 8rpx;
}

.btn-primary {
  background-color: #007AFF;
  color: white;
}
</style>

6. 内容管理后台页面

<!-- pages/admin/content-manage.vue -->
<template>
  <view class="content-manage">
    <view class="header">
      <text class="title">内容管理</text>
      <button class="add-btn" @click="navigateToEdit">+ 添加内容</button>
    </view>
    
    <view class="filter">
      <picker 
        :range="statusOptions" 
        v-model="statusIndex"
        @change="handleStatusChange"
      >
        <view class="filter-item">
          {{ statusOptions[statusIndex] }}
        </view>
      </picker>
      
      <picker 
        :range="typeOptions" 
        v-model="typeIndex"
        @change="handleTypeChange"
      >
        <view class="filter-item">
          {{ typeOptions[typeIndex] }}
        </view>
      </picker>
    </view>
    
    <view class="content-list">
      <view 
        v-for="item in contentList" 
        :key="item.id"
        class="content-item"
      >
        <view class="content-info">
          <text class="content-title">{{ item.title }}</text>
          <text class="content-meta">
            {{ item.author }} · {{ formatDate(item.createTime) }}
          </text>
        </view>
        
        <view class="content-status" :class="`status-${item.status}`">
          {{ getStatusText(item.status) }}
        </view>
        
        <view class="content-actions">
          <button 
            class="action-btn"
            @click="navigateToEdit(item.id)"
          >
            编辑
          </button>
          
          <button 
            v-if="item.status === 'draft' || item.status === 'pending'"
            class="action-btn primary"
            @click="handlePublish(item.id)"
          >
            发布
          </button>
          
          <button 
            class="action-btn danger"
            @click="handleDelete(item.id)"
          >
            删除
          </button>
        </view>
      </view>
    </view>
    
    <view v-if="loading" class="loading">加载中...</view>
    <view v-if="!loading && contentList.length === 0" class="empty">暂无内容</view>
  </view>
</template>

<script>
import { getContentList, publishContent, deleteContent } from '@/services/content-api';

export default {
  name: 'ContentManage',
  data() {
    return {
      contentList: [],
      loading: false,
      statusIndex: 0,
      statusOptions: ['全部', '草稿', '待审核', '已发布', '已归档'],
      typeIndex: 0,
      typeOptions: ['全部', '文章', '商品', '图片', '视频']
    };
  },
  mounted() {
    this.loadContentList();
  },
  methods: {
    async loadContentList() {
      this.loading = true;
      try {
        const statusMap = {
          0: '',
          1: 'draft',
          2: 'pending',
          3: 'published',
          4: 'archived'
        };
        
        const typeMap = {
          0: '',
          1: 'article',
          2: 'product',
          3: 'image',
          4: 'video'
        };
        
        const params = {
          status: statusMap[this.statusIndex],
          type: typeMap[this.typeIndex]
        };
        
        const result = await getContentList(params);
        this.contentList = result.data;
      } catch (error) {
        uni.showToast({ title: '加载失败', icon: 'none' });
      } finally {
        this.loading = false;
      }
    },
    
    handleStatusChange(e) {
      this.statusIndex = e.detail.value;
      this.loadContentList();
    },
    
    handleTypeChange(e) {
      this.typeIndex = e.detail.value;
      this.loadContentList();
    },
    
    navigateToEdit(id) {
      uni.navigateTo({
        url: `/pages/content/edit${id ? `?id=${id}` : ''}`
      });
    },
    
    async handlePublish(id) {
      uni.showModal({
        title: '确认发布',
        content: '确定要发布这篇内容吗?',
        success: async (res) => {
          if (res.confirm) {
            try {
              await publishContent(id);
              uni.showToast({ title: '发布成功' });
              this.loadContentList();
            } catch (error) {
              uni.showToast({ title: '发布失败', icon: 'none' });
            }
          }
        }
      });
    },
    
    async handleDelete(id) {
      uni.showModal({
        title: '确认删除',
        content: '确定要删除这篇内容吗?',
        success: async (res) => {
          if (res.confirm) {
            try {
              await deleteContent(id);
              uni.showToast({ title: '删除成功' });
              this.loadContentList();
            } catch (error) {
              uni.showToast({ title: '删除失败', icon: 'none' });
            }
          }
        }
      });
    },
    
    getStatusText(status) {
      const statusMap = {
        draft: '草稿',
        pending: '待审核',
        published: '已发布',
        archived: '已归档'
      };
      return statusMap[status] || status;
    },
    
    formatDate(date) {
      const d = new Date(date);
      return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
    }
  }
};
</script>

<style scoped>
.content-manage {
  padding: 16rpx;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 32rpx;
}

.title {
  font-size: 32rpx;
  font-weight: bold;
}

.add-btn {
  padding: 12rpx 24rpx;
  background-color: #007AFF;
  color: white;
  border-radius: 8rpx;
}

.filter {
  display: flex;
  margin-bottom: 32rpx;
  background-color: #f5f5f5;
  border-radius: 8rpx;
  overflow: hidden;
}

.filter-item {
  flex: 1;
  padding: 16rpx;
  text-align: center;
  border-right: 1rpx solid #ddd;
}

.filter-item:last-child {
  border-right: none;
}

.content-list {
  margin-bottom: 32rpx;
}

.content-item {
  background-color: white;
  border-radius: 8rpx;
  padding: 16rpx;
  margin-bottom: 16rpx;
  box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}

.content-info {
  margin-bottom: 16rpx;
}

.content-title {
  display: block;
  font-size: 28rpx;
  font-weight: bold;
  margin-bottom: 8rpx;
}

.content-meta {
  font-size: 24rpx;
  color: #999;
}

.content-status {
  display: inline-block;
  padding: 4rpx 12rpx;
  border-radius: 12rpx;
  font-size: 20rpx;
  margin-bottom: 16rpx;
}

.status-draft {
  background-color: #f0f0f0;
  color: #666;
}

.status-pending {
  background-color: #fff3cd;
  color: #856404;
}

.status-published {
  background-color: #d4edda;
  color: #155724;
}

.status-archived {
  background-color: #f8d7da;
  color: #721c24;
}

.content-actions {
  display: flex;
  justify-content: flex-end;
}

.action-btn {
  padding: 8rpx 16rpx;
  margin-left: 12rpx;
  border-radius: 8rpx;
  font-size: 24rpx;
}

.action-btn.primary {
  background-color: #007AFF;
  color: white;
}

.action-btn.danger {
  background-color: #ff3b30;
  color: white;
}

.loading,
.empty {
  text-align: center;
  padding: 64rpx 0;
  color: #999;
}
</style>

学习目标

通过本教程的学习,你应该能够:

  1. 理解内容管理系统的基本架构和核心概念

    • 掌握内容管理系统的分层架构
    • 了解不同类型的内容及其管理方法
    • 熟悉内容发布的完整流程
  2. 掌握 uni-app 中内容管理的实现方法

    • 学会设计内容数据模型
    • 掌握内容API的设计和实现
    • 学会开发内容列表、编辑等核心组件
    • 了解内容管理后台的实现方法
  3. 实现跨平台内容同步和管理

    • 掌握内容同步机制的实现方法
    • 了解如何处理多端内容一致性
    • 学会优化内容加载和展示性能
  4. 应用内容管理最佳实践

    • 掌握内容检索和过滤的实现方法
    • 了解内容审核和权限管理
    • 学会优化内容管理系统的用户体验
  5. 构建完整的内容管理功能

    • 能够独立开发内容管理系统
    • 掌握内容管理系统的部署和维护
    • 了解内容管理系统的扩展和集成方法

通过本教程的学习,你将能够在 uni-app 中构建功能完善、性能优化的内容管理系统,为应用提供高效的内容管理能力。

« 上一篇 uni-app 广告集成 下一篇 » uni-app 用户管理