feat: 消息与任务列表改为分页加载

此前一次性拉全量(硬编码 limit:100),任务/通知多了会拖慢首屏。

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

View File

@ -39,13 +39,18 @@
</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 } from "@dcloudio/uni-app";
import { onShow, onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
import { getNotifications, markNotificationRead } from "../../api/notification";
const TYPE_CONFIG = {
@ -58,11 +63,17 @@ 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();
fetchNotifications("initial");
}, 200);
});
@ -76,17 +87,69 @@ function loadUser() {
} catch {}
}
async function fetchNotifications() {
const userId = currentUser?.username || currentUser?.id || "";
if (!userId) { loading.value = false; return; }
loading.value = true;
// 🚀 下拉刷新:界面提示了「请下拉刷新重试」,就必须把功能做实。
// 分页后下拉必须重置 page 并清空列表 —— 否则新的第 1 页会和旧的第 2、3 页
// 混在一起,出现重复项与排序错乱。
onPullDownRefresh(async () => {
try {
const res = await getNotifications(userId);
notifications.value = res.notifications || [];
} catch {
notifications.value = [];
loadUser();
await fetchNotifications("refresh");
} finally {
loading.value = false;
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;
}
}
@ -165,4 +228,7 @@ async function handleCardTap(item) {
.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>

View File

@ -32,6 +32,11 @@
</view>
<view class="card-time">创建: {{ formatTime(task.created_at) }}</view>
</view>
<!-- 触底加载状态让工人知道"到底了"还是"还在拉" -->
<view v-if="loadingMore" class="load-more">加载中...</view>
<view v-else-if="!hasMore && tasks.length > 0" class="load-more"> 没有更多了 </view>
<view v-else-if="hasMore && tasks.length > 0" class="load-more">上拉加载更多</view>
</template>
</view>
</template>
@ -54,6 +59,11 @@ export default {
tasks: [],
loading: true,
currentUser: null,
// 📄 分页状态:此前硬编码 limit:100 一次性拉全量,任务多了会拖慢首屏
page: 1,
pageSize: 20,
hasMore: true,
loadingMore: false,
};
},
computed: {
@ -64,7 +74,21 @@ export default {
},
onShow() {
this.loadCurrentUser();
this.fetchTasks();
this.fetchTasks({ reset: true });
},
// 🚀 下拉刷新:回到第 1 页并清空现有列表重载(分页后必须重置,否则新旧页会错位)
async onPullDownRefresh() {
try {
this.loadCurrentUser();
await this.fetchTasks({ reset: true });
} finally {
uni.stopPullDownRefresh(); // 无论成败都要收起动画,否则会一直挂着
}
},
// 🚀 触底加载下一页
onReachBottom() {
if (this.loading || this.loadingMore || !this.hasMore) return;
this.loadMore();
},
methods: {
statusLabel(s) {
@ -95,20 +119,51 @@ export default {
if (u) this.currentUser = typeof u === "string" ? JSON.parse(u) : u;
} catch {}
},
async fetchTasks() {
this.loading = true;
// 排序:主干任务在前,协助分支在后;同组内按创建时间升序
compareTask(a, b) {
const aIsMain = !a.parent_task_id || a.task_type !== 'SPAWN';
const bIsMain = !b.parent_task_id || b.task_type !== 'SPAWN';
if (aIsMain && !bIsMain) return -1;
if (!aIsMain && bIsMain) return 1;
return new Date(a.created_at) - new Date(b.created_at);
},
/** 拉取一页任务reset=true 表示回到第 1 页并清空重载(下拉刷新 / 首次进入) */
async fetchTasks({ reset = false } = {}) {
if (reset) {
this.page = 1;
this.hasMore = true;
this.tasks = [];
}
if (reset) this.loading = true;
const target = this.page;
try {
const username = this.currentUser?.username || "";
const res = await get("/tasks/", { assignee_id: username, limit: 100 });
this.tasks = (res.tasks || []).sort((a, b) => {
const aIsMain = !a.parent_task_id || a.task_type !== 'SPAWN';
const bIsMain = !b.parent_task_id || b.task_type !== 'SPAWN';
if (aIsMain && !bIsMain) return -1;
if (!aIsMain && bIsMain) return 1;
return new Date(a.created_at) - new Date(b.created_at);
const res = await get("/tasks/", {
assignee_id: username,
skip: (target - 1) * this.pageSize,
limit: this.pageSize,
});
} catch { this.tasks = []; }
finally { this.loading = false; }
const batch = res.tasks || [];
// 按 id 去重后追加:翻页期间若有新任务插入,分页边界会错位导致重复项
const seen = new Set(this.tasks.map((t) => t.id));
this.tasks = this.tasks.concat(batch.filter((t) => !seen.has(t.id))).sort(this.compareTask);
// 后端给了 total 就以它为准;否则退回「本页是否满员」判断
this.hasMore = res.total != null
? this.tasks.length < res.total
: batch.length >= this.pageSize;
if (this.hasMore) this.page = target + 1;
} catch (e) {
console.error("[tasks] 拉取任务失败:", e);
if (reset) this.tasks = [];
this.hasMore = false;
} finally {
if (reset) this.loading = false;
}
},
async loadMore() {
this.loadingMore = true;
try { await this.fetchTasks(); } finally { this.loadingMore = false; }
},
goDetail(task) {
const sn = task.product_sn || task.product_id;
@ -155,4 +210,7 @@ export default {
.s-gray { background: #f3f4f6; color: #6b7280; }
.card-meta { font-size: 12px; color: #6b7280; display: flex; gap: 12px; }
.card-time { font-size: 11px; color: #9ca3af; margin-top: 4px; }
/* 触底加载状态 */
.load-more { text-align: center; font-size: 12px; color: #9ca3af; padding: 16px 0 4px; }
</style>