Files
rating/pages/message/index.vue

865 lines
20 KiB
Vue
Raw Normal View History

2026-06-15 00:15:23 +08:00
<template>
<view class="message-page">
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-content">
<view>
<text class="nav-title">系统消息</text>
<text class="nav-subtitle">审核通知评论互动和系统提醒都会在这里出现</text>
</view>
<view class="nav-right">
<view v-if="unreadCount > 0" class="unread-chip">
<text class="unread-chip-text">{{ unreadCount }} 未读</text>
</view>
</view>
</view>
</view>
<view :style="{ height: statusBarHeight + 64 + 'px' }"></view>
<view class="page-body">
<view v-if="!isLoggedIn" class="state-card login-card">
<text class="state-title">登录后查看你的系统消息</text>
<text class="state-desc">审核结果评论互动点赞提醒都会同步到这里</text>
<button class="primary-btn" @tap="openLoginPopup('请先登录后查看消息')">立即登录</button>
</view>
<template v-else>
<view class="filter-bar">
<view class="filter-tabs">
<view
v-for="tab in filterTabs"
:key="tab.value"
class="filter-tab"
:class="{ active: activeFilter === tab.value }"
@tap="activeFilter = tab.value"
>
<text class="filter-tab-text">{{ tab.label }}</text>
</view>
</view>
2026-06-15 07:18:36 +08:00
<view
class="read-all-btn filter-read-all-btn"
:class="{ disabled: readingAll || unreadCount === 0 }"
@tap="handleMarkAllRead"
>
<text class="read-all-text">{{ readingAll ? "处理中" : "全部已读" }}</text>
</view>
2026-06-15 00:15:23 +08:00
</view>
<view v-if="loading && !notificationList.length" class="state-card">
<text class="state-title">消息加载中...</text>
<text class="state-desc">正在同步你的最新通知</text>
</view>
<view v-else-if="loadError && !notificationList.length" class="state-card">
<text class="state-title">消息加载失败</text>
<text class="state-desc">{{ loadError }}</text>
<button class="primary-btn" @tap="refreshList">重新加载</button>
</view>
<view v-else-if="groupedNotifications.length" class="section-list">
<view v-for="section in groupedNotifications" :key="section.day" class="section-block">
<view class="section-header">
<text class="section-title">{{ formatDayLabel(section.day) }}</text>
</view>
<view
v-for="item in section.items"
:key="item.id"
class="message-card"
:class="{ unread: !item.isRead }"
@tap="handleItemTap(item)"
>
<view class="message-avatar-wrap">
<image class="message-avatar" :src="resolveAvatar(item)" mode="aspectFill" />
<view v-if="!item.isRead" class="message-dot"></view>
</view>
<view class="message-main">
<view class="message-head">
<view class="message-head-left">
<text class="message-title">{{ item.title || typeLabelMap[item.type] || "系统通知" }}</text>
<view
class="type-tag"
:style="{
color: resolveTypeStyle(item.type).color,
background: resolveTypeStyle(item.type).background
}"
>
<text class="type-tag-text">{{ typeLabelMap[item.type] || "提醒" }}</text>
</view>
</view>
<text class="message-time">{{ formatRelativeTime(item.createdAt) }}</text>
</view>
<text class="message-content">{{ item.content || "你有一条新的系统消息" }}</text>
<view class="message-footer">
<text class="message-meta">{{ resolveActionText(item) }}</text>
<text class="message-link">{{ resolveJumpText(item) }}</text>
</view>
</view>
</view>
</view>
<view class="list-status">
<text>{{ loading ? "加载中..." : hasMore ? "上拉加载更多" : "没有更多消息了" }}</text>
</view>
</view>
<view v-else class="state-card">
<text class="state-title">{{ activeFilter === "unread" ? "暂时没有未读消息" : "还没有系统消息" }}</text>
<text class="state-desc">{{ activeFilter === "unread" ? "已帮你处理完所有提醒" : "新的审核、评论和互动提醒会显示在这里" }}</text>
</view>
</template>
</view>
<LoginPopup ref="loginPopupRef" @logind="handleLoginSuccess" />
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
import { useUserStore } from "@/stores/user";
import { getStatusBarHeight } from "@/utils/system";
import { FILE_BASE_URL } from "@/utils/constants";
import { syncMessageTabBarBadge } from "@/utils/tabBar";
import {
fetchNotificationList,
fetchUnreadCount,
markAsRead,
markAllAsRead,
} from "@/api/notification";
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
const DEFAULT_AVATAR = "/static/images/icon/robot.png";
const userStore = useUserStore();
const loginPopupRef = ref(null);
const statusBarHeight = ref(getStatusBarHeight() || 20);
const loading = ref(false);
const readingAll = ref(false);
const page = ref(1);
const hasMore = ref(true);
const totalCount = ref(0);
const unreadCount = ref(0);
const loadError = ref("");
const activeFilter = ref("all");
const notificationList = ref([]);
const readingIds = ref([]);
const filterTabs = [
{ label: "全部", value: "all" },
{ label: "未读", value: "unread" },
];
const typeLabelMap = {
topic_submit: "话题提交",
topic_approved: "审核通过",
topic_rejected: "审核未过",
item_submit: "评分项提交",
item_approved: "评分项通过",
item_rejected: "评分项未过",
comment: "评论互动",
comment_like: "评论获赞",
system: "系统通知",
};
const typeStyleMap = {
topic_submit: { color: "#ff7d00", background: "rgba(255, 125, 0, 0.12)" },
topic_approved: { color: "#12b76a", background: "rgba(18, 183, 106, 0.12)" },
topic_rejected: { color: "#f04438", background: "rgba(240, 68, 56, 0.12)" },
item_submit: { color: "#7a5af8", background: "rgba(122, 90, 248, 0.12)" },
item_approved: { color: "#0ba5ec", background: "rgba(11, 165, 236, 0.12)" },
item_rejected: { color: "#ef6820", background: "rgba(239, 104, 32, 0.12)" },
comment: { color: "#ec4899", background: "rgba(236, 72, 153, 0.12)" },
comment_like: { color: "#f63d68", background: "rgba(246, 61, 104, 0.12)" },
system: { color: "#667085", background: "rgba(102, 112, 133, 0.12)" },
};
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
let firstShow = true;
const todayCount = computed(() => {
const today = formatDateKey(new Date());
return notificationList.value.filter((item) => (item.day || formatDateKey(item.createdAt)) === today).length;
});
const filteredNotifications = computed(() => {
if (activeFilter.value === "unread") {
return notificationList.value.filter((item) => !item.isRead);
}
return notificationList.value;
});
const groupedNotifications = computed(() => {
const map = {};
filteredNotifications.value.forEach((item) => {
const day = item.day || formatDateKey(item.createdAt);
if (!map[day]) {
map[day] = [];
}
map[day].push(item);
});
return Object.keys(map)
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())
.map((day) => ({
day,
items: map[day].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
}));
});
const openLoginPopup = (message = "请先登录后再操作") => {
loginPopupRef.value?.open();
if (message) {
uni.showToast({ title: message, icon: "none" });
}
};
const formatDateKey = (value) => {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "";
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, "0");
const day = `${date.getDate()}`.padStart(2, "0");
return `${year}-${month}-${day}`;
};
const formatRelativeTime = (value) => {
if (!value) return "刚刚";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "刚刚";
const diff = Date.now() - date.getTime();
const minute = 60 * 1000;
const hour = 60 * minute;
if (diff < minute) return "刚刚";
if (diff < hour) return `${Math.max(1, Math.floor(diff / minute))}分钟前`;
if (diff < 24 * hour) return `${Math.max(1, Math.floor(diff / hour))}小时前`;
const month = date.getMonth() + 1;
const day = date.getDate();
const hours = `${date.getHours()}`.padStart(2, "0");
const minutes = `${date.getMinutes()}`.padStart(2, "0");
const currentYear = new Date().getFullYear();
if (date.getFullYear() === currentYear) {
return `${month}${day}${hours}:${minutes}`;
}
return `${date.getFullYear()}-${`${month}`.padStart(2, "0")}-${`${day}`.padStart(2, "0")}`;
};
const formatDayLabel = (day) => {
if (!day) return "更早";
const today = formatDateKey(new Date());
const yesterdayDate = new Date();
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
const yesterday = formatDateKey(yesterdayDate);
if (day === today) return "今天";
if (day === yesterday) return "昨天";
const date = new Date(day);
if (Number.isNaN(date.getTime())) return day;
return `${date.getMonth() + 1}${date.getDate()}`;
};
const resolveAvatar = (item) => {
const avatar = item?.fromUser?.avatar;
if (!avatar) return DEFAULT_AVATAR;
return String(avatar).startsWith("http") ? avatar : `${FILE_BASE_URL}${avatar}`;
};
const resolveTypeStyle = (type) => {
return typeStyleMap[type] || typeStyleMap.system;
};
const resolveActionText = (item) => {
if (!item.isRead) return "点击后将标记为已读";
if (item.updatedAt && item.updatedAt !== item.createdAt) return "已处理";
return "查看详情";
};
const resolveJumpText = (item) => {
if (item.itemId) return "查看评分详情";
if (item.topicId) return "查看话题";
return "查看消息";
};
const normalizeListResponse = (res) => {
const source = res?.data || res || {};
const list = Array.isArray(source.list)
? source.list
: Array.isArray(res?.list)
? res.list
: [];
const total = Number(source.totalCount ?? res?.totalCount ?? list.length);
return { list, total };
};
const mergeNotificationList = (list, reset = false) => {
if (reset) {
notificationList.value = list;
return;
}
const map = new Map(notificationList.value.map((item) => [item.id, item]));
list.forEach((item) => {
map.set(item.id, item);
});
notificationList.value = Array.from(map.values()).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
};
const loadUnread = async () => {
if (!isLoggedIn.value) {
unreadCount.value = 0;
syncMessageTabBarBadge(0);
return;
}
try {
const res = await fetchUnreadCount();
const count =
Number(
res?.data?.count ??
res?.data?.unreadCount ??
res?.count ??
res?.unreadCount ??
res?.data ??
0
) || 0;
unreadCount.value = count;
syncMessageTabBarBadge(count);
} catch (error) {
unreadCount.value = unreadCount.value || 0;
syncMessageTabBarBadge(unreadCount.value);
}
};
const loadNotifications = async ({ reset = false } = {}) => {
if (!isLoggedIn.value) {
notificationList.value = [];
totalCount.value = 0;
hasMore.value = false;
loadError.value = "";
return;
}
if (loading.value) return;
if (!reset && !hasMore.value) return;
try {
loading.value = true;
loadError.value = "";
const nextPage = reset ? 1 : page.value;
const res = await fetchNotificationList({ page: nextPage });
const { list, total } = normalizeListResponse(res);
totalCount.value = total;
mergeNotificationList(list, reset);
hasMore.value = notificationList.value.length < total && list.length > 0;
page.value = nextPage + 1;
} catch (error) {
console.error("获取消息列表失败:", error);
loadError.value = "网络开了点小差,稍后再试";
if (reset) {
notificationList.value = [];
totalCount.value = 0;
}
} finally {
loading.value = false;
uni.stopPullDownRefresh();
}
};
const refreshList = async () => {
page.value = 1;
hasMore.value = true;
await Promise.all([loadUnread(), loadNotifications({ reset: true })]);
};
const setItemReadState = (id, isRead = true) => {
notificationList.value = notificationList.value.map((item) => {
if (item.id !== id) return item;
return {
...item,
isRead,
};
});
};
const handleNavigate = (item) => {
if (item.itemId) {
uni.navigateTo({
url: `/pages/rating/detail?topicId=${item.topicId || ""}&itemId=${item.itemId}`,
});
return;
}
if (item.topicId) {
uni.navigateTo({
url: `/pages/rating/index?topicId=${item.topicId}`,
});
return;
}
};
const markSingleAsRead = async (item) => {
if (!item?.id || item.isRead || readingIds.value.includes(item.id)) return;
readingIds.value = [...readingIds.value, item.id];
try {
await markAsRead(item.id);
setItemReadState(item.id, true);
unreadCount.value = Math.max(0, unreadCount.value - 1);
syncMessageTabBarBadge(unreadCount.value);
} catch (error) {
console.error("标记消息已读失败:", error);
} finally {
readingIds.value = readingIds.value.filter((id) => id !== item.id);
}
};
const handleItemTap = async (item) => {
await markSingleAsRead(item);
handleNavigate(item);
};
const handleMarkAllRead = async () => {
if (!isLoggedIn.value) {
openLoginPopup("请先登录后再操作");
return;
}
if (readingAll.value || unreadCount.value === 0) return;
try {
readingAll.value = true;
await markAllAsRead();
notificationList.value = notificationList.value.map((item) => ({
...item,
isRead: true,
}));
unreadCount.value = 0;
syncMessageTabBarBadge(0);
uni.showToast({ title: "已全部标记为已读", icon: "none" });
} catch (error) {
console.error("全部标记已读失败:", error);
uni.showToast({ title: "操作失败,请稍后再试", icon: "none" });
} finally {
readingAll.value = false;
}
};
const handleLoginSuccess = async () => {
await refreshList();
};
onLoad(() => {
refreshList();
});
onShow(() => {
if (firstShow) {
firstShow = false;
return;
}
refreshList();
});
onPullDownRefresh(() => {
refreshList();
});
onReachBottom(() => {
loadNotifications();
});
</script>
<style lang="scss" scoped>
.message-page {
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(255, 116, 71, 0.1) 0, rgba(255, 116, 71, 0) 36%),
#f7f7f8;
box-sizing: border-box;
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: rgba(247, 247, 248, 0.9);
backdrop-filter: blur(18rpx);
}
.nav-content {
min-height: 64px;
padding: 0 28rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.nav-title {
display: block;
font-size: 38rpx;
font-weight: 700;
color: #18181b;
}
.nav-subtitle {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: #8a8f99;
}
.nav-right {
display: flex;
align-items: center;
gap: 14rpx;
flex-shrink: 0;
}
.unread-chip {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 59, 48, 0.1);
}
.unread-chip-text {
font-size: 22rpx;
font-weight: 600;
color: #ff4d4f;
}
.read-all-btn {
padding: 12rpx 20rpx;
border-radius: 999rpx;
background: #111111;
}
2026-06-15 07:18:36 +08:00
.filter-read-all-btn {
flex-shrink: 0;
padding: 10rpx 22rpx;
}
2026-06-15 00:15:23 +08:00
.read-all-btn.disabled {
opacity: 0.45;
}
.read-all-text {
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
}
.page-body {
padding: 20rpx 24rpx 140rpx;
}
.hero-card {
padding: 30rpx 28rpx;
border-radius: 32rpx;
background: linear-gradient(135deg, #ffffff 0%, #fff5f2 100%);
box-shadow: 0 14rpx 34rpx rgba(15, 23, 42, 0.05);
}
.hero-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
}
.hero-title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: #171717;
}
.hero-desc {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.6;
color: #7a7f88;
}
.hero-icon {
width: 96rpx;
height: 96rpx;
flex-shrink: 0;
}
.hero-stats {
margin-top: 28rpx;
padding-top: 24rpx;
border-top: 1rpx solid rgba(17, 17, 17, 0.06);
display: flex;
align-items: center;
}
.hero-stat {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.hero-stat-num {
font-size: 34rpx;
font-weight: 700;
color: #111111;
}
.hero-stat-num.accent {
color: #ff5a36;
}
.hero-stat-label {
font-size: 22rpx;
color: #8a8f99;
}
.hero-divider {
width: 1rpx;
height: 56rpx;
background: rgba(17, 17, 17, 0.08);
}
.filter-bar {
margin-top: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.filter-tabs {
padding: 8rpx;
border-radius: 999rpx;
background: #ffffff;
display: inline-flex;
align-items: center;
gap: 8rpx;
box-shadow: 0 10rpx 24rpx rgba(15, 23, 42, 0.04);
}
.filter-tab {
min-width: 112rpx;
height: 60rpx;
padding: 0 24rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
}
.filter-tab.active {
background: #111111;
}
.filter-tab-text {
font-size: 24rpx;
font-weight: 600;
color: #8a8f99;
}
.filter-tab.active .filter-tab-text {
color: #ffffff;
}
.filter-hint {
font-size: 22rpx;
color: #9aa0aa;
}
.section-list {
margin-top: 20rpx;
}
.section-block + .section-block {
margin-top: 24rpx;
}
.section-header {
padding: 6rpx 8rpx 14rpx;
}
.section-title {
font-size: 24rpx;
font-weight: 600;
color: #8a8f99;
}
.message-card {
position: relative;
display: flex;
gap: 20rpx;
padding: 24rpx;
border-radius: 28rpx;
background: #ffffff;
box-shadow: 0 12rpx 28rpx rgba(15, 23, 42, 0.05);
}
.message-card + .message-card {
margin-top: 16rpx;
}
.message-card.unread {
background: linear-gradient(180deg, #ffffff 0%, #fff9f7 100%);
}
.message-avatar-wrap {
position: relative;
width: 84rpx;
height: 84rpx;
flex-shrink: 0;
}
.message-avatar {
width: 84rpx;
height: 84rpx;
border-radius: 50%;
background: #f4f5f7;
}
.message-dot {
position: absolute;
top: 0;
right: 2rpx;
width: 18rpx;
height: 18rpx;
border-radius: 50%;
background: #ff4d4f;
border: 4rpx solid #ffffff;
box-sizing: border-box;
}
.message-main {
flex: 1;
min-width: 0;
}
.message-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
}
.message-head-left {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10rpx;
}
.message-title {
font-size: 28rpx;
font-weight: 700;
color: #18181b;
}
.type-tag {
padding: 6rpx 14rpx;
border-radius: 999rpx;
}
.type-tag-text {
font-size: 20rpx;
font-weight: 600;
}
.message-time {
flex-shrink: 0;
font-size: 22rpx;
color: #a0a4ae;
}
.message-content {
display: block;
margin-top: 12rpx;
font-size: 26rpx;
line-height: 1.7;
color: #555b66;
}
.message-footer {
margin-top: 16rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.message-meta {
font-size: 22rpx;
color: #a0a4ae;
}
.message-link {
font-size: 22rpx;
font-weight: 600;
color: #ff5a36;
}
.state-card {
margin-top: 24rpx;
padding: 44rpx 32rpx;
border-radius: 32rpx;
background: #ffffff;
text-align: center;
box-shadow: 0 12rpx 28rpx rgba(15, 23, 42, 0.05);
}
.login-card {
margin-top: 28rpx;
}
.state-title {
display: block;
font-size: 30rpx;
font-weight: 700;
color: #18181b;
}
.state-desc {
display: block;
margin-top: 14rpx;
font-size: 24rpx;
line-height: 1.7;
color: #8a8f99;
}
.primary-btn {
margin-top: 24rpx;
height: 84rpx;
line-height: 84rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ff7a45 0%, #ff4d4f 100%);
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
}
.primary-btn::after {
border: none;
}
.list-status {
padding: 28rpx 0 12rpx;
text-align: center;
font-size: 22rpx;
color: #a0a4ae;
}
</style>