feat(collaboration): 完善在线文档协作与通知闭环
- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。 - 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。 - 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。 - 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。 - 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。 - 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。 - 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。 - 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。 - 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。 - 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。 - 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。 - 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { listGeneralNotifications, 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, 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, items: [] };
|
||||
return;
|
||||
}
|
||||
headerRemindersLoading.value = true;
|
||||
try {
|
||||
const { data } = await listGeneralNotifications(studyId, 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.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);
|
||||
await markGeneralNotificationRead(studyId, item.id).catch(() => {
|
||||
item.read_at = null;
|
||||
feed.value.unread_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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user