feat(collaboration): 完善在线文档协作与通知闭环
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。

- 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。

- 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。

- 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。

- 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。

- 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。

- 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。

- 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。

- 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。

- 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。

- 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。

- 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
This commit is contained in:
Cheng Zhou
2026-07-16 14:14:54 +08:00
parent c68dddfc01
commit 1d26646a96
97 changed files with 11911 additions and 316 deletions
+89 -152
View File
@@ -1,10 +1,11 @@
<template>
<el-container
class="layout-container web-layout-container"
:class="{ 'mobile-nav-open': isMobileNavOpen, 'mobile-layout': isMobileViewport }"
:class="{ 'mobile-nav-open': isMobileNavOpen, 'mobile-layout': isMobileViewport, 'is-immersive-workspace': route.meta.immersiveWorkspace }"
@keydown.esc="closeMobileNav"
>
<el-aside
v-if="!route.meta.immersiveWorkspace"
:width="isMobileViewport ? '280px' : (isCollapsed ? '0px' : '200px')"
class="layout-aside"
:class="{ collapsed: isCollapsed, 'mobile-open': isMobileNavOpen }"
@@ -121,7 +122,7 @@
<el-icon><Flag /></el-icon>
<span>{{ TEXT.menu.etmf }}</span>
</el-menu-item>
<el-sub-menu v-if="canAccessAnyProjectPath(['/knowledge/medical-consult', '/knowledge/precautions', '/knowledge/support-files', '/knowledge/instruction-files'])" index="knowledge">
<el-sub-menu v-if="canAccessAnyProjectPath(['/knowledge/medical-consult', '/knowledge/precautions', '/knowledge/support-files', '/knowledge/instruction-files', '/knowledge/collaboration'])" index="knowledge">
<template #title>
<el-icon><Notebook /></el-icon>
<span>{{ TEXT.menu.sharedLibrary }}</span>
@@ -130,13 +131,14 @@
<el-menu-item v-if="canAccessProjectPath('/knowledge/precautions')" index="/knowledge/precautions">{{ TEXT.menu.knowledgeNotes }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/knowledge/support-files')" index="/knowledge/support-files">{{ TEXT.menu.knowledgeSupportFiles }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/knowledge/instruction-files')" index="/knowledge/instruction-files">{{ TEXT.menu.knowledgeInstructionFiles }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/knowledge/collaboration')" index="/knowledge/collaboration">{{ TEXT.menu.knowledgeCollaboration }}</el-menu-item>
</el-sub-menu>
</el-menu-item-group>
</el-menu>
</el-aside>
<el-container class="main-container">
<el-header class="layout-header">
<el-header v-if="!route.meta.immersiveWorkspace" class="layout-header">
<div class="header-left">
<el-button
class="collapse-btn"
@@ -189,11 +191,6 @@
</div>
<div class="header-right">
<button v-if="hasProjectContext" class="workbench-return-button" type="button" @click="openWorkbenchEntry">
<el-icon><Suitcase /></el-icon>
<span>工作台</span>
</button>
<button v-if="isDesktop" class="desktop-command-trigger" type="button" @click="openCommandPalette">
<el-icon><Search /></el-icon>
<span>搜索命令</span>
@@ -233,7 +230,25 @@
</button>
</el-tooltip>
<el-dropdown trigger="click" placement="bottom-end" class="header-reminder-dropdown" @command="handleReminderCommand">
<el-tooltip v-if="webFullscreenAvailable" :content="webFullscreenLabel" placement="bottom">
<button
class="header-fullscreen-button"
type="button"
:aria-label="webFullscreenLabel"
:aria-pressed="webFullscreenActive"
@click="handleWebFullscreenToggle"
>
<el-icon><ScaleToOriginal v-if="webFullscreenActive" /><FullScreen v-else /></el-icon>
</button>
</el-tooltip>
<el-dropdown
trigger="click"
placement="bottom-end"
class="header-reminder-dropdown"
@command="handleReminderCommand"
@visible-change="handleReminderVisibilityChange"
>
<button class="header-reminder-button" :aria-label="TEXT.common.labels.projectReminders" type="button">
<el-badge
v-if="headerReminderTotal > 0"
@@ -261,27 +276,20 @@
:key="item.key"
:command="item.command"
class="header-reminder-item"
:class="{ 'is-read': item.isRead }"
>
<span class="header-reminder-item-mark" :class="`is-${item.tone}`"></span>
<span class="header-reminder-item-main">
<span class="header-reminder-item-title">{{ item.title }}</span>
<span class="header-reminder-item-desc">{{ item.description }}</span>
</span>
<span class="header-reminder-item-count">{{ item.count }}</span>
<span class="header-reminder-item-count">{{ item.timeLabel }}</span>
</el-dropdown-item>
</template>
<div v-else class="header-reminder-empty">
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
</div>
<el-dropdown-item
v-if="hasAnyRiskReminderAccess"
command="riskIssues"
divided
class="header-reminder-footer"
>
{{ TEXT.common.actions.view }}{{ TEXT.menu.riskIssues }}
</el-dropdown-item>
</div>
</el-dropdown-menu>
</template>
@@ -344,7 +352,7 @@
</el-container>
<button
v-if="isMobileNavOpen"
v-if="isMobileNavOpen && !route.meta.immersiveWorkspace"
type="button"
class="mobile-nav-backdrop"
aria-label="关闭导航菜单"
@@ -396,15 +404,13 @@ import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchStudies } from "../api/studies";
import { fetchOverdueAesCount } from "../api/dashboard";
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
import { TEXT } from "../locales";
import {
User, Suitcase, House, Calendar, Flag,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, UserFilled, Bell, Setting,
Search, Star, StarFilled, Clock, Monitor
Search, Star, StarFilled, Clock, Monitor, FullScreen, ScaleToOriginal
} from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
@@ -415,9 +421,14 @@ import {
getAppMetadata,
getDesktopServerUrl,
isTauriRuntime,
isWebFullscreenActive,
isWebFullscreenAvailable,
listenWebFullscreenChange,
refreshWebFullscreenState,
listenDesktopMenuCommand,
readDesktopFavoriteRoutes,
toggleDesktopFavoriteRoute,
toggleWebFullscreen,
type DesktopRoutePreference,
} from "../runtime";
import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefresh";
@@ -428,6 +439,7 @@ import AccountConnectionStatus from "./AccountConnectionStatus.vue";
import ProfileSettings from "../views/ProfileSettings.vue";
import DesktopPreferences from "../views/DesktopPreferences.vue";
import { getActiveLayoutPath } from "./layout/navigation";
import { useProjectNotifications } from "../composables/useProjectNotifications";
const auth = useAuthStore();
const study = useStudyStore();
@@ -459,14 +471,23 @@ const profileDialogVisible = ref(false);
const profileDialogDirty = ref(false);
const commandPaletteVisible = ref(false);
const desktopPreferencesVisible = ref(false);
const webFullscreenAvailable = ref(false);
const webFullscreenActive = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const headerRemindersLoading = ref(false);
const headerReminderStats = ref({
overdueAes: 0,
overdueMonitoringIssues: 0,
});
const {
headerRemindersLoading,
headerReminderItems,
headerReminderTotal,
headerReminderBadgeValue,
loadHeaderReminders,
handleReminderCommand,
handleReminderVisibilityChange,
startHeaderNotificationPolling,
stopHeaderNotificationPolling,
} = useProjectNotifications();
let desktopMenuUnlisten: (() => void) | undefined;
let webFullscreenUnlisten: (() => void) | undefined;
let mobileViewportChangeHandler: ((event: MediaQueryListEvent) => void) | undefined;
const isAdminContext = computed(() => route.path.startsWith("/admin"));
const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);
@@ -492,76 +513,6 @@ const userDisplayInitial = computed(() => {
});
const showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value);
const canReadRiskIssueAes = computed(() => hasProjectContext.value && canAccessProjectPath("/risk-issues/sae"));
const canReadMonitoringIssues = computed(() => hasProjectContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
const hasAnyRiskReminderAccess = computed(() => canReadRiskIssueAes.value || canReadMonitoringIssues.value);
const toHeaderNumber = (value: unknown) => {
const num = typeof value === "number" ? value : Number(value);
return Number.isFinite(num) && num > 0 ? num : 0;
};
const loadHeaderReminders = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !hasProjectContext.value || !hasAnyRiskReminderAccess.value) {
headerReminderStats.value = { overdueAes: 0, overdueMonitoringIssues: 0 };
return;
}
headerRemindersLoading.value = true;
try {
const [aesRes, monitoringRes] = await Promise.allSettled([
canReadRiskIssueAes.value ? fetchOverdueAesCount(studyId) : Promise.resolve(null),
canReadMonitoringIssues.value ? listMonitoringVisitIssues(studyId, { overdue: true, limit: 500 }) : Promise.resolve(null),
]);
if (study.currentStudy?.id !== studyId || isAdminContext.value) return;
headerReminderStats.value = {
overdueAes: aesRes.status === "fulfilled" && aesRes.value ? toHeaderNumber((aesRes.value.data as any)?.total) : 0,
overdueMonitoringIssues: monitoringRes.status === "fulfilled" && monitoringRes.value && Array.isArray(monitoringRes.value.data)
? monitoringRes.value.data.length
: 0,
};
} finally {
if (study.currentStudy?.id === studyId) {
headerRemindersLoading.value = false;
}
}
};
const headerReminderItems = computed(() => [
canReadRiskIssueAes.value && headerReminderStats.value.overdueAes > 0 ? {
key: "overdueAes",
command: "overdueAes",
title: TEXT.common.labels.overdueAes,
description: TEXT.common.labels.overdueAesDesc,
count: headerReminderStats.value.overdueAes,
tone: "danger",
} : null,
canReadMonitoringIssues.value && headerReminderStats.value.overdueMonitoringIssues > 0 ? {
key: "overdueMonitoringIssues",
command: "overdueMonitoringIssues",
title: TEXT.common.labels.overdueMonitoringIssues,
description: TEXT.common.labels.overdueMonitoringIssuesDesc,
count: headerReminderStats.value.overdueMonitoringIssues,
tone: "warning",
} : null,
].filter(Boolean) as Array<{
key: string;
command: string;
title: string;
description: string;
count: number;
tone: "danger" | "warning";
}>);
const headerReminderTotal = computed(() =>
headerReminderItems.value.reduce((sum, item) => sum + item.count, 0)
);
const headerReminderBadgeValue = computed(() =>
headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value
);
const accountProjectRoleLabel = computed(() => {
if (!hasProjectContext.value || !study.currentStudy) return "";
const roleCode = (projectRole.value || "").toUpperCase();
@@ -615,6 +566,7 @@ const desktopNavigationItems = computed<DesktopNavigationItem[]>(() => {
{ label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "note"] },
{ label: TEXT.menu.knowledgeSupportFiles, path: "/knowledge/support-files", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "support"] },
{ label: TEXT.menu.knowledgeInstructionFiles, path: "/knowledge/instruction-files", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "instruction"] },
{ label: TEXT.menu.knowledgeCollaboration, path: "/knowledge/collaboration", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "collaboration", "onlyoffice", "协作"] },
];
items.push(...projectItems.filter((item) => canAccessProjectPath(item.path)));
}
@@ -851,6 +803,7 @@ const breadcrumbs = computed(() => {
{ label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions" },
{ label: TEXT.menu.knowledgeSupportFiles, path: "/knowledge/support-files" },
{ label: TEXT.menu.knowledgeInstructionFiles, path: "/knowledge/instruction-files" },
{ label: TEXT.menu.knowledgeCollaboration, path: "/knowledge/collaboration" },
]},
];
@@ -940,25 +893,6 @@ const handleMenuSelect = (index: string) => {
router.push(index);
};
const handleReminderCommand = (cmd: string) => {
if (cmd === "overdueAes" && canReadRiskIssueAes.value) {
router.push("/risk-issues/sae");
return;
}
if (cmd === "overdueMonitoringIssues" && canReadMonitoringIssues.value) {
router.push("/risk-issues/monitoring-visits");
return;
}
if (cmd === "riskIssues") {
const path = canReadRiskIssueAes.value
? "/risk-issues/sae"
: canReadMonitoringIssues.value
? "/risk-issues/monitoring-visits"
: "";
if (path) router.push(path);
}
};
watch(() => route.path, () => {
closeMobileNav();
study.setViewContext(null);
@@ -987,6 +921,28 @@ const closeMobileNav = () => {
isMobileNavOpen.value = false;
};
const webFullscreenLabel = computed(() => webFullscreenActive.value ? "退出全屏" : "进入全屏");
const syncWebFullscreenState = () => {
webFullscreenAvailable.value = isWebFullscreenAvailable();
webFullscreenActive.value = isWebFullscreenActive();
};
const handleWebFullscreenToggle = async () => {
try {
await toggleWebFullscreen();
syncWebFullscreenState();
} catch (error) {
const message = isTauriRuntime()
? "无法切换桌面全屏,请使用“显示 > 进入全屏”重试"
: typeof error === "object" && error !== null && "name" in error && error.name === "NotAllowedError"
? "浏览器未允许进入全屏,请再次点击或检查站点权限"
: "无法切换全屏,请使用浏览器菜单重试";
ElMessage.warning(message);
syncWebFullscreenState();
}
};
const syncMobileViewport = (matches: boolean) => {
isMobileViewport.value = matches;
if (!matches) closeMobileNav();
@@ -1036,6 +992,9 @@ const onLogout = async () => {
};
onMounted(async () => {
syncWebFullscreenState();
webFullscreenUnlisten = listenWebFullscreenChange(syncWebFullscreenState);
void refreshWebFullscreenState().then(syncWebFullscreenState).catch(() => {});
if (typeof window.matchMedia === "function") {
mobileViewportMediaQuery = window.matchMedia("(max-width: 767px)");
syncMobileViewport(mobileViewportMediaQuery.matches);
@@ -1059,14 +1018,17 @@ onMounted(async () => {
}
loadStudies();
loadHeaderReminders();
startHeaderNotificationPolling();
});
onBeforeUnmount(() => {
webFullscreenUnlisten?.();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
if (mobileViewportMediaQuery && mobileViewportChangeHandler) {
mobileViewportMediaQuery.removeEventListener("change", mobileViewportChangeHandler);
}
desktopMenuUnlisten?.();
stopHeaderNotificationPolling();
});
const onCommand = (cmd: string) => {
@@ -1572,26 +1534,6 @@ useDesktopShortcuts(
flex-shrink: 0;
}
.workbench-return-button {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 10px;
border: 1px solid #c7d7ec;
border-radius: 8px;
background: #eff6ff;
color: #1d4ed8;
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.workbench-return-button:hover {
border-color: #93c5fd;
background: #dbeafe;
}
.desktop-command-trigger {
display: inline-grid;
grid-template-columns: auto minmax(0, auto) auto;
@@ -1743,6 +1685,7 @@ useDesktopShortcuts(
align-items: center;
}
.header-fullscreen-button,
.header-reminder-button {
display: inline-flex;
align-items: center;
@@ -1758,6 +1701,7 @@ useDesktopShortcuts(
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.header-fullscreen-button:hover,
.header-reminder-button:hover {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.14);
@@ -1765,6 +1709,7 @@ useDesktopShortcuts(
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.header-fullscreen-button .el-icon,
.header-reminder-button .el-icon {
font-size: 17px;
}
@@ -1835,6 +1780,7 @@ useDesktopShortcuts(
padding: 8px 7px !important;
border-radius: 7px;
}
:global(.header-reminder-item.is-read) { opacity: 0.62 !important; }
.header-reminder-item :deep(.el-dropdown-menu__item) {
width: 100%;
@@ -1854,6 +1800,7 @@ useDesktopShortcuts(
.header-reminder-item-mark.is-warning {
background: #f59e0b;
}
.header-reminder-item-mark.is-info { background: var(--ctms-primary); }
.header-reminder-item-main {
display: inline-flex;
@@ -1881,9 +1828,10 @@ useDesktopShortcuts(
}
.header-reminder-item-count {
color: #0f172a;
font-size: 16px;
font-weight: 750;
color: #7c8da3;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.header-reminder-empty {
@@ -2111,17 +2059,6 @@ useDesktopShortcuts(
gap: 3px;
}
.workbench-return-button {
width: 32px;
padding: 0;
font-size: 0;
}
.workbench-return-button .el-icon {
margin: 0;
font-size: 16px;
}
.header-account-role,
.user-profile .username,
.user-profile .el-icon,
@@ -2161,9 +2098,9 @@ useDesktopShortcuts(
max-width: 42vw;
}
.header-fullscreen-button,
.header-reminder-button,
.collapse-btn,
.workbench-return-button,
.dropdown-trigger.compact {
width: 30px;
height: 30px;