- 后端新增批量创建二维码接口 POST /admin/chats/qrcodes/batch - 前端二维码管理页支持多图拖拽批量上传 - 注册 qrcode 图片压缩场景(maxSide=2560, quality=0.95) - 后端 normalizeScene 注册 qrcode scene - 增强 AuthImage 组件支持 el-image 大图预览(previewSrcList) - 修复 WithdrawalDetailDialog 提现凭证图片 401 问题 - 删除重复的 components/AuthImage.vue,统一使用共享组件 - 提交3剩余: 会话列表群类型图标、发布成功跳群、管理端路由等
337 lines
8.0 KiB
Vue
337 lines
8.0 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { ElMessage } from 'element-plus'
|
|
import { Refresh, Tickets } from '@element-plus/icons-vue'
|
|
import { fetchChats, type ChatConversation } from '@/features/chats/api/chats'
|
|
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
|
import { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
|
|
import NotificationSettings from '@/features/chats/components/NotificationSettings.vue'
|
|
import { formatDateMinute } from '@/shared/utils/time'
|
|
|
|
const router = useRouter()
|
|
const currentUserId = Number(localStorage.getItem('user_id') || 0)
|
|
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 desktopNotification = useDesktopNotification('user')
|
|
const { onEvent } = useChatSSE('user', '/api/chats/events')
|
|
onEvent(handleSSEEvent)
|
|
|
|
onMounted(() => {
|
|
desktopNotification.requestPermissionSilently()
|
|
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)
|
|
}
|
|
if (event.type === 'new_message' && event.message) {
|
|
const isSelf = event.message.sender_type === 'user' && event.message.sender_id === currentUserId
|
|
if (!isSelf) {
|
|
desktopNotification.notify(event, null)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 conversationTypeMeta(item: ChatConversation) {
|
|
if (item.type === 'listing_group') return { icon: '📢', label: '账号群' }
|
|
if (item.type === 'order_group' || item.order_id) return { icon: '📦', label: '订单' }
|
|
return { icon: '🎧', label: '平台客服' }
|
|
}
|
|
|
|
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">
|
|
<NotificationSettings scope="user" />
|
|
<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>
|
|
<span class="conv-type-icon">{{ conversationTypeMeta(item).icon }}</span>
|
|
{{ 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">{{ conversationTypeMeta(item).label }}</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;
|
|
}
|
|
|
|
.conv-type-icon {
|
|
margin-right: 4px;
|
|
}
|
|
|
|
.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>
|