UniApp 开发实战:打造符合鸿蒙设计风格的日历活动安排组件
在移动应用开发中,日历和活动安排是非常常见的需求。本文将详细介绍如何使用 UniApp 框架开发一个优雅的日历活动安排组件,并融入鸿蒙系统的设计理念,实现一个既美观又实用的功能模块。
设计理念
在开发这个组件之前,我们需要深入理解鸿蒙系统的设计哲学。鸿蒙系统强调"自然、流畅、统一"的设计原则,这些特点将贯穿我们的整个组件实现过程:
- 自然:日历的切换和选择要符合用户的直觉
- 流畅:动画过渡要平滑,操作响应要及时
- 统一:视觉元素要保持一致性,符合系统设计规范
- 高效:快速获取和展示日程信息
组件实现
1. 日历选择器组件
首先实现一个基础的日历选择器组件:
<!-- components/harmony-calendar/harmony-calendar.vue -->
<template>
<view class="harmony-calendar">
<view class="calendar-header">
<view class="month-switcher">
<text class="iconfont icon-left" @click="changeMonth(-1)"></text>
<text class="current-month">{{ currentYear }}年{{ currentMonth + 1 }}月</text>
<text class="iconfont icon-right" @click="changeMonth(1)"></text>
</view>
<view class="weekday-header">
<text v-for="day in weekDays" :key="day">{{ day }}</text>
</view>
</view>
<view class="calendar-body" :class="{ 'with-animation': enableAnimation }">
<view class="days-grid">
<view v-for="(day, index) in daysArray"
:key="index"
class="day-cell"
:class="[
{ 'current-month': day.currentMonth },
{ 'selected': isSelected(day.date) },
{ 'has-events': hasEvents(day.date) }
]"
@click="selectDate(day)">
<text class="day-number">{{ day.dayNumber }}</text>
<view v-if="hasEvents(day.date)" class="event-indicator"></view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'HarmonyCalendar',
props: {
events: {
type: Array,
default: () => []
},
selectedDate: {
type: Date,
default: () => new Date()
}
},
data() {
return {
currentDate: new Date(),
currentMonth: new Date().getMonth(),
currentYear: new Date().getFullYear(),
weekDays: ['日', '一', '二', '三', '四', '五', '六'],
enableAnimation: true,
daysArray: []
}
},
watch: {
currentMonth: {
handler() {
this.generateCalendar()
},
immediate: true
}
},
methods: {
generateCalendar() {
const firstDay = new Date(this.currentYear, this.currentMonth, 1)
const lastDay = new Date(this.currentYear, this.currentMonth + 1, 0)
const daysInMonth = lastDay.getDate()
const startingDay = firstDay.getDay()
let days = []
// 上个月的日期
const prevMonth = new Date(this.currentYear, this.currentMonth - 1, 1)
const daysInPrevMonth = new Date(this.currentYear, this.currentMonth, 0).getDate()
for (let i = startingDay - 1; i >= 0; i--) {
days.push({
date: new Date(prevMonth.getFullYear(), prevMonth.getMonth(), daysInPrevMonth - i),
dayNumber: daysInPrevMonth - i,
currentMonth: false
})
}
// 当前月的日期
for (let i = 1; i <= daysInMonth; i++) {
days.push({
date: new Date(this.currentYear, this.currentMonth, i),
dayNumber: i,
currentMonth: true
})
}
// 下个月的日期
const remainingDays = 42 - days.length // 保持6行固定高度
for (let i = 1; i <= remainingDays; i++) {
days.push({
date: new Date(this.currentYear, this.currentMonth + 1, i),
dayNumber: i,
currentMonth: false
})
}
this.daysArray = days
},
changeMonth(delta) {
const newDate = new Date(this.currentYear, this.currentMonth + delta, 1)
this.currentMonth = newDate.getMonth()
this.currentYear = newDate.getFullYear()
this.enableAnimation = true
setTimeout(() => {
this.enableAnimation = false
}, 300)
},
isSelected(date) {
return date.toDateString() === this.selectedDate.toDateString()
},
hasEvents(date) {
return this.events.some(event =>
new Date(event.date).toDateString() === date.toDateString()
)
},
selectDate(day) {
this.$emit('date-selected', day.date)
}
}
}
</script>
<style lang="scss">
.harmony-calendar {
background-color: #ffffff;
border-radius: 24rpx;
padding: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
.calendar-header {
margin-bottom: 32rpx;
.month-switcher {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
.current-month {
font-size: 32rpx;
font-weight: 500;
color: #333333;
}
.iconfont {
font-size: 40rpx;
color: #666666;
padding: 16rpx;
&:active {
opacity: 0.7;
}
}
}
.weekday-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
text-align: center;
text {
font-size: 28rpx;
color: #999999;
padding: 16rpx 0;
}
}
}
.calendar-body {
.days-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8rpx;
.day-cell {
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
border-radius: 16rpx;
transition: all 0.2s ease-in-out;
&.current-month {
background-color: #f8f8f8;
.day-number {
color: #333333;
}
}
&.selected {
background-color: #2979ff;
.day-number {
color: #ffffff;
}
.event-indicator {
background-color: #ffffff;
}
}
.day-number {
font-size: 28rpx;
color: #999999;
}
.event-indicator {
width: 8rpx;
height: 8rpx;
border-radius: 4rpx;
background-color: #2979ff;
position: absolute;
bottom: 12rpx;
}
&:active {
transform: scale(0.95);
}
}
}
}
&.with-animation {
.days-grid {
animation: fadeInScale 0.3s ease-out;
}
}
}
@keyframes fadeInScale {
from {
opacity: 0.5;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>
2. 活动列表组件
接下来实现活动列表组件,用于展示选中日期的活动:
<!-- components/schedule-list/schedule-list.vue -->
<template>
<view class="schedule-list">
<view class="list-header">
<text class="title">活动安排</text>
<text class="add-btn" @click="$emit('add')">添加</text>
</view>
<view class="list-content">
<template v-if="filteredEvents.length">
<view v-for="event in filteredEvents"
:key="event.id"
class="schedule-item"
:class="{ 'completed': event.completed }"
@click="toggleEvent(event)">
<view class="event-time">{{ formatTime(event.time) }}</view>
<view class="event-info">
<text class="event-title">{{ event.title }}</text>
<text class="event-location" v-if="event.location">
{{ event.location }}
</text>
</view>
<text class="iconfont icon-check"></text>
</view>
</template>
<view v-else class="empty-tip">
<image src="/static/empty.png" mode="aspectFit"></image>
<text>暂无活动安排</text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'ScheduleList',
props: {
selectedDate: {
type: Date,
required: true
},
events: {
type: Array,
default: () => []
}
},
computed: {
filteredEvents() {
return this.events
.filter(event =>
new Date(event.date).toDateString() === this.selectedDate.toDateString()
)
.sort((a, b) => new Date(`${a.date} ${a.time}`) - new Date(`${b.date} ${b.time}`))
}
},
methods: {
formatTime(time) {
return time.slice(0, 5) // 只显示小时和分钟
},
toggleEvent(event) {
this.$emit('toggle', event)
}
}
}
</script>
<style lang="scss">
.schedule-list {
margin-top: 32rpx;
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
.title {
font-size: 32rpx;
font-weight: 500;
color: #333333;
}
.add-btn {
font-size: 28rpx;
color: #2979ff;
padding: 16rpx;
&:active {
opacity: 0.7;
}
}
}
.list-content {
.schedule-item {
display: flex;
align-items: center;
padding: 24rpx;
background-color: #ffffff;
border-radius: 16rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
transition: all 0.2s ease-in-out;
&.completed {
opacity: 0.6;
.event-info {
text-decoration: line-through;
}
.icon-check {
color: #52c41a;
}
}
.event-time {
font-size: 28rpx;
color: #666666;
margin-right: 24rpx;
min-width: 100rpx;
}
.event-info {
flex: 1;
.event-title {
font-size: 28rpx;
color: #333333;
margin-bottom: 8rpx;
}
.event-location {
font-size: 24rpx;
color: #999999;
}
}
.icon-check {
font-size: 40rpx;
color: #dddddd;
}
&:active {
transform: scale(0.98);
}
}
.empty-tip {
text-align: center;
padding: 64rpx 0;
image {
width: 200rpx;
height: 200rpx;
margin-bottom: 16rpx;
}
text {
font-size: 28rpx;
color: #999999;
}
}
}
}
</style>
3. 使用示例
下面展示如何在页面中使用这两个组件:
<!-- pages/schedule/schedule.vue -->
<template>
<view class="schedule-page">
<harmony-calendar
:events="events"
:selected-date="selectedDate"
@date-selected="handleDateSelect">
</harmony-calendar>
<schedule-list
:events="events"
:selected-date="selectedDate"
@toggle="toggleEventStatus"
@add="showAddEventPopup">
</schedule-list>
<!-- 添加活动弹窗 -->
<uni-popup ref="addEventPopup" type="bottom">
<view class="add-event-form">
<input v-model="newEvent.title"
placeholder="活动标题"
class="input-field" />
<input v-model="newEvent.location"
placeholder="活动地点"
class="input-field" />
<picker mode="time"
:value="newEvent.time"
@change="handleTimeChange"
class="input-field">
<view>{{ newEvent.time || '选择时间' }}</view>
</picker>
<button @click="saveEvent"
class="save-btn"
:disabled="!newEvent.title || !newEvent.time">
保存
</button>
</view>
</uni-popup>
</view>
</template>
<script>
import HarmonyCalendar from '@/components/harmony-calendar/harmony-calendar'
import ScheduleList from '@/components/schedule-list/schedule-list'
export default {
components: {
HarmonyCalendar,
ScheduleList
},
data() {
return {
selectedDate: new Date(),
events: [],
newEvent: {
title: '',
location: '',
time: '',
completed: false
}
}
},
methods: {
handleDateSelect(date) {
this.selectedDate = date
},
showAddEventPopup() {
this.$refs.addEventPopup.open()
},
handleTimeChange(e) {
this.newEvent.time = e.detail.value
},
saveEvent() {
const event = {
...this.newEvent,
id: Date.now(),
date: this.selectedDate
}
this.events.push(event)
this.$refs.addEventPopup.close()
// 重置表单
this.newEvent = {
title: '',
location: '',
time: '',
completed: false
}
// 提示保存成功
uni.showToast({
title: '添加成功',
icon: 'success'
})
},
toggleEventStatus(event) {
const index = this.events.findIndex(e => e.id === event.id)
if (index > -1) {
this.events[index].completed = !this.events[index].completed
}
}
}
}
</script>
<style lang="scss">
.schedule-page {
padding: 32rpx;
min-height: 100vh;
background-color: #f5f5f5;
}
.add-event-form {
background-color: #ffffff;
padding: 32rpx;
border-radius: 24rpx 24rpx 0 0;
.input-field {
width: 100%;
height: 88rpx;
border-bottom: 2rpx solid #f0f0f0;
margin-bottom: 24rpx;
padding: 0 24rpx;
font-size: 28rpx;
}
.save-btn {
margin-top: 32rpx;
background-color: #2979ff;
color: #ffffff;
border-radius: 44rpx;
height: 88rpx;
line-height: 88rpx;
font-size: 32rpx;
&:active {
opacity: 0.9;
}
&[disabled] {
background-color: #cccccc;
}
}
}
</style>
性能优化
- 虚拟列表优化
当活动数量较多时,可以使用虚拟列表优化性能:
// 在 schedule-list 组件中添加
import VirtualList from '@/components/virtual-list'
export default {
components: {
VirtualList
}
// ...其他配置
}
- 状态管理
对于大型应用,建议使用 Vuex 管理日程数据:
// store/modules/schedule.js
export default {
state: {
events: []
},
mutations: {
ADD_EVENT(state, event) {
state.events.push(event)
},
TOGGLE_EVENT(state, eventId) {
const event = state.events.find(e => e.id === eventId)
if (event) {
event.completed = !event.completed
}
}
},
actions: {
async fetchEvents({ commit }) {
// 从服务器获取数据
const events = await api.getEvents()
commit('SET_EVENTS', events)
}
}
}
- 缓存优化
使用本地存储缓存日程数据:
// utils/storage.js
export const saveEvents = (events) => {
try {
uni.setStorageSync('schedule_events', JSON.stringify(events))
} catch (e) {
console.error('保存失败:', e)
}
}
export const getEvents = () => {
try {
const events = uni.getStorageSync('schedule_events')
return events ? JSON.parse(events) : []
} catch (e) {
console.error('读取失败:', e)
return []
}
}
适配建议
- 暗黑模式支持
// 在组件样式中添加
.harmony-calendar {
&.dark {
background-color: #1c1c1e;
.calendar-header {
.current-month {
color: #ffffff;
}
}
.day-cell {
&.current-month {
background-color: #2c2c2e;
.day-number {
color: #ffffff;
}
}
}
}
}
- 响应式布局
使用 flex 布局和 rpx 单位确保在不同设备上的适配:
.schedule-page {
display: flex;
flex-direction: column;
min-height: 100vh;
@media screen and (min-width: 768px) {
flex-direction: row;
.harmony-calendar {
flex: 0 0 400rpx;
}
.schedule-list {
flex: 1;
margin-left: 32rpx;
}
}
}
总结
通过本文的讲解,我们实现了一个功能完整的日历活动安排组件。该组件不仅具有优雅的界面设计,还融入了鸿蒙系统的设计理念,同时考虑了性能优化和多端适配等实际开发中的重要因素。
在实际开发中,我们可以根据具体需求对组件进行扩展,例如:
- 添加重复活动功能
- 实现活动提醒
- 支持多人协作
- 添加数据同步功能
希望这篇文章能够帮助你更好地理解如何在 UniApp 中开发高质量的组件,同时也能为你的实际项目开发提供有价值的参考。