文章系列管理系统:拖拽排序与进度追踪

🔗 文章系列管理系统:拖拽排序与进度追踪

开发一个完整的文章系列管理系统,支持拖拽排序、阅读进度追踪、系列展示等功能

前言

在技术写作中,经常需要将相关的文章组织成系列,比如"Vue 3实战系列"、"TypeScript精通系列"等。为此,我开发了一个完整的文章系列管理系统,支持拖拽排序、阅读进度追踪、系列展示等功能。

功能需求

核心功能

  1. 系列管理:创建、编辑、删除文章系列
  2. 文章关联:将文章添加到系列中
  3. 拖拽排序:使用拖拽调整文章顺序
  4. 阅读进度:追踪用户阅读进度
  5. 系列展示:美观的系列卡片展示
  6. 状态管理:草稿、已发布、已归档

UI设计

复制代码
系列管理页面
┌─────────────────────────────────────┐
│ [创建系列]  [筛选▼]  [排序▼]      │
├─────────────────────────────────────┤
│ ┌─────────────┐  ┌─────────────┐  │
│ │ Vue 3实战  │  │ TS精通      │  │
│ │ [封面图]   │  │ [封面图]    │  │
│ │ 10篇文章   │  │ 8篇文章     │  │
│ │ [管理][查看]│  │ [管理][查看]│  │
│ │ ████████░░ │  │ ██████░░░░ │  │
│ │ 80%已读   │  │ 60%已读     │  │
│ └─────────────┘  └─────────────┘  │
└─────────────────────────────────────┘

系列详情页
┌─────────────────────────────────────┐
│ Vue 3实战系列                     │
│ 一系列Vue 3实战教程,从入门到精通  │
│ ─────────────────────────────────  │
│ ┌─────────────────────────────┐   │
│ │ 1. Vue 3 Composition API   │
│ │    [拖拽按钮]              │   │
│ │ ████████████ 100% ✓        │   │
│ ├─────────────────────────────┤   │
│ │ 2. Vue 3 响应式系统        │   │
│ │    [拖拽按钮]              │   │
│ │ ████████░░░ 60%           │   │
│ ├─────────────────────────────┤   │
│ │ 3. Vue 3 组件通信          │   │
│ │    [拖拽按钮]              │   │
│ │ ░░░░░░░░░░ 0%             │   │
│ └─────────────────────────────┘   │
│ [添加文章] [保存]               │
└─────────────────────────────────────┘

技术选型

bash 复制代码
# 安装拖拽库
npm install vuedraggable@next

vuedraggable - 基于Sortable.js的Vue 3拖拽组件,功能强大且易于使用。

数据结构设计

typescript 复制代码
// types/series.ts
export interface ArticleSeries {
  id: string
  title: string
  description: string
  coverImage?: string
  articleIds: number[]
  order: number[]
  createdAt: number
  updatedAt: number
  status: 'draft' | 'published' | 'archived'
}

export interface SeriesProgress {
  seriesId: string
  readArticles: number[]
  totalArticles: number
  percentage: number
  lastReadAt?: number
}

export interface SeriesCardProps {
  series: ArticleSeries
  progress?: SeriesProgress
  showProgress?: boolean
}

核心功能实现

1. 系列管理组件

vue 复制代码
<!-- components/SeriesManager.vue -->
<template>
  <div class="series-manager">
    <!-- 头部操作栏 -->
    <div class="manager-header">
      <el-button
        type="primary"
        icon="Plus"
        @click="handleCreate"
      >
        创建系列
      </el-button>

      <el-select
        v-model="filterStatus"
        placeholder="状态筛选"
        clearable
      >
        <el-option label="草稿" value="draft" />
        <el-option label="已发布" value="published" />
        <el-option label="已归档" value="archived" />
      </el-select>

      <el-select v-model="sortBy" placeholder="排序方式">
        <el-option label="创建时间" value="createdAt" />
        <el-option label="更新时间" value="updatedAt" />
        <el-option label="文章数量" value="articleCount" />
      </el-select>
    </div>

    <!-- 系列列表 -->
    <div class="series-grid">
      <SeriesCard
        v-for="series in filteredSeries"
        :key="series.id"
        :series="series"
        :progress="getSeriesProgress(series.id)"
        @manage="handleManage(series)"
        @view="handleView(series)"
      />
    </div>

    <!-- 空状态 -->
    <el-empty
      v-if="filteredSeries.length === 0"
      description="暂无系列"
    />

    <!-- 创建/编辑系列对话框 -->
    <el-dialog
      v-model="dialogVisible"
      :title="editingSeries ? '编辑系列' : '创建系列'"
      width="600px"
    >
      <el-form
        ref="formRef"
        :model="formData"
        :rules="formRules"
        label-width="80px"
      >
        <el-form-item label="系列名称" prop="title">
          <el-input v-model="formData.title" placeholder="请输入系列名称" />
        </el-form-item>

        <el-form-item label="系列描述" prop="description">
          <el-input
            v-model="formData.description"
            type="textarea"
            :rows="4"
            placeholder="请输入系列描述"
          />
        </el-form-item>

        <el-form-item label="封面图片">
          <ImageUploader v-model="formData.coverImage" />
        </el-form-item>

        <el-form-item label="状态" prop="status">
          <el-select v-model="formData.status">
            <el-option label="草稿" value="draft" />
            <el-option label="已发布" value="published" />
            <el-option label="已归档" value="archived" />
          </el-select>
        </el-form-item>
      </el-form>

      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="handleSubmit">
          {{ editingSeries ? '更新' : '创建' }}
        </el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { ArticleSeries, SeriesProgress } from '@/types/series'
import { useSeries } from '@/composables/useSeries'
import SeriesCard from './SeriesCard.vue'
import ImageUploader from './ImageUploader.vue'

const emit = defineEmits<{
  (e: 'refresh'): void
}>()

const {
  seriesList,
  createSeries,
  updateSeries,
  deleteSeries,
  getSeriesProgress
} = useSeries()

const dialogVisible = ref(false)
const editingSeries = ref<ArticleSeries | null>(null)
const filterStatus = ref('')
const sortBy = ref('createdAt')
const formRef = ref()

const formData = ref({
  title: '',
  description: '',
  coverImage: '',
  status: 'draft' as const
})

const formRules = {
  title: [
    { required: true, message: '请输入系列名称', trigger: 'blur' }
  ],
  description: [
    { required: true, message: '请输入系列描述', trigger: 'blur' }
  ]
}

// 筛选和排序系列
const filteredSeries = computed(() => {
  let result = [...seriesList.value]

  // 状态筛选
  if (filterStatus.value) {
    result = result.filter(s => s.status === filterStatus.value)
  }

  // 排序
  if (sortBy.value === 'createdAt') {
    result.sort((a, b) => b.createdAt - a.createdAt)
  } else if (sortBy.value === 'updatedAt') {
    result.sort((a, b) => b.updatedAt - a.updatedAt)
  } else if (sortBy.value === 'articleCount') {
    result.sort((a, b) => b.articleIds.length - a.articleIds.length)
  }

  return result
})

// 创建系列
const handleCreate = () => {
  editingSeries.value = null
  formData.value = {
    title: '',
    description: '',
    coverImage: '',
    status: 'draft'
  }
  dialogVisible.value = true
}

// 编辑系列
const handleManage = (series: ArticleSeries) => {
  editingSeries.value = series
  formData.value = {
    title: series.title,
    description: series.description,
    coverImage: series.coverImage || '',
    status: series.status
  }
  dialogVisible.value = true
}

// 查看系列详情
const handleView = (series: ArticleSeries) => {
  // 跳转到系列详情页
  console.log('View series:', series)
}

// 提交表单
const handleSubmit = async () => {
  if (!formRef.value) return

  await formRef.value.validate(async (valid) => {
    if (valid) {
      if (editingSeries.value) {
        // 更新系列
        await updateSeries(editingSeries.value.id, formData.value)
        ElMessage.success('更新成功')
      } else {
        // 创建系列
        await createSeries(formData.value)
        ElMessage.success('创建成功')
      }
      dialogVisible.value = false
      emit('refresh')
    }
  })
}
</script>

<style scoped lang="scss">
.series-manager {
  .manager-header {
    display: flex;
    gap: 12px;
    margin-bottom: 24px;
  }

  .series-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
    gap: 24px;
  }
}
</style>

2. 系列卡片组件

vue 复制代码
<!-- components/SeriesCard.vue -->
<template>
  <div class="series-card" @click="handleClick">
    <div class="series-cover">
      <img
        :src="series.coverImage || defaultCover"
        :alt="series.title"
        @error="handleImageError"
      />
      <div class="series-badge" :class="series.status">
        {{ statusText }}
      </div>
    </div>

    <div class="series-content">
      <h3 class="series-title">{{ series.title }}</h3>
      <p class="series-description">
        {{ series.description }}
      </p>

      <div class="series-stats">
        <span class="stat-item">
          <el-icon><Document /></el-icon>
          {{ series.articleIds.length }} 篇文章
        </span>
        <span class="stat-item">
          <el-icon><View /></el-icon>
          {{ formatTime(series.updatedAt) }}
        </span>
      </div>

      <div v-if="showProgress && progress" class="series-progress">
        <el-progress
          :percentage="progress.percentage"
          :status="getProgressStatus(progress.percentage)"
        />
        <span class="progress-text">
          已完成 {{ progress.readArticles.length }} / {{ progress.totalArticles }}
        </span>
      </div>

      <div class="series-actions">
        <el-button
          type="primary"
          size="small"
          @click.stop="handleManage"
        >
          管理
        </el-button>
        <el-button
          size="small"
          @click.stop="handleView"
        >
          查看
        </el-button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { ArticleSeries, SeriesProgress } from '@/types/series'

interface Props {
  series: ArticleSeries
  progress?: SeriesProgress
  showProgress?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  showProgress: true
})

const emit = defineEmits<{
  (e: 'manage'): void
  (e: 'view'): void
  (e: 'click'): void
}>()

// 默认封面图
const defaultCover = computed(() => {
  const colors = ['#667eea', '#764ba2', '#f093fb', '#f5576c', '#4facfe']
  const index = props.series.id.charCodeAt(0) % colors.length
  return `data:image/svg+xml,${encodeURIComponent(`
    <svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
      <rect width="400" height="200" fill="${colors[index]}"/>
      <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle"
        font-size="40" fill="white" font-family="Arial">
        ${props.series.title[0]}
      </text>
    </svg>
  `)}`
})

// 状态文本
const statusText = computed(() => {
  const statusMap = {
    draft: '草稿',
    published: '已发布',
    archived: '已归档'
  }
  return statusMap[props.series.status]
})

// 进度状态
const getProgressStatus = (percentage: number) => {
  if (percentage === 100) return 'success'
  if (percentage >= 60) return ''
  return 'warning'
}

// 格式化时间
const formatTime = (timestamp: number): string => {
  const diff = Date.now() - timestamp
  const days = Math.floor(diff / (24 * 60 * 60 * 1000))

  if (days === 0) return '今天'
  if (days === 1) return '昨天'
  if (days < 7) return `${days}天前`
  return new Date(timestamp).toLocaleDateString()
}

// 图片加载错误处理
const handleImageError = () => {
  // 可以在这里替换为默认图片
}

const handleClick = () => {
  emit('click')
}

const handleManage = () => {
  emit('manage')
}

const handleView = () => {
  emit('view')
}
</script>

<style scoped lang="scss">
.series-card {
  border-radius: 8px;
  overflow: hidden;
  border: 1px solid var(--el-border-color);
  cursor: pointer;
  transition: all 0.3s;

  &:hover {
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
    transform: translateY(-2px);
  }

  .series-cover {
    position: relative;
    width: 100%;
    height: 180px;
    overflow: hidden;

    img {
      width: 100%;
      height: 100%;
      object-fit: cover;
      transition: transform 0.3s;
    }

    &:hover img {
      transform: scale(1.05);
    }

    .series-badge {
      position: absolute;
      top: 12px;
      right: 12px;
      padding: 4px 12px;
      border-radius: 12px;
      font-size: 12px;
      font-weight: 500;
      color: white;

      &.draft {
        background: #909399;
      }

      &.published {
        background: #67c23a;
      }

      &.archived {
        background: #e6a23c;
      }
    }
  }

  .series-content {
    padding: 16px;

    .series-title {
      margin: 0 0 8px;
      font-size: 18px;
      font-weight: 600;
      color: var(--el-text-color-primary);
    }

    .series-description {
      margin: 0 0 16px;
      font-size: 14px;
      color: var(--el-text-color-secondary);
      line-height: 1.5;
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
    }

    .series-stats {
      display: flex;
      gap: 16px;
      margin-bottom: 12px;
      font-size: 13px;
      color: var(--el-text-color-secondary);

      .stat-item {
        display: flex;
        align-items: center;
        gap: 4px;
      }
    }

    .series-progress {
      margin-bottom: 16px;

      .progress-text {
        display: block;
        margin-top: 4px;
        font-size: 12px;
        color: var(--el-text-color-secondary);
        text-align: right;
      }
    }

    .series-actions {
      display: flex;
      gap: 8px;
    }
  }
}
</style>

3. 系列详情与拖拽排序

vue 复制代码
<!-- components/SeriesDetail.vue -->
<template>
  <div class="series-detail">
    <div class="series-header">
      <img
        :src="series.coverImage || defaultCover"
        :alt="series.title"
        class="series-cover"
      />
      <div class="series-info">
        <h1>{{ series.title }}</h1>
        <p>{{ series.description }}</p>
        <div class="series-meta">
          <el-tag>{{ series.articleIds.length }} 篇文章</el-tag>
          <el-tag type="info">{{ statusText }}</el-tag>
          <span>{{ formatTime(series.updatedAt) }}</span>
        </div>
      </div>
    </div>

    <div class="series-content">
      <div class="content-actions">
        <el-button
          type="primary"
          icon="Plus"
          @click="handleAddArticle"
        >
          添加文章
        </el-button>
        <el-button icon="Sort" @click="toggleSortMode">
          {{ sortMode ? '完成排序' : '拖拽排序' }}
        </el-button>
      </div>

      <!-- 拖拽排序模式 -->
      <draggable
        v-model="orderedArticles"
        item-key="id"
        handle=".drag-handle"
        :disabled="!sortMode"
        class="article-list"
      >
        <template #item="{ element, index }">
          <div
            class="article-item"
            :class="{ 'sort-mode': sortMode }"
          >
            <div class="drag-handle">
              <el-icon><DCaret /></el-icon>
            </div>

            <div class="article-number">{{ index + 1 }}</div>

            <div class="article-info">
              <h3>{{ element.title }}</h3>
              <p>{{ element.summary }}</p>
              <div class="article-meta">
                <span>{{ element.category }}</span>
                <el-tag
                  v-for="tag in element.tags"
                  :key="tag"
                  size="small"
                >
                  {{ tag }}
                </el-tag>
              </div>
            </div>

            <div class="article-actions">
              <el-button
                link
                icon="View"
                @click="handleViewArticle(element)"
              >
                查看
              </el-button>
              <el-button
                link
                type="danger"
                icon="Delete"
                @click="handleRemoveArticle(element.id)"
              >
                移除
              </el-button>
            </div>

            <div class="article-progress">
              <el-progress
                :percentage="getArticleProgress(element.id)"
                :show-text="false"
              />
            </div>
          </div>
        </template>
      </draggable>

      <!-- 文章选择对话框 -->
      <el-dialog
        v-model="articleDialogVisible"
        title="选择文章"
        width="600px"
      >
        <el-input
          v-model="searchText"
          placeholder="搜索文章..."
          clearable
          @input="handleSearch"
        >
          <template #prefix>
            <el-icon><Search /></el-icon>
          </template>
        </el-input>

        <div class="article-select-list">
          <div
            v-for="article in availableArticles"
            :key="article.id"
            class="article-select-item"
            @click="handleSelectArticle(article)"
          >
            <h4>{{ article.title }}</h4>
            <p>{{ article.summary }}</p>
          </div>
        </div>
      </el-dialog>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import draggable from 'vuedraggable'
import { ArticleSeries } from '@/types/series'
import { Article } from '@/types/article'
import { useSeries } from '@/composables/useSeries'
import { useArticle } from '@/composables/useArticle'

interface Props {
  series: ArticleSeries
}

const props = defineProps<Props>()

const { updateSeries, reorderArticles, getSeriesProgress } = useSeries()
const { articles } = useArticle()

const sortMode = ref(false)
const articleDialogVisible = ref(false)
const searchText = ref('')

const orderedArticles = ref<Article[]>([])

// 默认封面
const defaultCover = computed(() => {
  const colors = ['#667eea', '#764ba2', '#f093fb', '#f5576c', '#4facfe']
  const index = props.series.id.charCodeAt(0) % colors.length
  return colors[index]
})

// 状态文本
const statusText = computed(() => {
  const statusMap = {
    draft: '草稿',
    published: '已发布',
    archived: '已归档'
  }
  return statusMap[props.series.status]
})

// 可用文章(已排序且不在系列中)
const availableArticles = computed(() => {
  return articles.value.filter(a =>
    !props.series.articleIds.includes(a.id)
  )
})

// 初始化文章列表
const initArticles = () => {
  orderedArticles.value = props.series.articleIds
    .map(id => articles.value.find(a => a.id === id))
    .filter((a): a is Article => a !== undefined)
}

// 监听文章顺序变化
watch(orderedArticles, (newArticles) => {
  if (sortMode.value) {
    const newOrder = newArticles.map(a => a.id)
    reorderArticles(props.series.id, newOrder)
  }
}, { deep: true })

// 切换排序模式
const toggleSortMode = () => {
  sortMode.value = !sortMode.value
}

// 添加文章
const handleAddArticle = () => {
  articleDialogVisible.value = true
}

// 选择文章
const handleSelectArticle = (article: Article) => {
  const newArticleIds = [...props.series.articleIds, article.id]
  updateSeries(props.series.id, { articleIds: newArticleIds })
  articleDialogVisible.value = false
  ElMessage.success('添加成功')
  initArticles()
}

// 移除文章
const handleRemoveArticle = async (articleId: number) => {
  try {
    await ElMessageBox.confirm('确定要移除这篇文章吗?', '确认', {
      type: 'warning'
    })

    const newArticleIds = props.series.articleIds.filter(id => id !== articleId)
    await updateSeries(props.series.id, { articleIds: newArticleIds })
    ElMessage.success('移除成功')
    initArticles()
  } catch {
    // 用户取消
  }
}

// 查看文章
const handleViewArticle = (article: Article) => {
  // 跳转到文章详情
  console.log('View article:', article)
}

// 获取文章阅读进度
const getArticleProgress = (articleId: number): number => {
  const progress = getSeriesProgress(props.series.id)
  if (!progress) return 0
  return progress.readArticles.includes(articleId) ? 100 : 0
}

// 格式化时间
const formatTime = (timestamp: number): string => {
  const diff = Date.now() - timestamp
  const days = Math.floor(diff / (24 * 60 * 60 * 1000))

  if (days === 0) return '今天'
  if (days === 1) return '昨天'
  return `${days}天前`
}

// 初始化
initArticles()
</script>

<style scoped lang="scss">
.series-detail {
  max-width: 1000px;
  margin: 0 auto;
  padding: 24px;

  .series-header {
    display: flex;
    gap: 24px;
    margin-bottom: 32px;

    .series-cover {
      width: 300px;
      height: 180px;
      border-radius: 8px;
      object-fit: cover;
    }

    .series-info {
      flex: 1;

      h1 {
        margin: 0 0 12px;
        font-size: 28px;
      }

      p {
        margin: 0 0 16px;
        font-size: 15px;
        color: var(--el-text-color-secondary);
        line-height: 1.6;
      }

      .series-meta {
        display: flex;
        gap: 12px;
        align-items: center;
        font-size: 14px;
        color: var(--el-text-color-secondary);
      }
    }
  }

  .series-content {
    .content-actions {
      display: flex;
      gap: 12px;
      margin-bottom: 24px;
    }

    .article-list {
      display: flex;
      flex-direction: column;
      gap: 16px;

      .article-item {
        display: flex;
        gap: 16px;
        padding: 16px;
        border: 1px solid var(--el-border-color);
        border-radius: 8px;
        background: white;
        transition: all 0.3s;

        &.sort-mode {
          cursor: move;
          border-style: dashed;
          border-color: var(--el-color-primary);

          &:hover {
            background: var(--el-fill-color-light);
          }
        }

        .drag-handle {
          display: flex;
          align-items: center;
          justify-content: center;
          width: 32px;
          color: var(--el-text-color-placeholder);

          &.sort-mode {
            color: var(--el-color-primary);
          }
        }

        .article-number {
          display: flex;
          align-items: center;
          justify-content: center;
          width: 40px;
          height: 40px;
          border-radius: 50%;
          background: var(--el-color-primary);
          color: white;
          font-weight: 600;
        }

        .article-info {
          flex: 1;

          h3 {
            margin: 0 0 8px;
            font-size: 16px;
          }

          p {
            margin: 0 0 12px;
            font-size: 14px;
            color: var(--el-text-color-secondary);
          }

          .article-meta {
            display: flex;
            gap: 8px;
            align-items: center;
          }
        }

        .article-actions {
          display: flex;
          flex-direction: column;
          gap: 8px;
        }

        .article-progress {
          display: flex;
          align-items: center;
          width: 100px;
        }
      }
    }
  }
}
</style>

总结

通过实现文章系列管理系统,我们获得了:

  1. 系列管理 - 创建、编辑、删除系列
  2. 文章关联 - 将文章添加到系列中
  3. 拖拽排序 - 使用vuedraggable实现拖拽排序
  4. 阅读进度 - 追踪用户阅读进度
  5. 美观展示 - 系列卡片和详情展示
  6. 状态管理 - 支持草稿、已发布、已归档

这个系统让技术写作更加有条理,方便读者系统性地学习相关知识。


标签:#系列管理 #拖拽排序 #Vue3 #前端 #功能开发

点赞❤️ + 收藏⭐️ + 评论💬,你的支持是我创作的动力!

相关推荐
算是难了2 小时前
TypeORM vs Prisma
数据库·typescript·node.js
GISer_Jing2 小时前
AI Agent接口终局:MCP有弊端,CLI凭什么成为主流?
前端·人工智能
jiayong232 小时前
第 17 课:任务选择与批量操作
开发语言·前端·javascript·vue.js·学习
keyipatience2 小时前
3.Linux基本指令2
前端·html
Hhang2 小时前
从 ERP 系统出发,我是如何设计一套 LLM 多 Agent 系统的(二)
前端·人工智能·agent
源码老李2 小时前
Day 07 · 游戏也要管理状态:场景切换·资源加载·对象池实战
前端·javascript·游戏
aidenxian2 小时前
iOS App 真实包大小:你以为的大小为什么是错的
前端
donecoding2 小时前
遗嘱、水管与抢救室:TS 切入 Go 的流程控制、接口与并发
javascript·typescript·go
天才熊猫君2 小时前
📄 第三篇:Vue 3 命令式弹窗 Provide 污染与关闭动画修复
前端·javascript·vue.js