Messages.vue 14.1 KB
<template>
    <div class="min-h-screen bg-gray-50">
        <!-- Header -->
        <div class="bg-white shadow-sm sticky top-0 z-10">
            <div class="container mx-auto px-4 py-3">
                <div class="flex items-center justify-between">
                    <div class="flex items-center">
                        <button @click="goBack" class="mr-3 text-gray-600 hover:text-gray-900">
                            <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20"
                                fill="currentColor">
                                <path fill-rule="evenodd"
                                    d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
                                    clip-rule="evenodd" />
                            </svg>
                        </button>
                        <div>
                            <h1 class="text-lg font-medium text-gray-900 truncate max-w-xs">{{ activity?.title }}</h1>
                            <p class="text-sm text-gray-500">消息交流</p>
                        </div>
                    </div>
                    <router-link :to="`/activity/${activityId}`"
                        class="text-sm text-green-600 hover:text-green-700 font-medium">
                        活动详情
                    </router-link>
                </div>
            </div>
        </div>

        <!-- Message Tabs -->
        <div class="bg-white shadow-sm">
            <div class="container mx-auto px-4">
                <div class="flex overflow-x-auto hide-scrollbar">
                    <button v-for="tab in tabs" :key="tab.value" :class="[
                        'py-3 px-4 text-sm font-medium border-b-2 whitespace-nowrap',
                        activeTab === tab.value
                            ? 'border-green-500 text-green-600'
                            : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
                    ]" @click="activeTab = tab.value">
                        {{ tab.label }}
                    </button>
                </div>
            </div>
        </div>

        <!-- Messages Container -->
        <div class="container mx-auto px-4 py-4 flex flex-col h-[calc(100vh-13rem)]">
            <!-- Message List -->
            <div class="flex-grow overflow-y-auto mb-4 pr-1">
                <div v-if="filteredMessages.length === 0" class="flex flex-col items-center justify-center h-full">
                    <div class="text-gray-400">
                        <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16" fill="none" viewBox="0 0 24 24"
                            stroke="currentColor">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1"
                                d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
                        </svg>
                    </div>
                    <p class="text-gray-500 mt-2">暂无消息</p>
                    <button v-if="activeTab !== 'all'" @click="activeTab = 'all'"
                        class="mt-4 text-green-600 hover:text-green-700 font-medium text-sm">
                        查看全部消息
                    </button>
                </div>
                <div v-else>
                    <div v-for="date in sortedDates" :key="date">
                        <div class="flex items-center justify-center my-4">
                            <div class="border-t border-gray-200 flex-grow"></div>
                            <span class="mx-4 text-xs text-gray-500">{{ formatDate(date) }}</span>
                            <div class="border-t border-gray-200 flex-grow"></div>
                        </div>
                        <div v-for="message in groupedMessages[date]" :key="message.id">
                            <component :is="messageComponent(message)" :message="message" />
                        </div>
                    </div>
                    <div ref="messageEndRef" />
                </div>
            </div>

            <!-- Message Input -->
            <div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
                <div v-if="isOrganizer" class="p-2 bg-gray-50 border-b border-gray-200">
                    <div class="flex items-center">
                        <span
                            class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
                            组织者
                        </span>
                        <span class="text-xs text-gray-500 ml-2">
                            您可以发送公告消息
                        </span>
                    </div>
                </div>
                <form @submit.prevent="handleSendMessage" class="flex p-2">
                    <input type="text" v-model="messageInput" placeholder="输入消息..."
                        class="flex-grow border-none focus:ring-0 focus:outline-none" :disabled="isSubmitting" />
                    <Button type="submit" variant="primary" size="sm" :disabled="!messageInput.trim() || isSubmitting">
                        发送
                    </Button>
                </form>
            </div>
        </div>
    </div>
</template>

<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '../stores/app'
import Button from '../components/shared/Button.vue'

// Route and store setup
const route = useRoute()
const router = useRouter()
const store = useAppStore()

// Props and refs
const activityId = route.params.activityId
const messageEndRef = ref(null)
const messageInput = ref('')
const activeTab = ref('all')
const isSubmitting = ref(false)
const activity = ref(null)
const filteredMessages = ref([])
const loading = ref(true)
const error = ref(null)

// Computed properties
const isOrganizer = computed(() => {
    return store.currentUser && store.currentUser.id === activity.value?.organizer_id
})

// Tabs configuration
const tabs = [
    { label: '全部消息', value: 'all' },
    { label: '活动公告', value: 'announcements' },
    { label: '问答交流', value: 'questions' },
    { label: '我的消息', value: 'personal' }
]

// Methods
const goBack = () => router.back()

const formatTime = (dateString) => {
    const date = new Date(dateString.replace(' ', 'T'))
    return new Intl.DateTimeFormat('zh-CN', {
        month: 'short',
        day: 'numeric',
        hour: '2-digit',
        minute: '2-digit',
        hour12: false
    }).format(date)
}

const formatDate = (dateString) => {
    return new Date(dateString).toLocaleDateString('zh-CN')
}

const handleSendMessage = async () => {
    if (!messageInput.value.trim()) return

    if (!store.currentUser) {
        alert('请先登录再发送消息')
        return
    }

    isSubmitting.value = true

    try {
        const now = new Date().toISOString().replace('T', ' ').substring(0, 19)

        const newMessage = {
            activity_id: activityId,
            user_id: store.currentUser.id,
            type: 'question',
            content: messageInput.value,
            timestamp: now,
            user_name: store.currentUser.name,
            user_avatar: store.currentUser.avatar
        }

        const result = await store.addMessage(newMessage)

        if (result.success) {
            messageInput.value = ''
        } else {
            alert(`发送失败: ${result.error}`)
        }
    } catch (error) {
        console.error('Failed to send message:', error)
        alert('发送消息失败,请稍后重试')
    } finally {
        isSubmitting.value = false
    }
}

// Message components
const AnnouncementMessage = {
    props: ['message'],
    template: `
    <div class="bg-blue-50 border border-blue-100 rounded-lg p-4 mb-4">
      <div class="flex items-center mb-2">
        <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-500 mr-2" viewBox="0 0 20 20" fill="currentColor">
          <path fill-rule="evenodd" d="M18 3a1 1 0 00-1.447-.894L8.763 6H5a3 3 0 000 6h.28l1.771 5.316A1 1 0 008 18h1a1 1 0 001-1v-4.382l6.553 3.276A1 1 0 0018 15V3z" clip-rule="evenodd" />
        </svg>
        <span class="font-semibold text-blue-800">活动公告</span>
      </div>
      <p class="text-gray-700">{{ message.content }}</p>
      <div class="mt-2 text-xs text-gray-500 flex items-center justify-between">
        <span>{{ message.user_name }}</span>
        <span>{{ formatTime(message.timestamp) }}</span>
      </div>
    </div>
  `
}

const AnswerMessage = {
    props: ['message'],
    template: `
    <div class="bg-green-50 border border-green-100 rounded-lg p-4 mb-4">
      <div class="flex items-center mb-2">
        <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-500 mr-2" viewBox="0 0 20 20" fill="currentColor">
          <path fill-rule="evenodd" d="M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z" clip-rule="evenodd" />
        </svg>
        <span class="font-semibold text-green-800">回复</span>
      </div>
      <div v-if="message.reply_to" class="bg-white rounded p-2 mb-2 text-sm text-gray-600 border-l-2 border-gray-300">
        <p class="truncate">{{ message.reply_to }}</p>
      </div>
      <p class="text-gray-700">{{ message.content }}</p>
      <div class="mt-2 text-xs text-gray-500 flex items-center justify-between">
        <span>{{ message.user_name }}</span>
        <span>{{ formatTime(message.timestamp) }}</span>
      </div>
    </div>
  `
}

const DefaultMessage = {
    props: ['message'],
    setup(props) {
        const store = useAppStore()
        const isCurrentUser = computed(() => props.message.user_id === store.currentUser?.id)
        return { isCurrentUser, formatTime }
    },
    template: `
    <div :class="['flex', isCurrentUser ? 'justify-end' : 'justify-start', 'mb-4']">
      <div :class="['max-w-[80%]', isCurrentUser ? 'order-1' : 'order-2']">
        <div :class="['flex items-center', isCurrentUser ? 'justify-end' : 'justify-start', 'mb-1']">
          <span class="text-xs text-gray-500 mr-2">{{ message.user_name }}</span>
          <div class="h-6 w-6 rounded-full overflow-hidden bg-gray-200">
            <img v-if="message.user_avatar" :src="message.user_avatar" :alt="message.user_name" class="h-full w-full object-cover" />
            <svg v-else xmlns="http://www.w3.org/2000/svg" class="h-full w-full text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
            </svg>
          </div>
        </div>
        <div :class="['rounded-lg p-3', isCurrentUser ? 'bg-green-100' : 'bg-white border border-gray-200']">
          <p class="text-gray-700">{{ message.content }}</p>
        </div>
        <div :class="['text-xs text-gray-500 mt-1', isCurrentUser ? 'text-right' : 'text-left']">
          {{ formatTime(message.timestamp) }}
        </div>
      </div>
    </div>
  `
}

const messageComponent = (message) => {
    switch (message.type) {
        case 'announcement':
            return AnnouncementMessage
        case 'answer':
            return AnswerMessage
        default:
            return DefaultMessage
    }
}

// Computed properties for message grouping
const groupedMessages = computed(() => {
    return filteredMessages.value.reduce((groups, message) => {
        const date = message.timestamp.split(' ')[0]
        if (!groups[date]) {
            groups[date] = []
        }
        groups[date].push(message)
        return groups
    }, {})
})

const sortedDates = computed(() => {
    return Object.keys(groupedMessages.value).sort()
})

// Watch for changes in messages and scroll to bottom
watch(filteredMessages, () => {
    if (messageEndRef.value) {
        messageEndRef.value.scrollIntoView({ behavior: 'smooth' })
    }
})

// Filter messages based on active tab
watch([() => store.messages, activeTab], () => {
    if (!store.messages) return

    let filtered = store.messages.filter(msg => msg.activity_id === activityId)

    switch (activeTab.value) {
        case 'announcements':
            filtered = filtered.filter(msg => msg.type === 'announcement')
            break
        case 'questions':
            filtered = filtered.filter(msg => msg.type === 'question' || msg.type === 'answer')
            break
        case 'personal':
            filtered = filtered.filter(msg =>
                msg.user_id === store.currentUser?.id || msg.to_user_id === store.currentUser?.id
            )
            break
    }

    filteredMessages.value = filtered.sort((a, b) => {
        return new Date(a.timestamp.replace(' ', 'T')) - new Date(b.timestamp.replace(' ', 'T'))
    })
})

// Initial data fetch
onMounted(async () => {
    try {
        if (!store.currentUser) {
            error.value = '请先登录'
            loading.value = false
            return
        }

        if (store.activities.length > 0) {
            const foundActivity = store.getActivityById(activityId)

            if (foundActivity) {
                activity.value = foundActivity

                // Filter messages for this activity
                const activityMessages = store.messages.filter(
                    msg => msg.activity_id === activityId
                ).sort((a, b) => {
                    return new Date(a.timestamp.replace(' ', 'T')) - new Date(b.timestamp.replace(' ', 'T'))
                })

                filteredMessages.value = activityMessages
            } else {
                error.value = '未找到活动信息'
            }
        }
        loading.value = false
    } catch (err) {
        console.error('Failed to fetch data:', err)
        error.value = '加载数据失败'
        loading.value = false
    }
})
</script>

<style scoped>
.hide-scrollbar {
    -ms-overflow-style: none;
    scrollbar-width: none;
}

.hide-scrollbar::-webkit-scrollbar {
    display: none;
}
</style>