Files
ctms/frontend/src/composables/useProjectNotifications.ts
T
Cheng Zhou 3e77127687
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
功能(提醒):统一项目提醒中心与桌面通知链路
增加通用提醒状态、数据库迁移和定时同步,覆盖风险时效、文件回执、项目里程碑、访视窗口与协作申请。

新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。
2026-07-16 16:50:34 +08:00

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,
};
};