feat: 迭代三大模块 + 全系统 Bug 修复

新功能:
- 账单筛选统计:多维度筛选面板 + 保存方案 + 结果统计
- 通知公告:系统公告/群组通知/个人提醒 + 通知中心页面
- 管理员后台:数据看板 + 用户管理 + 发布公告

Bug 修复:
- 统计页 fetchPrevMonthData/fetchMaxSingle 添加 group_id
- 群组视图添加 t.group_id 过滤,防止私人数据泄露
- 通知列表添加群组通知条件
- 通知标记已读添加错误处理
- 管理员 API 添加 affectedRows 检查
- 筛选面板金额为 0 时正确回填
- profile onShow 补充 userStore.fetchUserInfo
- 全系统样式修复(overflow/box-sizing)
This commit is contained in:
wangxiaogang
2026-06-06 17:55:29 +08:00
parent bc6eca1e9b
commit 83571de723
35 changed files with 2255 additions and 37 deletions

View File

@@ -0,0 +1,346 @@
<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 }" @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>
<text class="notif-body" v-if="item.content">{{ item.content }}</text>
<text class="notif-time">{{ formatTime(item.created_at) }}</text>
</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 { getNotifications, markRead, markAllRead } 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 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 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 {
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-time {
font-size: 22rpx;
color: #BFB3B3;
display: block;
margin-top: 8rpx;
}
.unread-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #FF8C69;
flex-shrink: 0;
margin-top: 8rpx;
}
.no-more {
text-align: center;
padding: 32rpx 0;
}
.no-more-text {
font-size: 24rpx;
color: #BFB3B3;
}
</style>