此前一次性拉全量(硬编码 limit:100),任务/通知多了会拖慢首屏。 - 分页状态:page / pageSize(20) / hasMore / loadingMore,触底加载下一页 - 下拉刷新回到第 1 页并清空重载 —— 分页后必须重置,否则新旧页会错位 - 任务列表按 id 去重后追加:翻页期间若有新任务插入,分页边界会错位导致 重复项 - hasMore 优先以响应里的 total 为准,缺失时退回「本页是否满员」判断 - 底部新增状态提示(加载中 / 没有更多了 / 上拉加载更多), 让工人知道是「到底了」还是「还在拉」
235 lines
8.4 KiB
Vue
235 lines
8.4 KiB
Vue
<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 沿用旧的 page,onShow 只会去拉"第 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>
|