add Notify

This commit is contained in:
Timi
2026-08-21 13:52:52 +08:00
parent 1f37d36901
commit c97913da2d
9 changed files with 803 additions and 28 deletions
+432
View File
@@ -0,0 +1,432 @@
<template>
<div v-can="corePermissionCodes.notify.read" class="notify-list">
<t-segmented
v-model="filterMode"
class="filter"
block
:options="filterOptions"
@change="switchFilter"
/>
<t-pull-down-refresh
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="refresh"
@scrolltolower="loadMore"
>
<t-cell-group v-if="pageResult.list.length" class="items" theme="card">
<t-swipe-cell
v-for="item in pageResult.list"
:key="item.id"
class="item"
>
<t-cell
:description="getPreview(item)"
:note="getTime(item)"
arrow
hover
@click="toDetail(item)"
>
<template #leftIcon>
<div class="icon" :class="{ unread: isUnread(item) }">
<t-icon name="notification" size="1.25rem" />
</div>
</template>
<template #title>
<div class="title">
<span v-text="getSubject(item)" />
<t-tag
v-if="isUnread(item)"
class="unread-label"
theme="danger"
size="small"
>未读</t-tag>
</div>
</template>
</t-cell>
<template #right>
<t-button
class="delete"
theme="danger"
:loading="deletingId === item.id"
@click.stop="confirmDelete(item)"
>
删除
</t-button>
</template>
</t-swipe-cell>
</t-cell-group>
<t-empty
v-else-if="!isLoading"
description="暂无站内通知"
/>
<div class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
</div>
</template>
<script setup lang="ts">
import { NotifyAPI, Time, type NotifyDetail, type Page, type PageResult, useUserStore } from "timi-web";
import { DialogPlugin, Toast } from "tdesign-mobile-vue";
import { useNavBarStore } from "@/store/navBarStore";
import { getErrorMessage } from "@/utils/error";
import { corePermissionCodes } from "@/utils/permission";
type NotifyFilter = "all" | "unread";
defineOptions({
name: "NotifyList"
});
const pageSize = 20;
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const canReadNotify = computed(() => userStore.hasPermissions(corePermissionCodes.notify.read));
// ---------- 筛选 ----------
const filterMode = ref<NotifyFilter>("all");
const unreadCount = ref(0);
const unreadTabLabel = computed(() => 0 < unreadCount.value ? `未读 ${unreadCount.value}` : "未读");
const filterOptions = computed(() => [
{ value: "all" as NotifyFilter, label: "全部" },
{ value: "unread" as NotifyFilter, label: unreadTabLabel.value }
]);
// ---------- 列表分页 ----------
const isRefreshing = ref(false);
const isLoading = ref(false);
const isNoMore = ref(false);
const page = reactive<Page<NotifyDetail>>({
index: 0,
size: pageSize
});
const pageResult = reactive<PageResult<NotifyDetail>>({
total: 0,
list: []
});
let loadToken = 0;
let loadingPromise: Promise<void> | undefined;
let isPageActive = true;
const loadStateText = computed(() => {
if (isLoading.value) {
return "正在加载通知";
}
if (isNoMore.value) {
return pageResult.list.length ? "没有更多通知" : "";
}
return "上拉加载更多";
});
async function fetchPage(requestToken: number): Promise<void> {
isLoading.value = true;
const result = await NotifyAPI.listInternal(page, filterMode.value === "unread");
if (requestToken !== loadToken) {
return;
}
pageResult.total = result.total || 0;
pageResult.list = pageResult.list.concat(result.list || []);
page.index += 1;
isNoMore.value = pageResult.list.length >= pageResult.total || (result.list || []).length < page.size;
}
async function loadMore(): Promise<void> {
if (loadingPromise) {
await loadingPromise;
return;
}
if (isNoMore.value) {
return;
}
const requestToken = loadToken;
const request = (async () => {
try {
await fetchPage(requestToken);
} finally {
if (requestToken === loadToken) {
isLoading.value = false;
}
}
})();
loadingPromise = request;
try {
await request;
} finally {
if (loadingPromise === request) {
loadingPromise = undefined;
}
}
}
async function reloadList(): Promise<void> {
const requestToken = loadToken + 1;
loadToken = requestToken;
page.index = 0;
pageResult.total = 0;
pageResult.list = [];
isNoMore.value = false;
if (loadingPromise) {
await loadingPromise;
}
if (requestToken === loadToken) {
await loadMore();
}
}
async function refresh(): Promise<void> {
isRefreshing.value = true;
try {
await reloadList();
await loadUnreadCount();
} finally {
isRefreshing.value = false;
}
}
// ---------- 操作 ----------
const isMarkingAllRead = ref(false);
const deletingId = ref("");
async function loadUnreadCount(): Promise<void> {
try {
unreadCount.value = Math.max(0, await NotifyAPI.countUnreadInternal());
} catch (error) {
unreadCount.value = 0;
console.warn("未读通知数量加载失败", error);
} finally {
updateNavBarAction();
}
}
async function switchFilter(context: { value: string | number }): Promise<void> {
const nextFilter = String(context.value) as NotifyFilter;
if (nextFilter !== "all" && nextFilter !== "unread") {
return;
}
filterMode.value = nextFilter;
await reloadList();
}
async function markAllRead(): Promise<void> {
if (isMarkingAllRead.value || unreadCount.value === 0) {
return;
}
isMarkingAllRead.value = true;
try {
await NotifyAPI.markAllInternalRead();
unreadCount.value = 0;
await reloadList();
Toast.success("已全部标记为已读");
} catch (error) {
console.warn("全部通知标记已读失败", error);
Toast.error("全部标记已读失败");
} finally {
isMarkingAllRead.value = false;
updateNavBarAction();
}
}
async function confirmDelete(item: NotifyDetail): Promise<void> {
if (!item.id || deletingId.value) {
return;
}
const detailId = item.id;
const dialog = DialogPlugin.confirm({
title: "删除通知",
content: "确定删除这条通知吗?删除后将不再显示。",
cancelBtn: "取消",
confirmBtn: "删除",
onCancel() {
dialog.destroy();
},
async onConfirm() {
dialog.destroy();
await deleteNotification(detailId);
}
});
}
async function deleteNotification(detailId: string): Promise<void> {
if (deletingId.value) {
return;
}
deletingId.value = detailId;
try {
await NotifyAPI.deleteInternal(detailId);
await reloadList();
await loadUnreadCount();
Toast.success("通知已删除");
} catch (error) {
Toast.error(getErrorMessage(error, "通知删除失败"));
} finally {
deletingId.value = "";
}
}
function updateNavBarAction(): void {
if (!isPageActive) {
return;
}
navBarStore.clearRight();
if (0 < unreadCount.value) {
navBarStore.setRightText("全部已读", "var(--td-brand-color)", markAllRead);
}
}
// ---------- 展示和导航 ----------
function isUnread(item: NotifyDetail): boolean {
return !item.readAt;
}
function getSubject(item: NotifyDetail): string {
return item.subject?.trim() || "站内通知";
}
function getPreview(item: NotifyDetail): string {
const text = item.data?.replace(/\s+/g, " ").trim() || "无通知内容";
return 80 < text.length ? `${text.slice(0, 80)}...` : text;
}
function getTime(item: NotifyDetail): string {
return Time.toPassedDateTime(item.createdAt || item.sendAt) || "-";
}
async function toDetail(item: NotifyDetail): Promise<void> {
if (!item.id) {
return;
}
await router.push({
name: "NotifyDetail",
params: { id: item.id }
});
}
// ---------- 生命周期 ----------
onMounted(async () => {
navBarStore.clearRight();
if (!canReadNotify.value) {
return;
}
try {
await reloadList();
await loadUnreadCount();
} catch (error) {
console.warn("站内通知加载失败", error);
Toast.error("站内通知加载失败");
}
});
onUnmounted(() => {
isPageActive = false;
navBarStore.clearRight();
});
</script>
<style scoped lang="less">
.notify-list {
display: flex;
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
min-height: 100%;
flex-direction: column;
.filter {
width: calc(100% - 2rem);
margin: 0 1rem;
}
.items {
overflow: hidden;
padding-top: 1rem;
.item {
.delete {
height: 100%;
padding: 0 1rem;
border-radius: 0;
}
&:last-child {
:deep(.t-cell:after) {
display: none;
}
}
:deep(.t-cell__title) {
flex: 1 1 0;
min-width: 0;
}
:deep(.t-cell__description) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
:deep(.t-cell__note) {
white-space: nowrap;
}
.icon {
width: 3.5rem;
height: 3.5rem;
color: #64748b;
display: flex;
background: #e2e8f0;
align-items: center;
border-radius: .875rem;
justify-content: center;
&.unread {
color: #0f766e;
background: #ccfbf1;
}
}
.title {
gap: .5rem;
display: flex;
align-items: center;
> span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.unread-label {
font-size: .6875rem;
}
}
}
}
:global(html.theme-dark) {
.notify-list {
.icon {
color: #94a3b8;
background: #1e293b;
&.unread {
color: #5eead4;
background: rgba(13, 148, 136, .2);
}
}
.unread-label {
color: #5eead4;
background: rgba(13, 148, 136, .2);
}
}
}
</style>