前言

UniApp作为一个使用Vue.js开发所有前端应用的框架,让开发者可以编写一套代码,发布到iOS、Android、Web(响应式)、以及各种小程序(微信/支付宝/百度/头条/飞书/QQ/快手/钉钉/淘宝)、快应用等多个平台。

本教程将从零开始,带你构建一个功能完整的个人任务管理器小程序,包含任务的增删改查、分类管理、数据持久化等核心功能。通过这个实战项目,你将掌握UniApp的核心开发技能。

第一章:环境搭建与项目初始化

1.1 开发环境准备

必需工具:

  • HBuilderX(官方推荐IDE)
  • 微信开发者工具
  • Node.js(用于npm包管理)

安装步骤:

  1. 下载HBuilderX

    • 访问 https://www.dcloud.io/hbuilderx.html
    • 下载标准版即可,包含UniApp开发所需的所有功能
  2. 安装微信开发者工具

    • 访问 https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html
    • 下载对应系统版本并安装
  3. 配置开发环境

    # 检查Node.js版本
    node -v
    npm -v
    
    # 如果没有安装Node.js,请先安装
    # 推荐使用LTS版本
    

1.2 创建UniApp项目

  1. 使用HBuilderX创建项目

    • 打开HBuilderX
    • 文件 → 新建 → 项目
    • 选择uni-app项目
    • 项目名称:task-manager
    • 选择默认模板
  2. 项目结构解析

    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 性能优化

  1. 使用缓存:对于不经常变化的数据(如分类),可以在应用启动时加载并缓存,避免每次操作都从本地存储中读取。
  2. 懒加载:对于任务列表,可以考虑使用懒加载技术,只加载当前可见的任务,提高性能。
  3. 优化渲染:使用 v-ifv-for 的组合来优化渲染性能,避免不必要的渲染。

第九章:用户认证与权限管理

9.1 用户认证

为了增强应用的安全性,我们可以实现用户认证功能。用户需要登录才能使用任务管理器。我们可以使用微信小程序的登录接口来实现用户认证。

9.2 权限管理

在用户认证的基础上,我们可以实现权限管理。例如,只有登录用户才能添加、编辑或删除任务和分类。

第十章:应用发布与维护

10.1 应用发布

在完成所有功能开发后,我们可以将应用发布到微信小程序平台。发布前需要确保应用符合微信小程序的审核要求。

10.2 应用维护

发布后,我们需要定期维护和更新应用。这包括修复bug、添加新功能、优化性能等。

总结

通过本教程,你已经学习了如何使用UniApp构建一个功能完整的个人任务管理器小程序。从环境搭建到数据模型设计,再到页面开发和功能实现,你掌握了UniApp的核心开发技能。希望这个项目能帮助你更好地理解UniApp的开发流程,并为你的下一个项目打下坚实的基础。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐