实现草稿自动保存功能:5秒无操作自动保存

💾 实现草稿自动保存功能:5秒无操作自动保存

开发一个智能的草稿自动保存系统,防止内容丢失,提升用户体验

前言

在博客编辑器中,最糟糕的经历就是写了好几个小时,突然浏览器崩溃或意外关闭,所有内容都丢失了。为了解决这个问题,我开发了一个智能的草稿自动保存系统,支持5秒无操作自动保存、草稿管理等功能。

功能需求

核心功能

  1. 自动保存:用户停止输入5秒后自动保存
  2. 手动保存:提供手动保存按钮
  3. 草稿管理:查看、加载、删除草稿
  4. 保存状态:实时显示保存状态
  5. 多文章支持:不同文章的草稿独立管理
  6. 持久化:使用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)
}

总结

通过实现草稿自动保存功能,我们获得了:

  1. 用户体验提升 - 不再担心内容丢失
  2. 自动保存 - 5秒无操作自动保存
  3. 草稿管理 - 方便查看、加载、删除草稿
  4. 持久化存储 - 使用localStorage持久化
  5. 多文章支持 - 不同文章的草稿独立管理

这个功能让编辑器更加用户友好,大大降低了内容丢失的风险。


标签:#自动保存 #草稿管理 #Vue3 #前端 #用户体验

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

相关推荐
Cisyam^14 小时前
Bright Data Web Scraping 指南:用 MCP + Dify 自动采集 TikTok 与 LinkedIn数据
大数据·前端·人工智能
XGeFei15 小时前
【表单处理】——如何防止CSRF(跨站请求伪造)攻击的?
前端·网络·csrf
还不秃顶的计科生15 小时前
多模态模型下载
java·linux·前端
GISer_Jing15 小时前
笑不活了!蒸馏Skill竟能复刻前任、挽留同事?三大热门项目+完整地址汇总
前端·人工智能
Bigger16 小时前
🚀 mini-cc:打造你的专属轻量级 AI 编程智能体
前端·node.js·claude
小江的记录本16 小时前
【网络安全】《网络安全三大加密算法结构化知识体系》
java·前端·后端·python·安全·spring·web安全
广师大-Wzx16 小时前
JavaWeb:前端部分
java·前端·javascript·css·vue.js·前端框架·html
M ? A16 小时前
你的 Vue v-memo 与 v-once,VuReact 会编译成什么样的 React 代码?
前端·javascript·vue.js·经验分享·react.js·面试·vureact
是上好佳佳佳呀16 小时前
【前端(七)】CSS3 核心属性笔记:单位、背景、盒子模型与文本换行
前端·笔记·css3