feat: 全面公告系统 + 全系统 Bug 修复

全面公告系统:
- 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
This commit is contained in:
wangxiaogang
2026-06-06 22:02:28 +08:00
parent 83571de723
commit a35689cdda
20 changed files with 1058 additions and 202 deletions

View File

@@ -33,14 +33,30 @@
</view>
<view v-else class="notification-list">
<view v-for="item in list" :key="item.id" class="notification-item" :class="{ unread: !item.is_read }" @tap="handleTap(item)">
<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">
<text class="notif-title">{{ item.title }}</text>
<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>
<text class="notif-time">{{ formatTime(item.created_at) }}</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>
@@ -56,13 +72,16 @@
import { ref, computed, onMounted } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { useNotificationStore } from '@/stores/notification'
import { getNotifications, markRead, markAllRead } from '@/api/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: '全部' },
@@ -174,6 +193,48 @@ async function handleMarkAllRead() {
}
}
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()
}
@@ -301,6 +362,16 @@ onReachBottom(() => {
.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;
@@ -318,11 +389,22 @@ onReachBottom(() => {
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;
display: block;
margin-top: 8rpx;
}
.unread-dot {
@@ -334,6 +416,17 @@ onReachBottom(() => {
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;
@@ -343,4 +436,25 @@ onReachBottom(() => {
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>