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