全面公告系统: - notifications 表新增置顶/强提醒/定时发布/过期/配图/链接字段 - 后端增强:7 个接口(列表/置顶/未读数/已读/发布/编辑/删除) - 首页强提醒公告弹窗 - 通知中心:置顶标记、配图展示、链接跳转、管理员操作 - 管理员发布表单:标题+内容+链接+配图+置顶+强提醒+定时+过期 Bug 修复: - 401 重试队列竞态导致请求永久挂起 - 多分类筛选条件被静默丢弃 - 网络错误导致群组选择丢失 - Numpad 输入 '.' 显示异常 - transaction store 错误时清空数据 - 首页/预算页 Promise.all 部分失败阻塞关键数据 - CSS prefers-reduced-motion 类名不匹配 - 通知 read-all 遗漏群组通知 - auth.ts 错误日志缺少 stack trace - stats 页面 v-for key 使用 index - add 页面重复调用 fetchCategories
461 lines
11 KiB
Vue
461 lines
11 KiB
Vue
<template>
|
|
<view class="page">
|
|
<view class="header-fixed">
|
|
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
|
<view class="nav-bar" :style="{ paddingRight: capsuleRight + 'px' }">
|
|
<view class="nav-back" @tap="goBack">
|
|
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
|
|
</view>
|
|
<text class="nav-title">通知中心</text>
|
|
<view class="nav-action" @tap="handleMarkAllRead">
|
|
<text class="nav-action-text">全部已读</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Tab 切换 -->
|
|
<view class="tabs-wrap">
|
|
<view class="tabs">
|
|
<view v-for="tab in tabs" :key="tab.key" class="tab" :class="{ active: currentTab === tab.key }" @tap="switchTab(tab.key)">
|
|
<text class="tab-text">{{ tab.label }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 通知列表 -->
|
|
<view v-if="loading && list.length === 0" class="loading-box">
|
|
<text class="loading-text">加载中...</text>
|
|
</view>
|
|
|
|
<view v-else-if="list.length === 0" class="empty-box">
|
|
<Icon name="bell" :size="48" color="#BFB3B3" />
|
|
<text class="empty-text">暂无通知</text>
|
|
</view>
|
|
|
|
<view v-else class="notification-list">
|
|
<view v-for="item in list" :key="item.id" class="notification-item" :class="{ unread: !item.is_read, pinned: item.is_pinned }" @tap="handleTap(item)">
|
|
<view class="notif-icon" :style="{ background: getNotifBg(item.type) }">
|
|
<Icon :name="getIconName(item.type)" :size="28" :color="getIconColor(item.type)" />
|
|
</view>
|
|
<view class="notif-content">
|
|
<view class="notif-title-row">
|
|
<text v-if="item.is_pinned" class="pin-badge">📌</text>
|
|
<text class="notif-title">{{ item.title }}</text>
|
|
</view>
|
|
<text class="notif-body" v-if="item.content">{{ item.content }}</text>
|
|
<image v-if="item.image_url" class="notif-image" :src="item.image_url" mode="widthFix" />
|
|
<view class="notif-meta">
|
|
<text class="notif-time">{{ formatTime(item.created_at) }}</text>
|
|
<text v-if="item.link_url" class="notif-link" @tap.stop="openLink(item.link_url)">查看详情</text>
|
|
</view>
|
|
<!-- 管理员操作 -->
|
|
<view v-if="isAdmin && item.type === 'system'" class="admin-actions">
|
|
<view class="admin-btn" @tap.stop="handleTogglePin(item)">
|
|
<text class="admin-btn-text">{{ item.is_pinned ? '取消置顶' : '置顶' }}</text>
|
|
</view>
|
|
<view class="admin-btn danger" @tap.stop="handleDeleteNotif(item)">
|
|
<text class="admin-btn-text danger-text">删除</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
<view v-if="!item.is_read" class="unread-dot"></view>
|
|
</view>
|
|
|
|
<view v-if="noMore && list.length > 0" class="no-more">
|
|
<text class="no-more-text">没有更多了</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { onShow, onReachBottom } from '@dcloudio/uni-app'
|
|
import { useNotificationStore } from '@/stores/notification'
|
|
import { useUserStore } from '@/stores/user'
|
|
import { getNotifications, markRead, markAllRead, deleteNotification, updateNotification } from '@/api/notification'
|
|
import { waitForReady } from '@/utils/app-ready'
|
|
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
|
import Icon from '@/components/Icon/Icon.vue'
|
|
import type { Notification } from '@/api/notification'
|
|
|
|
const notifStore = useNotificationStore()
|
|
const userStore = useUserStore()
|
|
const isAdmin = computed(() => userStore.userInfo?.role === 'admin')
|
|
|
|
const tabs = [
|
|
{ key: 'all', label: '全部' },
|
|
{ key: 'system', label: '公告' },
|
|
{ key: 'group', label: '群组' },
|
|
{ key: 'personal', label: '个人' },
|
|
]
|
|
|
|
const currentTab = ref('all')
|
|
const list = ref<Notification[]>([])
|
|
const page = ref(1)
|
|
const total = ref(0)
|
|
const loading = ref(false)
|
|
let loadSeq = 0
|
|
|
|
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
|
|
|
|
onMounted(async () => {
|
|
await waitForReady()
|
|
await loadNotifications(true)
|
|
})
|
|
|
|
onShow(() => {
|
|
notifStore.fetchUnreadCount()
|
|
})
|
|
|
|
function switchTab(key: string) {
|
|
currentTab.value = key
|
|
loadNotifications(true)
|
|
}
|
|
|
|
async function loadNotifications(reset = false) {
|
|
if (reset) {
|
|
page.value = 1
|
|
list.value = []
|
|
}
|
|
const seq = ++loadSeq
|
|
loading.value = true
|
|
try {
|
|
const params: any = { page: page.value, pageSize: 20 }
|
|
if (currentTab.value !== 'all') params.type = currentTab.value
|
|
const data = await getNotifications(params)
|
|
if (seq !== loadSeq) return
|
|
if (reset) {
|
|
list.value = data.list
|
|
} else {
|
|
list.value = [...list.value, ...data.list]
|
|
}
|
|
total.value = data.total
|
|
} catch (e) {
|
|
console.error('Load notifications error:', e)
|
|
} finally {
|
|
if (seq === loadSeq) loading.value = false
|
|
}
|
|
}
|
|
|
|
function getIconName(type: string) {
|
|
if (type === 'system') return 'bell'
|
|
if (type === 'group') return 'user'
|
|
return 'alert-circle'
|
|
}
|
|
|
|
function getIconColor(type: string) {
|
|
if (type === 'system') return '#FF8C69'
|
|
if (type === 'group') return '#8B7E7E'
|
|
return '#BFB3B3'
|
|
}
|
|
|
|
function getNotifBg(type: string) {
|
|
if (type === 'system') return '#FFE8E0'
|
|
if (type === 'group') return '#F0E0D6'
|
|
return '#FFF0E6'
|
|
}
|
|
|
|
function formatTime(dateStr: string) {
|
|
const d = new Date(dateStr)
|
|
const now = new Date()
|
|
const diff = now.getTime() - d.getTime()
|
|
const minutes = Math.floor(diff / 60000)
|
|
if (minutes < 1) return '刚刚'
|
|
if (minutes < 60) return `${minutes}分钟前`
|
|
const hours = Math.floor(minutes / 60)
|
|
if (hours < 24) return `${hours}小时前`
|
|
const days = Math.floor(hours / 24)
|
|
if (days < 7) return `${days}天前`
|
|
return `${d.getMonth() + 1}月${d.getDate()}日`
|
|
}
|
|
|
|
async function handleTap(item: Notification) {
|
|
if (!item.is_read) {
|
|
try {
|
|
await markRead(item.id)
|
|
item.is_read = 1
|
|
notifStore.fetchUnreadCount()
|
|
} catch {
|
|
uni.showToast({ title: '操作失败', icon: 'none' })
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleMarkAllRead() {
|
|
try {
|
|
await markAllRead()
|
|
list.value.forEach(item => { item.is_read = 1 })
|
|
notifStore.fetchUnreadCount()
|
|
uni.showToast({ title: '已全部标记已读', icon: 'success' })
|
|
} catch {
|
|
uni.showToast({ title: '操作失败,请重试', icon: 'none' })
|
|
}
|
|
}
|
|
|
|
function openLink(url: string) {
|
|
// #ifdef H5
|
|
window.open(url, '_blank')
|
|
// #endif
|
|
// #ifdef MP-WEIXIN
|
|
uni.setClipboardData({
|
|
data: url,
|
|
success: () => uni.showToast({ title: '链接已复制', icon: 'success' })
|
|
})
|
|
// #endif
|
|
}
|
|
|
|
/** 管理员:切换置顶状态 */
|
|
async function handleTogglePin(item: Notification) {
|
|
try {
|
|
await updateNotification(item.id, { is_pinned: !item.is_pinned })
|
|
item.is_pinned = item.is_pinned ? 0 : 1
|
|
uni.showToast({ title: item.is_pinned ? '已置顶' : '已取消置顶', icon: 'success' })
|
|
} catch {
|
|
uni.showToast({ title: '操作失败', icon: 'none' })
|
|
}
|
|
}
|
|
|
|
/** 管理员:删除公告 */
|
|
function handleDeleteNotif(item: Notification) {
|
|
uni.showModal({
|
|
title: '删除公告',
|
|
content: `确定删除「${item.title}」?`,
|
|
success: async (res) => {
|
|
if (res.confirm) {
|
|
try {
|
|
await deleteNotification(item.id)
|
|
list.value = list.value.filter(n => n.id !== item.id)
|
|
uni.showToast({ title: '已删除', icon: 'success' })
|
|
} catch {
|
|
uni.showToast({ title: '删除失败', icon: 'none' })
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
function goBack() {
|
|
uni.navigateBack()
|
|
}
|
|
|
|
onReachBottom(() => {
|
|
if (noMore.value || loading.value) return
|
|
page.value++
|
|
loadNotifications(false)
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.page {
|
|
min-height: 100vh;
|
|
background: #FFF8F0;
|
|
padding: 0 0 180rpx;
|
|
}
|
|
|
|
.header-fixed {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
background: #FFF8F0;
|
|
}
|
|
|
|
.status-bar { background: #FFF8F0; }
|
|
|
|
.nav-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 16rpx 40rpx 24rpx;
|
|
}
|
|
|
|
.nav-back {
|
|
width: 88rpx;
|
|
height: 88rpx;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
&:active { opacity: 0.6; }
|
|
}
|
|
|
|
.nav-title {
|
|
flex: 1;
|
|
text-align: center;
|
|
font-size: 36rpx;
|
|
font-weight: 600;
|
|
color: #2D1B1B;
|
|
}
|
|
|
|
.nav-action {
|
|
padding: 8rpx 16rpx;
|
|
&:active { opacity: 0.6; }
|
|
}
|
|
|
|
.nav-action-text {
|
|
font-size: 26rpx;
|
|
color: #FF8C69;
|
|
}
|
|
|
|
.tabs-wrap { display: flex; justify-content: center; margin: 0 0 24rpx; }
|
|
|
|
.tabs {
|
|
display: inline-flex;
|
|
background: #FFF0E6;
|
|
border-radius: 24rpx;
|
|
padding: 8rpx;
|
|
}
|
|
|
|
.tab {
|
|
padding: 12rpx 32rpx;
|
|
border-radius: 20rpx;
|
|
font-size: 26rpx;
|
|
color: #8B7E7E;
|
|
transition: all 0.2s;
|
|
|
|
&.active {
|
|
background: #FFFFFF;
|
|
color: #FF8C69;
|
|
font-weight: 600;
|
|
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
|
|
}
|
|
}
|
|
|
|
.loading-box, .empty-box {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 16rpx;
|
|
padding: 120rpx 0;
|
|
}
|
|
|
|
.loading-text, .empty-text {
|
|
font-size: 28rpx;
|
|
color: #BFB3B3;
|
|
}
|
|
|
|
.notification-list { padding: 0 40rpx; }
|
|
|
|
.notification-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 20rpx;
|
|
padding: 28rpx 32rpx;
|
|
margin-bottom: 16rpx;
|
|
background: #FFFFFF;
|
|
border-radius: 28rpx;
|
|
border: 2rpx solid #F0E0D6;
|
|
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
|
position: relative;
|
|
overflow: hidden;
|
|
|
|
&.unread { border-left: 6rpx solid #FF8C69; }
|
|
}
|
|
|
|
.notif-icon {
|
|
width: 64rpx;
|
|
height: 64rpx;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.notif-content { flex: 1; min-width: 0; }
|
|
|
|
.notif-title-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8rpx;
|
|
}
|
|
|
|
.pin-badge {
|
|
font-size: 24rpx;
|
|
}
|
|
|
|
.notif-title {
|
|
font-size: 28rpx;
|
|
font-weight: 600;
|
|
color: #2D1B1B;
|
|
display: block;
|
|
}
|
|
|
|
.notif-body {
|
|
font-size: 24rpx;
|
|
color: #8B7E7E;
|
|
display: block;
|
|
margin-top: 8rpx;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.notif-image {
|
|
width: 100%;
|
|
border-radius: 12rpx;
|
|
margin-top: 12rpx;
|
|
}
|
|
|
|
.notif-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-top: 8rpx;
|
|
}
|
|
|
|
.notif-time {
|
|
font-size: 22rpx;
|
|
color: #BFB3B3;
|
|
}
|
|
|
|
.unread-dot {
|
|
width: 16rpx;
|
|
height: 16rpx;
|
|
border-radius: 50%;
|
|
background: #FF8C69;
|
|
flex-shrink: 0;
|
|
margin-top: 8rpx;
|
|
}
|
|
|
|
.notif-link {
|
|
font-size: 22rpx;
|
|
color: #FF8C69;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.pinned {
|
|
border-color: #FF8C69;
|
|
background: #FFFAF7;
|
|
}
|
|
|
|
.no-more {
|
|
text-align: center;
|
|
padding: 32rpx 0;
|
|
}
|
|
|
|
.no-more-text {
|
|
font-size: 24rpx;
|
|
color: #BFB3B3;
|
|
}
|
|
|
|
.admin-actions {
|
|
display: flex;
|
|
gap: 12rpx;
|
|
margin-top: 12rpx;
|
|
}
|
|
|
|
.admin-btn {
|
|
padding: 8rpx 16rpx;
|
|
background: #FFF0E6;
|
|
border-radius: 12rpx;
|
|
|
|
&.danger { background: #FFE8E8; }
|
|
&:active { opacity: 0.7; }
|
|
}
|
|
|
|
.admin-btn-text {
|
|
font-size: 22rpx;
|
|
color: #FF8C69;
|
|
&.danger-text { color: #FF6B6B; }
|
|
}
|
|
</style>
|