一、使用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 个主要组件层级:
- App/看板容器
- 顶部:标题 + 进度展示(已完成 3/9)
- 中间:三列容器(KanbanColumn)
- KanbanColumn(列组件)
- 接收 status(todo/doing/done)
- 从 store 的 getter 中获取该列的任务列表
- 循环渲染 TaskCard
- 底部放"+ 添加任务"按钮
- TaskCard(任务卡片组件)
- 显示:标题、优先级标签(高/中/低)、分类标签(产品/设计/开发)
- 如果有额外备注(如"已接入 GitHub Actions"),显示小字
- 卡片上可放置:编辑按钮、删除按钮
- 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 = () => {
// 清理任何拖拽状态
}
五、最终实现效果
