🔗 文章系列管理系统:拖拽排序与进度追踪
开发一个完整的文章系列管理系统,支持拖拽排序、阅读进度追踪、系列展示等功能
前言
在技术写作中,经常需要将相关的文章组织成系列,比如"Vue 3实战系列"、"TypeScript精通系列"等。为此,我开发了一个完整的文章系列管理系统,支持拖拽排序、阅读进度追踪、系列展示等功能。
功能需求
核心功能
- 系列管理:创建、编辑、删除文章系列
- 文章关联:将文章添加到系列中
- 拖拽排序:使用拖拽调整文章顺序
- 阅读进度:追踪用户阅读进度
- 系列展示:美观的系列卡片展示
- 状态管理:草稿、已发布、已归档
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>
总结
通过实现文章系列管理系统,我们获得了:
- 系列管理 - 创建、编辑、删除系列
- 文章关联 - 将文章添加到系列中
- 拖拽排序 - 使用vuedraggable实现拖拽排序
- 阅读进度 - 追踪用户阅读进度
- 美观展示 - 系列卡片和详情展示
- 状态管理 - 支持草稿、已发布、已归档
这个系统让技术写作更加有条理,方便读者系统性地学习相关知识。
标签:#系列管理 #拖拽排序 #Vue3 #前端 #功能开发
点赞❤️ + 收藏⭐️ + 评论💬,你的支持是我创作的动力!