增加站内信未读提醒和后台通知中心+txt 校验

This commit is contained in:
yml2213
2026-06-19 15:24:15 +08:00
parent 8d5094a8d0
commit a4cdc3e806
30 changed files with 1836 additions and 53 deletions
@@ -0,0 +1,56 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
export type AdminNotificationType = 'system' | 'chat' | (string & {})
export interface AdminNotification {
id: number
admin_user_id: number
type: AdminNotificationType
title: string
content: string
is_read: boolean
created_at: string
updated_at: string
}
export interface AdminNotificationQuery {
read?: 'unread'
page?: number
page_size?: number
}
export async function fetchAdminNotifications(query: AdminNotificationQuery = {}) {
const params = Object.fromEntries(
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
)
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminNotification>>>(
'/admin/notifications',
{ params }
)
return data.data
}
export async function fetchAdminNotificationUnreadCount() {
const { data } = await apiClient.get<ApiResponse<{ unread_count: number }>>(
'/admin/notifications/unread-count',
{
silent: true,
}
)
return data.data.unread_count
}
export async function markAdminNotificationRead(id: number) {
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(
`/admin/notifications/${id}/read`
)
return data.data
}
export async function markAllAdminNotificationsRead() {
const { data } = await apiClient.put<ApiResponse<{ read_count: number }>>(
'/admin/notifications/read-all'
)
return data.data
}
@@ -0,0 +1,100 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { fetchAdminNotificationUnreadCount } from '@/features/admin/api/adminNotifications'
import { useAdminSessionStore } from '@/stores/adminSession'
export const adminNotificationUnreadChangedEvent = 'admin-notification-unread-changed'
export function useAdminNotificationUnreadCount(route: RouteLocationNormalizedLoaded) {
const adminSession = useAdminSessionStore()
const unreadCount = ref(0)
let timer: number | null = null
let requestID = 0
const unreadLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)))
function stopPolling() {
if (timer) {
window.clearInterval(timer)
timer = null
}
}
async function loadUnreadCount() {
if (!adminSession.hasSessionHint) {
unreadCount.value = 0
return
}
const currentID = ++requestID
try {
const count = await fetchAdminNotificationUnreadCount()
if (currentID === requestID) {
unreadCount.value = count
}
} catch {
// 后台角标静默失败,避免干扰当前操作。
}
}
function startPolling() {
stopPolling()
void loadUnreadCount()
timer = window.setInterval(loadUnreadCount, 30_000)
}
function resumePollingIfVisible() {
if (document.hidden || !adminSession.hasSessionHint) return
startPolling()
}
function handleVisibilityChange() {
if (document.hidden) {
stopPolling()
return
}
resumePollingIfVisible()
}
function handleUnreadChanged() {
void loadUnreadCount()
}
onMounted(() => {
window.addEventListener(adminNotificationUnreadChangedEvent, handleUnreadChanged)
document.addEventListener('visibilitychange', handleVisibilityChange)
resumePollingIfVisible()
})
onBeforeUnmount(() => {
stopPolling()
window.removeEventListener(adminNotificationUnreadChangedEvent, handleUnreadChanged)
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
watch(
() => adminSession.hasSessionHint,
hasSession => {
if (hasSession) {
resumePollingIfVisible()
return
}
stopPolling()
unreadCount.value = 0
}
)
watch(
() => route.fullPath,
() => {
void loadUnreadCount()
}
)
return {
unreadCount,
unreadLabel,
refreshUnreadCount: loadUnreadCount,
}
}
+1
View File
@@ -6,6 +6,7 @@ export * from './api/adminWallet'
export * from './api/adminPayments'
export * from './api/adminFinance'
export * from './api/adminAudit'
export * from './api/adminNotifications'
export * from './api/systemConfigs'
export * from './api/supportGroups'
export * from './composables/useAdminTable'
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { Bell, Check, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import {
fetchAdminNotifications,
markAdminNotificationRead,
markAllAdminNotificationsRead,
type AdminNotification,
} from '@/features/admin/api/adminNotifications'
import { adminNotificationUnreadChangedEvent } from '@/features/admin/composables/useAdminNotificationUnreadCount'
import { formatDateTime } from '@/shared/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue'
const loading = ref(false)
const submitting = ref(false)
const notifications = ref<AdminNotification[]>([])
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const readFilter = ref<'all' | 'unread'>('all')
const unreadInPage = computed(() => notifications.value.filter(item => !item.is_read).length)
onMounted(loadNotifications)
async function loadNotifications() {
loading.value = true
try {
const result = await fetchAdminNotifications({
read: readFilter.value === 'unread' ? 'unread' : undefined,
page: currentPage.value,
page_size: currentPageSize.value,
})
notifications.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
async function handleFilterChange() {
currentPage.value = 1
await loadNotifications()
}
async function handleMarkRead(row: AdminNotification) {
if (row.is_read) return
await markAdminNotificationRead(row.id)
row.is_read = true
window.dispatchEvent(new Event(adminNotificationUnreadChangedEvent))
ElMessage.success('已标记为已读')
}
async function handleMarkAllRead() {
if (submitting.value) return
submitting.value = true
try {
const result = await markAllAdminNotificationsRead()
window.dispatchEvent(new Event(adminNotificationUnreadChangedEvent))
ElMessage.success(result.read_count > 0 ? `已标记 ${result.read_count} 条通知` : '暂无未读通知')
await loadNotifications()
} finally {
submitting.value = false
}
}
function typeLabel(type: string) {
const map: Record<string, string> = {
system: '系统',
chat: '聊天',
}
return map[type] || type
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Notifications</p>
<h1>通知中心</h1>
<p>查看库存预警客服协作等后台提醒</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadNotifications">刷新</el-button>
<el-button
type="primary"
:icon="Check"
:loading="submitting"
:disabled="unreadInPage === 0 && readFilter === 'unread'"
@click="handleMarkAllRead"
>
全部已读
</el-button>
</div>
</div>
<el-segmented
v-model="readFilter"
class="notification-filter"
:options="[
{ label: '全部', value: 'all' },
{ label: '未读', value: 'unread' },
]"
@change="handleFilterChange"
/>
<el-table v-loading="loading" class="table-panel" :data="notifications">
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.is_read ? 'info' : 'danger'" effect="plain">
{{ row.is_read ? '已读' : '未读' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag type="primary" effect="plain">{{ typeLabel(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="内容" min-width="420">
<template #default="{ row }">
<div class="notification-content" :class="{ unread: !row.is_read }">
<el-icon><Bell /></el-icon>
<div>
<strong>{{ row.title }}</strong>
<p>{{ row.content }}</p>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button v-if="!row.is_read" size="small" type="primary" @click="handleMarkRead(row)">
已读
</el-button>
<span v-else class="muted-text">-</span>
</template>
</el-table-column>
</el-table>
<AdminTablePagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:loading="loading"
@page-change="loadNotifications"
/>
</section>
</template>
<style scoped>
.notification-filter {
margin-bottom: 14px;
}
.notification-content {
display: flex;
align-items: flex-start;
gap: 10px;
color: #5b6575;
}
.notification-content.unread {
color: #17233d;
}
.notification-content .el-icon {
margin-top: 2px;
color: #409eff;
}
.notification-content strong {
display: block;
margin-bottom: 4px;
font-size: 14px;
}
.notification-content p {
margin: 0;
color: #6b7280;
line-height: 20px;
}
.muted-text {
color: #9ca3af;
}
</style>
@@ -28,3 +28,20 @@ export async function markNotificationRead(id: number) {
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/notifications/${id}/read`)
return data.data
}
export async function fetchUnreadNotificationCount() {
const { data } = await apiClient.get<ApiResponse<{ unread_count: number }>>(
'/notifications/unread-count',
{
silent: true,
}
)
return data.data.unread_count
}
export async function markAllNotificationsRead() {
const { data } = await apiClient.put<ApiResponse<{ read_count: number }>>(
'/notifications/read-all'
)
return data.data
}
@@ -0,0 +1,100 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { fetchUnreadNotificationCount } from '@/features/auth/api/notifications'
import { useSessionStore } from '@/stores/session'
export const notificationUnreadChangedEvent = 'notification-unread-changed'
export function useNotificationUnreadCount(route: RouteLocationNormalizedLoaded) {
const session = useSessionStore()
const unreadCount = ref(0)
let timer: number | null = null
let requestID = 0
const unreadLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)))
function stopPolling() {
if (timer) {
window.clearInterval(timer)
timer = null
}
}
async function loadUnreadCount() {
if (!session.isLoggedIn) {
unreadCount.value = 0
return
}
const currentID = ++requestID
try {
const count = await fetchUnreadNotificationCount()
if (currentID === requestID) {
unreadCount.value = count
}
} catch {
// 角标静默失败,避免影响主流程。
}
}
function startPolling() {
stopPolling()
void loadUnreadCount()
timer = window.setInterval(loadUnreadCount, 30_000)
}
function resumePollingIfVisible() {
if (document.hidden || !session.isLoggedIn) return
startPolling()
}
function handleVisibilityChange() {
if (document.hidden) {
stopPolling()
return
}
resumePollingIfVisible()
}
function handleUnreadChanged() {
void loadUnreadCount()
}
onMounted(() => {
window.addEventListener(notificationUnreadChangedEvent, handleUnreadChanged)
document.addEventListener('visibilitychange', handleVisibilityChange)
resumePollingIfVisible()
})
onBeforeUnmount(() => {
stopPolling()
window.removeEventListener(notificationUnreadChangedEvent, handleUnreadChanged)
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
watch(
() => session.isLoggedIn,
loggedIn => {
if (loggedIn) {
resumePollingIfVisible()
return
}
stopPolling()
unreadCount.value = 0
}
)
watch(
() => route.fullPath,
() => {
void loadUnreadCount()
}
)
return {
unreadCount,
unreadLabel,
refreshUnreadCount: loadUnreadCount,
}
}
+1
View File
@@ -2,3 +2,4 @@
export * from './api/auth'
export * from './api/realname'
export * from './api/notifications'
export * from './composables/useNotificationUnreadCount'
@@ -0,0 +1,262 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import {
fetchNotifications,
markNotificationRead,
type NotificationItem,
} from '@/features/auth/api/notifications'
import { notificationUnreadChangedEvent } from '@/features/auth/composables/useNotificationUnreadCount'
import { formatDateMinute } from '@/shared/utils/time'
const router = useRouter()
const loading = ref(false)
const refreshing = ref(false)
const loadingMore = ref(false)
const notifications = ref<NotificationItem[]>([])
const currentPage = ref(1)
const pageSize = 20
const total = ref(0)
const hasMore = computed(() => notifications.value.length < total.value)
onMounted(() => {
loadNotifications(true)
})
async function loadNotifications(reset = false) {
if ((reset && loading.value) || (!reset && loadingMore.value)) return
if (reset) {
currentPage.value = 1
loading.value = true
} else {
loadingMore.value = true
}
try {
const result = await fetchNotifications(currentPage.value, pageSize)
notifications.value = reset ? result.items : [...notifications.value, ...result.items]
total.value = result.total
if (notifications.value.length < result.total && result.items.length > 0) {
currentPage.value += 1
}
} catch {
showToast({ message: '站内信加载失败', icon: 'cross' })
} finally {
loading.value = false
refreshing.value = false
loadingMore.value = false
}
}
async function markRead(item: NotificationItem) {
if (item.read_at) return
try {
await markNotificationRead(item.id)
item.read_at = new Date().toISOString()
window.dispatchEvent(new Event(notificationUnreadChangedEvent))
showToast({ message: '已标记为已读', icon: 'passed' })
} catch {
showToast({ message: '操作失败', icon: 'cross' })
}
}
function openBiz(item: NotificationItem) {
if (!item.biz_id) return
if (item.biz_type === 'order') {
router.push(`/m/orders/${item.biz_id}`)
return
}
if (item.biz_type === 'listing') {
router.push('/m/seller/listings')
}
}
function typeLabel(type: string) {
const map: Record<string, string> = {
order: '订单',
handoff: '交接',
checkout: '结账',
settlement: '结算',
dispute: '申诉',
arbitration: '仲裁',
timeout: '超时',
listing_review: '审核',
listing_admin: '商品',
order_admin: '客服',
}
return map[type] || '系统'
}
</script>
<template>
<main class="mobile-notifications">
<header class="mobile-header">
<button type="button" class="header-back" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<div>
<h1>站内信</h1>
<p>{{ total > 0 ? `${total} 条通知` : '订单、审核和申诉消息会出现在这里' }}</p>
</div>
</header>
<van-pull-refresh v-model="refreshing" @refresh="loadNotifications(true)">
<section class="notification-list">
<van-empty v-if="!loading && notifications.length === 0" description="暂无站内信" />
<article
v-for="item in notifications"
:key="item.id"
class="notification-item"
:class="{ unread: !item.read_at }"
>
<div class="item-head">
<span class="type-chip">{{ typeLabel(item.type) }}</span>
<small>{{ formatDateMinute(item.created_at) }}</small>
</div>
<h2>{{ item.title }}</h2>
<p>{{ item.content }}</p>
<div class="item-actions">
<van-button
v-if="item.biz_id && (item.biz_type === 'order' || item.biz_type === 'listing')"
size="small"
plain
round
type="primary"
@click="openBiz(item)"
>
查看详情
</van-button>
<van-button v-if="!item.read_at" size="small" round type="primary" @click="markRead(item)">
已读
</van-button>
</div>
</article>
<van-button
v-if="hasMore"
block
plain
round
class="load-more"
:loading="loadingMore"
@click="loadNotifications(false)"
>
加载更多
</van-button>
</section>
</van-pull-refresh>
<MobileBottomNav />
</main>
</template>
<style scoped>
.mobile-notifications {
min-height: 100dvh;
padding: 0 0 calc(64px + env(safe-area-inset-bottom));
background: #f6f8fa;
color: #111827;
}
.mobile-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
border-bottom: 1px solid #eef1f5;
}
.header-back {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: none;
border-radius: 999px;
background: #f3f4f6;
color: #374151;
}
.mobile-header h1 {
margin: 0;
font-size: 18px;
line-height: 24px;
}
.mobile-header p {
margin: 2px 0 0;
color: #8a94a6;
font-size: 12px;
}
.notification-list {
display: grid;
gap: 10px;
padding: 12px;
}
.notification-item {
padding: 14px;
border: 1px solid #eef1f5;
border-radius: 8px;
background: #fff;
}
.notification-item.unread {
border-color: rgba(255, 106, 0, 0.35);
background: #fffaf5;
}
.item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.type-chip {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 999px;
background: #eef5ff;
color: #2563eb;
font-size: 12px;
font-weight: 700;
}
.item-head small {
color: #9ca3af;
font-size: 12px;
}
.notification-item h2 {
margin: 10px 0 6px;
color: #111827;
font-size: 15px;
line-height: 22px;
}
.notification-item p {
margin: 0;
color: #4b5563;
font-size: 13px;
line-height: 20px;
}
.item-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.load-more {
margin: 4px 0 12px;
}
</style>
@@ -5,6 +5,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { showDialog, showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { useNotificationUnreadCount } from '@/features/auth/composables/useNotificationUnreadCount'
import {
fetchWalletBalance,
@@ -19,6 +20,8 @@ import { formatCent } from '@/shared/utils/money'
const session = useSessionStore()
const router = useRouter()
const route = useRoute()
const { unreadCount: notificationUnreadCount, unreadLabel: notificationUnreadLabel } =
useNotificationUnreadCount(route)
/** 数据项 */
const availableBalanceCent = ref(0)
@@ -375,6 +378,13 @@ function resolveAvatarURL(url: string | undefined | null) {
<div class="menu-card cell-card">
<van-cell-group :border="false">
<van-cell title="消息中心" icon="chat-o" is-link to="/m/messages" />
<van-cell title="站内信" icon="bell" is-link to="/m/notifications">
<template #value>
<span v-if="notificationUnreadCount > 0" class="unread-cell-value">
{{ notificationUnreadLabel }} 未读
</span>
</template>
</van-cell>
<van-cell title="公告中心" icon="volume-o" is-link to="/m/announcements" />
<van-cell title="资料修改" icon="edit" is-link @click="openProfileEditor" />
<van-cell title="实名认证" icon="idcard" is-link to="/m/realname">
@@ -874,6 +884,10 @@ function resolveAvatarURL(url: string | undefined | null) {
.unverified-color {
color: #9ca3af;
}
.unread-cell-value {
color: #ef4444;
font-weight: 700;
}
/* 收支明细 Popup */
.ledgers-popup {
@@ -7,6 +7,7 @@ import {
markNotificationRead,
type NotificationItem,
} from '@/features/auth/api/notifications'
import { notificationUnreadChangedEvent } from '@/features/auth/composables/useNotificationUnreadCount'
import { formatDateTime } from '@/shared/utils/time'
const loading = ref(false)
@@ -35,6 +36,7 @@ function handleSizeChange() {
async function markRead(id: number) {
await markNotificationRead(id)
window.dispatchEvent(new Event(notificationUnreadChangedEvent))
ElMessage.success('已标记为已读')
await loadNotifications()
}
@@ -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>