Skip to content

实战项目:待办事项小程序(下)- 高级功能和优化

🎯 在前两篇的基础上,我们来完成高级功能并优化整个应用

1. 提醒功能

提醒服务

javascript
// services/reminder.js
class ReminderService {
  constructor() {
    this.reminders = new Map()
  }
  
  // 设置提醒
  async setReminder(taskId, reminderTime, taskTitle) {
    try {
      // 计算延迟时间
      const now = new Date().getTime()
      const reminderTimestamp = new Date(reminderTime).getTime()
      const delay = reminderTimestamp - now
      
      if (delay <= 0) {
        throw new Error('提醒时间不能是过去的时间')
      }
      
      // 清除已存在的提醒
      this.clearReminder(taskId)
      
      // 设置新的提醒
      const timerId = setTimeout(() => {
        this.showNotification(taskTitle, taskId)
        this.reminders.delete(taskId)
      }, delay)
      
      this.reminders.set(taskId, {
        timerId,
        reminderTime,
        taskTitle
      })
      
      // 保存到本地存储
      this.saveReminders()
      
      uni.showToast({
        title: '提醒设置成功',
        icon: 'success'
      })
      
      return true
    } catch (error) {
      console.error('设置提醒失败:', error)
      uni.showToast({
        title: error.message || '设置提醒失败',
        icon: 'error'
      })
      return false
    }
  }
  
  // 清除提醒
  clearReminder(taskId) {
    const reminder = this.reminders.get(taskId)
    if (reminder) {
      clearTimeout(reminder.timerId)
      this.reminders.delete(taskId)
      this.saveReminders()
    }
  }
  
  // 显示通知
  showNotification(taskTitle, taskId) {
    // 本地通知
    uni.showModal({
      title: '任务提醒',
      content: `任务"\${taskTitle}"需要处理了`,
      confirmText: '查看',
      cancelText: '稍后',
      success: (res) => {
        if (res.confirm) {
          // 跳转到任务详情
          uni.navigateTo({
            url: `/pages/task-detail/task-detail?id=\${taskId}`
          })
        }
      }
    })
    
    // 震动提醒
    uni.vibrateShort()
    
    // 如果支持,发送系统通知
    if (uni.createLocalNotification) {
      uni.createLocalNotification({
        title: '任务提醒',
        content: taskTitle,
        payload: { taskId }
      })
    }
  }
  
  // 保存提醒到本地存储
  saveReminders() {
    const reminderData = {}
    this.reminders.forEach((reminder, taskId) => {
      reminderData[taskId] = {
        reminderTime: reminder.reminderTime,
        taskTitle: reminder.taskTitle
      }
    })
    
    uni.setStorageSync('task_reminders', reminderData)
  }
  
  // 从本地存储恢复提醒
  restoreReminders() {
    try {
      const reminderData = uni.getStorageSync('task_reminders') || {}
      const now = new Date().getTime()
      
      Object.entries(reminderData).forEach(([taskId, reminder]) => {
        const reminderTime = new Date(reminder.reminderTime).getTime()
        
        // 只恢复未过期的提醒
        if (reminderTime > now) {
          this.setReminder(taskId, reminder.reminderTime, reminder.taskTitle)
        }
      })
    } catch (error) {
      console.error('恢复提醒失败:', error)
    }
  }
  
  // 获取所有提醒
  getAllReminders() {
    const reminders = []
    this.reminders.forEach((reminder, taskId) => {
      reminders.push({
        taskId,
        ...reminder
      })
    })
    return reminders
  }
}

export default new ReminderService()

提醒设置组件

vue
<!-- components/ReminderPicker.vue -->
<template>
  <view class="reminder-picker">
    <view class="reminder-header">
      <text class="title">设置提醒</text>
      <switch :checked="reminderEnabled" @change="onReminderToggle" />
    </view>
    
    <view v-if="reminderEnabled" class="reminder-content">
      <!-- 快捷选项 -->
      <view class="quick-options">
        <text class="section-title">快捷选项</text>
        <view class="option-grid">
          <view 
            v-for="option in quickOptions"
            :key="option.value"
            class="option-item"
            @click="selectQuickOption(option)"
          >
            <text class="option-icon">\{\{ option.icon \}\}</text>
            <text class="option-label">\{\{ option.label \}\}</text>
          </view>
        </view>
      </view>
      
      <!-- 自定义时间 -->
      <view class="custom-time">
        <text class="section-title">自定义时间</text>
        
        <view class="time-picker-row">
          <text class="label">日期:</text>
          <picker 
            mode="date"
            :value="selectedDate"
            :start="minDate"
            @change="onDateChange"
          >
            <view class="picker-text">\{\{ selectedDate || '选择日期' \}\}</view>
          </picker>
        </view>
        
        <view class="time-picker-row">
          <text class="label">时间:</text>
          <picker 
            mode="time"
            :value="selectedTime"
            @change="onTimeChange"
          >
            <view class="picker-text">\{\{ selectedTime || '选择时间' \}\}</view>
          </picker>
        </view>
        
        <view v-if="reminderDateTime" class="reminder-preview">
          <text class="preview-label">提醒时间:</text>
          <text class="preview-time">\{\{ formatReminderTime \}\}</text>
        </view>
      </view>
      
      <!-- 提醒方式 -->
      <view class="reminder-methods">
        <text class="section-title">提醒方式</text>
        
        <view class="method-item">
          <text class="method-label">弹窗提醒</text>
          <switch :checked="methods.popup" @change="onMethodChange('popup', $event)" />
        </view>
        
        <view class="method-item">
          <text class="method-label">震动提醒</text>
          <switch :checked="methods.vibrate" @change="onMethodChange('vibrate', $event)" />
        </view>
        
        <view class="method-item">
          <text class="method-label">声音提醒</text>
          <switch :checked="methods.sound" @change="onMethodChange('sound', $event)" />
        </view>
      </view>
    </view>
    
    <view class="reminder-actions">
      <button @click="onCancel" class="cancel-btn">取消</button>
      <button 
        @click="onConfirm" 
        :disabled="!canConfirm"
        class="confirm-btn"
        :class="{ disabled: !canConfirm }"
      >
        确定
      </button>
    </view>
  </view>
</template>

<script>
export default {
  name: 'ReminderPicker',
  
  props: {
    visible: {
      type: Boolean,
      default: false
    },
    initialReminder: {
      type: String,
      default: null
    }
  },
  
  data() {
    return {
      reminderEnabled: false,
      selectedDate: '',
      selectedTime: '',
      methods: {
        popup: true,
        vibrate: true,
        sound: false
      },
      quickOptions: [
        { value: 5, label: '5分钟后', icon: '⏰' },
        { value: 15, label: '15分钟后', icon: '⏰' },
        { value: 30, label: '30分钟后', icon: '⏰' },
        { value: 60, label: '1小时后', icon: '⏰' },
        { value: 1440, label: '明天', icon: '📅' },
        { value: 10080, label: '下周', icon: '📅' }
      ]
    }
  },
  
  computed: {
    minDate() {
      return new Date().toISOString().split('T')[0]
    },
    
    reminderDateTime() {
      if (!this.selectedDate || !this.selectedTime) return null
      return new Date(`\${this.selectedDate} \${this.selectedTime}`)
    },
    
    formatReminderTime() {
      if (!this.reminderDateTime) return ''
      
      const now = new Date()
      const reminder = this.reminderDateTime
      const diff = reminder.getTime() - now.getTime()
      
      if (diff < 0) return '时间已过期'
      
      const days = Math.floor(diff / (1000 * 60 * 60 * 24))
      const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
      const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
      
      let timeText = reminder.toLocaleString()
      
      if (days > 0) {
        timeText += ` (\${days}天后)`
      } else if (hours > 0) {
        timeText += ` (\${hours}小时后)`
      } else if (minutes > 0) {
        timeText += ` (\${minutes}分钟后)`
      } else {
        timeText += ' (即将到时)'
      }
      
      return timeText
    },
    
    canConfirm() {
      if (!this.reminderEnabled) return true
      return this.reminderDateTime && this.reminderDateTime > new Date()
    }
  },
  
  watch: {
    visible(newVal) {
      if (newVal) {
        this.initReminder()
      }
    }
  },
  
  methods: {
    initReminder() {
      if (this.initialReminder) {
        this.reminderEnabled = true
        const reminderDate = new Date(this.initialReminder)
        this.selectedDate = reminderDate.toISOString().split('T')[0]
        this.selectedTime = reminderDate.toTimeString().slice(0, 5)
      } else {
        this.reminderEnabled = false
        this.selectedDate = ''
        this.selectedTime = ''
      }
    },
    
    onReminderToggle(e) {
      this.reminderEnabled = e.detail.value
      
      if (!this.reminderEnabled) {
        this.selectedDate = ''
        this.selectedTime = ''
      }
    },
    
    selectQuickOption(option) {
      const now = new Date()
      const reminderTime = new Date(now.getTime() + option.value * 60 * 1000)
      
      this.selectedDate = reminderTime.toISOString().split('T')[0]
      this.selectedTime = reminderTime.toTimeString().slice(0, 5)
      this.reminderEnabled = true
    },
    
    onDateChange(e) {
      this.selectedDate = e.detail.value
    },
    
    onTimeChange(e) {
      this.selectedTime = e.detail.value
    },
    
    onMethodChange(method, e) {
      this.methods[method] = e.detail.value
    },
    
    onCancel() {
      this.$emit('cancel')
    },
    
    onConfirm() {
      if (!this.canConfirm) return
      
      const result = {
        enabled: this.reminderEnabled,
        datetime: this.reminderEnabled ? this.reminderDateTime.toISOString() : null,
        methods: { ...this.methods }
      }
      
      this.$emit('confirm', result)
    }
  }
}
</script>

<style lang="scss">
.reminder-picker {
  background: white;
  border-radius: 20rpx 20rpx 0 0;
  padding: 40rpx;
  max-height: 80vh;
  overflow-y: auto;
}

.reminder-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 40rpx;
  padding-bottom: 20rpx;
  border-bottom: 1rpx solid #f0f0f0;
}

.title {
  font-size: 36rpx;
  font-weight: bold;
  color: #333;
}

.reminder-content {
  margin-bottom: 40rpx;
}

.section-title {
  font-size: 28rpx;
  font-weight: bold;
  color: #333;
  display: block;
  margin-bottom: 20rpx;
}

.quick-options {
  margin-bottom: 40rpx;
}

.option-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20rpx;
}

.option-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 20rpx;
  background: #f8f9fa;
  border-radius: 12rpx;
  transition: all 0.3s ease;
  
  &:active {
    background: #e9ecef;
    transform: scale(0.95);
  }
}

.option-icon {
  font-size: 32rpx;
  margin-bottom: 8rpx;
}

.option-label {
  font-size: 24rpx;
  color: #666;
  text-align: center;
}

.custom-time {
  margin-bottom: 40rpx;
}

.time-picker-row {
  display: flex;
  align-items: center;
  margin-bottom: 20rpx;
}

.label {
  width: 120rpx;
  font-size: 28rpx;
  color: #333;
}

.picker-text {
  flex: 1;
  padding: 20rpx;
  background: #f8f9fa;
  border-radius: 8rpx;
  color: #333;
}

.reminder-preview {
  background: #e3f2fd;
  padding: 20rpx;
  border-radius: 8rpx;
  margin-top: 20rpx;
}

.preview-label {
  font-size: 26rpx;
  color: #1976d2;
}

.preview-time {
  font-size: 26rpx;
  color: #1976d2;
  font-weight: bold;
}

.reminder-methods {
  margin-bottom: 40rpx;
}

.method-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20rpx 0;
  border-bottom: 1rpx solid #f0f0f0;
  
  &:last-child {
    border-bottom: none;
  }
}

.method-label {
  font-size: 28rpx;
  color: #333;
}

.reminder-actions {
  display: flex;
  gap: 20rpx;
}

.cancel-btn,
.confirm-btn {
  flex: 1;
  height: 80rpx;
  border: none;
  border-radius: 8rpx;
  font-size: 28rpx;
}

.cancel-btn {
  background: #f8f9fa;
  color: #666;
}

.confirm-btn {
  background: #007aff;
  color: white;
  
  &.disabled {
    background: #ccc;
    color: #999;
  }
}
</style>

2. 数据统计功能

统计服务

javascript
// services/statistics.js
class StatisticsService {
  // 获取任务完成趋势
  getCompletionTrend(tasks, days = 7) {
    const trend = []
    const now = new Date()
    
    for (let i = days - 1; i >= 0; i--) {
      const date = new Date(now)
      date.setDate(date.getDate() - i)
      date.setHours(0, 0, 0, 0)
      
      const nextDate = new Date(date)
      nextDate.setDate(nextDate.getDate() + 1)
      
      const dayTasks = tasks.filter(task => {
        const completedAt = new Date(task.updatedAt)
        return task.completed && 
               completedAt >= date && 
               completedAt < nextDate
      })
      
      trend.push({
        date: date.toISOString().split('T')[0],
        completed: dayTasks.length,
        label: this.formatDateLabel(date)
      })
    }
    
    return trend
  }
  
  // 获取分类统计
  getCategoryStats(tasks, categories) {
    const stats = categories.map(category => {
      const categoryTasks = tasks.filter(task => task.category === category.id)
      const completed = categoryTasks.filter(task => task.completed).length
      const total = categoryTasks.length
      
      return {
        category,
        total,
        completed,
        active: total - completed,
        completionRate: total > 0 ? Math.round((completed / total) * 100) : 0
      }
    })
    
    return stats.sort((a, b) => b.total - a.total)
  }
  
  // 获取优先级统计
  getPriorityStats(tasks) {
    const priorities = ['high', 'medium', 'low']
    
    return priorities.map(priority => {
      const priorityTasks = tasks.filter(task => task.priority === priority)
      const completed = priorityTasks.filter(task => task.completed).length
      const total = priorityTasks.length
      
      return {
        priority,
        label: this.getPriorityLabel(priority),
        color: this.getPriorityColor(priority),
        total,
        completed,
        active: total - completed,
        completionRate: total > 0 ? Math.round((completed / total) * 100) : 0
      }
    })
  }
  
  // 获取时间统计
  getTimeStats(tasks) {
    const now = new Date()
    const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
    const thisWeek = new Date(today)
    thisWeek.setDate(today.getDate() - today.getDay())
    const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1)
    
    const todayTasks = tasks.filter(task => {
      const createdAt = new Date(task.createdAt)
      return createdAt >= today
    })
    
    const weekTasks = tasks.filter(task => {
      const createdAt = new Date(task.createdAt)
      return createdAt >= thisWeek
    })
    
    const monthTasks = tasks.filter(task => {
      const createdAt = new Date(task.createdAt)
      return createdAt >= thisMonth
    })
    
    return {
      today: {
        total: todayTasks.length,
        completed: todayTasks.filter(task => task.completed).length
      },
      week: {
        total: weekTasks.length,
        completed: weekTasks.filter(task => task.completed).length
      },
      month: {
        total: monthTasks.length,
        completed: monthTasks.filter(task => task.completed).length
      }
    }
  }
  
  // 获取效率分析
  getEfficiencyAnalysis(tasks) {
    const completedTasks = tasks.filter(task => task.completed)
    
    if (completedTasks.length === 0) {
      return {
        averageCompletionTime: 0,
        fastestCompletion: 0,
        slowestCompletion: 0,
        totalCompletedTasks: 0
      }
    }
    
    const completionTimes = completedTasks.map(task => {
      const created = new Date(task.createdAt)
      const completed = new Date(task.updatedAt)
      return completed.getTime() - created.getTime()
    })
    
    const averageTime = completionTimes.reduce((sum, time) => sum + time, 0) / completionTimes.length
    const fastestTime = Math.min(...completionTimes)
    const slowestTime = Math.max(...completionTimes)
    
    return {
      averageCompletionTime: this.formatDuration(averageTime),
      fastestCompletion: this.formatDuration(fastestTime),
      slowestCompletion: this.formatDuration(slowestTime),
      totalCompletedTasks: completedTasks.length
    }
  }
  
  // 辅助方法
  formatDateLabel(date) {
    const today = new Date()
    const yesterday = new Date(today)
    yesterday.setDate(yesterday.getDate() - 1)
    
    if (date.toDateString() === today.toDateString()) {
      return '今天'
    } else if (date.toDateString() === yesterday.toDateString()) {
      return '昨天'
    } else {
      return `\${date.getMonth() + 1}/\${date.getDate()}`
    }
  }
  
  getPriorityLabel(priority) {
    const labels = {
      high: '高优先级',
      medium: '中优先级',
      low: '低优先级'
    }
    return labels[priority] || priority
  }
  
  getPriorityColor(priority) {
    const colors = {
      high: '#ff3b30',
      medium: '#ff9500',
      low: '#34c759'
    }
    return colors[priority] || '#007aff'
  }
  
  formatDuration(milliseconds) {
    const seconds = Math.floor(milliseconds / 1000)
    const minutes = Math.floor(seconds / 60)
    const hours = Math.floor(minutes / 60)
    const days = Math.floor(hours / 24)
    
    if (days > 0) {
      return `\${days}天\${hours % 24}小时`
    } else if (hours > 0) {
      return `\${hours}小时\${minutes % 60}分钟`
    } else if (minutes > 0) {
      return `\${minutes}分钟`
    } else {
      return `\${seconds}秒`
    }
  }
}

export default new StatisticsService()

3. 数据导入导出

导入导出服务

javascript
// services/import-export.js
class ImportExportService {
  // 导出数据
  async exportData(format = 'json') {
    try {
      const storageService = (await import('@/services/storage.js')).default
      const data = await storageService.exportData()
      
      if (format === 'json') {
        return this.exportAsJSON(data)
      } else if (format === 'csv') {
        return this.exportAsCSV(data)
      }
      
      throw new Error('不支持的导出格式')
    } catch (error) {
      console.error('导出数据失败:', error)
      throw error
    }
  }
  
  // 导出为JSON格式
  async exportAsJSON(data) {
    const jsonString = JSON.stringify(data, null, 2)
    const fileName = `todo-backup-\${new Date().toISOString().split('T')[0]}.json`
    
    // 在小程序中,我们可以将数据复制到剪贴板
    await uni.setClipboardData({
      data: jsonString
    })
    
    uni.showModal({
      title: '导出成功',
      content: '数据已复制到剪贴板,您可以保存到文件中',
      showCancel: false
    })
    
    return {
      fileName,
      content: jsonString,
      size: new Blob([jsonString]).size
    }
  }
  
  // 导出为CSV格式
  async exportAsCSV(data) {
    const csvContent = this.convertToCSV(data.tasks)
    const fileName = `todo-tasks-\${new Date().toISOString().split('T')[0]}.csv`
    
    await uni.setClipboardData({
      data: csvContent
    })
    
    uni.showModal({
      title: '导出成功',
      content: 'CSV数据已复制到剪贴板',
      showCancel: false
    })
    
    return {
      fileName,
      content: csvContent,
      size: new Blob([csvContent]).size
    }
  }
  
  // 转换为CSV格式
  convertToCSV(tasks) {
    const headers = ['标题', '描述', '状态', '优先级', '分类', '创建时间', '截止时间']
    const csvRows = [headers.join(',')]
    
    tasks.forEach(task => {
      const row = [
        `"\${task.title}"`,
        `"\${task.description}"`,
        task.completed ? '已完成' : '进行中',
        task.priority,
        task.category,
        new Date(task.createdAt).toLocaleString(),
        task.dueDate ? new Date(task.dueDate).toLocaleString() : ''
      ]
      csvRows.push(row.join(','))
    })
    
    return csvRows.join('\n')
  }
  
  // 导入数据
  async importData(jsonString) {
    try {
      const data = JSON.parse(jsonString)
      
      // 验证数据格式
      if (!this.validateImportData(data)) {
        throw new Error('数据格式不正确')
      }
      
      const storageService = (await import('@/services/storage.js')).default
      const success = await storageService.importData(data)
      
      if (success) {
        uni.showToast({
          title: '导入成功',
          icon: 'success'
        })
        
        // 重新加载数据
        const store = getApp().globalData.store
        if (store) {
          await store.dispatch('tasks/loadTasks')
        }
        
        return true
      } else {
        throw new Error('导入失败')
      }
    } catch (error) {
      console.error('导入数据失败:', error)
      uni.showToast({
        title: error.message || '导入失败',
        icon: 'error'
      })
      return false
    }
  }
  
  // 验证导入数据
  validateImportData(data) {
    if (!data || typeof data !== 'object') {
      return false
    }
    
    // 检查必要字段
    if (!Array.isArray(data.tasks)) {
      return false
    }
    
    // 验证任务数据结构
    for (const task of data.tasks) {
      if (!task.id || !task.title || typeof task.completed !== 'boolean') {
        return false
      }
    }
    
    return true
  }
  
  // 从剪贴板导入
  async importFromClipboard() {
    try {
      const clipboardData = await uni.getClipboardData()
      return this.importData(clipboardData.data)
    } catch (error) {
      console.error('从剪贴板导入失败:', error)
      uni.showToast({
        title: '剪贴板数据无效',
        icon: 'error'
      })
      return false
    }
  }
  
  // 生成分享链接(如果有云端服务)
  async generateShareLink(data) {
    try {
      // 这里可以调用云端API生成分享链接
      const response = await uni.request({
        url: '/api/share/create',
        method: 'POST',
        data: {
          content: data,
          expireTime: Date.now() + 7 * 24 * 60 * 60 * 1000 // 7天后过期
        }
      })
      
      return response.data.shareUrl
    } catch (error) {
      console.error('生成分享链接失败:', error)
      throw error
    }
  }
}

export default new ImportExportService()

4. 性能优化

虚拟滚动优化

vue
<!-- components/VirtualTaskList.vue -->
<template>
  <view class="virtual-task-list">
    <scroll-view 
      scroll-y 
      :scroll-top="scrollTop"
      @scroll="onScroll"
      class="scroll-container"
      :style="{ height: containerHeight + 'px' }"
    >
      <view :style="{ height: totalHeight + 'px', position: 'relative' }">
        <task-item
          v-for="item in visibleItems"
          :key="item.task.id"
          :task="item.task"
          :style="{ 
            position: 'absolute',
            top: item.top + 'px',
            left: 0,
            right: 0,
            height: itemHeight + 'px'
          }"
          @toggle="$emit('toggle', item.task.id)"
          @edit="$emit('edit', item.task)"
          @delete="$emit('delete', item.task.id)"
        />
      </view>
    </scroll-view>
  </view>
</template>

<script>
import TaskItem from './TaskItem.vue'

export default {
  name: 'VirtualTaskList',
  components: {
    TaskItem
  },
  
  props: {
    tasks: {
      type: Array,
      default: () => []
    },
    itemHeight: {
      type: Number,
      default: 80
    },
    containerHeight: {
      type: Number,
      default: 600
    }
  },
  
  data() {
    return {
      scrollTop: 0,
      bufferSize: 5
    }
  },
  
  computed: {
    totalHeight() {
      return this.tasks.length * this.itemHeight
    },
    
    visibleCount() {
      return Math.ceil(this.containerHeight / this.itemHeight)
    },
    
    startIndex() {
      const index = Math.floor(this.scrollTop / this.itemHeight)
      return Math.max(0, index - this.bufferSize)
    },
    
    endIndex() {
      const index = this.startIndex + this.visibleCount + this.bufferSize * 2
      return Math.min(this.tasks.length - 1, index)
    },
    
    visibleItems() {
      const items = []
      for (let i = this.startIndex; i <= this.endIndex; i++) {
        if (this.tasks[i]) {
          items.push({
            task: this.tasks[i],
            top: i * this.itemHeight
          })
        }
      }
      return items
    }
  },
  
  methods: {
    onScroll(e) {
      this.scrollTop = e.detail.scrollTop
    }
  }
}
</script>

图片懒加载优化

vue
<!-- components/LazyImage.vue -->
<template>
  <view class="lazy-image" :style="{ width, height }">
    <image 
      v-if="shouldLoad"
      :src="src"
      :mode="mode"
      :lazy-load="true"
      @load="onLoad"
      @error="onError"
      class="image"
      :class="{ loaded: isLoaded }"
    />
    <view v-else class="placeholder">
      <text class="placeholder-text">\{\{ placeholderText \}\}</text>
    </view>
  </view>
</template>

<script>
export default {
  name: 'LazyImage',
  
  props: {
    src: {
      type: String,
      required: true
    },
    width: {
      type: String,
      default: '100%'
    },
    height: {
      type: String,
      default: '200rpx'
    },
    mode: {
      type: String,
      default: 'aspectFill'
    },
    placeholderText: {
      type: String,
      default: '加载中...'
    }
  },
  
  data() {
    return {
      shouldLoad: false,
      isLoaded: false,
      observer: null
    }
  },
  
  mounted() {
    this.createIntersectionObserver()
  },
  
  beforeDestroy() {
    if (this.observer) {
      this.observer.disconnect()
    }
  },
  
  methods: {
    createIntersectionObserver() {
      this.observer = uni.createIntersectionObserver(this)
      
      this.observer.relativeToViewport({ bottom: 100 })
        .observe('.lazy-image', (res) => {
          if (res.intersectionRatio > 0) {
            this.shouldLoad = true
            this.observer.disconnect()
          }
        })
    },
    
    onLoad() {
      this.isLoaded = true
    },
    
    onError() {
      console.error('图片加载失败:', this.src)
    }
  }
}
</script>

<style>
.lazy-image {
  position: relative;
  overflow: hidden;
}

.image {
  width: 100%;
  height: 100%;
  opacity: 0;
  transition: opacity 0.3s ease;
  
  &.loaded {
    opacity: 1;
  }
}

.placeholder {
  width: 100%;
  height: 100%;
  background: #f5f5f5;
  display: flex;
  align-items: center;
  justify-content: center;
}

.placeholder-text {
  color: #999;
  font-size: 24rpx;
}
</style>

小结

在这最后一篇中,我们完成了:

  • ✅ 提醒功能的完整实现
  • ✅ 数据统计和分析功能
  • ✅ 数据导入导出功能
  • ✅ 性能优化技巧
  • ✅ 虚拟滚动和懒加载

项目完成要点

  • 功能要考虑用户体验
  • 性能优化要有针对性
  • 数据安全和备份很重要
  • 代码要保持可维护性

下一篇预告

下一篇我们将学习《调试和测试技巧 - 保证代码质量》,学习如何调试和测试UniApp应用。


一个完整的项目不仅要功能丰富,更要性能优秀、体验流畅。通过这三篇实战,相信你已经掌握了UniApp开发的精髓!