Files
hfb_sys/frontend/src/views/account/MessagesView.vue
T
2026-05-29 10:15:21 +08:00

291 lines
6.8 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ChatDotRound, Refresh, Tickets } from '@element-plus/icons-vue'
import { fetchChats, type ChatConversation } from '@/api/chats'
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
const router = useRouter()
const loading = ref(false)
const conversations = ref<ChatConversation[]>([])
const page = ref(1)
const pageSize = 20
const total = ref(0)
const hasMore = computed(() => conversations.value.length < total.value)
const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum + item.unread_count, 0))
const { onEvent } = useChatSSE('user', '/api/chats/events')
onEvent(handleSSEEvent)
onMounted(() => loadChats(true))
async function loadChats(isRefresh = false, showLoading = true) {
if (isRefresh) page.value = 1
if (loading.value) return
if (showLoading) loading.value = true
try {
const res = await fetchChats(page.value, pageSize)
conversations.value = isRefresh ? res.items : [...conversations.value, ...res.items]
total.value = res.total
if (conversations.value.length < res.total && res.items.length > 0) {
page.value += 1
}
} catch {
ElMessage.error('获取会话失败')
} finally {
loading.value = false
}
}
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'new_message' || event.type === 'conversation_updated') {
loadChats(true, false)
}
}
function openConversation(item: ChatConversation) {
router.push(`/messages/${item.id}`)
}
function roleLabel(role: string) {
const map: Record<string, string> = { renter: '租客', owner: '号主', support: '客服', customer: '咨询' }
return map[role] || '成员'
}
function previewText(item: ChatConversation) {
return item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
}
</script>
<template>
<section class="page messages-page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Messages</p>
<h1>消息</h1>
<p>{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊和平台客服消息。' }}</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadChats(true)">刷新</el-button>
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">我的订单</el-button>
</div>
</div>
<div v-if="loading && conversations.length === 0" class="message-loading" v-loading="loading" />
<div v-else-if="!loading && conversations.length === 0" class="empty-panel">
<el-empty description="暂无会话">
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">查看订单</el-button>
</el-empty>
</div>
<div v-else v-loading="loading" class="conversation-list">
<button
v-for="item in conversations"
:key="item.id"
class="conversation-item"
type="button"
@click="openConversation(item)"
>
<div class="avatar-stack">
<span class="avatar main">{{ roleLabel(item.role).slice(0, 1) }}</span>
<span class="avatar support"></span>
</div>
<div class="conversation-body">
<div class="conversation-head">
<h2>{{ item.title }}</h2>
<span class="conversation-time">{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
</div>
<div class="conversation-meta">
<span class="role-chip">{{ roleLabel(item.role) }}</span>
<span class="order-id">{{ item.order_id ? `订单 #${item.order_id}` : '平台客服' }}</span>
</div>
<p class="conversation-preview">{{ previewText(item) }}</p>
</div>
<span v-if="item.unread_count > 0" class="unread-badge">
{{ item.unread_count > 99 ? '99+' : item.unread_count }}
</span>
</button>
</div>
<div v-if="hasMore && conversations.length > 0" class="pagination-wrap">
<el-button :loading="loading" @click="loadChats(false)">加载更多</el-button>
</div>
</section>
</template>
<style scoped>
.messages-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.page-header-row {
align-items: flex-end;
}
.header-actions {
display: flex;
gap: 10px;
}
.message-loading,
.empty-panel {
min-height: 360px;
border: 1px solid #e8edf3;
border-radius: 8px;
background: #fff;
}
.empty-panel {
display: grid;
place-items: center;
}
.conversation-list {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 180px;
}
.conversation-item {
position: relative;
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
gap: 12px;
width: 100%;
padding: 14px 16px;
border: 1px solid #e8edf3;
border-radius: 10px;
background: #fff;
text-align: left;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.conversation-item:hover {
border-color: #1477ff;
box-shadow: 0 4px 16px rgba(20, 119, 255, 0.1);
}
.avatar-stack {
position: relative;
width: 48px;
height: 48px;
flex-shrink: 0;
}
.avatar {
display: grid;
place-items: center;
border-radius: 50%;
color: #fff;
font-weight: 800;
}
.avatar.main {
width: 44px;
height: 44px;
background: #1477ff;
font-size: 16px;
}
.avatar.support {
position: absolute;
right: 0;
bottom: 0;
width: 22px;
height: 22px;
border: 2px solid #fff;
background: #10b981;
font-size: 11px;
}
.conversation-body {
min-width: 0;
}
.conversation-head {
display: flex;
align-items: center;
gap: 8px;
}
.conversation-head h2 {
flex: 1;
min-width: 0;
margin: 0;
overflow: hidden;
color: #17233d;
font-size: 15px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-time {
flex: none;
color: #9ca3af;
font-size: 12px;
}
.conversation-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
color: #6b7785;
font-size: 12px;
}
.role-chip {
padding: 1px 8px;
border-radius: 999px;
background: #eef6ff;
color: #1477ff;
font-size: 11px;
font-weight: 700;
}
.order-id {
color: #8a94a6;
}
.conversation-preview {
margin: 6px 0 0;
overflow: hidden;
color: #4b5563;
font-size: 13px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.unread-badge {
position: absolute;
right: 14px;
bottom: 14px;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-weight: 800;
line-height: 20px;
text-align: center;
}
.pagination-wrap {
display: flex;
justify-content: center;
}
</style>