家校沟通互动平台设计与实现

深入解析家校沟通平台的核心功能设计,涵盖通知公告、消息互动、成长档案、家长会管理等完整方案

家校沟通互动平台设计与实现

家校沟通是教育信息化的重要组成部分。一个优秀的家校互动平台能够促进教师与家长之间的有效沟通,共同关注学生成长。本文将系统讲解家校沟通平台的设计与实现方案。

平台功能架构

家校沟通平台的核心功能模块:

功能模块主要功能使用者
通知公告学校/班级通知、活动公告教师发布,家长查看
即时消息一对一沟通、群组交流教师、家长双向
成长档案学生表现记录、成长轨迹教师记录,家长查看
作业反馈作业完成情况、学习建议教师发布,家长确认
家长会在线预约、会议记录教师组织,家长参与
请假管理学生请假申请与审批家长申请,教师审批
费用缴纳学费、活动费用管理学校发布,家长缴纳

数据模型设计

核心实体定义

// 家长账户
interface ParentAccount {
  id: string
  userId: string              // 关联用户系统
  phone: string               // 手机号
  name: string                // 家长姓名
  relation: ParentRelation    // 与学生关系
  children: StudentBinding[]  // 绑定的学生
  notifySettings: NotifySettings  // 通知设置
  createdAt: Date
}

// 家长与学生的关系
type ParentRelation = 'father' | 'mother' | 'grandfather' | 'grandmother' | 'guardian' | 'other'

// 学生绑定关系
interface StudentBinding {
  studentId: string
  studentName: string
  classId: string
  className: string
  relation: ParentRelation
  bindTime: Date
  isPrimary: boolean          // 是否为主要联系人
}

// 通知设置
interface NotifySettings {
  pushEnabled: boolean        // 是否开启推送
  smsEnabled: boolean         // 是否开启短信
  emailEnabled: boolean       // 是否开启邮件
  quietHours: {               // 免打扰时段
    enabled: boolean
    start: string             // 如 "22:00"
    end: string               // 如 "07:00"
  }
  categories: {               // 分类设置
    announcement: boolean     // 通知公告
    homework: boolean         // 作业相关
    attendance: boolean       // 考勤相关
    grades: boolean           // 成绩相关
    activities: boolean       // 活动相关
  }
}

消息与通知模型

// 通知公告
interface Announcement {
  id: string
  title: string
  content: string             // 富文本内容
  type: AnnouncementType
  scope: {
    type: 'school' | 'grade' | 'class' | 'student'
    targetIds: string[]
  }
  attachments: Attachment[]
  requireConfirm: boolean     // 是否需要确认已读
  confirmDeadline?: Date      // 确认截止时间
  publisherId: string
  publisherName: string
  publisherRole: 'admin' | 'teacher'
  publishTime: Date
  status: 'draft' | 'published' | 'archived'
  readStats: {
    total: number
    read: number
    confirmed: number
  }
}

type AnnouncementType = 'notice' | 'activity' | 'emergency' | 'policy' | 'reminder'

// 私信消息
interface PrivateMessage {
  id: string
  conversationId: string
  senderId: string
  senderName: string
  senderRole: 'teacher' | 'parent'
  receiverId: string
  receiverName: string
  content: string
  messageType: 'text' | 'image' | 'voice' | 'file'
  attachments?: Attachment[]
  readAt?: Date
  createdAt: Date
}

// 会话
interface Conversation {
  id: string
  participants: {
    id: string
    name: string
    role: 'teacher' | 'parent'
    avatar?: string
  }[]
  studentId: string           // 关联学生
  studentName: string
  lastMessage: {
    content: string
    senderId: string
    createdAt: Date
  }
  unreadCount: Record<string, number>  // 各参与者未读数
  createdAt: Date
  updatedAt: Date
}

通知公告系统

通知发布组件

<template>
  <div class="announcement-editor">
    <Form ref="formRef" :model="formData" :rules="formRules">
      <!-- 基础信息 -->
      <FormItem label="通知类型" prop="type">
        <RadioGroup v-model="formData.type">
          <Radio value="notice">普通通知</Radio>
          <Radio value="activity">活动公告</Radio>
          <Radio value="emergency">紧急通知</Radio>
          <Radio value="reminder">温馨提示</Radio>
        </RadioGroup>
      </FormItem>
      
      <FormItem label="通知标题" prop="title">
        <Input v-model="formData.title" placeholder="请输入通知标题" maxlength="50" show-count />
      </FormItem>
      
      <!-- 发布范围 -->
      <FormItem label="发布范围" prop="scope">
        <div class="scope-selector">
          <Select v-model="formData.scope.type" @change="handleScopeTypeChange">
            <Option value="school">全校</Option>
            <Option value="grade">年级</Option>
            <Option value="class">班级</Option>
            <Option value="student">指定学生</Option>
          </Select>
          
          <!-- 根据范围类型显示不同选择器 -->
          <Select 
            v-if="formData.scope.type === 'grade'"
            v-model="formData.scope.targetIds"
            multiple
            placeholder="选择年级"
          >
            <Option v-for="grade in grades" :key="grade.id" :value="grade.id">
              {{ grade.name }}
            </Option>
          </Select>
          
          <Cascader
            v-if="formData.scope.type === 'class'"
            v-model="formData.scope.targetIds"
            :options="classOptions"
            multiple
            placeholder="选择班级"
          />
          
          <StudentSelector
            v-if="formData.scope.type === 'student'"
            v-model="formData.scope.targetIds"
          />
        </div>
      </FormItem>
      
      <!-- 通知内容 -->
      <FormItem label="通知内容" prop="content">
        <RichEditor 
          v-model="formData.content" 
          :max-length="5000"
          placeholder="请输入通知内容..."
        />
      </FormItem>
      
      <!-- 附件上传 -->
      <FormItem label="附件">
        <FileUploader
          v-model="formData.attachments"
          :max-count="5"
          :max-size="10"
          accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.png"
        />
      </FormItem>
      
      <!-- 确认设置 -->
      <FormItem label="阅读确认">
        <Checkbox v-model="formData.requireConfirm">
          需要家长确认已读
        </Checkbox>
        <DatePicker
          v-if="formData.requireConfirm"
          v-model="formData.confirmDeadline"
          type="datetime"
          placeholder="确认截止时间(可选)"
        />
      </FormItem>
      
      <!-- 操作按钮 -->
      <div class="form-actions">
        <Button @click="handleSaveDraft">保存草稿</Button>
        <Button @click="handlePreview">预览</Button>
        <Button type="primary" @click="handlePublish">立即发布</Button>
      </div>
    </Form>
  </div>
</template>

<script setup lang="ts">
const formData = reactive<AnnouncementForm>({
  type: 'notice',
  title: '',
  content: '',
  scope: {
    type: 'class',
    targetIds: []
  },
  attachments: [],
  requireConfirm: false,
  confirmDeadline: null
})

async function handlePublish() {
  try {
    await formRef.value.validate()
    
    // 计算接收人数
    const recipientCount = await calculateRecipients(formData.scope)
    
    // 确认发布
    const confirmed = await showConfirm({
      title: '确认发布',
      content: `该通知将发送给 ${recipientCount} 位家长,确认发布吗?`
    })
    
    if (!confirmed) return
    
    // 发布通知
    await publishAnnouncement(formData)
    message.success('通知发布成功')
    
    // 跳转到通知列表
    router.push('/announcements')
  } catch (error) {
    message.error('发布失败:' + error.message)
  }
}
</script>

通知阅读确认

// 通知确认服务
class AnnouncementConfirmService {
  // 获取确认状态统计
  async getConfirmStats(announcementId: string): Promise<ConfirmStats> {
    const announcement = await this.getAnnouncement(announcementId)
    const recipients = await this.getRecipients(announcement.scope)
    
    const confirmRecords = await this.getConfirmRecords(announcementId)
    
    return {
      total: recipients.length,
      read: confirmRecords.filter(r => r.readAt).length,
      confirmed: confirmRecords.filter(r => r.confirmedAt).length,
      unconfirmed: recipients.filter(r => 
        !confirmRecords.find(c => c.parentId === r.parentId && c.confirmedAt)
      )
    }
  }
  
  // 发送催促确认通知
  async sendReminder(announcementId: string, parentIds?: string[]) {
    const stats = await this.getConfirmStats(announcementId)
    const targets = parentIds || stats.unconfirmed.map(p => p.parentId)
    
    // 批量发送提醒
    await this.notifyService.batchSend({
      type: 'reminder',
      title: '请确认通知',
      content: '您有一条未确认的通知,请及时查看并确认',
      link: `/announcements/${announcementId}`,
      targets
    })
    
    return { sent: targets.length }
  }
  
  // 家长确认通知
  async confirmAnnouncement(announcementId: string, parentId: string) {
    return await this.confirmRecordRepo.upsert({
      announcementId,
      parentId,
      confirmedAt: new Date()
    })
  }
}

即时消息系统

消息服务实现

// 消息服务
class MessageService {
  private wsConnections: Map<string, WebSocket> = new Map()
  
  // 发送私信
  async sendMessage(data: SendMessageInput): Promise<PrivateMessage> {
    const { senderId, receiverId, content, messageType, attachments } = data
    
    // 获取或创建会话
    const conversation = await this.getOrCreateConversation(senderId, receiverId)
    
    // 创建消息记录
    const message = await this.messageRepo.create({
      conversationId: conversation.id,
      senderId,
      senderName: await this.getUserName(senderId),
      senderRole: await this.getUserRole(senderId),
      receiverId,
      receiverName: await this.getUserName(receiverId),
      content,
      messageType,
      attachments,
      createdAt: new Date()
    })
    
    // 更新会话最后消息
    await this.conversationRepo.update(conversation.id, {
      lastMessage: {
        content: this.getMessagePreview(content, messageType),
        senderId,
        createdAt: message.createdAt
      },
      [`unreadCount.${receiverId}`]: (conversation.unreadCount[receiverId] || 0) + 1,
      updatedAt: new Date()
    })
    
    // 实时推送给接收者
    await this.pushToUser(receiverId, {
      type: 'new_message',
      data: message
    })
    
    // 发送离线推送通知
    await this.sendPushNotification(receiverId, {
      title: message.senderName,
      body: this.getMessagePreview(content, messageType)
    })
    
    return message
  }
  
  // 获取消息列表
  async getMessages(
    conversationId: string, 
    pagination: PaginationParams
  ): Promise<PaginatedResult<PrivateMessage>> {
    return await this.messageRepo.findByConversation(conversationId, {
      ...pagination,
      orderBy: { createdAt: 'desc' }
    })
  }
  
  // 标记消息已读
  async markAsRead(conversationId: string, userId: string) {
    // 更新消息已读状态
    await this.messageRepo.updateMany(
      { conversationId, receiverId: userId, readAt: null },
      { readAt: new Date() }
    )
    
    // 清空未读计数
    await this.conversationRepo.update(conversationId, {
      [`unreadCount.${userId}`]: 0
    })
    
    // 通知发送方消息已读
    const conversation = await this.conversationRepo.findById(conversationId)
    const otherParticipant = conversation.participants.find(p => p.id !== userId)
    
    if (otherParticipant) {
      await this.pushToUser(otherParticipant.id, {
        type: 'messages_read',
        data: { conversationId, readerId: userId }
      })
    }
  }
  
  // WebSocket 连接管理
  handleConnection(userId: string, ws: WebSocket) {
    this.wsConnections.set(userId, ws)
    
    ws.on('close', () => {
      this.wsConnections.delete(userId)
    })
    
    ws.on('message', (data) => {
      this.handleWebSocketMessage(userId, JSON.parse(data.toString()))
    })
  }
  
  private async pushToUser(userId: string, payload: any) {
    const ws = this.wsConnections.get(userId)
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify(payload))
    }
  }
}

消息界面组件

<template>
  <div class="message-chat">
    <!-- 消息列表 -->
    <div ref="messageListRef" class="message-list" @scroll="handleScroll">
      <div v-if="loading" class="loading-more">
        <Spin size="small" />
      </div>
      
      <div 
        v-for="(message, index) in messages" 
        :key="message.id"
        class="message-item"
        :class="{ 'is-self': message.senderId === currentUserId }"
      >
        <!-- 时间分割线 -->
        <div 
          v-if="shouldShowTime(message, messages[index - 1])" 
          class="time-divider"
        >
          {{ formatMessageTime(message.createdAt) }}
        </div>
        
        <!-- 消息内容 -->
        <div class="message-bubble">
          <Avatar 
            v-if="message.senderId !== currentUserId"
            :src="getParticipantAvatar(message.senderId)"
            size="small"
          />
          
          <div class="bubble-content">
            <!-- 文本消息 -->
            <div v-if="message.messageType === 'text'" class="text-content">
              {{ message.content }}
            </div>
            
            <!-- 图片消息 -->
            <div v-else-if="message.messageType === 'image'" class="image-content">
              <img 
                :src="message.attachments[0].url" 
                @click="previewImage(message.attachments[0])"
              />
            </div>
            
            <!-- 语音消息 -->
            <div v-else-if="message.messageType === 'voice'" class="voice-content">
              <VoicePlayer :src="message.attachments[0].url" />
            </div>
            
            <!-- 文件消息 -->
            <div v-else-if="message.messageType === 'file'" class="file-content">
              <FileCard :file="message.attachments[0]" />
            </div>
            
            <span class="message-status">
              <Icon v-if="message.readAt" name="check-double" class="read" />
              <Icon v-else name="check" />
              {{ formatTime(message.createdAt) }}
            </span>
          </div>
        </div>
      </div>
    </div>
    
    <!-- 输入区域 -->
    <div class="message-input">
      <div class="input-toolbar">
        <Button text @click="showEmojiPicker = true">
          <Icon name="smile" />
        </Button>
        <Button text @click="handleImageUpload">
          <Icon name="image" />
        </Button>
        <Button text @click="handleFileUpload">
          <Icon name="paperclip" />
        </Button>
      </div>
      
      <Textarea
        v-model="inputContent"
        :rows="3"
        placeholder="输入消息..."
        @keydown.enter.ctrl="handleSend"
      />
      
      <Button 
        type="primary" 
        :disabled="!inputContent.trim()" 
        @click="handleSend"
      >
        发送
      </Button>
    </div>
  </div>
</template>

<script setup lang="ts">
const props = defineProps<{
  conversationId: string
}>()

const messages = ref<PrivateMessage[]>([])
const inputContent = ref('')
const loading = ref(false)

// 加载消息
async function loadMessages(before?: Date) {
  loading.value = true
  try {
    const result = await messageService.getMessages(props.conversationId, {
      before,
      limit: 20
    })
    
    if (before) {
      messages.value = [...result.data, ...messages.value]
    } else {
      messages.value = result.data.reverse()
      scrollToBottom()
    }
  } finally {
    loading.value = false
  }
}

// 发送消息
async function handleSend() {
  if (!inputContent.value.trim()) return
  
  const content = inputContent.value
  inputContent.value = ''
  
  // 乐观更新:先添加到列表
  const tempMessage: PrivateMessage = {
    id: `temp-${Date.now()}`,
    conversationId: props.conversationId,
    senderId: currentUserId.value,
    senderName: currentUserName.value,
    senderRole: 'parent',
    receiverId: otherParticipant.value.id,
    receiverName: otherParticipant.value.name,
    content,
    messageType: 'text',
    createdAt: new Date()
  }
  
  messages.value.push(tempMessage)
  scrollToBottom()
  
  try {
    const realMessage = await messageService.sendMessage({
      receiverId: otherParticipant.value.id,
      content,
      messageType: 'text'
    })
    
    // 替换临时消息
    const index = messages.value.findIndex(m => m.id === tempMessage.id)
    if (index > -1) {
      messages.value[index] = realMessage
    }
  } catch (error) {
    // 发送失败,标记消息
    const index = messages.value.findIndex(m => m.id === tempMessage.id)
    if (index > -1) {
      messages.value[index] = { ...tempMessage, failed: true }
    }
  }
}

// WebSocket 接收消息
onMounted(() => {
  loadMessages()
  
  wsService.on('new_message', (message: PrivateMessage) => {
    if (message.conversationId === props.conversationId) {
      messages.value.push(message)
      scrollToBottom()
      
      // 标记为已读
      messageService.markAsRead(props.conversationId, currentUserId.value)
    }
  })
})
</script>

成长档案系统

成长记录模型

// 成长记录
interface GrowthRecord {
  id: string
  studentId: string
  studentName: string
  recordType: GrowthRecordType
  title: string
  content: string
  attachments: Attachment[]    // 照片、视频、文件
  tags: string[]               // 标签:如"运动"、"阅读"、"艺术"
  visibility: 'public' | 'private'  // 是否对家长可见
  createdBy: {
    id: string
    name: string
    role: 'teacher' | 'admin'
  }
  likes: number
  comments: GrowthComment[]
  createdAt: Date
}

type GrowthRecordType = 
  | 'daily'           // 日常表现
  | 'achievement'     // 获得荣誉
  | 'activity'        // 活动参与
  | 'milestone'       // 成长里程碑
  | 'work'            // 作品展示
  | 'evaluation'      // 综合评价

// 成长评论
interface GrowthComment {
  id: string
  content: string
  authorId: string
  authorName: string
  authorRole: 'teacher' | 'parent'
  createdAt: Date
}

// 成长档案统计
interface GrowthSummary {
  studentId: string
  period: {
    start: Date
    end: Date
  }
  recordCount: number
  achievementCount: number
  activityCount: number
  topTags: { tag: string; count: number }[]
  monthlyTrend: { month: string; count: number }[]
}

成长档案展示

<template>
  <div class="growth-portfolio">
    <!-- 学生信息头部 -->
    <div class="portfolio-header">
      <Avatar :src="student.avatar" size="large" />
      <div class="student-info">
        <h2>{{ student.name }}</h2>
        <p>{{ student.className }} | {{ student.studentNo }}</p>
      </div>
      <div class="quick-stats">
        <StatCard label="成长记录" :value="summary.recordCount" icon="book" />
        <StatCard label="获得荣誉" :value="summary.achievementCount" icon="trophy" />
        <StatCard label="活动参与" :value="summary.activityCount" icon="calendar" />
      </div>
    </div>
    
    <!-- 成长时间线 -->
    <div class="growth-timeline">
      <div class="timeline-filter">
        <Select v-model="selectedType" placeholder="记录类型">
          <Option value="">全部</Option>
          <Option value="daily">日常表现</Option>
          <Option value="achievement">获得荣誉</Option>
          <Option value="activity">活动参与</Option>
          <Option value="work">作品展示</Option>
        </Select>
        <RangePicker v-model="dateRange" />
      </div>
      
      <Timeline>
        <TimelineItem 
          v-for="record in filteredRecords" 
          :key="record.id"
          :color="getTypeColor(record.recordType)"
        >
          <template #dot>
            <Icon :name="getTypeIcon(record.recordType)" />
          </template>
          
          <GrowthRecordCard 
            :record="record"
            :can-edit="canEdit"
            @like="handleLike"
            @comment="handleComment"
          />
        </TimelineItem>
      </Timeline>
      
      <div v-if="hasMore" class="load-more">
        <Button @click="loadMore">加载更多</Button>
      </div>
    </div>
    
    <!-- 成长统计图表 -->
    <div class="growth-analytics">
      <Card title="成长趋势">
        <LineChart :data="summary.monthlyTrend" />
      </Card>
      
      <Card title="成长标签">
        <TagCloud :tags="summary.topTags" />
      </Card>
    </div>
  </div>
</template>

请假管理系统

请假申请流程

// 请假申请
interface LeaveRequest {
  id: string
  studentId: string
  studentName: string
  classId: string
  className: string
  applicantId: string         // 申请人(家长)ID
  applicantName: string
  applicantRelation: ParentRelation
  leaveType: LeaveType
  startTime: Date
  endTime: Date
  duration: number            // 请假时长(小时或天)
  reason: string
  attachments: Attachment[]   // 证明材料
  status: LeaveStatus
  workflow: LeaveWorkflowStep[]
  createdAt: Date
  updatedAt: Date
}

type LeaveType = 'sick' | 'personal' | 'family' | 'other'
type LeaveStatus = 'pending' | 'approved' | 'rejected' | 'cancelled' | 'revoked'

interface LeaveWorkflowStep {
  step: number
  approverRole: 'teacher' | 'director' | 'principal'
  approverId?: string
  approverName?: string
  action?: 'approve' | 'reject'
  comment?: string
  actionTime?: Date
}

// 请假服务
class LeaveService {
  // 提交请假申请
  async submitLeaveRequest(data: SubmitLeaveInput): Promise<LeaveRequest> {
    // 计算请假时长
    const duration = this.calculateDuration(data.startTime, data.endTime)
    
    // 确定审批流程
    const workflow = this.determineWorkflow(duration, data.leaveType)
    
    // 创建请假记录
    const request = await this.leaveRepo.create({
      ...data,
      duration,
      status: 'pending',
      workflow,
      createdAt: new Date()
    })
    
    // 通知第一级审批人
    await this.notifyApprover(request, workflow[0])
    
    return request
  }
  
  // 确定审批流程
  private determineWorkflow(duration: number, type: LeaveType): LeaveWorkflowStep[] {
    // 病假3天以上需要年级主任审批
    // 7天以上需要校长审批
    const steps: LeaveWorkflowStep[] = [
      { step: 1, approverRole: 'teacher' }
    ]
    
    if (duration >= 3 || type === 'other') {
      steps.push({ step: 2, approverRole: 'director' })
    }
    
    if (duration >= 7) {
      steps.push({ step: 3, approverRole: 'principal' })
    }
    
    return steps
  }
  
  // 审批请假
  async approveLeave(
    requestId: string, 
    approverId: string, 
    action: 'approve' | 'reject',
    comment?: string
  ): Promise<LeaveRequest> {
    const request = await this.leaveRepo.findById(requestId)
    
    // 找到当前待审批步骤
    const currentStep = request.workflow.find(s => !s.action)
    if (!currentStep) {
      throw new Error('没有待审批的步骤')
    }
    
    // 验证审批权限
    await this.validateApprover(approverId, currentStep.approverRole, request)
    
    // 更新审批步骤
    currentStep.approverId = approverId
    currentStep.approverName = await this.getUserName(approverId)
    currentStep.action = action
    currentStep.comment = comment
    currentStep.actionTime = new Date()
    
    // 判断流程状态
    if (action === 'reject') {
      request.status = 'rejected'
    } else {
      const nextStep = request.workflow.find(s => !s.action)
      if (nextStep) {
        // 还有下一步审批
        await this.notifyApprover(request, nextStep)
      } else {
        // 审批完成
        request.status = 'approved'
      }
    }
    
    request.updatedAt = new Date()
    await this.leaveRepo.update(request)
    
    // 通知家长审批结果
    await this.notifyParent(request)
    
    return request
  }
}

家长会管理

家长会预约系统

<template>
  <div class="parent-meeting">
    <!-- 教师端:创建家长会 -->
    <Card v-if="isTeacher" title="创建家长会">
      <Form :model="meetingForm" :rules="meetingRules">
        <FormItem label="会议主题" prop="title">
          <Input v-model="meetingForm.title" />
        </FormItem>
        
        <FormItem label="会议时间" prop="timeRange">
          <RangePicker 
            v-model="meetingForm.timeRange" 
            type="datetime"
            :disabled-date="disablePastDates"
          />
        </FormItem>
        
        <FormItem label="会议形式" prop="format">
          <RadioGroup v-model="meetingForm.format">
            <Radio value="offline">线下会议</Radio>
            <Radio value="online">线上会议</Radio>
            <Radio value="hybrid">混合模式</Radio>
          </RadioGroup>
        </FormItem>
        
        <FormItem v-if="meetingForm.format !== 'online'" label="会议地点" prop="location">
          <Input v-model="meetingForm.location" />
        </FormItem>
        
        <FormItem label="预约设置">
          <Checkbox v-model="meetingForm.requireBooking">
            需要家长预约时间段
          </Checkbox>
        </FormItem>
        
        <FormItem v-if="meetingForm.requireBooking" label="时间段设置">
          <TimeSlotEditor v-model="meetingForm.timeSlots" />
        </FormItem>
        
        <Button type="primary" @click="createMeeting">创建家长会</Button>
      </Form>
    </Card>
    
    <!-- 家长端:预约时间 -->
    <Card v-if="isParent && activeMeeting" title="预约家长会">
      <div class="meeting-info">
        <h3>{{ activeMeeting.title }}</h3>
        <p>
          <Icon name="calendar" />
          {{ formatDateRange(activeMeeting.startTime, activeMeeting.endTime) }}
        </p>
        <p>
          <Icon name="location" />
          {{ activeMeeting.format === 'online' ? '线上会议' : activeMeeting.location }}
        </p>
      </div>
      
      <div class="time-slot-picker">
        <h4>选择时间段</h4>
        <div class="slots-grid">
          <div
            v-for="slot in availableSlots"
            :key="slot.id"
            class="slot-item"
            :class="{
              'is-selected': selectedSlot === slot.id,
              'is-booked': slot.booked,
              'is-mine': slot.bookedBy === currentParentId
            }"
            @click="selectSlot(slot)"
          >
            <span class="slot-time">{{ slot.startTime }} - {{ slot.endTime }}</span>
            <span v-if="slot.booked && slot.bookedBy !== currentParentId" class="slot-status">
              已预约
            </span>
            <span v-if="slot.bookedBy === currentParentId" class="slot-status mine">
              我的预约
            </span>
          </div>
        </div>
      </div>
      
      <div class="booking-actions">
        <Button 
          v-if="myBooking"
          @click="cancelBooking"
        >
          取消预约
        </Button>
        <Button 
          v-else
          type="primary"
          :disabled="!selectedSlot"
          @click="confirmBooking"
        >
          确认预约
        </Button>
      </div>
    </Card>
  </div>
</template>

推送通知系统

多渠道通知服务

// 通知服务
class NotificationService {
  private channels: NotificationChannel[] = []
  
  constructor() {
    this.channels = [
      new AppPushChannel(),
      new SMSChannel(),
      new WeChatChannel(),
      new EmailChannel()
    ]
  }
  
  async send(notification: NotificationPayload) {
    const { recipients, ...payload } = notification
    
    // 获取每个接收者的通知偏好
    for (const recipientId of recipients) {
      const settings = await this.getNotifySettings(recipientId)
      const contact = await this.getContactInfo(recipientId)
      
      // 检查免打扰时段
      if (this.isQuietHour(settings)) {
        await this.scheduleForLater(recipientId, payload)
        continue
      }
      
      // 按渠道发送
      const promises = []
      
      if (settings.pushEnabled) {
        promises.push(this.sendViaChannel('push', contact.pushToken, payload))
      }
      
      if (settings.smsEnabled && this.isHighPriority(payload)) {
        promises.push(this.sendViaChannel('sms', contact.phone, payload))
      }
      
      if (contact.wechatOpenId) {
        promises.push(this.sendViaChannel('wechat', contact.wechatOpenId, payload))
      }
      
      await Promise.allSettled(promises)
    }
  }
  
  private async sendViaChannel(
    channelType: string, 
    target: string, 
    payload: NotificationPayload
  ) {
    const channel = this.channels.find(c => c.type === channelType)
    if (!channel) return
    
    try {
      await channel.send(target, payload)
      await this.logNotification(channelType, target, payload, 'success')
    } catch (error) {
      await this.logNotification(channelType, target, payload, 'failed', error)
    }
  }
}

// 微信模板消息通道
class WeChatChannel implements NotificationChannel {
  type = 'wechat'
  
  async send(openId: string, payload: NotificationPayload) {
    const templateId = this.getTemplateId(payload.type)
    
    await this.wechatApi.sendTemplateMessage({
      touser: openId,
      template_id: templateId,
      miniprogram: {
        appid: process.env.MINIPROGRAM_APPID,
        pagepath: payload.link
      },
      data: this.formatTemplateData(payload)
    })
  }
  
  private formatTemplateData(payload: NotificationPayload) {
    return {
      first: { value: payload.title },
      keyword1: { value: payload.body },
      keyword2: { value: formatDate(new Date()) },
      remark: { value: '点击查看详情' }
    }
  }
}

总结

家校沟通平台的设计要点:

  1. 多样化沟通方式:支持公告、私信、评论等多种沟通形式
  2. 精准的消息触达:多渠道推送确保消息送达
  3. 完善的成长记录:记录学生成长点滴,促进家校共育
  4. 便捷的事务处理:请假、缴费等流程线上化
  5. 实时的互动反馈:WebSocket 实现即时通讯

通过系统化的设计,可以构建出高效、便捷的家校沟通平台,促进教育协作。