Files
track/track-uniapp/src/pages/notify/index.vue
duxingchen a7d1c53b1f feat: 消息与任务列表改为分页加载
此前一次性拉全量(硬编码 limit:100),任务/通知多了会拖慢首屏。

- 分页状态:page / pageSize(20) / hasMore / loadingMore,触底加载下一页
- 下拉刷新回到第 1 页并清空重载 —— 分页后必须重置,否则新旧页会错位
- 任务列表按 id 去重后追加:翻页期间若有新任务插入,分页边界会错位导致
  重复项
- hasMore 优先以响应里的 total 为准,缺失时退回「本页是否满员」判断
- 底部新增状态提示(加载中 / 没有更多了 / 上拉加载更多),
  让工人知道是「到底了」还是「还在拉」
2026-09-15 15:52:02 +08:00

235 lines
8.4 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page">
<view class="header">
<text class="title">消息通知</text>
<text class="subtitle">任务流转和系统通知</text>
</view>
<!-- 加载中 -->
<view v-if="loading" class="center">加载中...</view>
<!-- 空状态 -->
<view v-else-if="notifications.length === 0" class="empty">
<text class="empty-icon">🔔</text>
<text class="empty-text">暂无新消息</text>
</view>
<!-- 通知列表 -->
<view v-else class="list">
<view
v-for="item in notifications"
:key="item.id"
:class="['card', item.is_read ? '' : 'card-unread']"
@tap="handleCardTap(item)"
>
<view class="card-left">
<view v-if="!item.is_read" class="unread-dot" />
<text :class="['type-icon', item.is_read ? 'type-icon-read' : '']">
{{ typeIcon(item.type) }}
</text>
</view>
<view class="card-body">
<view class="card-top">
<text :class="['card-title', item.is_read ? '' : 'card-title-bold']">
{{ typeTitle(item.type) }}
</text>
<text class="card-time">{{ formatTime(item.created_at) }}</text>
</view>
<text class="card-content">{{ item.content }}</text>
</view>
<text class="card-arrow"></text>
</view>
<!-- 触底加载状态让工人知道"到底了"还是"还在拉" -->
<view v-if="loadingMore" class="load-more">加载中...</view>
<view v-else-if="!hasMore" class="load-more"> 没有更多了 </view>
<view v-else class="load-more">上拉加载更多</view>
</view>
</view>
</template>
<script setup>
import { ref } from "vue";
import { onShow, onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
import { getNotifications, markNotificationRead } from "../../api/notification";
const TYPE_CONFIG = {
TRANSFER: { icon: "🟢", title: "新任务派发" },
REJECT: { icon: "🔴", title: "品质驳回提醒" },
COMMENT: { icon: "💬", title: "收到新留言" },
};
const notifications = ref([]);
const loading = ref(true);
let currentUser = null;
// 📄 分页状态:此前用默认 limit=20 但翻不动页,第 21 条以后的消息永远看不到
const pageSize = 20;
let page = 1;
const hasMore = ref(true);
const loadingMore = ref(false);
onShow(() => {
loadUser();
setTimeout(() => {
if (!currentUser) loadUser();
fetchNotifications("initial");
}, 200);
});
function loadUser() {
try {
let user = uni.getStorageSync("user");
if (typeof user === "string" && user) {
try { user = JSON.parse(user); } catch (e) { user = null; }
}
if (user && typeof user === "object") currentUser = user;
} catch {}
}
// 🚀 下拉刷新:界面提示了「请下拉刷新重试」,就必须把功能做实。
// 分页后下拉必须重置 page 并清空列表 —— 否则新的第 1 页会和旧的第 2、3 页
// 混在一起,出现重复项与排序错乱。
onPullDownRefresh(async () => {
try {
loadUser();
await fetchNotifications("refresh");
} finally {
uni.stopPullDownRefresh(); // 无论成败都要收起动画,否则会一直挂着
}
});
// 🚀 触底加载下一页
onReachBottom(() => {
if (loading.value || loadingMore.value || !hasMore.value) return;
loadingMore.value = true;
fetchNotifications("more").finally(() => { loadingMore.value = false; });
});
/**
* 拉取通知
* @param {'initial'|'refresh'|'more'} mode
* initial — 首次进入/切页:显示整页 loading失败清空
* refresh — 下拉刷新:重置到第 1 页并清空重载
* more — 触底加载:追加下一页,失败保留已加载内容只提示
*/
async function fetchNotifications(mode = "initial") {
const userId = currentUser?.username || currentUser?.id || "";
if (!userId) {
if (mode === "initial") loading.value = false;
else uni.showToast({ title: "未获取到登录信息,请重新登录", icon: "none", duration: 2500 });
return;
}
// initial / refresh 都要从第 1 页重来:前者是切页回来,后者是下拉刷新。
// 若 initial 沿用旧的 pageonShow 只会去拉"第 N 页"并追加,列表永远刷不新。
if (mode === "initial" || mode === "refresh") {
page = 1;
hasMore.value = true;
notifications.value = [];
}
if (mode === "initial") loading.value = true;
const target = page;
try {
const res = await getNotifications(userId, (target - 1) * pageSize, pageSize);
const batch = res.notifications || [];
// 按 id 去重后追加:翻页期间若来了新消息,分页边界会错位导致重复项
const seen = new Set(notifications.value.map((n) => n.id));
notifications.value = notifications.value.concat(batch.filter((n) => !seen.has(n.id)));
// 后端给了 total 就以它为准;否则退回「本页是否满员」判断
hasMore.value = res.total != null
? notifications.value.length < res.total
: batch.length >= pageSize;
if (hasMore.value) page = target + 1;
} catch (e) {
console.error("[notify] 拉取消息失败:", e);
if (mode === "initial") notifications.value = [];
else uni.showToast({
title: mode === "more" ? "加载更多失败,请重试" : "刷新失败,请稍后重试",
icon: "none",
duration: 2500,
});
} finally {
if (mode === "initial") loading.value = false;
}
}
function typeIcon(type) { return (TYPE_CONFIG[type] || { icon: "📌" }).icon; }
function typeTitle(type) { return (TYPE_CONFIG[type] || { title: "系统通知" }).title; }
function formatTime(t) {
if (!t) return "";
const d = new Date(t);
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
async function handleCardTap(item) {
if (!item.is_read) {
try {
await markNotificationRead(item.id);
item.is_read = true;
} catch {
uni.showToast({ title: "标记已读失败,请下拉刷新重试", icon: "none", duration: 2000 });
return;
}
}
// 🚀 优先使用 product_serial_number兜底从 content 中解析
let sn = item.product_serial_number || "";
if (!sn && item.content) {
const match = item.content.match(/\[([A-Za-z0-9]{8,16})\]/);
if (match) sn = match[1];
}
if (sn) {
uni.navigateTo({ url: `/pages/scan/detail?serial=${sn}` });
} else if (item.task_id) {
uni.navigateTo({ url: `/pages/scan/detail?taskId=${item.task_id}` });
}
}
</script>
<style scoped>
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; background: #f3f4f6; }
.header { margin-bottom: 20px; }
.title { font-size: 20px; font-weight: 700; color: #1f2937; display: block; }
.subtitle { font-size: 13px; color: #9ca3af; margin-top: 4px; display: block; }
.center { text-align: center; padding: 80px 0; color: #9ca3af; font-size: 14px; }
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 80px; }
.empty-icon { font-size: 64px; margin-bottom: 12px; }
.empty-text { font-size: 14px; color: #9ca3af; }
.list { display: flex; flex-direction: column; gap: 10px; }
.card {
display: flex; align-items: flex-start; gap: 10px;
background: #fff; border-radius: 12px; padding: 14px 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
position: relative; transition: all 0.2s;
}
.card:active { transform: scale(0.98); }
.card-unread {
box-shadow: 0 1px 6px rgba(37,99,235,0.1);
border-left: 3px solid #2563eb;
}
.card-left { display: flex; flex-direction: column; align-items: center; gap: 4px; width: 28px; flex-shrink: 0; }
.unread-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,0.15);
}
.type-icon { font-size: 20px; line-height: 1; }
.type-icon-read { opacity: 0.5; }
.card-body { flex: 1; min-width: 0; }
.card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
.card-title { font-size: 15px; font-weight: 600; color: #374151; }
.card-title-bold { color: #1f2937; font-weight: 700; }
.card-time { font-size: 11px; color: #9ca3af; flex-shrink: 0; }
.card-content { font-size: 13px; color: #6b7280; line-height: 1.5; display: block; word-break: break-all; }
.card-arrow { font-size: 20px; color: #d1d5db; margin-top: 6px; flex-shrink: 0; }
/* 触底加载状态 */
.load-more { text-align: center; font-size: 12px; color: #9ca3af; padding: 16px 0 4px; }
</style>