1528 lines
38 KiB
Plaintext
1528 lines
38 KiB
Plaintext
<!-- 家属沟通 - 重构版本 -->
|
|
<template>
|
|
<view class="family-communication">
|
|
<!-- Header -->
|
|
<view class="header">
|
|
<text class="header-title">沟通交流</text>
|
|
<view class="header-actions">
|
|
<button class="action-btn" @click="showNewMessage">
|
|
<text class="btn-text">✏️ 发消息</text>
|
|
</button>
|
|
<button class="action-btn" @click="showVideoCall">
|
|
<text class="btn-text">📹 视频通话</text>
|
|
</button>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Communication Stats -->
|
|
<view class="stats-section">
|
|
<view class="stat-card">
|
|
<view class="stat-icon">💬</view>
|
|
<view class="stat-content">
|
|
<text class="stat-number">{{ stats.unread_messages }}</text>
|
|
<text class="stat-label">未读消息</text>
|
|
</view>
|
|
</view>
|
|
<view class="stat-card">
|
|
<view class="stat-icon">📞</view>
|
|
<view class="stat-content">
|
|
<text class="stat-number">{{ stats.call_requests }}</text>
|
|
<text class="stat-label">通话申请</text>
|
|
</view>
|
|
</view>
|
|
<view class="stat-card">
|
|
<view class="stat-icon">🗓️</view>
|
|
<view class="stat-content">
|
|
<text class="stat-number">{{ stats.visit_requests }}</text>
|
|
<text class="stat-label">探视预约</text>
|
|
</view>
|
|
</view>
|
|
<view class="stat-card">
|
|
<view class="stat-icon">📄</view>
|
|
<view class="stat-content">
|
|
<text class="stat-number">{{ stats.new_reports }}</text>
|
|
<text class="stat-label">新报告</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Tab Navigation -->
|
|
<view class="tab-navigation">
|
|
<button
|
|
class="tab-btn"
|
|
:class="{ active: activeTab === 'messages' }"
|
|
@click="setActiveTab('messages')"
|
|
>
|
|
<text class="tab-text">消息</text>
|
|
</button>
|
|
<button
|
|
class="tab-btn"
|
|
:class="{ active: activeTab === 'calls' }"
|
|
@click="setActiveTab('calls')"
|
|
>
|
|
<text class="tab-text">通话</text>
|
|
</button>
|
|
<button
|
|
class="tab-btn"
|
|
:class="{ active: activeTab === 'visits' }"
|
|
@click="setActiveTab('visits')"
|
|
>
|
|
<text class="tab-text">探视</text>
|
|
</button>
|
|
<button
|
|
class="tab-btn"
|
|
:class="{ active: activeTab === 'announcements' }"
|
|
@click="setActiveTab('announcements')"
|
|
>
|
|
<text class="tab-text">公告</text>
|
|
</button>
|
|
</view>
|
|
|
|
<!-- Messages Tab -->
|
|
<view v-if="activeTab === 'messages'" class="messages-section">
|
|
<scroll-view class="messages-list" scroll-y="true" :style="{ height: '500px' }">
|
|
<view
|
|
v-for="message in messages"
|
|
:key="message.id"
|
|
class="message-item"
|
|
:class="{ unread: !message.is_read }"
|
|
@click="readMessage(message)"
|
|
>
|
|
<view class="message-avatar">
|
|
<text class="avatar-text">{{ message.sender_name.charAt(0) }}</text>
|
|
</view>
|
|
|
|
<view class="message-content">
|
|
<view class="message-header">
|
|
<text class="sender-name">{{ message.sender_name }}</text>
|
|
<text class="sender-role">{{ getSenderRoleText(message.sender_role) }}</text>
|
|
<text class="message-time">{{ formatDateTime(message.created_at) }}</text>
|
|
</view>
|
|
|
|
<view class="message-preview">
|
|
<text class="message-text">{{ message.content }}</text>
|
|
</view>
|
|
|
|
<view class="message-meta" v-if="message.attachments && message.attachments.length > 0">
|
|
<text class="attachment-count">📎 {{ message.attachments.length }}个附件</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="message-actions">
|
|
<button class="action-btn small" @click.stop="replyMessage(message)">
|
|
<text class="btn-text">回复</text>
|
|
</button>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
</view>
|
|
|
|
<!-- Calls Tab -->
|
|
<view v-if="activeTab === 'calls'" class="calls-section">
|
|
<view class="quick-call-section">
|
|
<text class="section-title">快速通话</text>
|
|
<view class="contact-list">
|
|
<button
|
|
v-for="contact in quickContacts"
|
|
:key="contact.id"
|
|
class="contact-btn"
|
|
@click="initiateCall(contact)"
|
|
>
|
|
<view class="contact-avatar">
|
|
<text class="avatar-text">{{ contact.name.charAt(0) }}</text>
|
|
</view>
|
|
<view class="contact-info">
|
|
<text class="contact-name">{{ contact.name }}</text>
|
|
<text class="contact-role">{{ contact.role }}</text>
|
|
</view>
|
|
<view class="contact-status">
|
|
<text class="status-indicator" :class="contact.status"></text>
|
|
</view>
|
|
</button>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="call-history-section">
|
|
<text class="section-title">通话记录</text>
|
|
<scroll-view class="call-history-list" scroll-y="true" :style="{ height: '300px' }">
|
|
<view
|
|
v-for="call in callHistory"
|
|
:key="call.id"
|
|
class="call-item"
|
|
>
|
|
<view class="call-icon">
|
|
<text class="icon-text">{{ getCallIcon(call.type, call.status) }}</text>
|
|
</view>
|
|
|
|
<view class="call-info">
|
|
<text class="call-contact">{{ call.contact_name }}</text>
|
|
<text class="call-details">{{ getCallDetails(call) }}</text>
|
|
</view>
|
|
|
|
<view class="call-time">
|
|
<text class="time-text">{{ formatDateTime(call.created_at) }}</text>
|
|
</view>
|
|
|
|
<view class="call-actions">
|
|
<button class="action-btn small" @click="callBack(call)">
|
|
<text class="btn-text">回拨</text>
|
|
</button>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Visits Tab -->
|
|
<view v-if="activeTab === 'visits'" class="visits-section">
|
|
<view class="visit-request-section">
|
|
<button class="request-visit-btn" @click="showVisitRequest">
|
|
<text class="request-icon">📅</text>
|
|
<text class="request-text">申请探视</text>
|
|
</button>
|
|
</view>
|
|
|
|
<scroll-view class="visits-list" scroll-y="true" :style="{ height: '400px' }">
|
|
<view
|
|
v-for="visit in visits"
|
|
:key="visit.id"
|
|
class="visit-item"
|
|
:class="getVisitStatusClass(visit)"
|
|
>
|
|
<view class="visit-header">
|
|
<text class="visit-date">{{ formatDate(visit.visit_date) }}</text>
|
|
<text class="visit-status">{{ getVisitStatusText(visit.status) }}</text>
|
|
</view>
|
|
|
|
<view class="visit-details">
|
|
<view class="detail-row">
|
|
<text class="detail-label">时间:</text>
|
|
<text class="detail-value">{{ formatTime(visit.start_time) }} - {{ formatTime(visit.end_time) }}</text>
|
|
</view>
|
|
<view class="detail-row">
|
|
<text class="detail-label">访客:</text>
|
|
<text class="detail-value">{{ visit.visitor_name }}</text>
|
|
</view>
|
|
<view class="detail-row" v-if="visit.purpose">
|
|
<text class="detail-label">目的:</text>
|
|
<text class="detail-value">{{ visit.purpose }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="visit-actions">
|
|
<button
|
|
v-if="visit.status === 'pending'"
|
|
class="action-btn small warning"
|
|
@click="cancelVisit(visit)"
|
|
>
|
|
<text class="btn-text">取消</text>
|
|
</button>
|
|
<button
|
|
v-if="visit.status === 'approved'"
|
|
class="action-btn small info"
|
|
@click="viewVisitDetails(visit)"
|
|
>
|
|
<text class="btn-text">详情</text>
|
|
</button>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
</view>
|
|
|
|
<!-- Announcements Tab -->
|
|
<view v-if="activeTab === 'announcements'" class="announcements-section">
|
|
<scroll-view class="announcements-list" scroll-y="true" :style="{ height: '500px' }">
|
|
<view
|
|
v-for="announcement in announcements"
|
|
:key="announcement.id"
|
|
class="announcement-item"
|
|
:class="{ important: announcement.priority === 'high' }"
|
|
>
|
|
<view class="announcement-header">
|
|
<text class="announcement-title">{{ announcement.title }}</text>
|
|
<text class="announcement-date">{{ formatDate(announcement.created_at) }}</text>
|
|
</view>
|
|
|
|
<view class="announcement-content">
|
|
<text class="announcement-text">{{ announcement.content }}</text>
|
|
</view>
|
|
|
|
<view class="announcement-meta">
|
|
<text class="announcement-type">{{ getAnnouncementTypeText(announcement.type) }}</text>
|
|
<text class="announcement-priority" :class="announcement.priority">
|
|
{{ getPriorityText(announcement.priority) }}
|
|
</text>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
</view>
|
|
|
|
<!-- New Message Modal -->
|
|
<view v-if="showNewMessageModal" class="modal-overlay" @click="hideNewMessageModal">
|
|
<view class="modal-content" @click.stop>
|
|
<view class="modal-header">
|
|
<text class="modal-title">发送消息</text>
|
|
<button class="close-btn" @click="hideNewMessageModal">
|
|
<text class="close-text">✕</text>
|
|
</button>
|
|
</view>
|
|
|
|
<view class="modal-body">
|
|
<view class="form-group">
|
|
<text class="form-label">收件人</text>
|
|
<picker
|
|
:value="newMessageRecipientIndex"
|
|
:range="recipients"
|
|
range-key="name"
|
|
@change="onNewMessageRecipientChange"
|
|
>
|
|
<text class="picker-text">{{ newMessageRecipient?.name || '选择收件人' }}</text>
|
|
</picker>
|
|
</view>
|
|
|
|
<view class="form-group">
|
|
<text class="form-label">消息内容</text>
|
|
<textarea
|
|
class="form-textarea"
|
|
placeholder="请输入消息内容"
|
|
v-model="newMessage.content"
|
|
/>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="modal-footer">
|
|
<button class="cancel-btn" @click="hideNewMessageModal">
|
|
<text class="btn-text">取消</text>
|
|
</button>
|
|
<button class="confirm-btn" @click="sendMessage" :disabled="!isMessageValid">
|
|
<text class="btn-text">发送</text>
|
|
</button>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Visit Request Modal -->
|
|
<view v-if="showVisitRequestModal" class="modal-overlay" @click="hideVisitRequestModal">
|
|
<view class="modal-content" @click.stop>
|
|
<view class="modal-header">
|
|
<text class="modal-title">申请探视</text>
|
|
<button class="close-btn" @click="hideVisitRequestModal">
|
|
<text class="close-text">✕</text>
|
|
</button>
|
|
</view>
|
|
|
|
<view class="modal-body">
|
|
<view class="form-group">
|
|
<text class="form-label">探视日期</text>
|
|
<picker mode="date" @change="onVisitDateChange">
|
|
<text class="picker-text">{{ newVisit.visit_date || '选择日期' }}</text>
|
|
</picker>
|
|
</view>
|
|
|
|
<view class="form-group">
|
|
<text class="form-label">开始时间</text>
|
|
<picker mode="time" @change="onVisitStartTimeChange">
|
|
<text class="picker-text">{{ newVisit.start_time || '选择时间' }}</text>
|
|
</picker>
|
|
</view>
|
|
|
|
<view class="form-group">
|
|
<text class="form-label">结束时间</text>
|
|
<picker mode="time" @change="onVisitEndTimeChange">
|
|
<text class="picker-text">{{ newVisit.end_time || '选择时间' }}</text>
|
|
</picker>
|
|
</view>
|
|
|
|
<view class="form-group">
|
|
<text class="form-label">访客姓名</text>
|
|
<input
|
|
class="form-input"
|
|
placeholder="请输入访客姓名"
|
|
v-model="newVisit.visitor_name"
|
|
/>
|
|
</view>
|
|
|
|
<view class="form-group">
|
|
<text class="form-label">探视目的</text>
|
|
<textarea
|
|
class="form-textarea"
|
|
placeholder="请输入探视目的"
|
|
v-model="newVisit.purpose"
|
|
/>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="modal-footer">
|
|
<button class="cancel-btn" @click="hideVisitRequestModal">
|
|
<text class="btn-text">取消</text>
|
|
</button>
|
|
<button class="confirm-btn" @click="submitVisitRequest" :disabled="!isVisitValid">
|
|
<text class="btn-text">提交申请</text>
|
|
</button>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="uts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { formatDateTime, formatDate, formatTime } from '../types.uts'
|
|
|
|
// Message type
|
|
type Message = {
|
|
id: string
|
|
sender_id: string
|
|
sender_name: string
|
|
sender_role: string
|
|
recipient_id: string
|
|
content: string
|
|
attachments: string[] | null
|
|
is_read: boolean
|
|
created_at: string
|
|
}
|
|
|
|
// Call record type
|
|
type CallRecord = {
|
|
id: string
|
|
contact_name: string
|
|
type: string
|
|
status: string
|
|
duration: number | null
|
|
created_at: string
|
|
}
|
|
|
|
// Visit request type
|
|
type VisitRequest = {
|
|
id: string
|
|
visit_date: string
|
|
start_time: string
|
|
end_time: string
|
|
visitor_name: string
|
|
purpose: string | null
|
|
status: string
|
|
created_at: string
|
|
}
|
|
|
|
// Announcement type
|
|
type Announcement = {
|
|
id: string
|
|
title: string
|
|
content: string
|
|
type: string
|
|
priority: string
|
|
created_at: string
|
|
}
|
|
|
|
// Contact type
|
|
type Contact = {
|
|
id: string
|
|
name: string
|
|
role: string
|
|
status: string
|
|
}
|
|
|
|
// Communication stats type
|
|
type CommunicationStats = {
|
|
unread_messages: number
|
|
call_requests: number
|
|
visit_requests: number
|
|
new_reports: number
|
|
}
|
|
|
|
// Data
|
|
const messages = ref<Message[]>([])
|
|
const callHistory = ref<CallRecord[]>([])
|
|
const visits = ref<VisitRequest[]>([])
|
|
const announcements = ref<Announcement[]>([])
|
|
const quickContacts = ref<Contact[]>([])
|
|
const recipients = ref<Contact[]>([])
|
|
|
|
const stats = ref<CommunicationStats>({
|
|
unread_messages: 0,
|
|
call_requests: 0,
|
|
visit_requests: 0,
|
|
new_reports: 0
|
|
})
|
|
|
|
const activeTab = ref('messages')
|
|
|
|
// Modal states
|
|
const showNewMessageModal = ref(false)
|
|
const showVisitRequestModal = ref(false)
|
|
|
|
// Form data
|
|
const newMessage = ref({
|
|
recipient_id: '',
|
|
content: ''
|
|
})
|
|
|
|
const newVisit = ref<VisitRequest>({
|
|
id: '',
|
|
visit_date: '',
|
|
start_time: '',
|
|
end_time: '',
|
|
visitor_name: '',
|
|
purpose: '',
|
|
status: 'pending',
|
|
created_at: ''
|
|
})
|
|
|
|
const newMessageRecipientIndex = ref(-1)
|
|
|
|
// Computed
|
|
const newMessageRecipient = computed<Contact | null>(() => {
|
|
return newMessageRecipientIndex.value >= 0 ? recipients.value[newMessageRecipientIndex.value] : null
|
|
})
|
|
|
|
const isMessageValid = computed<boolean>(() => {
|
|
return newMessage.value.recipient_id !== '' && newMessage.value.content.trim() !== ''
|
|
})
|
|
|
|
const isVisitValid = computed<boolean>(() => {
|
|
return newVisit.value.visit_date !== '' &&
|
|
newVisit.value.start_time !== '' &&
|
|
newVisit.value.end_time !== '' &&
|
|
newVisit.value.visitor_name.trim() !== ''
|
|
})
|
|
|
|
// Methods
|
|
const loadMessages = async (): Promise<void> => {
|
|
try {
|
|
const response = await supa.executeAs('rpc/get_family_messages', {
|
|
family_id: getCurrentFamilyId()
|
|
})
|
|
if (response.success && response.data) {
|
|
messages.value = response.data as Message[]
|
|
}
|
|
} catch (error) {
|
|
console.error('加载消息失败:', error)
|
|
}
|
|
}
|
|
|
|
const loadCallHistory = async (): Promise<void> => {
|
|
try {
|
|
const response = await supa.executeAs('rpc/get_family_call_history', {
|
|
family_id: getCurrentFamilyId()
|
|
})
|
|
if (response.success && response.data) {
|
|
callHistory.value = response.data as CallRecord[]
|
|
}
|
|
} catch (error) {
|
|
console.error('加载通话记录失败:', error)
|
|
}
|
|
}
|
|
|
|
const loadVisits = async (): Promise<void> => {
|
|
try {
|
|
const response = await supa.executeAs('select', {
|
|
table: 'visit_requests',
|
|
select: '*',
|
|
match: { family_id: getCurrentFamilyId() },
|
|
order: 'visit_date desc'
|
|
})
|
|
if (response.success && response.data) {
|
|
visits.value = response.data as VisitRequest[]
|
|
}
|
|
} catch (error) {
|
|
console.error('加载探视记录失败:', error)
|
|
}
|
|
}
|
|
|
|
const loadAnnouncements = async (): Promise<void> => {
|
|
try {
|
|
const response = await supa.executeAs('select', {
|
|
table: 'announcements',
|
|
select: '*',
|
|
order: 'created_at desc',
|
|
limit: 50
|
|
})
|
|
if (response.success && response.data) {
|
|
announcements.value = response.data as Announcement[]
|
|
}
|
|
} catch (error) {
|
|
console.error('加载公告失败:', error)
|
|
}
|
|
}
|
|
|
|
const loadContacts = async (): Promise<void> => {
|
|
try {
|
|
const response = await supa.executeAs('rpc/get_family_contacts', {
|
|
family_id: getCurrentFamilyId()
|
|
})
|
|
if (response.success && response.data) {
|
|
quickContacts.value = response.data as Contact[]
|
|
recipients.value = response.data as Contact[]
|
|
}
|
|
} catch (error) {
|
|
console.error('加载联系人失败:', error)
|
|
}
|
|
}
|
|
|
|
const loadStats = async (): Promise<void> => {
|
|
try {
|
|
const response = await supa.executeAs('rpc/get_family_communication_stats', {
|
|
family_id: getCurrentFamilyId()
|
|
})
|
|
if (response.success && response.data && response.data.length > 0) {
|
|
stats.value = response.data[0] as CommunicationStats
|
|
}
|
|
} catch (error) {
|
|
console.error('加载统计数据失败:', error)
|
|
}
|
|
}
|
|
|
|
const getCurrentFamilyId = (): string => {
|
|
// 这里应该从用户状态获取当前家属ID
|
|
return 'current_family_id'
|
|
}
|
|
|
|
const getSenderRoleText = (role: string): string => {
|
|
const roleMap: Record<string, string> = {
|
|
'caregiver': '护理员',
|
|
'nurse': '护士',
|
|
'doctor': '医生',
|
|
'admin': '管理员',
|
|
'family': '家属'
|
|
}
|
|
return roleMap[role] ?? role
|
|
}
|
|
|
|
const getCallIcon = (type: string, status: string): string => {
|
|
if (status === 'missed') return '📞❌'
|
|
if (type === 'video') return '📹'
|
|
return '📞'
|
|
}
|
|
|
|
const getCallDetails = (call: CallRecord): string => {
|
|
const statusMap: Record<string, string> = {
|
|
'completed': '已接听',
|
|
'missed': '未接听',
|
|
'declined': '已拒绝',
|
|
'failed': '连接失败'
|
|
}
|
|
const statusText = statusMap[call.status] ?? call.status
|
|
|
|
if (call.duration) {
|
|
const minutes = Math.floor(call.duration / 60)
|
|
const seconds = call.duration % 60
|
|
return `${statusText} · ${minutes}:${seconds.toString().padStart(2, '0')}`
|
|
}
|
|
|
|
return statusText
|
|
}
|
|
|
|
const getVisitStatusClass = (visit: VisitRequest): string => {
|
|
switch (visit.status) {
|
|
case 'pending': return 'status-pending'
|
|
case 'approved': return 'status-approved'
|
|
case 'rejected': return 'status-rejected'
|
|
case 'completed': return 'status-completed'
|
|
default: return ''
|
|
}
|
|
}
|
|
|
|
const getVisitStatusText = (status: string): string => {
|
|
const statusMap: Record<string, string> = {
|
|
'pending': '待审核',
|
|
'approved': '已批准',
|
|
'rejected': '已拒绝',
|
|
'completed': '已完成'
|
|
}
|
|
return statusMap[status] ?? status
|
|
}
|
|
|
|
const getAnnouncementTypeText = (type: string): string => {
|
|
const typeMap: Record<string, string> = {
|
|
'general': '一般通知',
|
|
'health': '健康提醒',
|
|
'activity': '活动通知',
|
|
'policy': '政策公告',
|
|
'emergency': '紧急通知'
|
|
}
|
|
return typeMap[type] ?? type
|
|
}
|
|
|
|
const getPriorityText = (priority: string): string => {
|
|
const priorityMap: Record<string, string> = {
|
|
'low': '普通',
|
|
'normal': '一般',
|
|
'high': '重要',
|
|
'urgent': '紧急'
|
|
}
|
|
return priorityMap[priority] ?? priority
|
|
}
|
|
|
|
// Event handlers
|
|
const setActiveTab = (tab: string): void => {
|
|
activeTab.value = tab
|
|
}
|
|
|
|
const readMessage = async (message: Message): Promise<void> => {
|
|
if (!message.is_read) {
|
|
try {
|
|
await supa.executeAs('update', {
|
|
table: 'messages',
|
|
data: { is_read: true },
|
|
match: { id: message.id }
|
|
})
|
|
message.is_read = true
|
|
await loadStats()
|
|
} catch (error) {
|
|
console.error('标记消息已读失败:', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
const replyMessage = (message: Message): void => {
|
|
newMessage.value.recipient_id = message.sender_id
|
|
const recipientIndex = recipients.value.findIndex(r => r.id === message.sender_id)
|
|
newMessageRecipientIndex.value = recipientIndex
|
|
showNewMessageModal.value = true
|
|
}
|
|
|
|
const initiateCall = (contact: Contact): void => {
|
|
console.log('发起通话:', contact)
|
|
}
|
|
|
|
const callBack = (call: CallRecord): void => {
|
|
console.log('回拨:', call)
|
|
}
|
|
|
|
const viewVisitDetails = (visit: VisitRequest): void => {
|
|
console.log('查看探视详情:', visit)
|
|
}
|
|
|
|
const cancelVisit = async (visit: VisitRequest): Promise<void> => {
|
|
try {
|
|
const response = await supa.executeAs('update', {
|
|
table: 'visit_requests',
|
|
data: { status: 'cancelled' },
|
|
match: { id: visit.id }
|
|
})
|
|
if (response.success) {
|
|
await loadVisits()
|
|
await loadStats()
|
|
}
|
|
} catch (error) {
|
|
console.error('取消探视失败:', error)
|
|
}
|
|
}
|
|
|
|
// Modal methods
|
|
const showNewMessage = (): void => {
|
|
newMessage.value = {
|
|
recipient_id: '',
|
|
content: ''
|
|
}
|
|
newMessageRecipientIndex.value = -1
|
|
showNewMessageModal.value = true
|
|
}
|
|
|
|
const hideNewMessageModal = (): void => {
|
|
showNewMessageModal.value = false
|
|
}
|
|
|
|
const showVideoCall = (): void => {
|
|
console.log('发起视频通话')
|
|
}
|
|
|
|
const showVisitRequest = (): void => {
|
|
newVisit.value = {
|
|
id: '',
|
|
visit_date: '',
|
|
start_time: '',
|
|
end_time: '',
|
|
visitor_name: '',
|
|
purpose: '',
|
|
status: 'pending',
|
|
created_at: ''
|
|
}
|
|
showVisitRequestModal.value = true
|
|
}
|
|
|
|
const hideVisitRequestModal = (): void => {
|
|
showVisitRequestModal.value = false
|
|
}
|
|
|
|
const onNewMessageRecipientChange = (e: any): void => {
|
|
newMessageRecipientIndex.value = e.detail.value
|
|
if (newMessageRecipient.value) {
|
|
newMessage.value.recipient_id = newMessageRecipient.value.id
|
|
}
|
|
}
|
|
|
|
const onVisitDateChange = (e: any): void => {
|
|
newVisit.value.visit_date = e.detail.value
|
|
}
|
|
|
|
const onVisitStartTimeChange = (e: any): void => {
|
|
newVisit.value.start_time = e.detail.value
|
|
}
|
|
|
|
const onVisitEndTimeChange = (e: any): void => {
|
|
newVisit.value.end_time = e.detail.value
|
|
}
|
|
|
|
const sendMessage = async (): Promise<void> => {
|
|
if (!isMessageValid.value) return
|
|
|
|
try {
|
|
const response = await supa.executeAs('insert', {
|
|
table: 'messages',
|
|
data: {
|
|
sender_id: getCurrentFamilyId(),
|
|
recipient_id: newMessage.value.recipient_id,
|
|
content: newMessage.value.content,
|
|
is_read: false
|
|
}
|
|
})
|
|
|
|
if (response.success) {
|
|
hideNewMessageModal()
|
|
await loadMessages()
|
|
await loadStats()
|
|
}
|
|
} catch (error) {
|
|
console.error('发送消息失败:', error)
|
|
}
|
|
}
|
|
|
|
const submitVisitRequest = async (): Promise<void> => {
|
|
if (!isVisitValid.value) return
|
|
|
|
try {
|
|
const response = await supa.executeAs('insert', {
|
|
table: 'visit_requests',
|
|
data: {
|
|
family_id: getCurrentFamilyId(),
|
|
visit_date: newVisit.value.visit_date,
|
|
start_time: newVisit.value.start_time,
|
|
end_time: newVisit.value.end_time,
|
|
visitor_name: newVisit.value.visitor_name,
|
|
purpose: newVisit.value.purpose,
|
|
status: 'pending'
|
|
}
|
|
})
|
|
|
|
if (response.success) {
|
|
hideVisitRequestModal()
|
|
await loadVisits()
|
|
await loadStats()
|
|
}
|
|
} catch (error) {
|
|
console.error('提交探视申请失败:', error)
|
|
}
|
|
}
|
|
|
|
// Lifecycle
|
|
onMounted(async () => {
|
|
await Promise.all([
|
|
loadMessages(),
|
|
loadCallHistory(),
|
|
loadVisits(),
|
|
loadAnnouncements(),
|
|
loadContacts(),
|
|
loadStats()
|
|
])
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
.family-communication {
|
|
padding: 20px;
|
|
background: #f5f7fa;
|
|
min-height: 100vh;
|
|
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 24px;
|
|
padding: 20px;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
border-radius: 12px;
|
|
color: white;
|
|
|
|
.header-title {
|
|
font-size: 24px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
|
|
.action-btn {
|
|
padding: 8px 16px;
|
|
background: rgba(255, 255, 255, 0.2);
|
|
color: white;
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
|
|
.btn-text {
|
|
color: white;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.stats-section {
|
|
display: flex;
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
flex-wrap: wrap;
|
|
|
|
.stat-card {
|
|
flex: 1;
|
|
min-width: 150px;
|
|
padding: 20px;
|
|
background: white;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
|
|
.stat-icon {
|
|
font-size: 32px;
|
|
}
|
|
|
|
.stat-content {
|
|
.stat-number {
|
|
display: block;
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
color: #1a202c;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 14px;
|
|
color: #64748b;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.tab-navigation {
|
|
display: flex;
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 8px;
|
|
margin-bottom: 24px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
|
.tab-btn {
|
|
flex: 1;
|
|
padding: 12px 16px;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: #6b7280;
|
|
|
|
&.active {
|
|
background: #4a90e2;
|
|
color: white;
|
|
|
|
.tab-text {
|
|
color: white;
|
|
}
|
|
}
|
|
|
|
.tab-text {
|
|
color: inherit;
|
|
}
|
|
}
|
|
}
|
|
|
|
.messages-section {
|
|
background: white;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
|
.messages-list {
|
|
padding: 16px;
|
|
|
|
.message-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
padding: 16px;
|
|
border-radius: 12px;
|
|
margin-bottom: 12px;
|
|
background: #f8fafc;
|
|
cursor: pointer;
|
|
|
|
&.unread {
|
|
background: #eff6ff;
|
|
border-left: 4px solid #3b82f6;
|
|
}
|
|
|
|
&:hover {
|
|
background: #e5e7eb;
|
|
}
|
|
|
|
.message-avatar {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: #4a90e2;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
|
|
.avatar-text {
|
|
color: white;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
}
|
|
}
|
|
|
|
.message-content {
|
|
flex: 1;
|
|
|
|
.message-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
|
|
.sender-name {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #1a202c;
|
|
}
|
|
|
|
.sender-role {
|
|
font-size: 12px;
|
|
color: #6b7280;
|
|
background: #e5e7eb;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.message-time {
|
|
font-size: 12px;
|
|
color: #9ca3af;
|
|
margin-left: auto;
|
|
}
|
|
}
|
|
|
|
.message-preview {
|
|
margin-bottom: 8px;
|
|
|
|
.message-text {
|
|
font-size: 14px;
|
|
color: #374151;
|
|
line-height: 1.4;
|
|
}
|
|
}
|
|
|
|
.message-meta {
|
|
.attachment-count {
|
|
font-size: 12px;
|
|
color: #6b7280;
|
|
}
|
|
}
|
|
}
|
|
|
|
.message-actions {
|
|
.action-btn {
|
|
padding: 4px 8px;
|
|
background: #e5e7eb;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
|
|
.btn-text {
|
|
color: #374151;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.calls-section {
|
|
.quick-call-section {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
margin-bottom: 16px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
|
.section-title {
|
|
display: block;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #1a202c;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.contact-list {
|
|
display: flex;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
|
|
.contact-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px 16px;
|
|
background: #f8fafc;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
min-width: 150px;
|
|
|
|
&:hover {
|
|
background: #e5e7eb;
|
|
}
|
|
|
|
.contact-avatar {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 50%;
|
|
background: #10b981;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
.avatar-text {
|
|
color: white;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
}
|
|
}
|
|
|
|
.contact-info {
|
|
flex: 1;
|
|
|
|
.contact-name {
|
|
display: block;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: #1a202c;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.contact-role {
|
|
font-size: 12px;
|
|
color: #6b7280;
|
|
}
|
|
}
|
|
|
|
.contact-status {
|
|
.status-indicator {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: #10b981;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.call-history-section {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
|
.section-title {
|
|
display: block;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #1a202c;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.call-history-list {
|
|
.call-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
margin-bottom: 8px;
|
|
|
|
&:hover {
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.call-icon {
|
|
.icon-text {
|
|
font-size: 16px;
|
|
}
|
|
}
|
|
|
|
.call-info {
|
|
flex: 1;
|
|
|
|
.call-contact {
|
|
display: block;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: #1a202c;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.call-details {
|
|
font-size: 12px;
|
|
color: #6b7280;
|
|
}
|
|
}
|
|
|
|
.call-time {
|
|
.time-text {
|
|
font-size: 12px;
|
|
color: #9ca3af;
|
|
}
|
|
}
|
|
|
|
.call-actions {
|
|
.action-btn {
|
|
padding: 4px 8px;
|
|
background: #e5e7eb;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
|
|
.btn-text {
|
|
color: #374151;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.visits-section {
|
|
.visit-request-section {
|
|
margin-bottom: 16px;
|
|
|
|
.request-visit-btn {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 12px;
|
|
padding: 20px;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 12px;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
|
|
.request-icon {
|
|
font-size: 20px;
|
|
}
|
|
|
|
.request-text {
|
|
color: white;
|
|
}
|
|
}
|
|
}
|
|
|
|
.visits-list {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
|
.visit-item {
|
|
padding: 16px;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
margin-bottom: 12px;
|
|
|
|
&.status-pending {
|
|
border-left: 4px solid #f59e0b;
|
|
}
|
|
|
|
&.status-approved {
|
|
border-left: 4px solid #10b981;
|
|
}
|
|
|
|
&.status-rejected {
|
|
border-left: 4px solid #ef4444;
|
|
}
|
|
|
|
.visit-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
|
|
.visit-date {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #1a202c;
|
|
}
|
|
|
|
.visit-status {
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
background: #e5e7eb;
|
|
color: #374151;
|
|
}
|
|
}
|
|
|
|
.visit-details {
|
|
margin-bottom: 12px;
|
|
|
|
.detail-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 4px;
|
|
font-size: 14px;
|
|
|
|
.detail-label {
|
|
color: #6b7280;
|
|
min-width: 50px;
|
|
}
|
|
|
|
.detail-value {
|
|
color: #374151;
|
|
}
|
|
}
|
|
}
|
|
|
|
.visit-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
justify-content: flex-end;
|
|
|
|
.action-btn {
|
|
padding: 4px 12px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
|
|
&.warning {
|
|
background: #fef3c7;
|
|
color: #d97706;
|
|
}
|
|
|
|
&.info {
|
|
background: #e0e7ff;
|
|
color: #3730a3;
|
|
}
|
|
|
|
.btn-text {
|
|
color: inherit;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.announcements-section {
|
|
background: white;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
|
.announcements-list {
|
|
padding: 16px;
|
|
|
|
.announcement-item {
|
|
padding: 16px;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
margin-bottom: 12px;
|
|
|
|
&.important {
|
|
border-left: 4px solid #ef4444;
|
|
background: #fef2f2;
|
|
}
|
|
|
|
.announcement-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
|
|
.announcement-title {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #1a202c;
|
|
}
|
|
|
|
.announcement-date {
|
|
font-size: 12px;
|
|
color: #9ca3af;
|
|
}
|
|
}
|
|
|
|
.announcement-content {
|
|
margin-bottom: 12px;
|
|
|
|
.announcement-text {
|
|
font-size: 14px;
|
|
color: #374151;
|
|
line-height: 1.5;
|
|
}
|
|
}
|
|
|
|
.announcement-meta {
|
|
display: flex;
|
|
gap: 12px;
|
|
|
|
.announcement-type {
|
|
font-size: 12px;
|
|
color: #6b7280;
|
|
background: #e5e7eb;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.announcement-priority {
|
|
font-size: 12px;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
|
|
&.high {
|
|
background: #fef3c7;
|
|
color: #d97706;
|
|
}
|
|
|
|
&.urgent {
|
|
background: #fee2e2;
|
|
color: #dc2626;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.modal-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
|
|
.modal-content {
|
|
background: white;
|
|
border-radius: 12px;
|
|
width: 90%;
|
|
max-width: 500px;
|
|
max-height: 80vh;
|
|
overflow-y: auto;
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 20px;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
|
|
.modal-title {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #1a202c;
|
|
}
|
|
|
|
.close-btn {
|
|
padding: 4px;
|
|
background: none;
|
|
border: none;
|
|
font-size: 18px;
|
|
color: #6b7280;
|
|
}
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 20px;
|
|
|
|
.form-group {
|
|
margin-bottom: 16px;
|
|
|
|
.form-label {
|
|
display: block;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: #374151;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.form-input, .form-textarea {
|
|
width: 100%;
|
|
padding: 10px 16px;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
color: #374151;
|
|
}
|
|
|
|
.form-textarea {
|
|
height: 80px;
|
|
resize: vertical;
|
|
}
|
|
|
|
.picker-text {
|
|
padding: 10px 16px;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 8px;
|
|
background: white;
|
|
color: #374151;
|
|
width: 100%;
|
|
display: block;
|
|
}
|
|
}
|
|
}
|
|
|
|
.modal-footer {
|
|
display: flex;
|
|
gap: 12px;
|
|
justify-content: flex-end;
|
|
padding: 20px;
|
|
border-top: 1px solid #e5e7eb;
|
|
|
|
.cancel-btn, .confirm-btn {
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
|
|
.btn-text {
|
|
color: inherit;
|
|
}
|
|
}
|
|
|
|
.cancel-btn {
|
|
background: #f3f4f6;
|
|
color: #374151;
|
|
}
|
|
|
|
.confirm-btn {
|
|
background: #4a90e2;
|
|
color: white;
|
|
|
|
&:disabled {
|
|
background: #d1d5db;
|
|
cursor: not-allowed;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|