用 Vue3 + Vite + Pinia 写一个极简的“个人任务看板”(数据存本地 LocalStorage)

一、使用figema拿到高保真图

1.打开figema网站

2.注册个人账号

3.找到右上角"make"

4.输入准确指令"帮我设计一个简洁的个人任务看板,分成待处理、进行中、已完成三列,可以拖拽任务卡片"生成对应ui图(这些figema都有源代码,但我想从0开始写就不下载了)

二、项目初始化

bash 复制代码
# 创建项目
npm create vite@latest task-board -- --template vue
cd task-board

# 安装依赖
npm install
npm install pinia

# 启动开发服务器
npm run dev

三、整体思路

第一步:设计数据结构(LocalStorage 的模型)

看板的核心是"任务",先把任务的数据结构定死,后面所有逻辑都围绕它转。

一个任务对象(Task)至少包含:

  • id:唯一标识(用 Date.now() 或 crypto.randomUUID())
  • title:任务标题(如"整理产品需求文档")
  • status:状态(todo / doing / done)------对应你的三列
  • priority:优先级(high / medium /low)------对应标签
  • category:分类(产品 / 设计 / 开发 / 市场)
  • description(可选):如"第二轮评审反馈修改中"这种额外描述,可放备注 createTime / updateTime
  • createTime:创建时间
  • updateTime:更新时间

存储键名:'kanban-tasks'

初始化:如果 LocalStorage 为空,可以预置截图里的几条示例数据,方便开发调试。

第二步:Pinia Store 设计(核心逻辑)

Store 只做三件事:读数据、改数据、存数据。

  • List item
  • state:一个 tasks: Task\[\] 数组
  • getters(派生状态):
    • todoTasks / doingTasks / doneTasks(按状态过滤)
    • progress:计算 done / total 百分比(你的"3/9")
  • actions(唯一修改数据的地方):
    • loadTasks():从 localStorage 读取并填充 state
    • saveTasks():将当前 state 写入 localStorage(每次修改后自动调用)
    • addTask(task):push 新任务 → 调用 save
    • updateTask(id, updates):找到任务合并更新 → 调用 save(拖拽改状态也走这个)
    • deleteTask(id):过滤删除 → 调用 save
    • moveTask(id, newStatus):专门用于拖拽,实际上调 updateTask

关键点:所有对任务的增删改,都先修改 state 中的 tasks 数组,再统一 save 到 localStorage。不要在组件里直接操作 localStorage。

第三步:页面布局拆分(组件树)

按照截图,可以拆成 4 个主要组件层级:

  1. App/看板容器
    • 顶部:标题 + 进度展示(已完成 3/9)
    • 中间:三列容器(KanbanColumn)
  2. KanbanColumn(列组件)
    • 接收 status(todo/doing/done)
    • 从 store 的 getter 中获取该列的任务列表
    • 循环渲染 TaskCard
    • 底部放"+ 添加任务"按钮
  3. TaskCard(任务卡片组件)
    • 显示:标题、优先级标签(高/中/低)、分类标签(产品/设计/开发)
    • 如果有额外备注(如"已接入 GitHub Actions"),显示小字
    • 卡片上可放置:编辑按钮、删除按钮
  4. TaskForm(弹窗/抽屉)
    • 用于"添加"和"编辑"
    • 表单字段:标题、优先级、分类、状态(默认为所在列)、备注
    • 提交时调用 store 的 addTask 或 updateTask

第四步:拖拽功能实现思路(简化版)

Vue3 实现拖拽有两种思路:

  • 方案 A(推荐):使用 @vueuse/core 的 useDraggable 或 Sortable.js(Vue 官方示例常用)
  • 方案 B(原生):用 dragstart / dragover / drop 事件

逻辑层面:

当用户把卡片拖到另一列时,获取卡片的 id 和目标列的 status,调用 store.moveTask(id, newStatus),Pinia 自动更新状态并保存。

注意:拖拽时只改 status,别的字段不动。

第五步:LocalStorage 的读取时机与初始化

在 App.vue 或 main.js 中,在组件挂载时调用 store.loadTasks()。

如果 localStorage 为空,用截图里的 9 条示例数据(中/高/低优先级混杂)填充,这样一打开页面就有内容,不用手动添加。

四、代码实现

第一步:设计数据结构 + 准备示例数据

创建示例数据文件

在 src 文件夹下新建一个文件 data/exampleTasks.js(先建 data 文件夹):

javascript 复制代码
// src/data/exampleTasks.js

export const exampleTasks = [
  {
    id: 'task-1',
    title: '整理产品需求文档',
    status: 'todo',        // todo / doing / done
    priority: 'high',      // high / medium / low
    category: '产品',
    description: '中优先级 · 产品',
    createTime: Date.now() - 1000000,
    updateTime: Date.now() - 1000000
  },
  // ... 继续添加其他任务
];

把截图里的9个任务都按照这个格式写出来

第二步:创建 Pinia Store

创建 store 文件

先下载pinia

bash 复制代码
npm install pinia

再在 src/stores 文件夹下新建 kanbanStore.js(如果 stores 文件夹不存在,先创建它):

javascript 复制代码
// src/stores/kanbanStore.js

// src/stores/kanbanStore.js

import { defineStore } from 'pinia'
import { exampleTasks } from '@/data/exampleTasks'

export const useKanbanStore = defineStore('kanban', {
  // ========== state:数据仓库 ==========
  state: () => ({
    tasks: []  // 所有任务的数组
  }),

  // ========== getters:计算属性 ==========
  getters: {
    // 1. 按状态过滤
    todoTasks: (state) => state.tasks.filter(task => task.status === 'todo'),
    doingTasks: (state) => state.tasks.filter(task => task.status === 'doing'),
    doneTasks: (state) => state.tasks.filter(task => task.status === 'done'),

    // 2. 总任务数
    totalTasks: (state) => state.tasks.length,

    // 3. 已完成数量
    doneCount: (state) => state.tasks.filter(task => task.status === 'done').length,

    // 4. 进度百分比
    progress: (state) => {
      const total = state.tasks.length
      const done = state.tasks.filter(task => task.status === 'done').length
      return total === 0 ? 0 : Math.round((done / total) * 100)
    }
  },

  // ========== actions:修改数据的方法 ==========
  actions: {
    // 1. 从 localStorage 加载数据
    loadTasks() {
      const stored = localStorage.getItem('kanban-tasks')
      if (stored) {
        // 如果 localStorage 有数据,解析并使用
        this.tasks = JSON.parse(stored)
      } else {
        // 如果没有,使用示例数据
        this.tasks = exampleTasks
        this.saveTasks() // 立即保存到 localStorage
      }
    },

    // 2. 保存到 localStorage
    saveTasks() {
      localStorage.setItem('kanban-tasks', JSON.stringify(this.tasks))
    },

    // 3. 添加任务
    addTask(taskData) {
      const newTask = {
        id: Date.now().toString(), // 用时间戳做唯一ID
        ...taskData,
        createTime: Date.now(),
        updateTime: Date.now()
      }
      this.tasks.push(newTask)
      this.saveTasks()
      return newTask
    },

    // 4. 更新任务
    updateTask(id, updates) {
      const index = this.tasks.findIndex(task => task.id === id)
      if (index !== -1) {
        this.tasks[index] = {
          ...this.tasks[index],
          ...updates,
          updateTime: Date.now()
        }
        this.saveTasks()
        return true
      }
      return false
    },

    // 5. 删除任务
    deleteTask(id) {
      this.tasks = this.tasks.filter(task => task.id !== id)
      this.saveTasks()
    },

    // 6. 移动任务(专门用于拖拽)
    moveTask(id, newStatus) {
      return this.updateTask(id, { status: newStatus })
    }
  }
})

第三步:测试 Store 是否正常工作

在 src/App.vue 中,我们先用最简单的代码测试一下:

html 复制代码
<!-- src/App.vue -->
<template>
  <div>
    <h1>个人看板测试</h1>
    <p>总任务数:{{ store.totalTasks }}</p>
    <p>已完成:{{ store.doneCount }}</p>
    
    <h3>待处理 ({{ store.todoTasks.length }})</h3>
    <ul>
      <li v-for="task in store.todoTasks" :key="task.id">
        {{ task.title }} - {{ task.priority }} - {{ task.category }}
      </li>
    </ul>

    <h3>进行中 ({{ store.doingTasks.length }})</h3>
    <ul>
      <li v-for="task in store.doingTasks" :key="task.id">
        {{ task.title }} - {{ task.priority }} - {{ task.category }}
      </li>
    </ul>

    <h3>已完成 ({{ store.doneTasks.length }})</h3>
    <ul>
      <li v-for="task in store.doneTasks" :key="task.id">
        {{ task.title }} - {{ task.priority }} - {{ task.category }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { useKanbanStore } from './stores/kanbanStore'
import { onMounted } from 'vue'

const store = useKanbanStore()

onMounted(() => {
  store.loadTasks()
})
</script>

第四步:创建页面布局组件

4.1 先看整体结构

4.2 创建组件文件

在 src/components 文件夹下创建三个文件:

  • KanbanColumn.vue - 列组件
html 复制代码
<template>
    <div class="column" :class="statusClassName">
        <div class="column-header" :class="statusClassName">
            <div class="column-header-left-title">{{ title }}</div>
            <div class="column-header-left-number" :class="statusClassName">{{ tasks.length }}</div>
        </div>
        <div class="task-list">
            <TaskCard v-for="task in tasks" :key="task.id" :task="task" @delete="handleDelete">
            </TaskCard>
        </div>
        <button class="add-task-btn" :class="statusClassName" @click="emit('add-task', status)">
            + 添加任务
        </button>
    </div>
</template>

<script setup lang="ts">
import TaskCard from './TaskCard.vue'
import { useKanbanStore } from '../stores/kanbanStore.js'

const props = defineProps<{
    title: string
    status: string
    tasks: any[]
}>()

// 优先级对应的CSS类
const statusClass = {
    todo: 'status-todo',
    doing: 'status-doing',
    done: 'status-done'
}

const statusClassName = statusClass[props.status as keyof typeof statusClass] || ''

const emit = defineEmits(['add-task'])

const store = useKanbanStore()

// 删除任务
const handleDelete = (taskId:String) => {
    if (confirm('确定要删除这个任务吗?')) {
        store.deleteTask(taskId)
    }
}
</script>

<style scoped>
.column {
    border: 1px solid #d9cfc3;
    background-color: #EEE8DF;
    border-radius: 15px;
    padding: 15px;
}

.column.status-doing {
    border: 1px solid #c8d4e8;
    background-color: #E8EDF5;
}

.column.status-done {
    border: 1px solid #c0d8c0;
    background-color: #e8f2e8;
}

.column-header {
    display: flex;
    gap: 10px;
    font-weight: 800;
    margin: 0 0 15px;
    color: #7a7060;
}

.column-header.status-doing {
    color: #4a6090;
}

.column-header.status-done {
    color: #426842;
}

.column-header-left-number {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 24px;
    /* 最小宽度,保证单个数字也是圆 */
    height: 24px;
    /* 高度固定 */
    aspect-ratio: 1 / 1;
    background: #b5a898;
    border-radius: 50%;
    /* 圆角50% = 圆形 */
    font-size: 13px;
    font-weight: 600;
    color: #ffffff;
    line-height: 1;
    /* 防止文字偏下 */
}

.column-header-left-number.status-doing {
    background-color: #5878b8;
}

.column-header-left-number.status-done {
    background-color: #5a9a5a;
}

.task-list {
    display: flex;
    flex-direction: column;
    gap: 10px;
    margin-bottom: 16px;
}

.add-task-btn {
    width: 100%;
    padding: 10px;
    background: transparent;
    border: 2px dashed #d0d5dd;
    border-radius: 8px;
    color: #7a7060;
    font-size: 14px;
    cursor: pointer;
    transition: all 0.2s;
}

.add-task-btn.status-doing {
    color: #4a6090;
}

.add-task-btn.status-done {
    color: #426842;
}


.add-task-btn:hover {
    background: #f2ede8;
    border-color: #7a7060;
    color: #7a7060;
}

.add-task-btn:hover.status-doing {
    background: #eff3fa;
    border-color: #4a6090;
    color: #4a6090;
}

.add-task-btn:hover.status-done {
    background: #f6fdf6;
    border-color: #426842;
    color: #426842;
}
</style>
  • TaskCard.vue - 任务卡片组件
html 复制代码
<template>
  <div class="task-card" :class="priorityClassName">
    <!-- 任务标题 -->
    <div class="task-title">{{ task.title }}</div>

    <!-- 描述(如果有) -->
    <div v-if="task.description" class="task-description">
      {{ task.description }}
    </div>
    <div class="task-bottom">
      <!-- 标签区域 -->
      <div class="task-tags">
        <span class="tag priority-tag" :class="priorityClassName">
          {{ priorityLabel }}
        </span>
        <span class="tag category-tag">
          {{ task.category }}
        </span>

      </div>
      <!-- 操作按钮 -->
      <div class="task-actions">
        <div class="action-btn delete-btn" @click="emit('delete', task.id)">×</div>
      </div>
    </div>

  </div>
</template>

<script setup lang="ts">
const props = defineProps({
  task: {
    type: Object,
    required: true
  }
})

const emit = defineEmits(['delete', 'edit'])

// 优先级对应的中文
const priorityMap = {
  high: '高优先级',
  medium: '中优先级',
  low: '低优先级'
}

// 优先级对应的CSS类
const priorityClass = {
  high: 'priority-high',
  medium: 'priority-medium',
  low: 'priority-low'
}

const priorityLabel = priorityMap[props.task.priority as keyof typeof priorityMap] || props.task.priority
const priorityClassName = priorityClass[props.task.priority as keyof typeof priorityClass] || ''
</script>


<style scoped>
.task-card {
  background-color: #FAF8F5;
  border-radius: 15px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  padding: 14px;
}

.task-title {
  color: #1C1A17;
  font-size: 13.125px;
}

.task-description {
  margin-top: 3.75px;
  font-size: 11.25px;
  color: #7A7060;
}

.task-bottom {
  display: flex;
  justify-content: space-between;
}

.task-tags {
  margin-top: 11.25px;
}

.tag {
  font-size: 11px;
  padding: 2px 7.5px;
  border-radius: 15px;
  font-weight: 500;
  color: #1C1A17;
  background: #E8E2DA;
  margin-right: 11.25px;
}

.priority-tag.priority-high {
  color: #8A3020;
  background: #F5DDD8;
}

.priority-tag.priority-medium {
  color: #8A6030;
  background: #F5EAD8;
}

.priority-tag.priority-low {
  color: #7A7060;
  background: #E8E2DA;
}

.action-btn {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 14px;
  border-radius: 4px;
  transition: background 0.2s;
}

.action-btn:hover {
  background: #f0f2f5;
}
</style>
  • TaskForm.vue - 添加/编辑任务表单
html 复制代码
<!-- src/components/TaskForm.vue -->
<template>
    <div class="modal-overlay" @click.self="emit('close')">
      <div class="modal">
        <h2>新建任务</h2>
        
        <form @submit.prevent="handleSubmit">
          <!-- 任务标题 -->
          <div class="form-group">
            <label>任务标题 *</label>
            <input 
              v-model="form.title" 
              type="text" 
              placeholder="输入任务标题"
              required
            />
          </div>
  
          <!-- 描述 -->
          <div class="form-group">
            <label>描述(可选)</label>
            <textarea 
              v-model="form.description" 
              placeholder="补充任务详情"
              rows="3"
            ></textarea>
          </div>
  
          <!-- 优先级和分类 -->
          <div class="form-row">
            <div class="form-group">
              <label>优先级</label>
              <select v-model="form.priority">
                <option value="high">高优先级</option>
                <option value="medium">中优先级</option>
                <option value="low">低优先级</option>
              </select>
            </div>
            <div class="form-group">
              <label>标签</label>
              <select v-model="form.category">
                <option value="产品">产品</option>
                <option value="设计">设计</option>
                <option value="开发">开发</option>
                <option value="市场">市场</option>
                <option value="运营">运营</option>
                <option value="测试">测试</option>
              </select>
            </div>
          </div>
  
          <!-- 按钮 -->
          <div class="form-actions">
            <button type="button" class="btn-cancel" @click="emit('close')">
              取消
            </button>
            <button type="submit" class="btn-submit">
              添加任务
            </button>
          </div>
        </form>
      </div>
    </div>
  </template>
  
  <script setup>
  import { ref } from 'vue'
  
  const props = defineProps({
    status: {
      type: String,
      default: 'todo'
    }
  })
  
  const emit = defineEmits(['close', 'submit'])
  
  // 表单数据
  const form = ref({
    title: '',
    description: '',
    priority: 'medium',
    category: '产品'
  })
  
  // 提交表单
  const handleSubmit = () => {
    if (!form.value.title.trim()) {
      alert('请输入任务标题')
      return
    }
    
    emit('submit', {
      title: form.value.title,
      description: form.value.description,
      priority: form.value.priority,
      category: form.value.category
    })
    
    // 重置表单
    form.value = {
      title: '',
      description: '',
      priority: 'medium',
      category: '产品'
    }
  }
  </script>
  
  <style scoped>
  .modal-overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0,0,0,0.5);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;
  }
  
  .modal {
    background: white;
    border-radius: 16px;
    padding: 32px;
    width: 480px;
    max-width: 90%;
    max-height: 90vh;
    overflow-y: auto;
  }
  
  .modal h2 {
    font-size: 20px;
    margin: 0 0 24px 0;
    color: #1a2332;
  }
  
  .form-group {
    margin-bottom: 16px;
  }
  
  .form-group label {
    display: block;
    font-size: 13px;
    font-weight: 500;
    color: #4a5568;
    margin-bottom: 6px;
  }
  
  .form-group input,
  .form-group select,
  .form-group textarea {
    width: 100%;
    padding: 10px 12px;
    border: 1px solid #d0d5dd;
    border-radius: 8px;
    font-size: 14px;
    font-family: inherit;
    transition: border-color 0.2s;
    box-sizing: border-box;
  }
  
  .form-group input:focus,
  .form-group select:focus,
  .form-group textarea:focus {
    outline: none;
    border-color: #4f7df3;
    box-shadow: 0 0 0 3px rgba(79, 125, 243, 0.1);
  }
  
  .form-row {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 16px;
  }
  
  .form-actions {
    display: flex;
    gap: 12px;
    justify-content: flex-end;
    margin-top: 24px;
    padding-top: 16px;
    border-top: 1px solid #e8ecf1;
  }
  
  .btn-cancel,
  .btn-submit {
    padding: 10px 24px;
    border: none;
    border-radius: 8px;
    font-size: 14px;
    font-weight: 500;
    cursor: pointer;
    transition: all 0.2s;
  }
  
  .btn-cancel {
    background: #f0f2f5;
    color: #4a5568;
  }
  
  .btn-cancel:hover {
    background: #e8ecf1;
  }
  
  .btn-submit {
    background: #4f7df3;
    color: white;
  }
  
  .btn-submit:hover {
    background: #3b6de0;
    transform: translateY(-1px);
  }
  </style>

第五步:实现拖拽功能

1.在KanbanColumn.vue内添加事件并绑定

typescript 复制代码
// 拖拽开始
const onDragStart = (taskId: string) => {
    // 存储被拖拽的任务ID
    if (typeof window !== 'undefined') {
        window.sessionStorage.setItem('dragged-task-id', taskId)
    }
}

// 拖拽经过
const onDragOver = (e: DragEvent) => {
    e.preventDefault() // 必须,允许放置
}

// 放置事件
const onDrop = (e: DragEvent) => {
    e.preventDefault()

    // 获取被拖拽的任务ID
    const taskId = window.sessionStorage.getItem('dragged-task-id')
    if (!taskId) return

    // 获取当前列的状态
    const newStatus = props.status

    // 找到被拖拽的任务
    const task = store.tasks.find(t => t.id === taskId)
    if (!task) return

    // 如果任务已经在这个状态,不做任何操作
    if (task.status === newStatus) {
        window.sessionStorage.removeItem('dragged-task-id')
        return
    }

    // 更新任务状态
    store.moveTask(taskId, newStatus)

    // 清理存储
    window.sessionStorage.removeItem('dragged-task-id')
}

2.在TaskCard.vue内添加事件

typescript 复制代码
// 拖拽开始
const onDragStart = (e: DragEvent) => {
  console.log('拖拽开始:', props.task.id)
  // 设置拖拽数据
  if (e.dataTransfer) {
    e.dataTransfer.effectAllowed = 'move'
    e.dataTransfer.setData('text/plain', props.task.id)
    // 可以设置拖拽时的光标样式
    e.dataTransfer.dropEffect = 'move'
  }
  // 通知父组件
  emit('drag-start', props.task.id)
}

// 拖拽结束
const onDragEnd = () => {
  // 清理任何拖拽状态
}

五、最终实现效果

代码仓库

相关推荐
shawxlee13 小时前
vue3在public下封装config.js自定义配置动态数据,可在打包后直接修改,方便后端部署及后续维护
前端·javascript·经验分享·vue·团队开发·js·项目优化
jieyucx14 小时前
Nuxt4阶段六:工程化与进阶 —— 模块、中间件、插件、TS 与测试
中间件·vue·web·nuxt·全栈·ssr
jieyucx15 小时前
Nuxt4阶段五:渲染模式与性能优化
性能优化·vue·nuxt
csdn2015_1 天前
nodejs安装
node.js·vue
jieyucx3 天前
Nuxt4阶段一:环境搭建与第一个 Nuxt 4 项目
vue·nuxt·全栈·ssr
小堂子这厢有礼了4 天前
Chet.Admin 模块详解②:用户管理与个人中心
vue·rbac·后台管理系统·用户权限·.net10·vbenadmin
sugar__salt4 天前
Vue.js 前置知识:ES6+ 核心特性完全指南
前端·javascript·vue.js·vue·es6
sugar__salt4 天前
Vue3 基础知识点整理 —— 从入门到响应式核心完全指南
vue.js·笔记·vue·vue3
钛态5 天前
前端安全防线:CSRF 攻击链路与双重 Token 校验的工程实现
前端·vue·react·web