实战项目:待办事项小程序(下)- 高级功能和优化
🎯 在前两篇的基础上,我们来完成高级功能并优化整个应用
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开发的精髓!