增加站内信未读提醒和后台通知中心+txt 校验
This commit is contained in:
@@ -1,16 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, Tickets } from '@element-plus/icons-vue'
|
||||
import { Bell, ChatDotRound, 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'
|
||||
import {
|
||||
fetchNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
type NotificationItem,
|
||||
} from '@/features/auth/api/notifications'
|
||||
import {
|
||||
notificationUnreadChangedEvent,
|
||||
useNotificationUnreadCount,
|
||||
} from '@/features/auth/composables/useNotificationUnreadCount'
|
||||
import { formatDateMinute, formatDateTime } from '@/shared/utils/time'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const currentUserId = Number(localStorage.getItem('user_id') || 0)
|
||||
|
||||
// ── 聊天状态 ──
|
||||
const loading = ref(false)
|
||||
const conversations = ref<ChatConversation[]>([])
|
||||
const page = ref(1)
|
||||
@@ -22,6 +35,21 @@ const unreadTotal = computed(() =>
|
||||
conversations.value.reduce((sum, item) => sum + item.unread_count, 0)
|
||||
)
|
||||
|
||||
// ── 通知状态 ──
|
||||
const notiLoading = ref(false)
|
||||
const notiSubmitting = ref(false)
|
||||
const notifications = ref<NotificationItem[]>([])
|
||||
const notiPage = ref(1)
|
||||
const notiPageSize = 20
|
||||
const notiTotal = ref(0)
|
||||
const { unreadCount: notificationUnreadCount } = useNotificationUnreadCount(route)
|
||||
|
||||
// ── 合并未读 ──
|
||||
const combinedUnread = computed(() => unreadTotal.value + notificationUnreadCount.value)
|
||||
|
||||
// ── Tab ──
|
||||
const activeTab = ref('chats')
|
||||
|
||||
const desktopNotification = useDesktopNotification('user')
|
||||
const { onEvent } = useChatSSE('user', '/api/chats/events')
|
||||
onEvent(handleSSEEvent)
|
||||
@@ -31,6 +59,7 @@ onMounted(() => {
|
||||
loadChats(true)
|
||||
})
|
||||
|
||||
// ── 聊天方法 ──
|
||||
async function loadChats(isRefresh = false, showLoading = true) {
|
||||
if (isRefresh) page.value = 1
|
||||
if (loading.value) return
|
||||
@@ -75,7 +104,6 @@ function roleLabel(role: string) {
|
||||
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: '订单' }
|
||||
@@ -88,6 +116,63 @@ function previewText(item: ChatConversation) {
|
||||
(item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
|
||||
)
|
||||
}
|
||||
|
||||
// ── 通知方法 ──
|
||||
async function loadNotifications(reset = false) {
|
||||
if (notiLoading.value) return
|
||||
if (reset) notiPage.value = 1
|
||||
notiLoading.value = true
|
||||
try {
|
||||
const result = await fetchNotifications(notiPage.value, notiPageSize)
|
||||
notifications.value = reset ? result.items : [...notifications.value, ...result.items]
|
||||
notiTotal.value = result.total
|
||||
if (notifications.value.length < result.total && result.items.length > 0) {
|
||||
notiPage.value += 1
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('站内信加载失败')
|
||||
} finally {
|
||||
notiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markNotiRead(item: NotificationItem) {
|
||||
if (item.read_at) return
|
||||
try {
|
||||
await markNotificationRead(item.id)
|
||||
item.read_at = new Date().toISOString()
|
||||
window.dispatchEvent(new Event(notificationUnreadChangedEvent))
|
||||
ElMessage.success('已标记为已读')
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllNotiRead() {
|
||||
if (notiSubmitting.value) return
|
||||
notiSubmitting.value = true
|
||||
try {
|
||||
const result = await markAllNotificationsRead()
|
||||
window.dispatchEvent(new Event(notificationUnreadChangedEvent))
|
||||
ElMessage.success(result.read_count > 0 ? `已标记 ${result.read_count} 条通知` : '暂无未读通知')
|
||||
await loadNotifications(true)
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
notiSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function notiHasMore() {
|
||||
return notifications.value.length < notiTotal.value
|
||||
}
|
||||
|
||||
// ── Tab 切换 ──
|
||||
function handleTabChange(tab: string) {
|
||||
if (tab === 'notifications' && notifications.value.length === 0) {
|
||||
loadNotifications(true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -96,7 +181,13 @@ function previewText(item: ChatConversation) {
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Messages</p>
|
||||
<h1>消息</h1>
|
||||
<p>{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊和平台客服消息。' }}</p>
|
||||
<p>
|
||||
{{
|
||||
combinedUnread > 0
|
||||
? `${combinedUnread} 条未读`
|
||||
: '查看订单群聊、平台客服消息和系统通知。'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<NotificationSettings scope="user" />
|
||||
@@ -107,53 +198,130 @@ function previewText(item: ChatConversation) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && conversations.length === 0" class="message-loading" v-loading="loading" />
|
||||
<el-tabs v-model="activeTab" class="messages-tabs" @tab-change="handleTabChange">
|
||||
<!-- 聊天 Tab -->
|
||||
<el-tab-pane name="chats">
|
||||
<template #label>
|
||||
<span class="tab-label">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
聊天
|
||||
<em v-if="unreadTotal > 0" class="tab-badge">{{ unreadTotal > 99 ? '99+' : unreadTotal }}</em>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<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-if="loading && conversations.length === 0" class="message-loading" v-loading="loading" />
|
||||
|
||||
<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 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 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>
|
||||
<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>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 通知 Tab -->
|
||||
<el-tab-pane name="notifications">
|
||||
<template #label>
|
||||
<span class="tab-label">
|
||||
<el-icon><Bell /></el-icon>
|
||||
通知
|
||||
<em v-if="notificationUnreadCount > 0" class="tab-badge">{{ notificationUnreadCount > 99 ? '99+' : notificationUnreadCount }}</em>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="noti-toolbar">
|
||||
<span v-if="notificationUnreadCount > 0" class="noti-toolbar-text">
|
||||
{{ notificationUnreadCount }} 条未读
|
||||
</span>
|
||||
<span v-else class="noti-toolbar-text">暂无未读通知</span>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="notiSubmitting"
|
||||
:disabled="notificationUnreadCount === 0"
|
||||
@click="markAllNotiRead"
|
||||
>全部已读</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="notiLoading && notifications.length === 0" class="message-loading" v-loading="notiLoading" />
|
||||
|
||||
<div v-else-if="!notiLoading && notifications.length === 0" class="empty-panel">
|
||||
<el-empty description="暂无站内信" />
|
||||
</div>
|
||||
|
||||
<div v-else v-loading="notiLoading" class="notification-list">
|
||||
<div
|
||||
v-for="item in notifications"
|
||||
:key="item.id"
|
||||
class="notification-item"
|
||||
:class="{ unread: !item.read_at }"
|
||||
>
|
||||
<div class="notification-body">
|
||||
<span class="noti-type-chip">{{ item.type }}</span>
|
||||
<h2>{{ item.title }}</h2>
|
||||
<p>{{ item.content }}</p>
|
||||
<small>{{ formatDateTime(item.created_at) }}</small>
|
||||
</div>
|
||||
<div class="notification-actions">
|
||||
<el-button
|
||||
v-if="item.biz_type === 'order' && item.biz_id"
|
||||
size="small"
|
||||
@click="router.push(`/orders/${item.biz_id}`)"
|
||||
>查看订单</el-button>
|
||||
<el-button
|
||||
v-if="!item.read_at"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="markNotiRead(item)"
|
||||
>已读</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="notiHasMore()" class="pagination-wrap">
|
||||
<el-button :loading="notiLoading" @click="loadNotifications(false)">加载更多</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -173,6 +341,35 @@ function previewText(item: ChatConversation) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ── Tabs ── */
|
||||
.messages-tabs {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── 通用 ── */
|
||||
.message-loading,
|
||||
.empty-panel {
|
||||
min-height: 360px;
|
||||
@@ -186,6 +383,26 @@ function previewText(item: ChatConversation) {
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* ── 通知工具栏 ── */
|
||||
.noti-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.noti-toolbar-text {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── 聊天列表 ── */
|
||||
.conversation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -329,8 +546,77 @@ function previewText(item: ChatConversation) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
/* ── 通知列表 ── */
|
||||
.notification-list {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e8edf3;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
border-color: #1477ff;
|
||||
box-shadow: 0 4px 16px rgba(20, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
border-color: rgba(255, 106, 0, 0.35);
|
||||
background: #fffaf5;
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.noti-type-chip {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef6ff;
|
||||
color: #1477ff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notification-body h2 {
|
||||
margin: 8px 0 4px;
|
||||
color: #17233d;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notification-body p {
|
||||
margin: 0;
|
||||
color: #4b5563;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.notification-body small {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user