3e77127687
增加通用提醒状态、数据库迁移和定时同步,覆盖风险时效、文件回执、项目里程碑、访视窗口与协作申请。 新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。
137 lines
5.4 KiB
TypeScript
137 lines
5.4 KiB
TypeScript
import { computed, ref } from "vue";
|
|
import { useRoute, useRouter } from "vue-router";
|
|
import {
|
|
listGeneralNotifications,
|
|
markAllGeneralNotificationsRead,
|
|
markGeneralNotificationRead,
|
|
} from "../api/notifications";
|
|
import { useStudyStore } from "../store/study";
|
|
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
|
|
|
|
export const PROJECT_NOTIFICATIONS_CHANGED_EVENT = "ctms:project-notifications-changed";
|
|
const POLL_INTERVAL_MS = 60_000;
|
|
|
|
export const notifyProjectNotificationsChanged = () => {
|
|
if (typeof window !== "undefined") window.dispatchEvent(new Event(PROJECT_NOTIFICATIONS_CHANGED_EVENT));
|
|
};
|
|
|
|
const formatNotificationTime = (value: string) => {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return "";
|
|
const elapsed = Date.now() - date.getTime();
|
|
if (elapsed < 60_000) return "刚刚";
|
|
if (elapsed < 3_600_000) return `${Math.max(1, Math.floor(elapsed / 60_000))} 分钟前`;
|
|
if (elapsed < 86_400_000) return `${Math.max(1, Math.floor(elapsed / 3_600_000))} 小时前`;
|
|
return `${date.getMonth() + 1}-${String(date.getDate()).padStart(2, "0")}`;
|
|
};
|
|
|
|
const notificationTone = (item: GeneralNotificationItem): "danger" | "warning" | "info" => {
|
|
if (item.priority === "URGENT") return "danger";
|
|
if (item.priority === "HIGH") return "warning";
|
|
return "info";
|
|
};
|
|
|
|
export const useProjectNotifications = () => {
|
|
const study = useStudyStore();
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const headerRemindersLoading = ref(false);
|
|
const feed = ref<GeneralNotificationFeed>({ unread_count: 0, total_count: 0, items: [] });
|
|
let requestId = 0;
|
|
let pollTimer: number | undefined;
|
|
|
|
const loadHeaderReminders = async () => {
|
|
const studyId = study.currentStudy?.id;
|
|
const currentRequest = ++requestId;
|
|
if (!studyId || route.path.startsWith("/admin")) {
|
|
feed.value = { unread_count: 0, total_count: 0, items: [] };
|
|
return;
|
|
}
|
|
headerRemindersLoading.value = true;
|
|
try {
|
|
const { data } = await listGeneralNotifications(studyId, { limit: 10 });
|
|
if (currentRequest === requestId && study.currentStudy?.id === studyId) feed.value = data;
|
|
} catch {
|
|
// 短时网络异常时保留当前内存中的提醒,避免角标在轮询失败时闪烁消失。
|
|
} finally {
|
|
if (currentRequest === requestId) headerRemindersLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const headerReminderItems = computed(() => feed.value.items.map((item) => ({
|
|
key: item.id,
|
|
command: `notification:${item.id}`,
|
|
title: item.title,
|
|
description: item.message,
|
|
timeLabel: formatNotificationTime(item.created_at),
|
|
tone: notificationTone(item),
|
|
isRead: Boolean(item.read_at),
|
|
})));
|
|
const headerReminderTotal = computed(() => feed.value.unread_count);
|
|
const headerReminderBadgeValue = computed(() => headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value);
|
|
|
|
const handleReminderCommand = async (command: string) => {
|
|
if (command === "notification:center") {
|
|
await router.push("/project/notifications");
|
|
return;
|
|
}
|
|
if (command === "notification:read-all") {
|
|
const studyId = study.currentStudy?.id;
|
|
if (!studyId || feed.value.unread_count <= 0) return;
|
|
await markAllGeneralNotificationsRead(studyId);
|
|
await loadHeaderReminders();
|
|
return;
|
|
}
|
|
if (!command.startsWith("notification:")) return;
|
|
const notificationId = command.slice("notification:".length);
|
|
const item = feed.value.items.find((candidate) => candidate.id === notificationId);
|
|
const studyId = study.currentStudy?.id;
|
|
if (!item || !studyId) return;
|
|
if (!item.read_at) {
|
|
item.read_at = new Date().toISOString();
|
|
feed.value.unread_count = Math.max(0, feed.value.unread_count - 1);
|
|
const response = await markGeneralNotificationRead(studyId, item.id).catch(() => {
|
|
item.read_at = null;
|
|
feed.value.unread_count += 1;
|
|
return null;
|
|
});
|
|
if (response?.data.resolved_at) {
|
|
feed.value.items = feed.value.items.filter((candidate) => candidate.id !== item.id);
|
|
feed.value.total_count = Math.max(0, feed.value.total_count - 1);
|
|
}
|
|
}
|
|
if (item.action_path?.startsWith("/")) await router.push(item.action_path);
|
|
};
|
|
const handleReminderVisibilityChange = (visible: boolean) => {
|
|
if (visible) void loadHeaderReminders();
|
|
};
|
|
|
|
const handleWindowFocus = () => { void loadHeaderReminders(); };
|
|
const startHeaderNotificationPolling = () => {
|
|
if (pollTimer !== undefined || typeof window === "undefined") return;
|
|
pollTimer = window.setInterval(() => { void loadHeaderReminders(); }, POLL_INTERVAL_MS);
|
|
window.addEventListener("focus", handleWindowFocus);
|
|
window.addEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleWindowFocus);
|
|
};
|
|
const stopHeaderNotificationPolling = () => {
|
|
if (pollTimer !== undefined) window.clearInterval(pollTimer);
|
|
pollTimer = undefined;
|
|
if (typeof window !== "undefined") {
|
|
window.removeEventListener("focus", handleWindowFocus);
|
|
window.removeEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleWindowFocus);
|
|
}
|
|
};
|
|
|
|
return {
|
|
headerRemindersLoading,
|
|
headerReminderItems,
|
|
headerReminderTotal,
|
|
headerReminderBadgeValue,
|
|
loadHeaderReminders,
|
|
handleReminderCommand,
|
|
handleReminderVisibilityChange,
|
|
startHeaderNotificationPolling,
|
|
stopHeaderNotificationPolling,
|
|
};
|
|
};
|