💾 实现草稿自动保存功能:5秒无操作自动保存
开发一个智能的草稿自动保存系统,防止内容丢失,提升用户体验
前言
在博客编辑器中,最糟糕的经历就是写了好几个小时,突然浏览器崩溃或意外关闭,所有内容都丢失了。为了解决这个问题,我开发了一个智能的草稿自动保存系统,支持5秒无操作自动保存、草稿管理等功能。
功能需求
核心功能
- 自动保存:用户停止输入5秒后自动保存
- 手动保存:提供手动保存按钮
- 草稿管理:查看、加载、删除草稿
- 保存状态:实时显示保存状态
- 多文章支持:不同文章的草稿独立管理
- 持久化:使用localStorage持久化草稿
UI设计
编辑器
┌─────────────────────────────────────┐
│ 标题: [____________________] [保存] │
│ │
│ 文章内容... │
│ │
│ 💾 已于 14:32:15 保存 [打开草稿箱] │
└─────────────────────────────────────┘
草稿箱
┌─────────────────────────────────────┐
│ 草稿管理 [X] │
│ │
│ 📝 草稿1 10分钟前
│ Vue 3教程... │
│ [加载] [删除] │
│ │
│ 📝 草稿2 1小时前
│ TypeScript高级类型... │
│ [加载] [删除] │
└─────────────────────────────────────┘
实现方案
1. 数据结构设计
typescript
// types/draft.ts
export interface Draft {
id: string // 草稿ID
articleId: string | null // 关联的文章ID(null表示新文章)
title: string // 文章标题
content: string // 文章内容
summary: string // 摘要
category: string // 分类
tags: string[] // 标签
savedAt: number // 保存时间戳
autoSave: boolean // 是否自动保存
}
export interface DraftStats {
total: number // 总草稿数
todayCount: number // 今日草稿数
spaceUsed: number // 占用空间(字节)
}
2. Composable实现
typescript
// composables/useAutoSave.ts
import { ref, watch, onUnmounted } from 'vue'
import { v4 as uuidv4 } from 'uuid'
interface ArticleForm {
id?: string
title: string
content: string
summary: string
category: string
tags: string[]
}
export function useAutoSave(formData: ArticleForm) {
const isSaving = ref(false)
const lastSaved = ref<number | null>(null)
const autoSaveEnabled = ref(true)
let saveTimer: any = null
// 生成草稿ID
const draftId = computed(() => {
return formData.id ? `draft_${formData.id}` : `draft_${uuidv4()}`
})
// 保存草稿到localStorage
const saveDraft = () => {
const draft: Draft = {
id: draftId.value,
articleId: formData.id || null,
title: formData.title,
content: formData.content,
summary: formData.summary,
category: formData.category,
tags: formData.tags,
savedAt: Date.now(),
autoSave: true
}
// 保存到localStorage
const drafts = getDrafts()
drafts[draft.id] = draft
localStorage.setItem('blog_drafts', JSON.stringify(drafts))
// 更新保存状态
lastSaved.value = Date.now()
isSaving.value = false
}
// 获取所有草稿
const getDrafts = (): Record<string, Draft> => {
const drafts = localStorage.getItem('blog_drafts')
return drafts ? JSON.parse(drafts) : {}
}
// 自动保存逻辑
const handleAutoSave = () => {
if (!autoSaveEnabled.value) return
// 清除之前的定时器
clearTimeout(saveTimer)
// 设置新的定时器(5秒后保存)
isSaving.value = true
saveTimer = setTimeout(() => {
saveDraft()
}, 5000)
}
// 手动保存
const manualSave = () => {
clearTimeout(saveTimer)
isSaving.value = true
saveDraft()
}
// 加载草稿
const loadDraft = (draftId: string): Draft | null => {
const drafts = getDrafts()
return drafts[draftId] || null
}
// 删除草稿
const deleteDraft = (draftId: string) => {
const drafts = getDrafts()
delete drafts[draftId]
localStorage.setItem('blog_drafts', JSON.stringify(drafts))
}
// 监听表单变化
watch(
() => formData,
() => {
handleAutoSave()
},
{ deep: true }
)
// 组件卸载时清理定时器
onUnmounted(() => {
clearTimeout(saveTimer)
})
return {
isSaving,
lastSaved,
autoSaveEnabled,
saveDraft,
manualSave,
loadDraft,
deleteDraft,
getDrafts
}
}
3. 草稿管理组件
vue
<!-- components/DraftManager.vue -->
<template>
<el-drawer
v-model="visible"
title="草稿箱"
size="400px"
>
<div class="draft-manager">
<!-- 统计信息 -->
<div class="draft-stats">
<el-statistic title="总草稿数" :value="stats.total" />
<el-statistic title="今日草稿" :value="stats.todayCount" />
<el-statistic
title="占用空间"
:value="formatSize(stats.spaceUsed)"
/>
</div>
<!-- 筛选和排序 -->
<div class="draft-filter">
<el-input
v-model="searchText"
placeholder="搜索草稿..."
clearable
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select v-model="sortBy" placeholder="排序方式">
<el-option label="保存时间" value="savedAt" />
<el-option label="标题" value="title" />
</el-select>
</div>
<!-- 草稿列表 -->
<div class="draft-list">
<div
v-for="draft in filteredDrafts"
:key="draft.id"
class="draft-item"
>
<div class="draft-header">
<span class="draft-title">{{ draft.title || '无标题' }}</span>
<span class="draft-time">{{ formatRelativeTime(draft.savedAt) }}</span>
</div>
<div class="draft-preview">
{{ draft.content?.substring(0, 100) }}...
</div>
<div class="draft-meta">
<el-tag size="small">{{ draft.category }}</el-tag>
<el-tag
v-for="tag in draft.tags"
:key="tag"
size="small"
>
{{ tag }}
</el-tag>
</div>
<div class="draft-actions">
<el-button
type="primary"
size="small"
@click="loadDraft(draft)"
>
加载
</el-button>
<el-button
type="danger"
size="small"
@click="deleteDraft(draft.id)"
>
删除
</el-button>
</div>
</div>
<el-empty
v-if="filteredDrafts.length === 0"
description="暂无草稿"
/>
</div>
<!-- 批量操作 -->
<div class="draft-footer">
<el-button @click="loadAllDrafts">
加载所有草稿
</el-button>
<el-button
type="danger"
@click="clearAllDrafts"
>
清空草稿箱
</el-button>
</div>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Draft } from '@/types/draft'
const visible = defineModel<boolean>()
const emit = defineEmits<{
(e: 'load', draft: Draft): void
}>()
const searchText = ref('')
const sortBy = ref<'savedAt' | 'title'>('savedAt')
const drafts = ref<Draft[]>([])
// 加载草稿列表
const loadDraftList = () => {
const draftData = localStorage.getItem('blog_drafts')
if (draftData) {
drafts.value = Object.values(JSON.parse(draftData))
}
}
// 草稿统计
const stats = computed(() => {
const today = new Date().setHours(0, 0, 0, 0)
const todayDrafts = drafts.value.filter(d => d.savedAt >= today)
// 计算占用空间
const spaceUsed = JSON.stringify(drafts.value).length
return {
total: drafts.value.length,
todayCount: todayDrafts.length,
spaceUsed
}
})
// 筛选和排序草稿
const filteredDrafts = computed(() => {
let result = [...drafts.value]
// 搜索过滤
if (searchText.value) {
const search = searchText.value.toLowerCase()
result = result.filter(d =>
d.title.toLowerCase().includes(search) ||
d.content.toLowerCase().includes(search)
)
}
// 排序
if (sortBy.value === 'savedAt') {
result.sort((a, b) => b.savedAt - a.savedAt)
} else {
result.sort((a, b) => a.title.localeCompare(b.title))
}
return result
})
// 格式化文件大小
const formatSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
return (bytes / 1024 / 1024).toFixed(2) + ' MB'
}
// 格式化相对时间
const formatRelativeTime = (timestamp: number): string => {
const now = Date.now()
const diff = now - timestamp
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) return '刚刚'
if (diff < hour) return Math.floor(diff / minute) + '分钟前'
if (diff < day) return Math.floor(diff / hour) + '小时前'
return Math.floor(diff / day) + '天前'
}
// 加载单个草稿
const loadDraft = (draft: Draft) => {
emit('load', draft)
visible.value = false
}
// 删除单个草稿
const deleteDraft = (draftId: string) => {
ElMessageBox.confirm('确定要删除这个草稿吗?', '确认', {
type: 'warning'
}).then(() => {
const draftData = JSON.parse(localStorage.getItem('blog_drafts') || '{}')
delete draftData[draftId]
localStorage.setItem('blog_drafts', JSON.stringify(draftData))
loadDraftList()
ElMessage.success('删除成功')
})
}
// 加载所有草稿
const loadAllDrafts = () => {
ElMessageBox.confirm('确定要加载所有草稿吗?', '确认', {
type: 'warning'
}).then(() => {
drafts.value.forEach(draft => {
emit('load', draft)
})
visible.value = false
ElMessage.success('加载成功')
})
}
// 清空所有草稿
const clearAllDrafts = () => {
ElMessageBox.confirm('确定要清空所有草稿吗?此操作不可恢复!', '警告', {
type: 'error'
}).then(() => {
localStorage.removeItem('blog_drafts')
drafts.value = []
ElMessage.success('已清空草稿箱')
})
}
// 监听抽屉打开
watch(visible, (val) => {
if (val) {
loadDraftList()
}
})
</script>
<style scoped lang="scss">
.draft-manager {
height: 100%;
display: flex;
flex-direction: column;
.draft-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 16px;
background: var(--el-fill-color-light);
border-radius: 4px;
margin-bottom: 16px;
}
.draft-filter {
display: flex;
gap: 12px;
margin-bottom: 16px;
.el-select {
width: 120px;
}
}
.draft-list {
flex: 1;
overflow-y: auto;
padding: 8px;
.draft-item {
padding: 12px;
border: 1px solid var(--el-border-color);
border-radius: 4px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: var(--el-color-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.draft-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.draft-title {
font-weight: 600;
font-size: 14px;
}
.draft-time {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.draft-preview {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
line-height: 1.5;
}
.draft-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.draft-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
}
.draft-footer {
padding-top: 16px;
border-top: 1px solid var(--el-border-color);
display: flex;
justify-content: space-between;
}
}
</style>
4. 在编辑器中使用
vue
<!-- views/Editor.vue -->
<template>
<div class="editor-page">
<div class="editor-header">
<el-input
v-model="formData.title"
placeholder="请输入文章标题"
size="large"
/>
<el-button
type="primary"
:loading="isSaving"
@click="manualSave"
>
<el-icon><Check /></el-icon>
保存
</el-button>
<el-button @click="openDraftManager">
<el-icon><FolderOpened /></el-icon>
草稿箱
</el-button>
</div>
<div class="editor-content">
<EnhancedMarkdownEditor
v-model="formData.content"
:auto-save="false"
/>
</div>
<div class="editor-footer">
<span v-if="isSaving" class="saving-status">
<el-icon class="is-loading"><Loading /></el-icon>
正在保存...
</span>
<span v-else-if="lastSaved" class="saved-status">
<el-icon><Check /></el-icon>
已于 {{ formatTime(lastSaved) }} 保存
</span>
</div>
<!-- 草稿管理器 -->
<DraftManager
v-model="draftManagerVisible"
@load="handleLoadDraft"
/>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAutoSave } from '@/composables/useAutoSave'
import DraftManager from '@/components/DraftManager.vue'
import EnhancedMarkdownEditor from '@/components/editor/EnhancedMarkdownEditor.vue'
import type { Draft } from '@/types/draft'
const router = useRouter()
const formData = reactive({
id: undefined as string | undefined,
title: '',
content: '',
summary: '',
category: '',
tags: []
})
const draftManagerVisible = ref(false)
// 使用自动保存
const {
isSaving,
lastSaved,
manualSave,
getDrafts
} = useAutoSave(formData)
// 格式化时间
const formatTime = (timestamp: number): string => {
const date = new Date(timestamp)
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 打开草稿管理器
const openDraftManager = () => {
draftManagerVisible.value = true
}
// 加载草稿
const handleLoadDraft = (draft: Draft) => {
ElMessageBox.confirm(
'加载草稿会覆盖当前内容,确定继续吗?',
'确认',
{ type: 'warning' }
).then(() => {
formData.title = draft.title
formData.content = draft.content
formData.summary = draft.summary
formData.category = draft.category
formData.tags = draft.tags
if (draft.articleId) {
formData.id = draft.articleId
}
ElMessage.success('草稿加载成功')
})
}
// 页面卸载时提示
onMounted(() => {
window.addEventListener('beforeunload', (e) => {
if (formData.title || formData.content) {
e.preventDefault()
e.returnValue = ''
}
})
})
</script>
<style scoped lang="scss">
.editor-page {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
.editor-header {
display: flex;
gap: 12px;
margin-bottom: 24px;
.el-input {
flex: 1;
}
}
.editor-content {
min-height: 600px;
margin-bottom: 16px;
}
.editor-footer {
display: flex;
justify-content: flex-end;
gap: 16px;
padding: 8px 16px;
background: var(--el-fill-color-light);
border-radius: 4px;
.saving-status {
color: var(--el-color-warning);
}
.saved-status {
color: var(--el-color-success);
}
}
}
</style>
高级功能
1. 草稿自动清理
typescript
// 定期清理30天前的草稿
const cleanOldDrafts = () => {
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000
const drafts = getDrafts()
Object.keys(drafts).forEach(key => {
if (drafts[key].savedAt < thirtyDaysAgo) {
delete drafts[key]
}
})
localStorage.setItem('blog_drafts', JSON.stringify(drafts))
}
// 每天自动清理一次
setInterval(cleanOldDrafts, 24 * 60 * 60 * 1000)
2. 草稿冲突检测
typescript
const detectConflict = (draft: Draft): boolean => {
if (!draft.articleId) return false
const drafts = getDrafts()
const currentDraft = drafts[draft.id]
// 检查是否有更新的版本
if (currentDraft && currentDraft.savedAt > draft.savedAt) {
return true
}
return false
}
const handleLoadWithConflictCheck = async (draft: Draft) => {
if (detectConflict(draft)) {
try {
await ElMessageBox.confirm(
'检测到更新的草稿版本,是否加载?',
'冲突提示',
{
confirmButtonText: '加载最新',
cancelButtonText: '继续当前',
type: 'warning'
}
)
// 加载最新草稿
const latestDraft = loadDraft(draft.id)
if (latestDraft) {
handleLoadDraft(latestDraft)
}
} catch {
// 用户选择继续当前
}
} else {
handleLoadDraft(draft)
}
}
3. 草稿导出
typescript
const exportDraft = (draft: Draft) => {
const data = JSON.stringify(draft, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `draft_${draft.savedAt}.json`
link.click()
URL.revokeObjectURL(url)
}
const importDraft = (file: File) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const draft = JSON.parse(e.target?.result as string)
// 验证草稿格式
if (!draft.id || !draft.content) {
throw new Error('无效的草稿文件')
}
// 保存草稿
const drafts = getDrafts()
drafts[draft.id] = draft
localStorage.setItem('blog_drafts', JSON.stringify(drafts))
ElMessage.success('导入成功')
loadDraftList()
} catch (e) {
ElMessage.error('导入失败:' + (e as Error).message)
}
}
reader.readAsText(file)
}
总结
通过实现草稿自动保存功能,我们获得了:
- 用户体验提升 - 不再担心内容丢失
- 自动保存 - 5秒无操作自动保存
- 草稿管理 - 方便查看、加载、删除草稿
- 持久化存储 - 使用localStorage持久化
- 多文章支持 - 不同文章的草稿独立管理
这个功能让编辑器更加用户友好,大大降低了内容丢失的风险。
标签:#自动保存 #草稿管理 #Vue3 #前端 #用户体验
点赞❤️ + 收藏⭐️ + 评论💬,你的支持是我创作的动力!