
1. 技术栈
| 分类 | 技术 | 版本 |
|---|---|---|
| 框架 | Vue | 3.5+ |
| 构建工具 | Vite | 8.0 |
| 样式 | Scoped CSS | - |
| 图标 | 内联 SVG | - |
2. 核心技术要点
2.1 Composition API
使用 Vue 3 Composition API 进行状态管理:
import { ref, computed } from 'vue'
状态定义:
newTask- 输入框绑定值filter- 当前筛选模式(all/active/completed)nextId- 任务ID自增器todos- 任务列表数据
2.2 计算属性
filteredTodos - 响应式筛选:
const filteredTodos = computed(() => {
if (filter.value === 'active') return todos.value.filter(t => !t.completed)
if (filter.value === 'completed') return todos.value.filter(t => t.completed)
return todos.value
})
remainingCount - 未完成计数:
const remainingCount = computed(() => todos.value.filter(t => !t.completed).length)
2.3 响应式数据操作
添加任务 - unshift 插入头部:
todos.value.unshift({ id: nextId.value++, text: text, completed: false })
删除任务 - filter 创建新数组:
todos.value = todos.value.filter(t => t.id !== id)
切换状态 - 直接修改响应式对象:
const todo = todos.value.find(t => t.id === id)
todo.completed = !todo.completed
3. 模板渲染
3.1 列表渲染
<li
v-for="todo in filteredTodos"
:key="todo.id"
class="todo-item"
:class="{ completed: todo.completed }"
>
关键点:
:key="todo.id"优化 Diff 算法性能:class动态绑定完成状态样式
3.2 自定义复选框
<label class="todo-checkbox-wrapper">
<input type="checkbox" class="todo-checkbox" :checked="todo.completed" />
<span class="todo-checkmark">
<svg v-if="todo.completed" ...>...</svg>
</span>
</label>
实现原理:
- 隐藏原生 checkbox(
opacity: 0) - 使用
<span>+ SVG 自定义外观 - 利用
<label>绑定点击事件
3.3 条件渲染
- 删除按钮默认隐藏,hover 时显示
- SVG 勾选图标仅在完成状态渲染
4. 样式系统
4.1 颜色规范
| 用途 | 色值 |
|---|---|
| 主背景 | #ffffff |
| 标题栏 | #ffe9cc |
| 输入区 | #fff5e6 |
| 悬停态 | #fffaf0 |
| 主强调色 | #b83a2b |
| 完成态 | #4caf50 |
4.2 交互状态
| 元素 | 默认 | Hover | Active/Focus |
|---|---|---|---|
| 输入框 | 浅边框 | 边框加深 | 红边框+外发光 |
| 列表项 | 白色 | 浅橙背景 | - |
| 复选框 | 灰色圈 | 红圈 | 绿圈+勾 |
| 筛选按钮 | 白底浅边 | 浅橙底 | 红底白字 |
4.3 动画过渡
统一使用 0.2s ease 过渡:
- 边框颜色
- 背景颜色
- 文字颜色
- 透明度
- 阴影效果
5. 响应式适配
@media (max-width: 600px) {
.todo-title { font-size: 22px; }
.todo-footer { flex-direction: column; }
}
完整案例
<script setup>
import { ref, computed } from 'vue'
const newTask = ref('')
const filter = ref('all')
const nextId = ref(4)
const todos = ref([
{ id: 1, text: '晨练', completed: false },
{ id: 2, text: '练书法', completed: true },
{ id: 3, text: '读书', completed: false }
])
const filteredTodos = computed(() => {
if (filter.value === 'active') {
return todos.value.filter(t => !t.completed)
} else if (filter.value === 'completed') {
return todos.value.filter(t => t.completed)
}
return todos.value
})
const remainingCount = computed(() => {
return todos.value.filter(t => !t.completed).length
})
const addTodo = () => {
const text = newTask.value.trim()
if (text === '') return
todos.value.unshift({
id: nextId.value++,
text: text,
completed: false
})
newTask.value = ''
}
const removeTodo = (id) => {
todos.value = todos.value.filter(t => t.id !== id)
}
const toggleTodo = (id) => {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed
}
}
const setFilter = (f) => {
filter.value = f
}
</script>
<template>
<div class="todo-app">
<div class="todo-wrapper">
<header class="todo-header">
<h1 class="todo-title">待办事项-25计应3班任佳乐</h1>
</header>
<section class="todo-input-section">
<div class="input-group">
<input
type="text"
class="todo-input"
v-model.trim="newTask"
@keyup.enter="addTodo"
placeholder="请填写任务"
/>
</div>
</section>
<section class="todo-list-section">
<ul class="todo-list">
<li
v-for="todo in filteredTodos"
:key="todo.id"
class="todo-item"
:class="{ completed: todo.completed }"
>
<label class="todo-checkbox-wrapper">
<input
type="checkbox"
class="todo-checkbox"
:checked="todo.completed"
@change="toggleTodo(todo.id)"
/>
<span class="todo-checkmark">
<svg
v-if="todo.completed"
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 12 10 18 20 6"></polyline>
</svg>
</span>
</label>
<span class="todo-text">{{ todo.text }}</span>
<button
type="button"
class="todo-delete"
@click="removeTodo(todo.id)"
aria-label="删除任务"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="5" x2="19" y2="19"></line>
<line x1="19" y1="5" x2="5" y2="19"></line>
</svg>
</button>
</li>
</ul>
</section>
<footer class="todo-footer">
<span class="todo-count">共{{ remainingCount }}条未完成任务</span>
<div class="todo-filters">
<button
type="button"
class="filter-btn"
:class="{ active: filter === 'all' }"
@click="setFilter('all')"
>
All
</button>
<button
type="button"
class="filter-btn"
:class="{ active: filter === 'active' }"
@click="setFilter('active')"
>
Active
</button>
<button
type="button"
class="filter-btn"
:class="{ active: filter === 'completed' }"
@click="setFilter('completed')"
>
Completed
</button>
</div>
</footer>
</div>
</div>
</template>
<style scoped>
.todo-app {
min-height: 100vh;
background: #ffffff;
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif;
color: #333333;
padding: 24px 12px;
}
.todo-wrapper {
max-width: 880px;
margin: 0 auto;
border: 1px solid #e8d6b8;
border-radius: 4px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.todo-header {
background: #ffe9cc;
padding: 18px 24px;
text-align: center;
border-bottom: 1px solid #e8d6b8;
}
.todo-title {
margin: 0;
font-size: 28px;
font-weight: 600;
color: #b83a2b;
letter-spacing: 1px;
}
.todo-input-section {
background: #fff5e6;
padding: 14px 24px;
border-bottom: 1px solid #f0e0c8;
}
.todo-input {
width: 100%;
box-sizing: border-box;
padding: 10px 14px;
font-size: 15px;
line-height: 1.5;
color: #333333;
background: #ffffff;
border: 1px solid #d9c8a8;
border-radius: 3px;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.todo-input::placeholder {
color: #aa9980;
}
.todo-input:hover {
border-color: #c9a876;
}
.todo-input:focus {
border-color: #b83a2b;
box-shadow: 0 0 0 2px rgba(184, 58, 43, 0.12);
}
.todo-list-section {
background: #ffffff;
}
.todo-list {
list-style: none;
margin: 0;
padding: 0;
}
.todo-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 24px;
border-bottom: 1px solid #f2ead9;
transition: background-color 0.2s ease;
}
.todo-item:last-child {
border-bottom: none;
}
.todo-item:hover {
background: #fffaf0;
}
.todo-checkbox-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex-shrink: 0;
cursor: pointer;
}
.todo-checkbox {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
margin: 0;
cursor: pointer;
}
.todo-checkmark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: 2px solid #b8b8b8;
border-radius: 50%;
color: #4caf50;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.todo-checkbox-wrapper .todo-checkmark {
border: 2px solid #b8b8b8;
background: #ffffff;
color: transparent;
}
.todo-item.completed .todo-checkmark {
border-color: #4caf50;
background: #ffffff;
color: #4caf50;
}
.todo-checkbox-wrapper:hover .todo-checkmark {
border-color: #b83a2b;
}
.todo-item.completed .todo-checkbox-wrapper:hover .todo-checkmark {
border-color: #3d8b40;
}
.todo-text {
flex: 1;
font-size: 15px;
line-height: 1.5;
color: #333333;
user-select: none;
transition: color 0.2s ease, text-decoration 0.2s ease;
}
.todo-item.completed .todo-text {
color: #999999;
text-decoration: line-through;
}
.todo-delete {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
color: #b83a2b;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease, background-color 0.2s ease, color 0.2s ease;
flex-shrink: 0;
}
.todo-item:hover .todo-delete,
.todo-delete:focus-visible {
opacity: 1;
}
.todo-delete:hover {
background: #fdecec;
color: #9a2a1e;
}
.todo-delete:active {
background: #f9d9d9;
}
.todo-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 14px 24px;
background: #fffaf0;
border-top: 1px solid #f0e0c8;
font-size: 13px;
color: #666666;
}
.todo-count {
line-height: 1.5;
}
.todo-filters {
display: flex;
gap: 8px;
}
.filter-btn {
padding: 6px 14px;
font-size: 13px;
line-height: 1.4;
color: #555555;
background: #ffffff;
border: 1px solid #d9c8a8;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.filter-btn:hover {
background: #fff5e6;
border-color: #c9a876;
}
.filter-btn.active {
color: #ffffff;
background: #b83a2b;
border-color: #b83a2b;
}
.filter-btn.active:hover {
background: #9a2a1e;
border-color: #9a2a1e;
}
@media (max-width: 600px) {
.todo-app {
padding: 12px 6px;
}
.todo-header {
padding: 14px 16px;
}
.todo-title {
font-size: 22px;
}
.todo-input-section,
.todo-item,
.todo-footer {
padding-left: 16px;
padding-right: 16px;
}
.todo-footer {
flex-direction: column;
align-items: flex-start;
}
}
</style>
