增加站内信未读提醒和后台通知中心+txt 校验
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user