Files
hfb_sys/frontend/src/features/auth/views/MobileNotificationsView.vue
T

269 lines
6.1 KiB
Vue

<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>