feat(notify): 新增站内消息通知中心(订阅消息兜底)+ 清理后台伪造数据

- 新表 user_notifications + 仓储方法(创建/列表/未读数/标记已读)
- 用户接口 GET /api/notifications、POST /api/notifications/read(生产需登录)
- 关键节点落库:订单开始服务/接单/拒单/申请完成/完成结算/提现到账/报名成功/退款受理
- 小程序新增「消息通知」页(pages/user/notifications) + 个人中心入口与未读角标;api.js 增加 getNotifications/markNotificationsRead
- 清理后台伪造数据:/api/user/certification 改空态、/api/user/my-project/list 改空数组、/api/user/info 去掉编造邮箱/电话/网址
- 更新 pages 数量断言(26→27);新增回归护栏 notification-center.spec.ts(92 套件/658 用例全绿);冒烟 9/9
这个提交包含在:
xcbyy
2026-10-19 14:31:11 +08:00
父节点 12bbf4fc6b
当前提交 ca7f849b14
共有 10 个文件被更改,包括 598 次插入 和 43 次删除

查看文件

@@ -149,6 +149,12 @@
<text class="row-label">官方在线客服</text>
<image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" />
</view>
<view class="list-row btn-active" @click="openNotifications">
<image class="row-svg" :src="ICONS.chat" mode="aspectFit" />
<text class="row-label">消息通知</text>
<text class="row-sub-tag" v-if="noticeUnread > 0">{{ noticeUnread }}条未读</text>
<image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" />
</view>
<view class="list-row btn-active" @click="openNotice">
<image class="row-svg" :src="ICONS.bell" mode="aspectFit" />
<text class="row-label">平台消息与通知</text>
@@ -351,7 +357,7 @@ import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, setTheme, THEME_KEYS, applyThemeToNativeUI } from '@/common/theme.js';
import { getCurrentRole, setCurrentRole, ROLE_TYPES, toggleRole as toggleUserRole, isExpertCertified, getExpertCertStatus, setExpertCertStatus, CERT_STATUS } from '@/common/role.js';
import { getNavBarInfo } from '@/common/nav.js';
import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords, getMyReports, bindInviteCode } from '@/common/api.js';
import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords, getMyReports, bindInviteCode, getNotifications } from '@/common/api.js';
import { preloadSubscribeTemplates, requestSubscribeAll } from '@/common/subscribe.js';
const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200';
@@ -384,6 +390,7 @@ const openMyReportsModal = () => {
// 系统与隐私设置
const privacyHidePhone = ref(uni.getStorageSync('privacy_hide_phone') === '1');
const noticeSubscribe = ref(uni.getStorageSync('notice_subscribe') === '1');
const noticeUnread = ref(0);
const cacheSizeText = ref('1.25 MB');
// 分销推广管理
@@ -643,6 +650,7 @@ onShow(() => {
loadPlatformConfigs();
privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1';
noticeSubscribe.value = uni.getStorageSync('notice_subscribe') === '1';
loadNoticeUnread();
if (isLoggedIn.value) {
loadFavorites();
loadMyReports();
@@ -1214,6 +1222,26 @@ const openNotice = () => {
});
};
const openNotifications = () => {
uni.navigateTo({
url: '/pages/user/notifications',
fail: () => {
uni.showToast({ title: '页面打开失败,请稍后重试', icon: 'none' });
}
});
};
const loadNoticeUnread = async () => {
const uid = uni.getStorageSync('userId') || 0;
if (!uid) return;
try {
const res = await getNotifications(uid, 1);
noticeUnread.value = Number(res?.unread || 0);
} catch (_) {
// 未登录或网络异常时保持原值
}
};
const openFaq = () => {
uni.navigateTo({
url: '/pages/user/help-faq',

查看文件

@@ -0,0 +1,317 @@
<template>
<view class="notif-page" :class="['theme-' + currentThemeKey]" :style="navStyle">
<!-- 顶部沉浸式自定义导航栏 -->
<view class="custom-nav-bar">
<view class="nav-content">
<view class="btn-nav-back btn-active" @click="handleBack">
<image class="nav-back-icon" :src="ICONS.chevronLeft" mode="aspectFit" />
</view>
<view class="nav-title-wrap">
<text class="nav-title">消息通知</text>
</view>
<view class="nav-action btn-active" v-if="unreadCount > 0" @click="handleReadAll">
<text class="nav-action-text">全部已读</text>
</view>
<view class="nav-capsule-placeholder" v-else></view>
</view>
</view>
<scroll-view
scroll-y
class="notif-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
>
<view class="notif-body">
<view class="notif-summary">
<text class="summary-title">通知中心</text>
<text class="summary-sub">{{ unreadCount > 0 ? `你有 ${unreadCount} 条未读通知` : '暂无未读通知,订单/提现/报名进展都会在这里留痕' }}</text>
</view>
<view v-if="isLoading" class="notif-empty">
<text class="empty-text">加载中…</text>
</view>
<view v-else-if="list.length === 0" class="notif-empty">
<text class="empty-title">暂无消息</text>
<text class="empty-text">下单、支付、接单、提现到账等节点,平台都会在此提醒你</text>
</view>
<view v-else class="notif-list">
<view
v-for="item in list"
:key="item.id"
class="notif-card btn-active"
:class="{ 'is-unread': !item.isRead }"
@click="handleOpen(item)"
>
<view class="notif-dot" v-if="!item.isRead"></view>
<view class="notif-main">
<view class="notif-head">
<text class="notif-title">{{ item.title }}</text>
<text class="notif-time">{{ formatTime(item.createdAt) }}</text>
</view>
<text class="notif-content">{{ item.content }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey } from '@/common/theme.js';
import { getNavBarInfo } from '@/common/nav.js';
import { getNotifications, markNotificationsRead } from '@/common/api.js';
const currentThemeKey = ref(getCurrentThemeKey());
const navInfo = ref(getNavBarInfo());
const isLoading = ref(true);
const isRefreshing = ref(false);
const list = ref([]);
const unreadCount = ref(0);
const navStyle = computed(() => ({
'--safe-header-gap': `${navInfo.value.totalHeaderHeight}px`,
'--capsule-right-space': `${navInfo.value.capsuleRightSpace}px`,
'--capsule-height': `${navInfo.value.capsuleHeight}px`
}));
const currentUserId = () => uni.getStorageSync('userId') || uni.getStorageSync('uid') || 0;
const formatTime = (v) => {
if (!v) return '';
const s = String(v).replace('T', ' ').replace('Z', '');
return s.length >= 16 ? s.slice(0, 16) : s;
};
const loadData = async () => {
const uid = currentUserId();
if (!uid) {
isLoading.value = false;
return;
}
try {
const res = await getNotifications(uid, 50);
list.value = Array.isArray(res?.list) ? res.list : [];
unreadCount.value = Number(res?.unread || 0);
} catch (_) {
list.value = [];
unreadCount.value = 0;
} finally {
isLoading.value = false;
isRefreshing.value = false;
}
};
const onRefresh = () => {
isRefreshing.value = true;
loadData();
};
const handleReadAll = async () => {
const uid = currentUserId();
if (!uid) return;
try {
await markNotificationsRead(uid);
list.value = list.value.map((x) => ({ ...x, isRead: 1 }));
unreadCount.value = 0;
uni.showToast({ title: '已全部标记为已读', icon: 'none' });
} catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' });
}
};
const handleOpen = async (item) => {
const uid = currentUserId();
if (!item.isRead && uid) {
try {
await markNotificationsRead(uid, [item.id]);
item.isRead = 1;
unreadCount.value = Math.max(0, unreadCount.value - 1);
} catch (_) {}
}
const link = String(item.link || '');
if (!link) return;
const tabPages = [
'/pages/index/index',
'/pages/activity/list',
'/pages/order/create',
'/pages/community/index',
'/pages/user/index'
];
if (tabPages.includes(link)) {
uni.switchTab({ url: link, fail: () => {} });
} else {
uni.navigateTo({ url: link, fail: () => {} });
}
};
const handleBack = () => {
const pages = getCurrentPages();
if (pages && pages.length > 1) {
uni.navigateBack();
} else {
uni.switchTab({ url: '/pages/user/index' });
}
};
onShow(() => {
navInfo.value = getNavBarInfo();
currentThemeKey.value = getCurrentThemeKey();
loadData();
});
</script>
<style scoped>
.notif-page {
min-height: 100vh;
background: #0a0f1d;
}
.custom-nav-bar {
position: sticky;
top: 0;
z-index: 50;
background: rgba(10, 15, 29, 0.92);
padding-top: var(--safe-header-gap, 0);
}
.nav-content {
display: flex;
align-items: center;
height: 44px;
padding: 0 12px;
}
.btn-nav-back {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-back-icon {
width: 18px;
height: 18px;
}
.nav-title-wrap {
flex: 1;
text-align: center;
}
.nav-title {
color: #fff;
font-size: 17px;
font-weight: 600;
}
.nav-action {
min-width: 56px;
text-align: right;
}
.nav-action-text {
color: #8b9cff;
font-size: 13px;
}
.nav-capsule-placeholder {
width: 56px;
}
.notif-scroll {
height: calc(100vh - 44px - var(--safe-header-gap, 0px));
}
.notif-body {
padding: 12px 16px 40px;
}
.notif-summary {
padding: 16px;
border-radius: 16px;
background: linear-gradient(135deg, #1b2340 0%, #141a30 100%);
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.summary-title {
color: #fff;
font-size: 16px;
font-weight: 700;
}
.summary-sub {
color: #9aa7c7;
font-size: 12px;
line-height: 1.5;
}
.notif-empty {
padding: 48px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.empty-title {
color: #e6eaf5;
font-size: 15px;
font-weight: 600;
}
.empty-text {
color: #7d89a8;
font-size: 12px;
text-align: center;
line-height: 1.6;
}
.notif-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.notif-card {
position: relative;
padding: 14px 14px 14px 18px;
border-radius: 14px;
background: #141a30;
display: flex;
align-items: flex-start;
gap: 10px;
}
.notif-card.is-unread {
background: #182143;
}
.notif-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ff5f57;
margin-top: 6px;
}
.notif-main {
flex: 1;
min-width: 0;
}
.notif-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.notif-title {
color: #fff;
font-size: 14px;
font-weight: 600;
}
.notif-time {
color: #6d7997;
font-size: 11px;
flex-shrink: 0;
}
.notif-content {
display: block;
margin-top: 6px;
color: #a8b3d1;
font-size: 12px;
line-height: 1.6;
}
.btn-active:active {
opacity: 0.75;
}
</style>