从0使用UniApp构建一个简单小程序:打造个人任务管理器
前言
UniApp作为一个使用Vue.js开发所有前端应用的框架,让开发者可以编写一套代码,发布到iOS、Android、Web(响应式)、以及各种小程序(微信/支付宝/百度/头条/飞书/QQ/快手/钉钉/淘宝)、快应用等多个平台。
本教程将从零开始,带你构建一个功能完整的个人任务管理器小程序,包含任务的增删改查、分类管理、数据持久化等核心功能。通过这个实战项目,你将掌握UniApp的核心开发技能。
第一章:环境搭建与项目初始化
1.1 开发环境准备
必需工具:
- HBuilderX(官方推荐IDE)
- 微信开发者工具
- Node.js(用于npm包管理)
安装步骤:
-
下载HBuilderX
- 访问 https://www.dcloud.io/hbuilderx.html
- 下载标准版即可,包含UniApp开发所需的所有功能
-
安装微信开发者工具
- 访问 https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html
- 下载对应系统版本并安装
-
配置开发环境
# 检查Node.js版本 node -v npm -v # 如果没有安装Node.js,请先安装 # 推荐使用LTS版本
1.2 创建UniApp项目
-
使用HBuilderX创建项目
- 打开HBuilderX
- 文件 → 新建 → 项目
- 选择uni-app项目
- 项目名称:
task-manager - 选择默认模板
-
项目结构解析
task-manager/ ├── pages/ # 页面文件夹 │ └── index/ │ ├── index.vue # 首页 ├── static/ # 静态资源 ├── components/ # 组件文件夹 ├── utils/ # 工具函数 ├── store/ # 状态管理 ├── App.vue # 应用配置 ├── main.js # 入口文件 ├── manifest.json # 应用配置 ├── pages.json # 页面路由配置 └── uni.scss # 全局样式
1.3 配置pages.json
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "任务管理器",
"navigationBarBackgroundColor": "#007AFF",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/add-task/add-task",
"style": {
"navigationBarTitleText": "添加任务",
"navigationBarBackgroundColor": "#007AFF",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/task-detail/task-detail",
"style": {
"navigationBarTitleText": "任务详情",
"navigationBarBackgroundColor": "#007AFF",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/categories/categories",
"style": {
"navigationBarTitleText": "分类管理",
"navigationBarBackgroundColor": "#007AFF",
"navigationBarTextStyle": "white"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "任务管理器",
"navigationBarBackgroundColor": "#007AFF",
"backgroundColor": "#F8F8F8"
},
"tabBar": {
"color": "#7A7E83",
"selectedColor": "#007AFF",
"borderStyle": "black",
"backgroundColor": "#ffffff",
"list": [
{
"pagePath": "pages/index/index",
"iconPath": "static/icons/home.png",
"selectedIconPath": "static/icons/home-active.png",
"text": "首页"
},
{
"pagePath": "pages/categories/categories",
"iconPath": "static/icons/category.png",
"selectedIconPath": "static/icons/category-active.png",
"text": "分类"
}
]
}
}
第二章:数据模型设计与存储
2.1 数据结构设计
创建 utils/storage.js 文件,实现本地数据存储:
// utils/storage.js
class TaskStorage {
constructor() {
this.TASKS_KEY = 'tasks';
this.CATEGORIES_KEY = 'categories';
this.initDefaultCategories();
}
// 初始化默认分类
initDefaultCategories() {
const categories = this.getCategories();
if (categories.length === 0) {
const defaultCategories = [
{ id: 1, name: '工作', color: '#FF6B6B', icon: '💼' },
{ id: 2, name: '学习', color: '#4ECDC4', icon: '📚' },
{ id: 3, name: '生活', color: '#45B7D1', icon: '🏠' },
{ id: 4, name: '健康', color: '#96CEB4', icon: '💪' }
];
this.setCategories(defaultCategories);
}
}
// 任务相关方法
getTasks() {
try {
const tasks = uni.getStorageSync(this.TASKS_KEY);
return tasks ? JSON.parse(tasks) : [];
} catch (e) {
console.error('获取任务失败:', e);
return [];
}
}
setTasks(tasks) {
try {
uni.setStorageSync(this.TASKS_KEY, JSON.stringify(tasks));
return true;
} catch (e) {
console.error('保存任务失败:', e);
return false;
}
}
addTask(task) {
const tasks = this.getTasks();
const newTask = {
id: Date.now(),
title: task.title,
description: task.description || '',
categoryId: task.categoryId,
priority: task.priority || 'medium',
status: 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
dueDate: task.dueDate || null,
completed: false
};
tasks.unshift(newTask);
return this.setTasks(tasks) ? newTask : null;
}
updateTask(taskId, updates) {
const tasks = this.getTasks();
const taskIndex = tasks.findIndex(task => task.id === taskId);
if (taskIndex !== -1) {
tasks[taskIndex] = {
...tasks[taskIndex],
...updates,
updatedAt: new Date().toISOString()
};
return this.setTasks(tasks) ? tasks[taskIndex] : null;
}
return null;
}
deleteTask(taskId) {
const tasks = this.getTasks();
const filteredTasks = tasks.filter(task => task.id !== taskId);
return this.setTasks(filteredTasks);
}
getTaskById(taskId) {
const tasks = this.getTasks();
return tasks.find(task => task.id === taskId) || null;
}
// 分类相关方法
getCategories() {
try {
const categories = uni.getStorageSync(this.CATEGORIES_KEY);
return categories ? JSON.parse(categories) : [];
} catch (e) {
console.error('获取分类失败:', e);
return [];
}
}
setCategories(categories) {
try {
uni.setStorageSync(this.CATEGORIES_KEY, JSON.stringify(categories));
return true;
} catch (e) {
console.error('保存分类失败:', e);
return false;
}
}
getCategoryById(categoryId) {
const categories = this.getCategories();
return categories.find(cat => cat.id === categoryId) || null;
}
// 统计方法
getTaskStats() {
const tasks = this.getTasks();
return {
total: tasks.length,
completed: tasks.filter(task => task.completed).length,
pending: tasks.filter(task => !task.completed).length,
overdue: tasks.filter(task => {
if (!task.dueDate || task.completed) return false;
return new Date(task.dueDate) < new Date();
}).length
};
}
getTasksByCategory(categoryId) {
const tasks = this.getTasks();
return tasks.filter(task => task.categoryId === categoryId);
}
}
export default new TaskStorage();
2.2 工具函数
创建 utils/helpers.js:
// utils/helpers.js
export const formatDate = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffTime = date.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) return '今天';
if (diffDays === 1) return '明天';
if (diffDays === -1) return '昨天';
if (diffDays > 1) return `${diffDays}天后`;
if (diffDays < -1) return `${Math.abs(diffDays)}天前`;
return date.toLocaleDateString();
};
export const getPriorityText = (priority) => {
const priorityMap = {
high: '高优先级',
medium: '中优先级',
low: '低优先级'
};
return priorityMap[priority] || '中优先级';
};
export const getPriorityColor = (priority) => {
const colorMap = {
high: '#FF6B6B',
medium: '#FFD93D',
low: '#6BCF7F'
};
return colorMap[priority] || '#FFD93D';
};
export const showToast = (title, icon = 'none') => {
uni.showToast({
title,
icon,
duration: 2000
});
};
export const showModal = (title, content) => {
return new Promise((resolve) => {
uni.showModal({
title,
content,
success: (res) => {
resolve(res.confirm);
}
});
});
};
第三章:首页开发
3.1 首页布局设计
修改 pages/index/index.vue:
<template>
<view class="container">
<!-- 统计卡片 -->
<view class="stats-section">
<view class="stats-card">
<view class="stat-item">
<text class="stat-number">{{ stats.total }}</text>
<text class="stat-label">总任务</text>
</view>
<view class="stat-item">
<text class="stat-number">{{ stats.completed }}</text>
<text class="stat-label">已完成</text>
</view>
<view class="stat-item">
<text class="stat-number">{{ stats.pending }}</text>
<text class="stat-label">待完成</text>
</view>
<view class="stat-item">
<text class="stat-number overdue">{{ stats.overdue }}</text>
<text class="stat-label">已逾期</text>
</view>
</view>
</view>
<!-- 快速操作 -->
<view class="quick-actions">
<button class="add-btn" @click="goToAddTask">
<text class="add-icon">+</text>
<text>添加任务</text>
</button>
</view>
<!-- 筛选器 -->
<view class="filter-section">
<scroll-view class="filter-scroll" scroll-x="true">
<view class="filter-item"
:class="{ active: currentFilter === 'all' }"
@click="setFilter('all')">
全部
</view>
<view class="filter-item"
:class="{ active: currentFilter === 'pending' }"
@click="setFilter('pending')">
待完成
</view>
<view class="filter-item"
:class="{ active: currentFilter === 'completed' }"
@click="setFilter('completed')">
已完成
</view>
<view class="filter-item"
:class="{ active: currentFilter === 'overdue' }"
@click="setFilter('overdue')">
已逾期
</view>
</scroll-view>
</view>
<!-- 任务列表 -->
<view class="task-list">
<view v-if="filteredTasks.length === 0" class="empty-state">
<text class="empty-icon">📝</text>
<text class="empty-text">暂无任务</text>
<text class="empty-desc">点击上方按钮添加你的第一个任务吧!</text>
</view>
<view v-else>
<task-item
v-for="task in filteredTasks"
:key="task.id"
:task="task"
:category="getCategoryById(task.categoryId)"
@toggle="toggleTask"
@delete="deleteTask"
@click="goToTaskDetail"
/>
</view>
</view>
</view>
</template>
<script>
import TaskStorage from '@/utils/storage.js';
import { formatDate, showToast, showModal } from '@/utils/helpers.js';
import TaskItem from '@/components/TaskItem.vue';
export default {
components: {
TaskItem
},
data() {
return {
tasks: [],
categories: [],
currentFilter: 'all',
stats: {
total: 0,
completed: 0,
pending: 0,
overdue: 0
}
};
},
computed: {
filteredTasks() {
let filtered = [...this.tasks];
switch (this.currentFilter) {
case 'pending':
filtered = filtered.filter(task => !task.completed);
break;
case 'completed':
filtered = filtered.filter(task => task.completed);
break;
case 'overdue':
filtered = filtered.filter(task => {
if (task.completed || !task.dueDate) return false;
return new Date(task.dueDate) < new Date();
});
break;
}
return filtered.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
}
},
onLoad() {
this.loadData();
},
onShow() {
this.loadData();
},
methods: {
loadData() {
this.tasks = TaskStorage.getTasks();
this.categories = TaskStorage.getCategories();
this.stats = TaskStorage.getTaskStats();
},
setFilter(filter) {
this.currentFilter = filter;
},
getCategoryById(categoryId) {
return this.categories.find(cat => cat.id === categoryId) || null;
},
async toggleTask(taskId) {
const task = this.tasks.find(t => t.id === taskId);
if (!task) return;
const updatedTask = TaskStorage.updateTask(taskId, {
completed: !task.completed
});
if (updatedTask) {
this.loadData();
showToast(task.completed ? '任务标记为未完成' : '任务已完成', 'success');
}
},
async deleteTask(taskId) {
const confirmed = await showModal('确认删除', '确定要删除这个任务吗?');
if (confirmed) {
const success = TaskStorage.deleteTask(taskId);
if (success) {
this.loadData();
showToast('任务已删除', 'success');
}
}
},
goToAddTask() {
uni.navigateTo({
url: '/pages/add-task/add-task'
});
},
goToTaskDetail(taskId) {
uni.navigateTo({
url: `/pages/task-detail/task-detail?id=${taskId}`
});
}
}
};
</script>
<style lang="scss" scoped>
.container {
padding: 20rpx;
background-color: #f8f8f8;
min-height: 100vh;
}
.stats-section {
margin-bottom: 30rpx;
}
.stats-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20rpx;
padding: 40rpx;
display: flex;
justify-content: space-between;
box-shadow: 0 10rpx 30rpx rgba(102, 126, 234, 0.3);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
color: white;
}
.stat-number {
font-size: 48rpx;
font-weight: bold;
margin-bottom: 10rpx;
&.overdue {
color: #FFD93D;
}
}
.stat-label {
font-size: 24rpx;
opacity: 0.9;
}
.quick-actions {
margin-bottom: 30rpx;
}
.add-btn {
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
color: white;
border: none;
border-radius: 50rpx;
padding: 30rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: bold;
box-shadow: 0 10rpx 30rpx rgba(255, 107, 107, 0.3);
}
.add-icon {
font-size: 40rpx;
margin-right: 20rpx;
}
.filter-section {
margin-bottom: 30rpx;
}
.filter-scroll {
white-space: nowrap;
}
.filter-item {
display: inline-block;
padding: 20rpx 40rpx;
margin-right: 20rpx;
background-color: white;
border-radius: 50rpx;
font-size: 28rpx;
color: #666;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
&.active {
background: linear-gradient(135deg, #007AFF 0%, #0056CC 100%);
color: white;
}
}
.task-list {
flex: 1;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 40rpx;
text-align: center;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
}
.empty-text {
font-size: 36rpx;
color: #333;
margin-bottom: 20rpx;
font-weight: bold;
}
.empty-desc {
font-size: 28rpx;
color: #999;
line-height: 1.5;
}
</style>
3.2 任务项组件
创建 components/TaskItem.vue:
<template>
<view class="task-item" @click="$emit('click', task.id)">
<view class="task-content">
<view class="task-header">
<view class="task-title-row">
<text class="task-title" :class="{ completed: task.completed }">
{{ task.title }}
</text>
<view class="task-priority" :style="{ backgroundColor: getPriorityColor(task.priority) }">
{{ getPriorityText(task.priority) }}
</view>
</view>
<view class="task-meta">
<view v-if="category" class="category-tag" :style="{ backgroundColor: category.color }">
<text class="category-icon">{{ category.icon }}</text>
<text class="category-name">{{ category.name }}</text>
</view>
<view v-if="task.dueDate" class="due-date" :class="{ overdue: isOverdue }">
<text class="due-icon">⏰</text>
<text>{{ formatDate(task.dueDate) }}</text>
</view>
</view>
<text v-if="task.description" class="task-description">
{{ task.description }}
</text>
</view>
<view class="task-actions">
<button class="action-btn toggle-btn"
:class="{ completed: task.completed }"
@click.stop="$emit('toggle', task.id)">
<text class="action-icon">{{ task.completed ? '✓' : '○' }}</text>
</button>
<button class="action-btn delete-btn" @click.stop="$emit('delete', task.id)">
<text class="action-icon">🗑</text>
</button>
</view>
</view>
</view>
</template>
<script>
import { formatDate, getPriorityText, getPriorityColor } from '@/utils/helpers.js';
export default {
props: {
task: {
type: Object,
required: true
},
category: {
type: Object,
default: null
}
},
computed: {
isOverdue() {
if (!this.task.dueDate || this.task.completed) return false;
return new Date(this.task.dueDate) < new Date();
}
},
methods: {
formatDate,
getPriorityText,
getPriorityColor
}
};
</script>
<style lang="scss" scoped>
.task-item {
background-color: white;
border-radius: 20rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.task-content {
padding: 30rpx;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.task-header {
flex: 1;
margin-right: 20rpx;
}
.task-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20rpx;
}
.task-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
flex: 1;
margin-right: 20rpx;
&.completed {
text-decoration: line-through;
color: #999;
}
}
.task-priority {
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 20rpx;
color: white;
font-weight: bold;
}
.task-meta {
display: flex;
align-items: center;
margin-bottom: 20rpx;
flex-wrap: wrap;
gap: 20rpx;
}
.category-tag {
display: flex;
align-items: center;
padding: 10rpx 20rpx;
border-radius: 30rpx;
color: white;
font-size: 24rpx;
}
.category-icon {
margin-right: 10rpx;
}
.due-date {
display: flex;
align-items: center;
font-size: 24rpx;
color: #666;
&.overdue {
color: #FF6B6B;
}
}
.due-icon {
margin-right: 8rpx;
}
.task-description {
font-size: 28rpx;
color: #666;
line-height: 1.5;
}
.task-actions {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.action-btn {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
border: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
}
.toggle-btn {
background-color: #f0f0f0;
color: #999;
&.completed {
background-color: #4ECDC4;
color: white;
}
}
.delete-btn {
background-color: #FFE5E5;
color: #FF6B6B;
}
.action-icon {
font-size: 28rpx;
}
</style>
第四章:添加任务页面
4.1 添加任务表单
创建 pages/add-task/add-task.vue:
<template>
<view class="container">
<form @submit="handleSubmit">
<view class="form-section">
<view class="form-group">
<label class="form-label">任务标题 *</label>
<input
class="form-input"
type="text"
v-model="form.title"
placeholder="请输入任务标题"
maxlength="50"
/>
</view>
<view class="form-group">
<label class="form-label">任务描述</label>
<textarea
class="form-textarea"
v-model="form.description"
placeholder="请输入任务描述(可选)"
maxlength="200"
/>
</view>
<view class="form-group">
<label class="form-label">选择分类</label>
<scroll-view class="category-scroll" scroll-x="true">
<view
v-for="category in categories"
:key="category.id"
class="category-option"
:class="{ active: form.categoryId === category.id }"
@click="selectCategory(category.id)"
>
<text class="category-icon">{{ category.icon }}</text>
<text class="category-name">{{ category.name }}</text>
</view>
</scroll-view>
</view>
<view class="form-group">
<label class="form-label">优先级</label>
<view class="priority-options">
<view
v-for="priority in priorityOptions"
:key="priority.value"
class="priority-option"
:class="{ active: form.priority === priority.value }"
@click="selectPriority(priority.value)"
>
<view
class="priority-dot"
:style="{ backgroundColor: priority.color }"
></view>
<text class="priority-text">{{ priority.label }}</text>
</view>
</view>
</view>
<view class="form-group">
<label class="form-label">截止日期</label>
<view class="date-picker-wrapper">
<picker
mode="date"
:value="form.dueDate"
@change="onDateChange"
:start="today"
>
<view class="date-picker">
<text v-if="form.dueDate" class="date-text">
{{ formatDisplayDate(form.dueDate) }}
</text>
<text v-else class="date-placeholder">选择截止日期(可选)</text>
<text class="date-icon">📅</text>
</view>
</picker>
<button
v-if="form.dueDate"
class="clear-date-btn"
@click="clearDate"
>
清除
</button>
</view>
</view>
</view>
<view class="form-actions">
<button class="cancel-btn" @click="goBack">取消</button>
<button
class="submit-btn"
:disabled="!canSubmit"
@click="handleSubmit"
>
{{ isEditing ? '更新任务' : '创建任务' }}
</button>
</view>
</form>
</view>
</template>
<script>
import TaskStorage from '@/utils/storage.js';
import { showToast } from '@/utils/helpers.js';
export default {
data() {
return {
isEditing: false,
taskId: null,
form: {
title: '',
description: '',
categoryId: null,
priority: 'medium',
dueDate: ''
},
categories: [],
priorityOptions: [
{ value: 'high', label: '高', color: '#FF6B6B' },
{ value: 'medium', label: '中', color: '#FFD93D' },
{ value: 'low', label: '低', color: '#6BCF7F' }
]
};
},
computed: {
today() {
const today = new Date();
return `${today.getFullYear()}-${(today.getMonth() + 1).toString().padStart(2, '0')}-${today.getDate().toString().padStart(2, '0')}`;
},
canSubmit() {
return this.form.title && this.form.categoryId;
}
},
onLoad(options) {
if (options.id) {
this.isEditing = true;
this.taskId = options.id;
this.loadTask();
}
this.loadCategories();
},
methods: {
loadCategories() {
this.categories = TaskStorage.getCategories();
},
loadTask() {
const task = TaskStorage.getTaskById(this.taskId);
if (task) {
this.form = { ...task };
}
},
selectCategory(categoryId) {
this.form.categoryId = categoryId;
},
selectPriority(priority) {
this.form.priority = priority;
},
onDateChange(e) {
this.form.dueDate = e.detail.value;
},
clearDate() {
this.form.dueDate = '';
},
formatDisplayDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString();
},
handleSubmit() {
if (this.isEditing) {
TaskStorage.updateTask(this.taskId, this.form);
showToast('任务已更新', 'success');
} else {
TaskStorage.addTask(this.form);
showToast('任务已创建', 'success');
}
uni.navigateBack();
},
goBack() {
uni.navigateBack();
}
}
};
</script>
<style lang="scss" scoped>
.container {
padding: 20rpx;
background-color: #f8f8f8;
min-height: 100vh;
}
.form-section {
margin-bottom: 30rpx;
}
.form-group {
margin-bottom: 20rpx;
}
.form-label {
font-size: 28rpx;
color: #333;
margin-bottom: 10rpx;
}
.form-input {
width: 100%;
padding: 10rpx;
border: 1rpx solid #ccc;
border-radius: 10rpx;
font-size: 28rpx;
}
.form-textarea {
width: 100%;
height: 100rpx;
padding: 10rpx;
border: 1rpx solid #ccc;
border-radius: 10rpx;
font-size: 28rpx;
}
.category-scroll {
white-space: nowrap;
margin-bottom: 20rpx;
}
.category-option {
display: inline-block;
padding: 10rpx 20rpx;
margin-right: 20rpx;
background-color: white;
border: 1rpx solid #ccc;
border-radius: 10rpx;
font-size: 28rpx;
color: #333;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
&.active {
background-color: #007AFF;
color: white;
}
}
.priority-options {
display: flex;
gap: 20rpx;
margin-bottom: 20rpx;
}
.priority-option {
display: flex;
align-items: center;
gap: 10rpx;
cursor: pointer;
&.active {
background-color: #f0f0f0;
}
}
.priority-dot {
width: 30rpx;
height: 30rpx;
border-radius: 50%;
}
.priority-text {
font-size: 28rpx;
}
.date-picker-wrapper {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 20rpx;
}
.date-picker {
display: flex;
align-items: center;
gap: 10rpx;
border: 1rpx solid #ccc;
border-radius: 10rpx;
padding: 10rpx;
font-size: 28rpx;
}
.date-text {
font-size: 28rpx;
}
.date-placeholder {
font-size: 28rpx;
color: #999;
}
.date-icon {
font-size: 32rpx;
margin-left: 10rpx;
}
.clear-date-btn {
background-color: #FF6B6B;
color: white;
border: none;
border-radius: 10rpx;
padding: 10rpx 20rpx;
font-size: 28rpx;
cursor: pointer;
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.cancel-btn {
background-color: #ccc;
color: white;
border: none;
border-radius: 10rpx;
padding: 10rpx 20rpx;
font-size: 28rpx;
cursor: pointer;
}
.submit-btn {
background-color: #007AFF;
color: white;
border: none;
border-radius: 10rpx;
padding: 10rpx 20rpx;
font-size: 28rpx;
cursor: pointer;
}
</style>
第五章:任务详情页面
5.1 任务详情布局设计
创建 pages/task-detail/task-detail.vue:
<template>
<view class="container">
<view class="task-detail">
<view class="task-header">
<text class="task-title">{{ task.title }}</text>
<view class="task-actions">
<button class="action-btn toggle-btn"
:class="{ completed: task.completed }"
@click="toggleTaskCompletion">
<text class="action-icon">{{ task.completed ? '✓' : '○' }}</text>
</button>
<button class="action-btn delete-btn" @click="deleteTask">
<text class="action-icon">🗑</text>
</button>
</view>
</view>
<view class="task-meta">
<view v-if="category" class="category-tag" :style="{ backgroundColor: category.color }">
<text class="category-icon">{{ category.icon }}</text>
<text class="category-name">{{ category.name }}</text>
</view>
<view v-if="task.dueDate" class="due-date" :class="{ overdue: isOverdue }">
<text class="due-icon">⏰</text>
<text>{{ formatDate(task.dueDate) }}</text>
</view>
</view>
<view class="task-description">
<text>{{ task.description || '无描述' }}</text>
</view>
</view>
</view>
</template>
<script>
import TaskStorage from '@/utils/storage.js';
import { formatDate, getPriorityText, getPriorityColor, showToast, showModal } from '@/utils/helpers.js';
export default {
data() {
return {
taskId: null,
task: {},
category: {}
};
},
computed: {
isOverdue() {
if (!this.task.dueDate || this.task.completed) return false;
return new Date(this.task.dueDate) < new Date();
}
},
onLoad(options) {
if (options.id) {
this.taskId = options.id;
this.loadTask();
}
},
methods: {
loadTask() {
const task = TaskStorage.getTaskById(this.taskId);
if (task) {
this.task = { ...task };
this.category = TaskStorage.getCategoryById(task.categoryId) || {};
}
},
toggleTaskCompletion() {
const updatedTask = TaskStorage.updateTask(this.taskId, {
completed: !this.task.completed
});
if (updatedTask) {
this.task = { ...updatedTask };
showToast(this.task.completed ? '任务已完成' : '任务标记为未完成', 'success');
}
},
async deleteTask() {
const confirmed = await showModal('确认删除', '确定要删除这个任务吗?');
if (confirmed) {
const success = TaskStorage.deleteTask(this.taskId);
if (success) {
showToast('任务已删除', 'success');
uni.navigateBack();
}
}
}
}
};
</script>
<style lang="scss" scoped>
.container {
padding: 20rpx;
background-color: #f8f8f8;
min-height: 100vh;
}
.task-detail {
background-color: white;
border-radius: 20rpx;
padding: 30rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.task-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
flex: 1;
margin-right: 20rpx;
}
.task-actions {
display: flex;
gap: 20rpx;
}
.action-btn {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
border: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
}
.toggle-btn {
background-color: #f0f0f0;
color: #999;
&.completed {
background-color: #4ECDC4;
color: white;
}
}
.delete-btn {
background-color: #FFE5E5;
color: #FF6B6B;
}
.action-icon {
font-size: 28rpx;
}
.task-meta {
display: flex;
align-items: center;
margin-bottom: 20rpx;
flex-wrap: wrap;
gap: 20rpx;
}
.category-tag {
display: flex;
align-items: center;
padding: 10rpx 20rpx;
border-radius: 30rpx;
color: white;
font-size: 24rpx;
}
.category-icon {
margin-right: 10rpx;
}
.due-date {
display: flex;
align-items: center;
font-size: 24rpx;
color: #666;
&.overdue {
color: #FF6B6B;
}
}
.due-icon {
margin-right: 8rpx;
}
.task-description {
font-size: 28rpx;
color: #666;
line-height: 1.5;
}
</style>
第六章:分类管理页面
6.1 分类管理布局设计
创建 pages/categories/categories.vue:
<template>
<view class="container">
<view class="categories-section">
<view class="categories-header">
<text class="categories-title">分类管理</text>
<button class="add-category-btn" @click="goToAddCategory">
<text class="add-icon">+</text>
<text>添加分类</text>
</button>
</view>
<view class="categories-list">
<view v-if="!categories.length" class="empty-state">
<text class="empty-icon">📚</text>
<text class="empty-text">暂无分类</text>
<text class="empty-desc">点击上方按钮添加你的第一个分类吧!</text>
</view>
<view v-else>
<view
v-for="category in categories"
:key="category.id"
class="category-item"
@click="goToEditCategory(category.id)"
>
<view class="category-content">
<view class="category-icon" :style="{ backgroundColor: category.color }">
{{ category.icon }}
</view>
<view class="category-info">
<text class="category-name">{{ category.name }}</text>
<text class="category-count">{{ getTaskCountByCategory(category.id) }} 个任务</text>
</view>
</view>
<button class="delete-category-btn" @click.stop="deleteCategory(category.id)">
<text class="delete-icon">🗑</text>
</button>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import TaskStorage from '@/utils/storage.js';
import { showToast, showModal } from '@/utils/helpers.js';
export default {
data() {
return {
categories: []
};
},
onLoad() {
this.loadCategories();
},
methods: {
loadCategories() {
this.categories = TaskStorage.getCategories();
},
getTaskCountByCategory(categoryId) {
return TaskStorage.getTasksByCategory(categoryId).length;
},
goToAddCategory() {
uni.navigateTo({
url: '/pages/add-category/add-category'
});
},
goToEditCategory(categoryId) {
uni.navigateTo({
url: `/pages/add-category/add-category?id=${categoryId}`
});
},
async deleteCategory(categoryId) {
const confirmed = await showModal('确认删除', '确定要删除这个分类吗?');
if (confirmed) {
const success = TaskStorage.deleteCategory(categoryId);
if (success) {
this.loadCategories();
showToast('分类已删除', 'success');
}
}
}
}
};
</script>
<style lang="scss" scoped>
.container {
padding: 20rpx;
background-color: #f8f8f8;
min-height: 100vh;
}
.categories-section {
background-color: white;
border-radius: 20rpx;
padding: 30rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.categories-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
}
.categories-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.add-category-btn {
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
color: white;
border: none;
border-radius: 50rpx;
padding: 30rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: bold;
box-shadow: 0 10rpx 30rpx rgba(255, 107, 107, 0.3);
}
.add-icon {
font-size: 40rpx;
margin-right: 20rpx;
}
.categories-list {
flex: 1;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 40rpx;
text-align: center;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
}
.empty-text {
font-size: 36rpx;
color: #333;
margin-bottom: 20rpx;
font-weight: bold;
}
.empty-desc {
font-size: 28rpx;
color: #999;
line-height: 1.5;
}
.category-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
background-color: white;
border-radius: 20rpx;
padding: 20rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.category-content {
display: flex;
align-items: center;
flex: 1;
}
.category-icon {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 32rpx;
color: white;
margin-right: 20rpx;
}
.category-info {
display: flex;
flex-direction: column;
justify-content: center;
}
.category-name {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 10rpx;
}
.category-count {
font-size: 28rpx;
color: #666;
}
.delete-category-btn {
background-color: #FFE5E5;
color: #FF6B6B;
border: none;
border-radius: 50%;
padding: 10rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
cursor: pointer;
}
.delete-icon {
font-size: 28rpx;
}
</style>
第七章:添加/编辑分类页面
7.1 添加/编辑分类表单
创建 pages/add-category/add-category.vue:
<template>
<view class="container">
<form @submit="handleSubmit">
<view class="form-section">
<view class="form-group">
<label class="form-label">分类名称 *</label>
<input
class="form-input"
type="text"
v-model="form.name"
placeholder="请输入分类名称"
maxlength="50"
/>
</view>
<view class="form-group">
<label class="form-label">分类图标 *</label>
<input
class="form-input"
type="text"
v-model="form.icon"
placeholder="请输入分类图标(可选)"
maxlength="1"
/>
</view>
<view class="form-group">
<label class="form-label">分类颜色 *</label>
<view class="color-picker">
<view
v-for="color in colorOptions"
:key="color"
class="color-option"
:class="{ active: form.color === color }"
@click="selectColor(color)"
>
<view
class="color-dot"
:style="{ backgroundColor: color }"
></view>
</view>
</view>
</view>
</view>
<view class="form-actions">
<button class="cancel-btn" @click="goBack">取消</button>
<button
class="submit-btn"
:disabled="!canSubmit"
@click="handleSubmit"
>
{{ isEditing ? '更新分类' : '创建分类' }}
</button>
</view>
</form>
</view>
</template>
<script>
import TaskStorage from '@/utils/storage.js';
import { showToast } from '@/utils/helpers.js';
export default {
data() {
return {
isEditing: false,
categoryId: null,
form: {
name: '',
icon: '',
color: '#FF6B6B'
},
colorOptions: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFD93D', '#6BCF7F']
};
},
computed: {
canSubmit() {
return this.form.name && this.form.icon && this.form.color;
}
},
onLoad(options) {
if (options.id) {
this.isEditing = true;
this.categoryId = options.id;
this.loadCategory();
}
},
methods: {
loadCategory() {
const category = TaskStorage.getCategoryById(this.categoryId);
if (category) {
this.form = { ...category };
}
},
selectColor(color) {
this.form.color = color;
},
handleSubmit() {
if (this.isEditing) {
TaskStorage.updateCategory(this.categoryId, this.form);
showToast('分类已更新', 'success');
} else {
TaskStorage.addCategory(this.form);
showToast('分类已创建', 'success');
}
uni.navigateBack();
},
goBack() {
uni.navigateBack();
}
}
};
</script>
<style lang="scss" scoped>
.container {
padding: 20rpx;
background-color: #f8f8f8;
min-height: 100vh;
}
.form-section {
margin-bottom: 30rpx;
}
.form-group {
margin-bottom: 20rpx;
}
.form-label {
font-size: 28rpx;
color: #333;
margin-bottom: 10rpx;
}
.form-input {
width: 100%;
padding: 10rpx;
border: 1rpx solid #ccc;
border-radius: 10rpx;
font-size: 28rpx;
}
.color-picker {
display: flex;
gap: 20rpx;
margin-bottom: 20rpx;
}
.color-option {
display: flex;
align-items: center;
gap: 10rpx;
cursor: pointer;
&.active {
background-color: #f0f0f0;
}
}
.color-dot {
width: 30rpx;
height: 30rpx;
border-radius: 50%;
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.cancel-btn {
background-color: #ccc;
color: white;
border: none;
border-radius: 10rpx;
padding: 10rpx 20rpx;
font-size: 28rpx;
cursor: pointer;
}
.submit-btn {
background-color: #007AFF;
color: white;
border: none;
border-radius: 10rpx;
padding: 10rpx 20rpx;
font-size: 28rpx;
cursor: pointer;
}
</style>
第八章:数据持久化与性能优化
8.1 数据持久化
在 utils/storage.js 中,我们已经实现了本地数据存储。为了确保数据持久化,我们需要在应用启动时加载数据,并在数据发生变化时保存数据。
8.2 性能优化
- 使用缓存:对于不经常变化的数据(如分类),可以在应用启动时加载并缓存,避免每次操作都从本地存储中读取。
- 懒加载:对于任务列表,可以考虑使用懒加载技术,只加载当前可见的任务,提高性能。
- 优化渲染:使用
v-if和v-for的组合来优化渲染性能,避免不必要的渲染。
第九章:用户认证与权限管理
9.1 用户认证
为了增强应用的安全性,我们可以实现用户认证功能。用户需要登录才能使用任务管理器。我们可以使用微信小程序的登录接口来实现用户认证。
9.2 权限管理
在用户认证的基础上,我们可以实现权限管理。例如,只有登录用户才能添加、编辑或删除任务和分类。
第十章:应用发布与维护
10.1 应用发布
在完成所有功能开发后,我们可以将应用发布到微信小程序平台。发布前需要确保应用符合微信小程序的审核要求。
10.2 应用维护
发布后,我们需要定期维护和更新应用。这包括修复bug、添加新功能、优化性能等。
总结
通过本教程,你已经学习了如何使用UniApp构建一个功能完整的个人任务管理器小程序。从环境搭建到数据模型设计,再到页面开发和功能实现,你掌握了UniApp的核心开发技能。希望这个项目能帮助你更好地理解UniApp的开发流程,并为你的下一个项目打下坚实的基础。
更多推荐



所有评论(0)